Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
},
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/check-base-freshness.mjs\""
}
]
}
Expand Down
26 changes: 26 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/sh
# Pre-push safety net. Delegates to scripts/guard-push.mjs, which runs the
# auto-merge race sentinel, format-before-push, and drift-manifest freshness
# guards. Each guard has an explicit override env var (see the script header).
#
# Installed by scripts/install-git-hooks.mjs via `git config core.hooksPath
# .githooks` (run automatically on npm install/ci). Git passes the pushed refs
# on stdin as "<localRef> <localSha> <remoteRef> <remoteSha>" lines, which the
# script reads to scope the format and drift checks to what is actually pushed.
#
# Set GUARD_PUSH_DISABLE=1 to bypass every guard at once (individual guards also
# have their own override flags).
set -eu

if [ "${GUARD_PUSH_DISABLE:-}" = "1" ]; then
exit 0
fi

# Resolve node; if unavailable, do not block the push (fail open).
if ! command -v node >/dev/null 2>&1; then
echo "[pre-push] node not found on PATH — skipping push guards" >&2
exit 0
fi

repo_root=$(git rev-parse --show-toplevel)
exec node "$repo_root/scripts/guard-push.mjs" "$@"
58 changes: 58 additions & 0 deletions docs/operator-backlog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Operator backlog

Single source of truth for **human-only / provider-gated actions** that cannot be done from a coding
session (they touch Supabase, Railway, OpenAI, or GitHub settings, per the AGENTS.md provider boundary).
This exists so that launch-blocking state lives in the repo instead of chat memory.

**How to use:** work top to bottom; each row links to the detailed runbook. `Status` values are
`⏳ pending`, `🔎 verify` (may already be done — confirm before repeating), `✅ done`, `—` (n/a).
Update the row (and its runbook) when an action lands. The sequenced flow with exact commands and
approval gates is [launch-operator-runbook.md](launch-operator-runbook.md); this table is the index.

> Status column is seeded from repo runbooks + session memory and **must be confirmed against live
> state** before acting — do not treat a `🔎 verify` row as authoritative.

## Launch-gating actions

| Action | Status | Blocked by | Verify command | Runbook |
| -------------------------------------------------------------------------- | ---------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Apply July-8 migration batch (a–g) to live | 🔎 verify | queue must be quiet | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) |
| Apply drift-codify forward migration (step 1h) | ⏳ pending | migration artifact committed + live fingerprint recapture | `npm run check:drift` then `npm run eval:retrieval:quality` (36/36) | [database-drift-detection.md](database-drift-detection.md) |
| Full release gate (bounded OpenAI spend) | ⏳ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md §2](launch-operator-runbook.md) |
| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | ⏳ pending | — | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) |
| Staging soak + rollback rehearsal on Railway | ⏳ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 ≤ 25 s) | [launch-operator-runbook.md §4](launch-operator-runbook.md) · [capacity-review.md](capacity-review.md) |
| Production deploy to Railway | ⏳ pending | release gate + soak pass | `GET /api/health` → `{"status":"ok"}`; `npm run check:deployment-readiness` | [deployment-architecture.md](deployment-architecture.md) |

## Post-deploy actions

| Action | Status | Blocked by | Verify command | Runbook |
| --------------------------------------------------------------------- | ---------- | ------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Redeploy worker (one always-on instance) | ⏳ pending | migration `20260708130000` live | `npm run reindex:health` | [worker-deploy-runbook.md](worker-deploy-runbook.md) |
| Seed registry / differentials / medications (prod) | ⏳ pending | prod deploy | Services/Forms surfaces non-empty | [launch-operator-runbook.md §6](launch-operator-runbook.md) |
| Switch auth connection cap 10-absolute → percentage-based (dashboard) | ⏳ pending | before first vertical scale-up | dashboard — not SQL/MCP settable | [auth-connection-cap-runbook.md](auth-connection-cap-runbook.md) · [capacity-review.md](capacity-review.md) |
| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | nightly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) |

## Standing secret / config placement (per environment)

Each environment gets **separate** service-role + OpenAI keys (per-env blast radius). Placement is a
dashboard/CLI action, never committed.

| Secret / config | Status | Where | Notes |
| ------------------------------------- | ---------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | PIA-2 fail-closed guard requires it at boot (min 16 chars); its absence has broken main CI before |
| `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key |
| `OPENAI_API_KEY` (per env) | ⏳ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` |
| OpenAI DPA / ZDR execution | ⏳ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal — see [openai-cross-border-basis.md](openai-cross-border-basis.md) |

