AdvisoryAI AAI-9 maintenance window — execution plan
Prepared 2026-09-04 by the live-deploy operator. This is a PLAN, not authorization to run it. The owner approved planning this window on 2026-09-04. Executing the data move needs a separate, explicit go.
Step authority is advisoryai-aai9-data-move.md— the 13 numbered steps, the copy contract, findings D1-D6 and the rollback/unfreeze conditions live there and are not duplicated here. This document adds what that runbook does not carry: a timed sequence, a preflight NO-GO list with exit codes, the point of no return, per-phase rollback with its artifact, the forcing functions, the operator roles, duration estimates with their basis, and the failure modes with detection and response.
Where this plan and the runbook disagree, this plan’s corrections win and are listed in §10 with the evidence; fold them into the runbook before the window opens.
1. Offline prep executed 2026-09-04 (no live state changed)
Every value below was measured today. Nothing was deployed, copied, revoked, retired or restarted.
1.1 Successor images — BUILT, clean, digest-pinned
Built from an isolated detached worktree at HEAD (tmp/worktrees/aai9-build-9d2e5ef, git status --porcelain = 0 lines) with MSBUILDDISABLENODEREUSE=1 DOTNET_BUILD_SERVER=0 PUBLISH_MAX_CPU_COUNT=2, invoking the worktree’s own relative bash devops/docker/build-service-publish.sh. Confirmed from the build log that every publish path sits under the worktree. Not pushed to any registry — building is not publishing.
| Receipt | Value |
|---|---|
advisoryai-web | stellaops/advisoryai-web@sha256:8930b967515bb6720b27a103346a523057e552024f05b4ceddf0d69d23feb60b |
advisoryai-worker | stellaops/advisoryai-worker@sha256:d9d2be13b4486c796fda66d973ec0c5d7857558e6f5ce11c5a41ca5d8deac44e |
gitSha (both) | 9d2e5ef54193b01881793dcef35d525e0c3734f0 |
gitCommitTime (both) | 2026-09-04T16:36:15+03:00 |
worktreeState / dirtyFileCount (both) | clean / 0 |
imageBuiltAt | web 2026-09-04T13:50:11Z, worker 2026-09-04T13:54:15Z |
| Image budget | web 10 layers / 576 MB, both within the 20-layer / 1000 MB limit |
Acceptance check is gitSha == the commit you intended to pin, not worktreeState == clean— a mis-invoked build against the main clone produces an honest clean stamp of the wrong commit. Both images stamp 9d2e5ef5, the HEAD at build time.
These digests expire. HEAD moved twice during this prep session (ebc9ff5a → 9d2e5ef5) because roughly seven lanes are landing work. If HEAD has moved past 9d2e5ef5 when the window opens, and any moved commit touches src/AdvisoryAI/**, src/Router/**, src/OpsMemory/** or devops/compose/docker-compose.advisoryai.yml, rebuild and re-record — do not deploy a stale digest against a newer runbook. Verify with git diff --name-only 9d2e5ef54193b01881793dcef35d525e0c3734f0..HEAD -- src/AdvisoryAI src/Router src/OpsMemory devops/compose/docker-compose.advisoryai.yml.
1.2 Rollback artifacts — all three exist
The research lane’s report that “the web and OpsMemory :dev tags are ABSENT” is correct but is not a gap: those two predecessors were never on :dev. Each of the three has both a tag and an ID, and each container’s compose chain already ends in a pin naming the image ID, so rollback does not depend on any tag surviving.
| Container | Image ID (authoritative rollback artifact) | Tag that still resolves it | Built |
|---|---|---|---|
stellaops-advisory-ai-web | sha256:7433b4ac04658ea62e11943083260f7f9da4ca874a71d77b6bd41a9b95d51bbf | stellaops/advisory-ai-web:bl2-9796c887-20260724 | 2026-07-24 |
stellaops-advisory-ai-worker | sha256:358667b46aeee93eeecfe50c752deb1444d58a982022efdacac18a88828ffacd | stellaops/advisory-ai-worker:dev | 2026-07-30 |
stellaops-opsmemory-web | sha256:0c43d0233a48f3a8921ca963eae0d34c093976e093340a1dae0c668fe8db38f3 | stellaops/opsmemory-web:bl2-9796c887-20260724 | 2026-07-24 |
All three are Up (healthy), uptime 28 h at measurement.
The compose chains are heterogeneous and end in ID pins (read from com.docker.compose.project.config_files, not reconstructed):
advisory-ai-web— 18 files, last pintmp/kestrel-rotation/20260822T091500Z/overlays/phase2-new-cert-advisory-ai-web.pin.yml→image: "sha256:7433b4ac…".advisory-ai-worker— 15 files, last pin…phase2-new-cert-advisory-ai-worker.pin.yml.opsmemory-web— 10 files, last pin…phase2-new-cert-opsmemory-web.pin.yml.
All 10/15/18 files were verified present on disk today.
Rollback fragility — act on this before the window. Five of those chain files live under
tmp/(tmp/build/bl2-stage0/*.override.yml,tmp/kestrel-rotation/**).tmp/build/is swept underLOCATIONS.md, and the pins that name the rollback image IDs are the last entries in every chain. A sweep between now and the window silently destroys the rollback recipe while leaving the images intact. Copy all three chains and every file they name into${AAI9_ROOT}/rollback/at step 2 and assert the copies, not the originals.
1.3 Source re-measurement at the boundary — unchanged from 2026-08-28
| Measure | 2026-08-28 | 2026-09-04 (today) | Verdict |
|---|---|---|---|
Tables (advisoryai + opsmemory, relkind='r') | 28 | 28 (26 + 2) | match |
| Database-wide rows incl. ledgers | 13,241 | 13,241 | match |
| Carried rows (25 tables, ledgers excluded) | 13,238 | 13,238 | match |
advisoryai total size | 232 MB | 232 MB / 243,654,656 B | +851,968 B vs the recorded 242,802,688 B — index/TOAST growth, not rows |
opsmemory total size | 144 kB | 144 kB / 147,456 B | match |
| Forced/enabled RLS | 0/0 of 26 and 0/0 of 2, 0 pg_policy | identical | D6 still does not apply |
| Sequences / identity columns | 0 / 0 | 0 / 0 | setval class still absent |
pg_available_extensions for vector | 0 rows | 0 rows | D5 still a no-op |
| PostgreSQL | 18.1 | 18.1 (Debian 18.1-1.pgdg13+2) | match |
Per-table non-zero counts (all five equal the copy contract): api_operation 2190, api_spec 1, doctor_search_projection 186, kb_chunk 10154, kb_doc 707. The three excluded ledger rows are advisoryai.schema_migrations, advisoryai.__migration_history, opsmemory.schema_migrations, one row each.
Storage split, which is what sets the restore duration: kb_chunk is 37 MB heap + 88 MB indexes + ~98 MB TOAST = 223 MB. The restore rebuilds those 88 MB of indexes (13 on kb_chunk, including five per-language GIN tsvectors) from 10,154 rows.
Canonical source fingerprint captured today (to_jsonb ordered digest, the step-5 comparator), first 16 hex of each:
advisoryai.api_operation 2190 781a37ddfc060196
advisoryai.api_spec 1 41edc1d28029ed3c
advisoryai.doctor_search_projection 186 00d7f1f204b2752f
advisoryai.kb_chunk 10154 baa4e13111cf39f0
advisoryai.kb_doc 707 cd4806d2204d7b64
These digests are NOT a window baseline. See §1.6 — a live writer rewrites kb_doc/kb_chunk on a timer. parity/source-a.tsv must be taken inside the freeze.
1.4 Target database and isolation
stellaops_advisoryaiexists, owneradvisoryai; 0 relations, extensions =plpgsqlonly.- Role
advisoryai:LOGIN, andNOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLSall confirmed false inpg_roles. dataclon the target is{=T/advisoryai,advisoryai=CTc/advisoryai}— PUBLIC holdsTEMPonly, noCONNECT. Same shape asstellaops_platformandstellaops_findings.probe-database-isolation.sh: 18/18 rows correct foradvisoryai—CONNECTEDtostellaops_advisoryaiandrefusedon all 17 siblings. Whole-matrix verdict== PASS: every role reaches exactly its own database ==.
But the bare invocation the runbook’s step 3 uses exits 1 today — see NO-GO item P-04 and defect D-3.
1.5 Carried volumes
Measured against the real volume names, which are compose_-prefixed:
| Volume | Files | Bytes |
|---|---|---|
compose_advisory-ai-queue | 0 | 8192 (empty dir) |
compose_advisory-ai-plans | 0 | 4096 |
compose_advisory-ai-outputs | 0 | 4096 |
compose_advisoryai-plugin-scratch | 0 | 4096 |
Nothing is queued, so the swap carries no in-flight work. Ownership: the three carried volumes are 10001:10001 drwx------, matching the runtime uid (stella, 10001) in both predecessor and successor. compose_advisoryai-plugin-scratch is 0:0 drwxr-xr-xand advisoryai-data-init does not chown it — see risk W-02.
1.6 The source is NOT static — per-source ingest baseline
kb_doc.max(indexed_at) is 2026-09-04 13:37:56Z, minutes before this measurement. The live predecessor runs UnifiedSearchIndexRefreshService on a timer:
Unified search incremental indexing completed: domains=8, seen_chunks=20, changed_chunks=0, removed=0
Unified search periodic indexing run completed: domains=8, chunks=20, duration_ms=149
Baseline by document source, so window damage is separable from pre-existing noise:
doc_type | kb_doc | kb_chunk | last indexed |
|---|---|---|---|
markdown | 500 | 7758 | 2026-09-03 09:08:06Z |
openapi | 1 | 2190 | 2026-09-03 09:08:06Z |
doctor | 186 | 186 | 2026-09-03 09:08:06Z |
platform | 5 | 5 | 2026-09-04 13:37:56Z |
findings | 3 | 3 | 2026-09-04 13:37:56Z |
policy | 3 | 3 | 2026-09-04 13:37:56Z |
vex | 3 | 3 | 2026-09-04 13:37:56Z |
graph | 2 | 2 | 2026-09-04 13:37:56Z |
opsmemory | 2 | 2 | 2026-09-04 13:37:56Z |
scanner | 2 | 2 | 2026-09-04 13:37:56Z |
| total | 707 | 10154 |
Sibling tables: api_spec 1, api_operation 2190, doctor_search_projection 186.
Pre-existing error noise to baseline, so it is not misread as window damage. The predecessor’s VEX adapter fails its Authority token fetch on every unified-index pass and falls back to the snapshot:
StellaOps.Auth.Client.StellaOpsDiscoveryCache.GetAsync … EnsureSuccessStatusCode()
at StellaOps.AdvisoryAI.UnifiedSearch.Adapters.VexSearchAdapter.FetchFromServiceAsync
info: Unified search refresh domain 'vex' completed: seen_chunks=3, changed_chunks=0, removed=0
This is pre-existing and unrelated to AAI-9. It must appear in the pre-window log snapshot, or the step-12 soak will attribute it to the move.
Two window consequences, both live findings:
- The live predecessor sets three unified-search adapter base URLs; the successor overlay sets none. Live:
ADVISORYAI__KnowledgeSearch__{Findings,Vex,Policy}AdapterBaseUrl=http://scanner.stella-ops.local,http://concelier.stella-ops.local,http://policy-engine.stella-ops.local.grep -n 'AdapterBaseUrl' devops/compose/docker-compose.advisoryai.yml→ no match. After the cutover the eight unified domains (20 docs / 20 chunks) stop refreshing from the live services and serve snapshots indefinitely. Step 10’s flip ofUnifiedAutoIndexOnStartup=truedoes not restore it, becauseUnifiedAutoIndexEnableddefaultsfalseand the early return atUnifiedSearchIndexRefreshService.cs:27-33needs both conditions. The key shape also changed — the predecessor image bindsKnowledgeSearch:*while the successor host strips prefixADVISORYAI__and binds sectionAdvisoryAI:KnowledgeSearch(KnowledgeSearchOptions.SectionName,WebService/Program.cs:58). Copying the predecessor’s literal variable names forward would bind nothing and fail silently. The successor form isADVISORYAI__AdvisoryAI__KnowledgeSearch__FindingsAdapterBaseUrl. ⇒ Decision required before the window (see P-09): either accept the regression explicitly and record it, or add the three keys in the successor shape to step 10’s activation environment. Do not discover this during the soak. - The runbook’s own precision note is confirmed live. The fourth carried-table writer is inert on the overlay only because no adapter URL is set — not because
UnifiedAutoIndexOnStartup=false. The step-3 render check would stay green with an adapter URL present.tables_checked=27 rows=0is the only assertion that catches it.
2. Roles — who is needed, and for what
Six roles, taken from the runbook’s Audience line plus its approvals table. One person may hold several hats; what matters is that each responsibility has a named owner before the window opens.
| # | Role | What only this role does | Steps |
|---|---|---|---|
| 1 | Approving owner | Gives the explicit go for the irreversible move; rules on the P-09 adapter-URL decision and on re-enabling retention | before step 1; consulted at step 10 |
| 2 | Window owner | Holds the window ID, drives the sequence, calls GO/NO-GO and calls rollback | all |
| 3 | Database operator | Executes every psql/pg_dump/pg_restore, owns the freeze and the parity verdict | 3-9, 13 |
| 4 | Live-deploy operator | Renders and reads compose chains, recreates containers, verifies swaps and health | 2, 3, 5, 10, 11 |
| 5 | AdvisoryAI implementer | Runs the forcing functions and judges their output; owns the Console prefix flip and the FE dist rebuild | 11, 12 |
| 6 | Rollback owner | Holds rollback/, is not executing the move, decides the unfreeze against the five conditions | rollback path only |
Scripted vs human. Steps 2-9 are entirely scripted (copy-paste from the runbook, exit-code gated) — a human reads verdicts and stops on the first failure, but types nothing original. Human judgment is required at exactly four points: the GO decision after the preflight list; the parity verdict at 9b/9e (the ordinal diff is the one place a non-empty diff is the pass condition, and its membership must be read); the three forcing functions at step 12, which are judged not asserted; and the rollback/unfreeze call.
3. Timed sequence
Times are cumulative from T+0 = the GO after the preflight list. Basis for each duration is in §7.
| T+ | Dur | Phase | Step | Command / action | Receipt |
|---|---|---|---|---|---|
| — | 45 m | P0 Preflight | — | §4, every row | preflight/* |
| 00:00 | 5 m | P1 Workspace | 1 | mkdir -p "${AAI9_ROOT}"/{preflight,backup,parity,rollback,activation}; git rev-parse HEAD; git status --short | preflight/repository-head.txt, repository-status.txt |
| 00:05 | 10 m | P1 | 2 | Capture rollback topology: three docker inspect loops plus the chain-file copies (§1.2); measure the four volumes by their compose_ names | rollback/*.image.txt, *.config-files.txt, *.environment.sha256, copies of all 18/15/10 chain files, preflight/carried-volumes.txt |
| 00:15 | 5 m | P2 Converge | 3a | Assert target relations=0; run probe-database-isolation.sh with the augmented env file (P-04) | preflight/target-relations-before.txt, database-isolation.txt |
| 00:20 | 2 m | P2 | 3b | CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public; as role advisoryai over 127.0.0.1 | preflight/target-pg-trgm.txt — prints public |
| 00:22 | 5 m | P2 | 3c | Render the live successor chain to JSON; run the jq -e writer/connection assertion | preflight/advisoryai-compose-render.json |
| 00:27 | 8 m | P2 | 3d | up -d --no-deps advisoryai-web on the digest pin; wait healthy; capture log; docker stop | preflight/advisoryai-web-converge.log, converge-stop.txt |
| 00:35 | 4 m | P2 | 3e | Ledger = 2 rows w/ the two known checksums; __migration_history and opsmemory.schema_migrations both NULL; tables_checked=27 rows=0 | preflight/target-ledger.txt, target-empty-proof.txt |
| 00:39 | 3 m | P2 | 3f | Re-measure source counts at the boundary | parity/source-counts-window-open.txt |
| 00:42 | 3 m | P3 Backup | 4 | Full pg_dump -Fc --schema=advisoryai --schema=opsmemory; sha256; pg_restore --list inside the container (D-2) | backup/source-advisoryai-full.dump{,.sha256,.list} — 28 TABLE DATA |
| 00:45 | 20 m | P3 | 4b | Isolated restore test on a scratch PostgreSQL 18, reproducing 13,238 carried rows | preflight/backup-restore-proof.txt |
| 01:05 | 3 m | P4 Freeze ⚠ | 5a | docker stop the three predecessors; assert all five containers not running; pg_stat_activity AdvisoryAI-free | preflight/writer-fence-stop.txt, source-sessions-after-fence.txt |
| 01:08 | 3 m | P4 | 5b | aai9_fingerprints → parity/source-a.tsv | parity/source-a.tsv (25 rows) |
| 01:11 | 2 m | P4 | 5c | sleep 30; second pass; cmp must exit 0 | parity/source-b.tsv |
| 01:13 | 2 m | P4 | 6 | Sequence/identity inventory both sides; cmp (both empty today) | parity/source-sequences.tsv, target-sequences.before.tsv |
| 01:15 | 3 m | P4 | 7 | Fenced data-only dump, 25 --table= + 3 --exclude-table=; sha256; list; two ! grep -Eq | backup/source-advisoryai-fenced-data.dump{,.sha256,.list} — 5 TABLE DATA |
| 01:18 | 5 m | P5 Restore — POINT OF NO RETURN 🔴 | 8 | pg_restore --single-transaction --exit-on-error --data-only --no-owner --no-privileges into the target | backup/target-restore.txt, exit 0, no error |
| 01:23 | 3 m | P6 Parity | 9a | Source fingerprint again; cmp vs source-a.tsv | parity/source-after.tsv |
| 01:26 | 3 m | P6 | 9b | Target fingerprint; diff -u empty; kb_chunk 10154; sum = 13,238 | parity/target-after.tsv, table-parity.diff (empty) |
| 01:29 | 2 m | P6 | 9c | Evidence Pack tables = 0; ledger = 2; both legacy ledgers NULL → t | inline |
| 01:31 | 3 m | P6 | 9d | pg_trgm in public; similarity() under search_path=public → t; both trigram indexdef unqualified; kb_chunk 13 indexes | inline |
| 01:34 | 10 m | P6 | 9e | Six shape probes both sides; ordinal diff exactly kb_chunk + search_events | parity/*.diff |
| 01:44 | 5 m | P7 Repoint | 10a | Set the four writer flags true (+ the P-09 adapter decision); render; jq -e | activation/advisoryai-compose-render.json |
| 01:49 | 2 m | P7 | 10b | up -d --no-deps advisoryai-data-init (chowns the carried volumes; W-02) | container exit 0 |
| 01:51 | 8 m | P7 | 10c | up -d --no-deps advisoryai-web advisoryai-worker; wait healthy; record .Config.Image + .Image; docker exec … cat /app/buildinfo.json | activation/stellaops-advisoryai-{web,worker}.image.txt |
| 01:59 | 5 m | P7 | 10d | Zero sibling reach (advisoryai sessions on stellaops_platform = 0); target sessions named; re-measure the four volumes | activation/target-sessions.txt |
| 02:04 | 8 m | P8 AAI-7 | 10e | Set ADVISORYAI_DOCTOR_REGISTRATION_ENABLED=true, leave Doctor__Registration__ClientId unset, recreate advisoryai-web, assert a platform.doctor_capabilities row → t. This closes AAI-7’s open criterion | activation/doctor-capability-row.txt |
| 02:12 | 5 m | P9 Surface | 11a | Three router configs read advisoryai=0 opsmemory=0; both guard suites (15/0 and 13/0) | activation/route-config-zero.txt, route-guard-*.txt |
| 02:17 | 5 m | P9 | 11b | Authenticated request through the gateway to /api/advisoryai/v1/** returns the host’s answer | activation/gateway-consolidated-probe.txt |
| 02:22 | 25 m | P9 | 11c | Flip the six values in api-path-prefixes.ts; rebuild + deploy the dist; assert the built dist carries /api/advisoryai/v1 and no legacy AdvisoryAI prefix | activation/console-dist-prefixes.txt |
| 02:47 | 40 m | P10 Forcing | 12 | The three forcing functions (§6) plus health/auth, the four-persona gate, and the soak start | activation/* per row |
| 03:27 | 60 m | P10 | 12b | Error-log soak: zero permission errors, zero does not exist, no function similarity(…) does not exist | activation/soak.log |
| 04:27 | 10 m | P11 Revoke | 13a | psql -f preflight/grants-forward.sql; dump role_table_grants | activation/source-revoke.txt, source-grants-after.txt |
| 04:37 | 30 m | P11 | 13b | Revocation soak — zero permission errors in any service log | activation/revoke-soak.log |
| 05:07 | 15 m | P11 | 13c | Flip the four matrix rows (:59, :60, :191, :192, located by content); record parity numbers in the sprint | commit |
| 05:22 | Window closes | Predecessor containers stay stopped, not removed; schemas, images and config blobs retained for AAI-10 |
Total: ~5 h 20 m from GO, ~6 h 05 m including preflight.
4. Preflight NO-GO list
Every row must hold before step 5 (the freeze). Each names the command and the exit code that means STOP. A row that cannot be proven is a NO-GO, not a judgement call.
| # | Condition | Command | STOP on | Status 2026-09-04 |
|---|---|---|---|---|
| P-01 | Window ID, approving owner, window owner, DB operator, live-deploy operator, AdvisoryAI implementer and rollback owner all named | test -s "${AAI9_ROOT}/preflight/window-approval.txt" | exit 1 | UNSATISFIED — no window approved |
| P-02 | Both image receipts carry a @sha256: digest | grep -Eq '@sha256:[0-9a-f]{64}$' …-image.txt | exit 1 | SATISFIED (§1.1) |
| P-03 | Those digests still describe current HEAD for the four relevant trees | git diff --name-only 9d2e5ef5..HEAD -- src/AdvisoryAI src/Router src/OpsMemory devops/compose/docker-compose.advisoryai.yml | any output ⇒ rebuild | satisfied at 9d2e5ef5; re-check at window open |
| P-04 | Database isolation PASS, whole matrix | probe-database-isolation.sh --env-file <augmented> | exit 1 (coverage or connect violation), exit 2 (cannot enumerate) | PASS only with the augmented env. Bare run exits 1 today: stellaops_vuln is not in devops/compose/.env. See D-3 |
| P-05 | Target empty | psql -d stellaops_advisoryai … count(*) relations | != 0 | SATISFIED — 0, extensions plpgsql |
| P-06 | Role hardened, PUBLIC has no CONNECT | pg_roles + pg_database.datacl | any of rolsuper/rolcreatedb/rolcreaterole/rolbypassrls true, or =Tc in the ACL | SATISFIED |
| P-07 | 0 forced-RLS, 0 policies (D6) | the D6 survey | any non-zero ⇒ re-plan the converge boot | SATISFIED — 0/0 of 26, 0/0 of 2, 0 policies |
| P-08 | Sequence/identity inventory empty and identical | step 6 cmp | exit 1 | SATISFIED — 0 and 0 |
| P-09 | Unified-search adapter-URL disposition decided and recorded | owner ruling attached under preflight/ | absent | UNSATISFIED — §1.6. The successor overlay would silently stop refreshing eight unified domains |
| P-10 | Backup restore-tested on a scratch PostgreSQL 18, reproducing 13,238 rows | preflight/backup-restore-proof.txt non-empty | exit 1 | UNSATISFIED — not yet performed |
| P-11 | Forward and rollback grant SQL reviewed | test -s grants-forward.sql grants-rollback.sql | exit 1 | UNSATISFIED — neither file exists |
| P-12 | A working authenticated token for the forcing functions | password grant at /connect/token, client_id=stellaops-cli | HTTP != 200 | UNSATISFIED — the credential in .claude/operator-context.local.md returns 401 today |
| P-13 | The gateway resolves the family’s published paths today | curl -sk -o /dev/null -w '%{http_code}' https://stella-ops.local/v1/search (authenticated) | 404 with TargetService=(none) | UNPROVEN for the five /v1/** values; EXPLAINED and expected for opsMemory. Unauthenticated probes of /v1/search, /api/advisoryai/v1/search and /api/v1/opsmemory all returned 404 TargetService=(none)today, though both services do replay endpoint metadata to the gateway. /api/v1/opsmemory is explained — its only route was the retired wildcard (§8). The other two are not. Resolve before the window or gate 6’s forcing function cannot distinguish “the move broke it” from “it was never reachable” |
| P-14 | All chain files copied into rollback/ and asserted | test -s on each of the 18/15/10 copies | exit 1 | UNSATISFIED — see the sweep hazard in §1.2 |
| P-15 | Three router configs carry zero AdvisoryAI rows | the step-11 grep -c loop | any row not advisoryai=0 opsmemory=0 | re-check at window open |
| P-16 | Both router guard suites green before the window | test-targeted-xunit.ps1 ×2 | Failed: != 0, or a ran-count below 15 / 13 | re-check at window open |
| P-17 | api-path-prefixes.ts not concurrently held | git status --porcelain -- src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.ts | non-empty and not yours | clean now; coordinate with impl-EVD-6b — see §8 |
| P-18 | The clone is quiet enough to rebuild if P-03 forces one | per-PID CPU delta over 20 s plus a new-PID count | any new PID, or several PIDs accumulating | BUSY at prep time — 20 MSBuild nodes accumulating and a live VBCSCompiler. Not a blocker for a window that does not rebuild |
5. Point of no return, and rollback per phase
The point of no return is step 8, the pg_restore into the target (T+01:18).
It is not the freeze. Everything up to and including step 7 is reversible by starting three containers you never removed.
Why step 8 and not step 10. Before it, the target is empty and provably so (tables_checked=27 rows=0), so “abandon the window” is docker start ×3 and nothing else — the target can be dropped and re-converged at any later date with no evidence lost. After it, the target holds a copy of production data whose provenance is a specific frozen instant of the source. A second attempt cannot reuse it: --single-transaction --exit-on-error makes one retry against a still-empty target safe, but a successful restore followed by a later rollback means the next attempt must re-converge a fresh empty target and re-freeze the source, because the first copy’s freeze window is gone. That is the step where cost becomes measured in re-running the whole window rather than in restarting containers.
Step 8 is also the last step whose failure is cheap: it either commits or leaves nothing.
| Phase | Rollback | Depends on | Cost |
|---|---|---|---|
| P0-P1 (preflight, topology) | Nothing to undo | — | zero |
| P2 (converge) | DROP DATABASE stellaops_advisoryai and re-provision, or leave it converged and empty — it is not reachable by any running service | nothing; the target is not wired to anything | minutes |
| P3 (backup) | Delete the artifacts | — | zero |
| P4 (freeze, dump) | docker start the three predecessors from rollback/*.image.txt; unfreeze once the five conditions hold | the three image IDs and their chain files (§1.2) | ~10 min of AdvisoryAI downtime |
| P5 (restore) 🔴 | Same container rollback, plus the target is now evidence: retain it unchanged, do not drop it | the same rollback artifacts | window must be re-run end to end |
| P6 (parity fails) | Keep the freeze, change no route/grant/flag, enter the runbook’s rollback | as above | as above |
| P7 (repoint) | Stop both successors; recreate the three predecessors on their recorded chains pinned back to stellaops_platform— never on the successor overlay, whose legacy images carry the predecessor migration lineage | rollback/predecessor-compose-render.json asserted by the jq -e Database=stellaops_platform test | ~15 min |
| P8 (AAI-7 doctor) | Set ADVISORYAI_DOCTOR_REGISTRATION_ENABLED=false and recreate; the capability row is additive and harmless if left | none | minutes |
| P9 (Console) | Revert the six api-path-prefixes.ts values and rebuild the dist. For opsMemory this reverts to a path that is already dead at HEAD — back to broken, not back to working (§8); the other five revert to working paths | git revert of one file + an FE build | ~25 min |
| P10-P11 (revoke) | psql -f preflight/grants-rollback.sql | grants-rollback.sql reviewed before the window (P-11) | minutes |
The freeze is not lifted by entering rollback. Only the five UNFREEZE conditions in the runbook’s final section lift it, all five at once.
6. The three forcing functions
Each is a live behavioural proof against the migrated data, not a synthetic fixture and not a health check. All three must pass before step 13.
| # | Forcing function | What it proves | How it fails vacuously |
|---|---|---|---|
| FF-1 | An advisory draft generates and cites chunks that exist in the target kb_chunk | The 10,154-row copy is not merely present but queryable through the application’s own path — FTS, the trigram fallback, and the D1 search_path resolution all work under the service role. This is the only check that exercises D1’s failure mode | It passes vacuously if the draft is judged against raw advisory text. AAI-LD changed the worker’s document source: judge it against the hub consensus projection. It also passes vacuously if the cited chunk ids are not actually looked up in the target — verify at least one cited id with a SELECT against stellaops_advisoryai |
| FF-2 | An OpsMemory recall answers on the consolidated host | The gate-9 dissolution is real: IOpsMemoryClient/NullOpsMemoryClient are gone and the in-process provider serves from the AdvisoryAI-owned DSN. Proves the second schema moved and that no second migrator was introduced | opsmemory.decisions is empty (0 rows, today and on 2026-08-28), so a recall that returns “nothing found” is indistinguishable from a broken provider. Write a decision through the consolidated host first, then recall it — an empty-corpus recall proves nothing |
| FF-3 | Evidence pack create → get → run → export, persisting across a real host restart, with the export through the actual Router Microservice transport | Closes gate 11 / F-AAI5-6. Proves forward migration 002’s target-native tables work under the new ownership, and that export survives the real transport rather than a Router-disabled scratch host | It passes vacuously against direct HTTP to a Router-disabled host — that is exactly the shortcut F-AAI5-6 was raised about. It also passes vacuously on a trivially small pack: use a realistically sized export, because the recorded concern is a single-frame transport ceiling |
Supporting checks that are not forcing functions (they can be green while the move is broken): health/ready 200, an unauthenticated 401/403, container healthy, and the compose render. The runbook’s own precedent is explicit that Up (healthy) is not evidence of a working service.
7. Duration estimates and their basis
| Phase | Estimate | Basis |
|---|---|---|
| P0 Preflight | 45 m | 18 checks, most seconds each; dominated by assembling receipts and the P-09/P-12/P-13 resolutions. Unmeasured |
| P1 Workspace + topology | 15 m | docker inspect loops are seconds; the chain-file copies are new work. Unmeasured |
| P2 Converge | 27 m | pg_trgm and the assertions are seconds (measured: the 27-table zero-row proof is one round trip). The converge boot is the unknown: unmeasured — no successor image has ever booted against this database. 8 m is an allowance, not a measurement |
| P3 Backup | 3 m + 20 m restore test | Measured today: full pg_dump -Fc --schema=advisoryai --schema=opsmemory = 1.96 s, 11,451,375 bytes, 28 TABLE DATA entries. The isolated restore test is unmeasured — it needs a scratch PostgreSQL 18 that does not exist yet |
| P4 Freeze + fingerprints + dump | 13 m | Measured today: one full 25-table aai9_fingerprints pass = 58.5 s (kb_chunk alone 4.9 s). Step 5 is two passes + a 30 s sleep ≈ 2 m 30 s. Fenced data-only dump = 3.28 s, 11,389,502 bytes, 5 TABLE DATA entries |
| P5 Restore | 5 m | Unmeasured. The dump is only 11 MB, but the restore rebuilds ~88 MB of indexes on kb_chunk — 13 indexes including five per-language GIN tsvectors. 5 m is an allowance; it could be 1 m |
| P6 Parity | 21 m | Measured: 9a + 9b are two more 58.5 s passes. 9e’s six shape probes are unmeasured; on 2026-08-28 they produced 260/201/85/0/26 rows, so they are small queries plus human reading of the ordinal diff |
| P7 Repoint | 20 m | Unmeasured. Two container starts to healthy, plus the render and the session checks |
| P8 AAI-7 | 8 m | Unmeasured. One recreate plus one query |
| P9 Surface + Console | 35 m | Guard suites: 15 and 13 cases, prior runs on this repo are ~30-100 s each. The FE dist rebuild is the dominant term and is unmeasured here — budget from the last measured ng build on this host |
| P10 Forcing + soak | 100 m | Forcing functions unmeasured; the 60 m soak is a chosen duration, not a measurement |
| P11 Revoke + soak + matrix | 55 m | Revoke is seconds; the 30 m soak is chosen; the matrix flip is a four-row edit plus a commit |
| Total | ~5 h 20 m from GO | Of which ~2 h 40 m is unmeasured allowance. Treat the total as a planning envelope, not a commitment |
Flagged as unmeasured and load-bearing: the converge boot (P2), the restore (P5), the FE dist rebuild (P9), and every forcing function (P10). The two soaks are policy choices. If the window must be time-boxed, measure the converge boot and the FE build in a rehearsal first — both are safe to rehearse without touching the source.
8. Gate 7 and the shared Console file
src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.ts is clean in the primary tree right now. Two windows edit it.
Anchor gate 7 by symbol, never by line. Line numbers in this file have been found stale three separate times today. EVD-6 (
28546a50fc, merged13f504b236) grew the Evidence-block comment and moved the whole AdvisoryAI block down: the block header sits at:305and the six values at:346-351as of16ba2dfbb9, and they will move again. Locate them withgrep -n 'advisoryAiSearch:\|advisoryAiApi:\|advisoryAi:\|advisoryAiRuns:\|evidencePacks:\|opsMemory:' > src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.ts. A window step that says “edit line N” is a trap.
AAI-9 owns exactly six values, all in the AdvisoryAI block (locate by symbol):
advisoryAiSearch: '/v1/search' -> '/api/advisoryai/v1/search'
advisoryAiApi: '/v1/advisory-ai' -> '/api/advisoryai/v1/advisory-ai'
advisoryAi: '/v1/advisory-ai' -> '/api/advisoryai/v1/advisory-ai'
advisoryAiRuns: '/v1/advisory-ai/runs'-> '/api/advisoryai/v1/advisory-ai/runs'
evidencePacks: '/v1/evidence-packs' -> '/api/advisoryai/v1/evidence-packs'
opsMemory: '/api/v1/opsmemory' -> '/api/advisoryai/v1/opsmemory'
evidencePacks is AAI-9’s, and the reason is the verb set, not the name
The file’s own guard comment (“evidencePacks used to live here. It is NOT an Evidence-family…”) says where it belongs. EVD-6 re-opened the question — should a Console call through evidencePacks move to the consolidated Evidence host? — and the answer is no. Recorded here so nobody re-opens it mid-window. Verified at 16ba2dfbb9:
evidencePacks (AAI-9) | Consolidated Evidence host | |
|---|---|---|
| Base | EVIDENCE_PACK_API_BASE_URL (app.config.ts:765-773) → evidence-pack.client.ts:56 | MapGroup("{prefix}/evidence"), EvidenceAuditEndpoints.cs:34 |
| Surface | five verbs, authoring: POST {base}, GET {base}/{id}, POST /{id}/sign, POST /{id}/verify, GET /{id}/export (evidence-pack.client.ts:62,69,92,99,107) | three GETs, read-only: GET /evidence, GET /evidence/packs, GET /evidence/packs/{id} — all EvidenceRead |
| Create / sign / verify / export | yes | none of them |
They are not one capability under two spellings. Repointing would silently drop /sign — a plausible-reading path that 404s.
No collision. git show 28546a50fc -- api-path-prefixes.ts has zero added or removed lines matching evidencePacks|advisoryAi|opsMemory — verified. EVD-6’s edits are in the Evidence block around :200-215, a disjoint region well above the six AAI-9 values.
Coordination rule: neither lane may edit the other’s region. Because the regions are disjoint, a rebase resolves cleanly — but only if AAI-9’s flip is a six-line edit and not a reformat of the surrounding comment block.
The wildcard-retirement premise test — opsMemory is already dead at HEAD
The research lane rewrote gate 7 once for a void premise. Re-tested here against the other static-route assumption, and it does not fully hold either.
^/api/v1/([^/]+)(.*) — the generic microservice wildcard — is in the retired-route registry (src/Router/__Tests/.../Configuration/retired-routes.json, 168 paths; ^/api/v2/([^/]+)(.*), ^/v1/advisory-ai(.*), ^/v1/evidence-packs(.*), ^/api/v1/search(.*) and ^/api/v1/advisory-ai(.*) are retired alongside it). The file’s own comment states that opsMemory: '/api/v1/opsmemory' “has NO explicit gateway route and never has: /api/v1/opsmemory/** reaches opsmemory-web only through ^/api/v1/([^/]+)(.*)”.
⇒ That path is dead at HEAD, today — not “goes dark when AAI-8 retires the container”. This is the direct explanation for P-13’s measurement: /api/v1/opsmemory returned 404 with TargetService=(none) through the live gateway today, while opsmemory was demonstrably replaying endpoint metadata to that same gateway.
Three consequences for gate 7:
- For
opsMemorythe flip is a repair, not a flip between two working paths. The successor image maps("/api/v1/opsmemory", "/api/advisoryai/v1/opsmemory")(AdvisoryAiConsolidatedRouteAliases.cs:28), so the window restores a surface that is currently unreachable. That strengthens the gate; it does not void it. - The P9 rollback row is wrong for this one value. “Revert the six values” restores a dead path for
opsMemory. Reverting is still correct for the other five; foropsMemorythe honest rollback statement is back to broken, as it is now — record it, do not present it as a restoration. - All six Console targets are covered by the successor’s alias map — verified at
AdvisoryAiConsolidatedRouteAliases.cs:22-29:/v1/advisory-ai,/v1/evidence-packs,/v1/runs/,/v1/search,/api/v1/chat,/api/v1/opsmemory, all underConsolidatedRoot = "/api/advisoryai/v1".advisoryAiRunsresolves through the/v1/advisory-aiprefix.
Do not fix the comment block from this window. It is false in three places (F-AAI9-16: the “AAI-5 gateway group”, the seven-route swap manifest, and “the gateway swap is atomic across all seven routes”). The values are correct; the prose is not. Fixing it here would collide with EVD-6b for no window benefit.
Sequencing is strict: gate 7 runs after step 10c, never before. The native /api/advisoryai/v1/** aliases exist only in the successor image (AdvisoryAiConsolidatedRouteAliases.cs, discovered by AspNetCoreEndpointDiscoveryProvider.DiscoverAspNetEndpoints). An early flip is a 404 for as long as the predecessor is serving — because the image is not running, not because any swap is atomic.
9. What could go wrong
| # | Failure mode | Detection | Response |
|---|---|---|---|
| W-01 | The converge boot writes carried rows — a fifth writer nobody enumerated | preflight/target-empty-proof.txt reads anything but tables_checked=27 rows=0 | Stop. Do not restore. Re-derive F-AAI9-11’s instance list. The render check will still be green; that is the point of having both |
| W-02 | compose_advisoryai-plugin-scratch is root-owned (0:0 drwxr-xr-x) while the host runs as uid 10001, and advisoryai-data-init chowns only /var/lib/advisory-ai/* | EACCES / UnauthorizedAccessException on the plugin scratch root in the converge-boot log; or the plugin host degrading silently | Chown it with a one-shot docker run --rm -v compose_advisoryai-plugin-scratch:/s busybox chown -R 10001:10001 /s before the converge boot, and add the path to advisoryai-data-init in a follow-up. Detected in P2, so it is cheap |
| W-03 | A tmp/ sweep destroys the rollback chain between prep and window | test -s on the copied chain files fails, or docker compose … config on the rollback chain errors | P-14 copies them into ${AAI9_ROOT}/rollback/ and asserts the copies. Render the rollback chain while the predecessors are still running — a rollback recipe first exercised after the fence cannot protect the window |
| W-04 | The wrong pin wins. A chain accumulates pins and the last one wins; three of these chains end in tmp/kestrel-rotation overlays | docker inspect <container> --format '{{.Config.Image}}' after a recreate does not equal the intended digest | Append a new pin last; never edit an existing one. Verify by reading the running container plus docker exec … cat /app/buildinfo.json — a successful recreate proves nothing about which image it recreated onto |
| W-05 | The alias collides. The successor overlay publishes advisoryai.stella-ops.local, the same alias advisory-ai-web publishes in docker-compose.stella-services.yml | Two containers resolve one alias; requests land nondeterministically | The window stops the legacy hosts first (step 5) and must never compose the overlay together with docker-compose.stella-services.yml while both run. Assert the render before up |
| W-06 | Parity fails at 9b | table-parity.diff non-empty, or the sum != 13,238 | Keep the freeze, change no route/grant/flag, enter rollback. Retain the target unchanged as evidence — a converged target with a failed parity proof is the primary evidence |
| W-07 | A writer escapes the fence during the copy | 9a’s cmp against source-a.tsv fails | This is a database-recovery incident, not permission to merge target rows back. Keep every writer stopped; escalate |
| W-08 | D1 reappears — trigram search resolves on the source and not on the target | function similarity(unknown, unknown) does not exist in the soak. Only queries that miss the FTS index take the trigram fallback, so it will not show up early | The step-3 pg_trgm SCHEMA public pin prevents it; 9d asserts it. If it appears post-cutover, the pin was skipped — re-pin and re-run 9d |
| W-09 | Unified search silently stops refreshing after the cutover | kb_doc.max(indexed_at) for the eight unified doc_types stops advancing; no Unified search periodic indexing run completed line in the successor log | Resolved at P-09 as a decision, not discovered here. Baseline is in §1.6 |
| W-10 | Retention prunes migrated rows. SearchAnalyticsRetentionEnabled=true at step 10 DELETEs from search_events, search_history, search_feedback, search_quality_alerts on a 90-day cutoff | Row counts in those four tables drop after step 10 | All four are empty today, so today’s blast radius is nil. If step 3’s re-measurement finds rows in any of them, re-enabling retention is a decision to record, not a default |
| W-11 | The :dev tag moves off a running predecessor. Building advisoryai-worker writes stellaops/advisoryai-worker:dev; the predecessor worker holds stellaops/advisory-ai-worker:dev | Not a live effect: the chains pin by image ID, and the repositories differ by a hyphen | No action. Recorded because the names are one character apart and a rollback typed from memory could name the wrong repository |
| W-12 | A --force-recreate succeeds onto a pruned image record. The nightly docker image prune -af can drop an image record while its container keeps running; recreate then silently swaps onto whatever the tag now points at | docker image inspect <running container's image id> says “No such image” | Sweep with two calls before the window: compare docker inspect --format '{{.Image}}' across all containers against docker image ls -a --no-trunc --format '{{.ID}}' |
| W-13 | Scheduled host maintenance kills the estate mid-window. Two Windows tasks run every 2 days at 01:00 / 02:00 UTC; the estate does not come back on its own (measured gaps 2 h 58 m and 7 h 04 m) | Containers show identical StartedAt; postgres logs “not properly shut down; automatic recovery in progress” | Check Get-ScheduledTask StellaOps-WslVhdxOptimize | Get-ScheduledTaskInfo and schedule the window to clear NextRunTime by the full 5 h 20 m. This is a preflight item in practice even though it is not a runbook step |
| W-14 | stellaops-vulnerabilities-worker is disturbed. A concurrent lane (deploy-BIN-11) has a multi-hour measurement running on it | Any up/restart/stop naming it, or a chain rendered without --no-deps | Never name it and always pass --no-deps. Omit the flag and the chain recreates everything it renders — one gateway chain measured 14 services resolving to different images |
| W-15 | A forcing function passes vacuously. Five false greens were produced in this repo today | Each of FF-1/2/3 has its own vacuity trap in §6; step 7’s ! grep -Eq 'TABLE DATA advisoryai evidence_pack' is structurally vacuous — those tables do not exist in the source, so no dump of it can ever contain them | Before accepting any green, name what would have made it red. Where an assertion cannot go red, label it a belt, not a proof |
10. Defects found in the runbook during this prep
All five were verified today. Fold them in before the window opens.
| # | Defect | Evidence | Fix |
|---|---|---|---|
| D-1 | Step 2’s volume loop measures volumes that do not exist. It names advisory-ai-queue, advisory-ai-plans, advisory-ai-outputs; the real volumes are compose_-prefixed. docker run --rm -v advisory-ai-queue:/v:ro busybox … creates a new empty anonymous volume and reports 0 files 0 bytes — a green that proves nothing, and the exact class of false green this window must avoid | docker volume ls -q | grep -iE 'advisory|opsmemory' returns compose_advisory-ai-{queue,plans,outputs} and compose_advisoryai-plugin-scratch | Use the compose_ names, and add the fourth volume. Real values in §1.5 |
| D-2 | Steps 4 and 7 call pg_restore --list on the HOST. There is no pg_restore on this host. Under set -euo pipefail step 4 aborts (before the freeze, recoverable) and step 7 aborts (after the freeze, before the restore, recoverable). If a future operator loosens the pipeline, step 7’s two ledger-exclusion assertions run against an empty file and pass vacuously | pg_restore --version → command not found; exit 127 | docker exec -i "${AAI9_PG_CONTAINER}" pg_restore --list < "<dumpfile>". Verified: 28 TABLE DATA on the full dump, 5 on the fenced dump, and the ledger grep pattern positive-controls MATCHES on a list that contains the ledgers |
| D-3 | Step 3’s isolation probe exits 1 today. bash probe-database-isolation.sh | tee …; grep -q PASS fails: the probe refuses to report PASS because stellaops_vuln exists on the server but is absent from devops/compose/.env. It aborts at coverage before evaluating any advisoryai row | Bare run: EXIT=1, == COVERAGE FAIL: 1 service database(s) … unproven: stellaops_vuln == | Use the probe’s own documented augmentation: copy .env, append STELLAOPS_POSTGRES_VULN_CONNECTION from the live stellaops-vulnerabilities-web container, run with --env-file, delete the file. Verified PASS, advisoryai 18/18 correct. The clean fix is adding the key to .env, which is the vuln family’s to make |
| D-4 | There is no recorded live compose array for the successor. Steps 3 and 10 say “Execute with the reviewed Compose array from advisoryai-deploy-aai8-staged-stack.md” — that document records only a scratch array (-p "$AAI5_SCRATCH_ID" --env-file /dev/null -f docker-compose.advisoryai.yml -f docker-compose.advisoryai.scratch.yml), which is deliberately isolated and cannot be used to compose into the live -p stellaops project | grep -n 'docker compose' docs/runbooks/advisoryai/advisoryai-deploy-aai8-staged-stack.md | Construct and review the live array before the window, record it under preflight/, and render it to JSON before any up. Note the overlay’s network is name: ${STELLAOPS_ADVISORYAI_NETWORK:-stellaops} — an explicit name:, so a different -p is not isolation |
| D-5 | up -d --no-deps advisoryai-web skips advisoryai-data-init, which advisoryai-web and advisoryai-worker both declare as condition: service_completed_successfully. Its job is to chown the three carried volumes | devops/compose/docker-compose.advisoryai.yml:144-170,262-265 | Harmless for the converge boot; at step 10 run up -d --no-deps advisoryai-data-init first (its blast radius is the three volumes). Ownership is already 10001:10001, so this is belt — but the plugin-scratch volume it does not cover is root-owned (W-02) |
Two corrections to the briefing that commissioned this plan, both minor: evidencePacks is at api-path-prefixes.ts:350, not ~:299 (that line is a cross-reference comment in the Notify block); and rollback artifacts do exist for all three predecessors — the :dev tags are absent for web and OpsMemory, but both carry bl2-9796c887-20260724 tags and every chain pins by image ID.
11. Open NO-GO inputs, as of 2026-09-04
Seven preflight rows are unsatisfied today. Five are ordinary window paperwork; two are substantive and need work before the window can be scheduled:
- P-09 — the unified-search adapter-URL disposition. An owner decision, informed by §1.6.
- P-12 / P-13 — no working authenticated token, and the family’s published gateway paths do not resolve today. Every forcing function and gate 6’s proof depend on both. This is engineering work, not paperwork, and it must be settled before the window, not inside it.
Ordinary paperwork: P-01 (window approval and the six named roles), P-10 (the isolated restore test), P-11 (grants-forward.sql / grants-rollback.sql), P-14 (chain-file copies).
