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.

ReceiptValue
advisoryai-webstellaops/advisoryai-web@sha256:8930b967515bb6720b27a103346a523057e552024f05b4ceddf0d69d23feb60b
advisoryai-workerstellaops/advisoryai-worker@sha256:d9d2be13b4486c796fda66d973ec0c5d7857558e6f5ce11c5a41ca5d8deac44e
gitSha (both)9d2e5ef54193b01881793dcef35d525e0c3734f0
gitCommitTime (both)2026-09-04T16:36:15+03:00
worktreeState / dirtyFileCount (both)clean / 0
imageBuiltAtweb 2026-09-04T13:50:11Z, worker 2026-09-04T13:54:15Z
Image budgetweb 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 (ebc9ff5a9d2e5ef5) 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.

ContainerImage ID (authoritative rollback artifact)Tag that still resolves itBuilt
stellaops-advisory-ai-websha256:7433b4ac04658ea62e11943083260f7f9da4ca874a71d77b6bd41a9b95d51bbfstellaops/advisory-ai-web:bl2-9796c887-202607242026-07-24
stellaops-advisory-ai-workersha256:358667b46aeee93eeecfe50c752deb1444d58a982022efdacac18a88828ffacdstellaops/advisory-ai-worker:dev2026-07-30
stellaops-opsmemory-websha256:0c43d0233a48f3a8921ca963eae0d34c093976e093340a1dae0c668fe8db38f3stellaops/opsmemory-web:bl2-9796c887-202607242026-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):

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 under LOCATIONS.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

Measure2026-08-282026-09-04 (today)Verdict
Tables (advisoryai + opsmemory, relkind='r')2828 (26 + 2)match
Database-wide rows incl. ledgers13,24113,241match
Carried rows (25 tables, ledgers excluded)13,23813,238match
advisoryai total size232 MB232 MB / 243,654,656 B+851,968 B vs the recorded 242,802,688 B — index/TOAST growth, not rows
opsmemory total size144 kB144 kB / 147,456 Bmatch
Forced/enabled RLS0/0 of 26 and 0/0 of 2, 0 pg_policyidenticalD6 still does not apply
Sequences / identity columns0 / 00 / 0setval class still absent
pg_available_extensions for vector0 rows0 rowsD5 still a no-op
PostgreSQL18.118.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

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:

VolumeFilesBytes
compose_advisory-ai-queue08192 (empty dir)
compose_advisory-ai-plans04096
compose_advisory-ai-outputs04096
compose_advisoryai-plugin-scratch04096

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_typekb_dockb_chunklast indexed
markdown50077582026-09-03 09:08:06Z
openapi121902026-09-03 09:08:06Z
doctor1861862026-09-03 09:08:06Z
platform552026-09-04 13:37:56Z
findings332026-09-04 13:37:56Z
policy332026-09-04 13:37:56Z
vex332026-09-04 13:37:56Z
graph222026-09-04 13:37:56Z
opsmemory222026-09-04 13:37:56Z
scanner222026-09-04 13:37:56Z
total70710154

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:

  1. 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 of UnifiedAutoIndexOnStartup=true does not restore it, because UnifiedAutoIndexEnabled defaults false and the early return at UnifiedSearchIndexRefreshService.cs:27-33 needs both conditions. The key shape also changed — the predecessor image binds KnowledgeSearch:* while the successor host strips prefix ADVISORYAI__ and binds section AdvisoryAI:KnowledgeSearch (KnowledgeSearchOptions.SectionName, WebService/Program.cs:58). Copying the predecessor’s literal variable names forward would bind nothing and fail silently. The successor form is ADVISORYAI__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.
  2. 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=0 is 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.