## Disaster-recovery re-creation (does NOT survive a schema restore)

Per [disaster-recovery-runbook.md](disaster-recovery-runbook.md) — config & secrets are the layer a schema
restore does not bring back:

| Action | Status | Notes |
| ------------------------------------------------------------------------------------------------------------------ | ---------- | -------------------------------- |
| Re-create pg_cron schedules | ⏳ pending | e.g. ingestion / retention crons |
| Re-add Vault secrets (`cron_ingestion_jwt`) | ⏳ pending | — |
| Re-set custom GUCs | ⏳ pending | — |
| Redeploy edge functions | ⏳ pending | needs Deno v2.x |
| Re-enter dashboard config (auth providers/SSO redirect URLs, connection-pool caps, per-project keys, `E2E_USER_*`) | ⏳ pending | — |
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"scripts": {
"dev": "node scripts/dev-free-port.mjs",
"preinstall": "node scripts/check-node-engine.cjs",
"postinstall": "node scripts/install-git-hooks.mjs",
"hooks:install": "node scripts/install-git-hooks.mjs",
"guard:push": "node scripts/guard-push.mjs",
"guard:push:self-test": "node scripts/guard-push.mjs --self-test",
"check:base-freshness": "node scripts/check-base-freshness.mjs",
"ensure": "node scripts/ensure-local-server.mjs",
"build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack && node scripts/check-client-bundle-secrets.mjs",
"build:analyze": "node scripts/build-analyze.mjs",
Expand Down
78 changes: 78 additions & 0 deletions scripts/check-base-freshness.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* check-base-freshness — advisory stale-base tripwire.
*
* main moves fast (claude/* branches auto-merge on green), so a worktree branched
* a while ago can be many commits behind origin/main — which is how duplicate work
* gets built against a stale base. This fetches origin/main and reports how far
* behind the current branch is, warning loudly past a threshold.
*
* ADVISORY ONLY — never exits non-zero on staleness, so it is safe to wire into a
* SessionStart hook or statusline without ever blocking work. Exit is non-zero
* only on a genuine tooling error under `--strict` (off by default).
*
* Env:
* STALE_BASE_THRESHOLD commits-behind that triggers the loud warning (default 10)
* BASE_FRESHNESS_NO_FETCH=1 skip the network fetch (use last-known origin/main)
* Flags:
* --json machine-readable output
* --strict exit 1 when the base ref cannot be resolved (default: exit 0)
*/
import { execFileSync } from "node:child_process";

const threshold = Number.parseInt(process.env.STALE_BASE_THRESHOLD ?? "10", 10) || 10;
const asJson = process.argv.includes("--json");
const strict = process.argv.includes("--strict");

function git(args) {
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}

function tryGit(args) {
try {
return git(args);
} catch {
return undefined;
}
}

function finish(result) {
if (asJson) {
console.log(JSON.stringify(result));
} else if (result.error) {
console.error(`[base-freshness] ${result.error}`);
} else if (result.behind > threshold) {
console.error(
`\n⚠ [base-freshness] ${result.branch} is ${result.behind} commits BEHIND origin/main ` +
`(ahead ${result.ahead}).\n` +
` You may be building on a stale base — rebase/merge origin/main before starting new work.\n`,
);
} else {
console.error(
`[base-freshness] ${result.branch}: behind ${result.behind}, ahead ${result.ahead} vs origin/main — ok`,
);
}
process.exit(result.error && strict ? 1 : 0);
}

const branch = tryGit(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "(unknown)";

if (process.env.BASE_FRESHNESS_NO_FETCH !== "1") {
try {
execFileSync("git", ["fetch", "--quiet", "origin", "main"], { stdio: "ignore" });
} catch {
// Offline or no remote — fall back to whatever origin/main we already have.
}
}

if (!tryGit(["rev-parse", "--verify", "--quiet", "origin/main"])) {
finish({ branch, error: "origin/main not resolvable (no remote or never fetched)", behind: 0, ahead: 0 });
}

const counts = tryGit(["rev-list", "--left-right", "--count", "origin/main...HEAD"]);
if (!counts) {
finish({ branch, error: "could not compute ahead/behind vs origin/main", behind: 0, ahead: 0 });
}

const [behind, ahead] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
finish({ branch, behind, ahead, threshold });
Loading
Loading