#RoleWhat only this role doesSteps
1Approving ownerGives the explicit go for the irreversible move; rules on the P-09 adapter-URL decision and on re-enabling retentionbefore step 1; consulted at step 10
2Window ownerHolds the window ID, drives the sequence, calls GO/NO-GO and calls rollbackall
3Database operatorExecutes every psql/pg_dump/pg_restore, owns the freeze and the parity verdict3-9, 13
4Live-deploy operatorRenders and reads compose chains, recreates containers, verifies swaps and health2, 3, 5, 10, 11
5AdvisoryAI implementerRuns the forcing functions and judges their output; owns the Console prefix flip and the FE dist rebuild11, 12
6Rollback ownerHolds rollback/, is not executing the move, decides the unfreeze against the five conditionsrollback 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+DurPhaseStepCommand / actionReceipt
45 mP0 Preflight§4, every rowpreflight/*
00:005 mP1 Workspace1mkdir -p "${AAI9_ROOT}"/{preflight,backup,parity,rollback,activation}; git rev-parse HEAD; git status --shortpreflight/repository-head.txt, repository-status.txt
00:0510 mP12Capture rollback topology: three docker inspect loops plus the chain-file copies (§1.2); measure the four volumes by their compose_ namesrollback/*.image.txt, *.config-files.txt, *.environment.sha256, copies of all 18/15/10 chain files, preflight/carried-volumes.txt
00:155 mP2 Converge3aAssert target relations=0; run probe-database-isolation.sh with the augmented env file (P-04)preflight/target-relations-before.txt, database-isolation.txt
00:202 mP23bCREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public; as role advisoryai over 127.0.0.1preflight/target-pg-trgm.txt — prints public
00:225 mP23cRender the live successor chain to JSON; run the jq -e writer/connection assertionpreflight/advisoryai-compose-render.json
00:278 mP23dup -d --no-deps advisoryai-web on the digest pin; wait healthy; capture log; docker stoppreflight/advisoryai-web-converge.log, converge-stop.txt
00:354 mP23eLedger = 2 rows w/ the two known checksums; __migration_history and opsmemory.schema_migrations both NULL; tables_checked=27 rows=0preflight/target-ledger.txt, target-empty-proof.txt
00:393 mP23fRe-measure source counts at the boundaryparity/source-counts-window-open.txt
00:423 mP3 Backup4Full 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:4520 mP34bIsolated restore test on a scratch PostgreSQL 18, reproducing 13,238 carried rowspreflight/backup-restore-proof.txt
01:053 mP4 Freeze5adocker stop the three predecessors; assert all five containers not running; pg_stat_activity AdvisoryAI-freepreflight/writer-fence-stop.txt, source-sessions-after-fence.txt
01:083 mP45baai9_fingerprintsparity/source-a.tsvparity/source-a.tsv (25 rows)
01:112 mP45csleep 30; second pass; cmp must exit 0parity/source-b.tsv
01:132 mP46Sequence/identity inventory both sides; cmp (both empty today)parity/source-sequences.tsv, target-sequences.before.tsv
01:153 mP47Fenced data-only dump, 25 --table= + 3 --exclude-table=; sha256; list; two ! grep -Eqbackup/source-advisoryai-fenced-data.dump{,.sha256,.list} — 5 TABLE DATA
01:185 mP5 Restore — POINT OF NO RETURN 🔴8pg_restore --single-transaction --exit-on-error --data-only --no-owner --no-privileges into the targetbackup/target-restore.txt, exit 0, no error
01:233 mP6 Parity9aSource fingerprint again; cmp vs source-a.tsvparity/source-after.tsv
01:263 mP69bTarget fingerprint; diff -u empty; kb_chunk 10154; sum = 13,238parity/target-after.tsv, table-parity.diff (empty)
01:292 mP69cEvidence Pack tables = 0; ledger = 2; both legacy ledgers NULL → tinline
01:313 mP69dpg_trgm in public; similarity() under search_path=publict; both trigram indexdef unqualified; kb_chunk 13 indexesinline
01:3410 mP69eSix shape probes both sides; ordinal diff exactly kb_chunk + search_eventsparity/*.diff
01:445 mP7 Repoint10aSet the four writer flags true (+ the P-09 adapter decision); render; jq -eactivation/advisoryai-compose-render.json
01:492 mP710bup -d --no-deps advisoryai-data-init (chowns the carried volumes; W-02)container exit 0
01:518 mP710cup -d --no-deps advisoryai-web advisoryai-worker; wait healthy; record .Config.Image + .Image; docker exec … cat /app/buildinfo.jsonactivation/stellaops-advisoryai-{web,worker}.image.txt
01:595 mP710dZero sibling reach (advisoryai sessions on stellaops_platform = 0); target sessions named; re-measure the four volumesactivation/target-sessions.txt
02:048 mP8 AAI-710eSet 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 criterionactivation/doctor-capability-row.txt
02:125 mP9 Surface11aThree router configs read advisoryai=0 opsmemory=0; both guard suites (15/0 and 13/0)activation/route-config-zero.txt, route-guard-*.txt
02:175 mP911bAuthenticated request through the gateway to /api/advisoryai/v1/** returns the host’s answeractivation/gateway-consolidated-probe.txt
02:2225 mP911cFlip the six values in api-path-prefixes.ts; rebuild + deploy the dist; assert the built dist carries /api/advisoryai/v1 and no legacy AdvisoryAI prefixactivation/console-dist-prefixes.txt
02:4740 mP10 Forcing12The three forcing functions (§6) plus health/auth, the four-persona gate, and the soak startactivation/* per row
03:2760 mP1012bError-log soak: zero permission errors, zero does not exist, no function similarity(…) does not existactivation/soak.log
04:2710 mP11 Revoke13apsql -f preflight/grants-forward.sql; dump role_table_grantsactivation/source-revoke.txt, source-grants-after.txt
04:3730 mP1113bRevocation soak — zero permission errors in any service logactivation/revoke-soak.log
05:0715 mP1113cFlip the four matrix rows (:59, :60, :191, :192, located by content); record parity numbers in the sprintcommit
05:22Window closesPredecessor 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.

#ConditionCommandSTOP onStatus 2026-09-04
P-01Window ID, approving owner, window owner, DB operator, live-deploy operator, AdvisoryAI implementer and rollback owner all namedtest -s "${AAI9_ROOT}/preflight/window-approval.txt"exit 1UNSATISFIED — no window approved
P-02Both image receipts carry a @sha256: digestgrep -Eq '@sha256:[0-9a-f]{64}$' …-image.txtexit 1SATISFIED (§1.1)
P-03Those digests still describe current HEAD for the four relevant treesgit diff --name-only 9d2e5ef5..HEAD -- src/AdvisoryAI src/Router src/OpsMemory devops/compose/docker-compose.advisoryai.ymlany output ⇒ rebuildsatisfied at 9d2e5ef5; re-check at window open
P-04Database isolation PASS, whole matrixprobe-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-05Target emptypsql -d stellaops_advisoryai … count(*) relations!= 0SATISFIED0, extensions plpgsql
P-06Role hardened, PUBLIC has no CONNECTpg_roles + pg_database.dataclany of rolsuper/rolcreatedb/rolcreaterole/rolbypassrls true, or =Tc in the ACLSATISFIED
P-070 forced-RLS, 0 policies (D6)the D6 surveyany non-zero ⇒ re-plan the converge bootSATISFIED — 0/0 of 26, 0/0 of 2, 0 policies
P-08Sequence/identity inventory empty and identicalstep 6 cmpexit 1SATISFIED — 0 and 0
P-09Unified-search adapter-URL disposition decided and recordedowner ruling attached under preflight/absentUNSATISFIED§1.6. The successor overlay would silently stop refreshing eight unified domains
P-10Backup restore-tested on a scratch PostgreSQL 18, reproducing 13,238 rowspreflight/backup-restore-proof.txt non-emptyexit 1UNSATISFIED — not yet performed
P-11Forward and rollback grant SQL reviewedtest -s grants-forward.sql grants-rollback.sqlexit 1UNSATISFIED — neither file exists
P-12A working authenticated token for the forcing functionspassword grant at /connect/token, client_id=stellaops-cliHTTP != 200UNSATISFIED — the credential in .claude/operator-context.local.md returns 401 today
P-13The gateway resolves the family’s published paths todaycurl -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-14All chain files copied into rollback/ and assertedtest -s on each of the 18/15/10 copiesexit 1UNSATISFIED — see the sweep hazard in §1.2
P-15Three router configs carry zero AdvisoryAI rowsthe step-11 grep -c loopany row not advisoryai=0 opsmemory=0re-check at window open
P-16Both router guard suites green before the windowtest-targeted-xunit.ps1 ×2Failed: != 0, or a ran-count below 15 / 13re-check at window open
P-17api-path-prefixes.ts not concurrently heldgit status --porcelain -- src/Web/StellaOps.Web/src/app/core/api/api-path-prefixes.tsnon-empty and not yoursclean now; coordinate with impl-EVD-6b — see §8
P-18The clone is quiet enough to rebuild if P-03 forces oneper-PID CPU delta over 20 s plus a new-PID countany new PID, or several PIDs accumulatingBUSY 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.

PhaseRollbackDepends onCost
P0-P1 (preflight, topology)Nothing to undozero
P2 (converge)DROP DATABASE stellaops_advisoryai and re-provision, or leave it converged and empty — it is not reachable by any running servicenothing; the target is not wired to anythingminutes
P3 (backup)Delete the artifactszero
P4 (freeze, dump)docker start the three predecessors from rollback/*.image.txt; unfreeze once the five conditions holdthe 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 itthe same rollback artifactswindow must be re-run end to end
P6 (parity fails)Keep the freeze, change no route/grant/flag, enter the runbook’s rollbackas aboveas 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 lineagerollback/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 leftnoneminutes
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 pathsgit revert of one file + an FE build~25 min
P10-P11 (revoke)psql -f preflight/grants-rollback.sqlgrants-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 functionWhat it provesHow it fails vacuously
FF-1An advisory draft generates and cites chunks that exist in the target kb_chunkThe 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 modeIt 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-2An OpsMemory recall answers on the consolidated hostThe 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 introducedopsmemory.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-3Evidence pack create → get → run → export, persisting across a real host restart, with the export through the actual Router Microservice transportCloses 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 hostIt 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

PhaseEstimateBasis
P0 Preflight45 m18 checks, most seconds each; dominated by assembling receipts and the P-09/P-12/P-13 resolutions. Unmeasured
P1 Workspace + topology15 mdocker inspect loops are seconds; the chain-file copies are new work. Unmeasured
P2 Converge27 mpg_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 Backup3 m + 20 m restore testMeasured 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 + dump13 mMeasured 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 Restore5 mUnmeasured. 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 Parity21 mMeasured: 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 Repoint20 mUnmeasured. Two container starts to healthy, plus the render and the session checks
P8 AAI-78 mUnmeasured. One recreate plus one query
P9 Surface + Console35 mGuard 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 + soak100 mForcing functions unmeasured; the 60 m soak is a chosen duration, not a measurement
P11 Revoke + soak + matrix55 mRevoke 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 GOOf 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, merged 13f504b236) grew the Evidence-block comment and moved the whole AdvisoryAI block down: the block header sits at :305 and the six values at :346-351 as of 16ba2dfbb9, and they will move again. Locate them with grep -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
BaseEVIDENCE_PACK_API_BASE_URL (app.config.ts:765-773) → evidence-pack.client.ts:56MapGroup("{prefix}/evidence"), EvidenceAuditEndpoints.cs:34
Surfacefive 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 / exportyesnone 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:

  1. For opsMemory the 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.
  2. 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; for opsMemory the honest rollback statement is back to broken, as it is now — record it, do not present it as a restoration.
  3. 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 under ConsolidatedRoot = "/api/advisoryai/v1". advisoryAiRuns resolves through the /v1/advisory-ai prefix.

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 modeDetectionResponse
W-01The converge boot writes carried rows — a fifth writer nobody enumeratedpreflight/target-empty-proof.txt reads anything but tables_checked=27 rows=0Stop. 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-02compose_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 silentlyChown 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-03A tmp/ sweep destroys the rollback chain between prep and windowtest -s on the copied chain files fails, or docker compose … config on the rollback chain errorsP-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-04The wrong pin wins. A chain accumulates pins and the last one wins; three of these chains end in tmp/kestrel-rotation overlaysdocker inspect <container> --format '{{.Config.Image}}' after a recreate does not equal the intended digestAppend 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-05The alias collides. The successor overlay publishes advisoryai.stella-ops.local, the same alias advisory-ai-web publishes in docker-compose.stella-services.ymlTwo containers resolve one alias; requests land nondeterministicallyThe 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-06Parity fails at 9btable-parity.diff non-empty, or the sum != 13,238Keep 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-07A writer escapes the fence during the copy9a’s cmp against source-a.tsv failsThis is a database-recovery incident, not permission to merge target rows back. Keep every writer stopped; escalate
W-08D1 reappears — trigram search resolves on the source and not on the targetfunction 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 earlyThe 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-09Unified search silently stops refreshing after the cutoverkb_doc.max(indexed_at) for the eight unified doc_types stops advancing; no Unified search periodic indexing run completed line in the successor logResolved at P-09 as a decision, not discovered here. Baseline is in §1.6
W-10Retention prunes migrated rows. SearchAnalyticsRetentionEnabled=true at step 10 DELETEs from search_events, search_history, search_feedback, search_quality_alerts on a 90-day cutoffRow counts in those four tables drop after step 10All 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-11The :dev tag moves off a running predecessor. Building advisoryai-worker writes stellaops/advisoryai-worker:dev; the predecessor worker holds stellaops/advisory-ai-worker:devNot a live effect: the chains pin by image ID, and the repositories differ by a hyphenNo action. Recorded because the names are one character apart and a rollback typed from memory could name the wrong repository
W-12A --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 atdocker 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-13Scheduled 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-14stellaops-vulnerabilities-worker is disturbed. A concurrent lane (deploy-BIN-11) has a multi-hour measurement running on itAny up/restart/stop naming it, or a chain rendered without --no-depsNever 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-15A forcing function passes vacuously. Five false greens were produced in this repo todayEach 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 themBefore 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.

#DefectEvidenceFix
D-1Step 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 avoiddocker volume ls -q | grep -iE 'advisory|opsmemory' returns compose_advisory-ai-{queue,plans,outputs} and compose_advisoryai-plugin-scratchUse the compose_ names, and add the fourth volume. Real values in §1.5
D-2Steps 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 vacuouslypg_restore --versioncommand not found; exit 127docker 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-3Step 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 rowBare 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-4There 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 projectgrep -n 'docker compose' docs/runbooks/advisoryai/advisoryai-deploy-aai8-staged-stack.mdConstruct 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-5up -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 volumesdevops/compose/docker-compose.advisoryai.yml:144-170,262-265Harmless 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:

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).