AdvisoryAI consolidation — S0 target design (SPRINT_20260722_013 AAI-1)

Status: APPROVED (owner, 2026-08-03 — ledger round 88, ruling C “ALL TEN S0 drafts APPROVED”); S2+S3 BUILT (2026-08-04, AAI-3 — see §2.3/§2.4); S4 DONE (2026-08-05, AAI-4 — the OpsMemory HTTP surface lifted byte-identically into src/AdvisoryAI/__Libraries/StellaOps.OpsMemory.Application, the dead OpsMemoryDecisionHook deleted with five build-boundary pins, and every question this design parked on AAI-4 ruled: see §2.2A, §3 and §4). Verification base: the sprint’s recorded dig rounds (12/13 RAG-store anatomy), the live migration baselines (tables re-enumerated from src/ this pass), and the 2026-08-03 build-boundary register. All ⚠ items are closed; §4 records which questions AAI-3 answered and which stay open with their owners.

1. Current → target shape

CurrentTarget
Deployablesadvisory-ai-web + advisory-ai-worker, opsmemory-webadvisoryai-web + advisoryai-worker (OpsMemory becomes a MODULE of the family, not a deployable)
Sourcesrc/AdvisoryAI/StellaOps.AdvisoryAI.*, StellaOps.OpsMemory.*`src/AdvisoryAI/StellaOps.AdvisoryAI.WebService
DALmigrations in StellaOps.AdvisoryAI/Storage/Migrations + __Libraries/StellaOps.OpsMemory/MigrationsStellaOps.AdvisoryAI.Persistence (D12 name), fresh 001_*, database stellaops_advisoryai, env STELLAOPS_POSTGRES_ADVISORYAI_CONNECTION fail-closed

2. Table inventory + dispositions (P13)

2.1 advisoryai (24 tables)

GroupTablesP13 classDisposition
RAG storekb_doc, kb_chunk, entity_aliasderived-rebuildable (embeddings need a declared retention class)carry, but the index budget decision is the S1 headline: kb_chunk = 130 MB of index over 37 MB heap, 12 indexes incl. FIVE per-language tsvectors + two trigram; embeddings are float4[] (no pgvector). Decide ONCE with measurements: pgvector vs app-side similarity; per-language FTS vs one config + trigram (P13 named-reader rule)
Chat familychat_sessions, chat_messages, chat_tool_invocations, chat_evidence_links, chat_policy_decisions, conversations, turns, runtime_chat_settings_overridesoperational + auditLineage question ANSWERED (code dig 2026-08-03): not duplicates — two ROLES. conversations/turns = the EF conversation STATE store (ConversationStore/PostgresConversationService); chat_* = the chat AUDIT lineage (PostgresAdvisoryChatAuditLogger via AdvisoryChatService). Both DI-wired and live. Carry BOTH with distinct P13 classes (operational vs audit); all empty live, so the baseline is free to normalize names
AI attestationsai_run_attestations, ai_claim_attestationsevidence-adjacent (append-audit)carry byte-exact
Consentai_consentsauditcarry
Runtimeruntime_runs, runtime_explanations, runtime_policy_intentsoperationalcarry
Search telemetrysearch_events, search_history, search_feedback, search_quality_alertscache-boundedcarry with cache-bounded classes. (The draft row said “windowed” in the class column and “cache-bounded” in the disposition; cache-bounded is the canonical P13 class and is what AAI-3 stamped on all four.)
API registryapi_spec, api_operationregistrycarry — readers VERIFIED (2026-08-03): in-family only (KnowledgeSearch/UnifiedSearch stores + KnowledgeIndexer)
doctor_search_projectiondoctor search read modelderivedcoordinate fate with 009 (doctor registry/aggregation succeeded the central probing; do not carry silently)

2.2 opsmemory (1 table)

Code side (AAI-4, 2026-08-05): the OpsMemory HTTP surface — the six decision/suggestion/stats endpoints, OpsMemoryTenantResolver and OpsMemoryPolicies — moved byte-identically out of the retiring opsmemory-web host into src/AdvisoryAI/__Libraries/StellaOps.OpsMemory.Application. AAI-9 gate 9 now mounts them in the surviving advisoryai-web; AAI-10 still owns predecessor deletion. Program.cs and its Translations/*.json deliberately stay behind: the host registers the bundle from Assembly.GetExecutingAssembly(), so moving the resources with the code would have silently broken localization on a live service. The moved types still declare StellaOps.OpsMemory.WebService.* namespaces — renaming is AAI-10’s job with the host deletion, and keeping them is what makes the move auditable as a pure move.

decisions — the OpsMemory decision memory. P13 class: source-of-truth(stamped by AAI-3; the draft assigned this table no retention class, which P13 requires of every table). Source of truth rather than derived because an operator’s decision and its recorded outcome have no rebuild path from anything else in the estate. Carry into the consolidated baseline (module, not deployable).

2.2A Not in the table inventory: the second, private migration ledger

Found by AAI-3 (2026-08-04) and not enumerated by this design. Before the AAI-9 source cut, the live advisoryai schema carried a SECOND migration ledger beside the platform runner’s advisoryai.schema_migrations: PostgresKnowledgeSearchStore.EnsureSchemaAsync creates advisoryai.__migration_history, then loads every embedded .Storage.Migrations. resource from StellaOps.AdvisoryAI and applies whatever that ledger reports unapplied. It runs on every index pass (KnowledgeIndexer.cs:48, UnifiedSearchIndexer.cs:49 and :143), so it is a fourth migration discovery path in this family and a second migration authority over one schema — a §2.7 discipline defect the merge must not inherit.

The consolidated baseline does not reproduce it: one schema set, one ledger, pinned by OnlyOneMigrationLedgerExists_TheLegacyPrivateOneDoesNotCarry. It also cannot reach the staged baseline (it scans the legacy assembly for a .Storage.Migrations. resource path, and the new assembly is neither), so the AAI-3 staging invariant survives it.

Ruled by AAI-4 (2026-08-05) — the retirement is an AAI-9 step, not an S4 one (D-AAI4-2). Two facts moved it. The duplication is now proven exact: the collapsed legacy tree embeds exactly one resource, StellaOps.AdvisoryAI.Storage.Migrations.001_v1_advisoryai_baseline.sql (enumerated from GetManifestResourceNames() on the built assembly; everything else is under _archived/ and excluded by the csproj), and the host’s AddStartupMigrations(...) applies that same resource from that same assembly into advisoryai.schema_migrations — so the private ledger is a pure duplicate authority over one file. But the two authorities do not necessarily share a connection: the startup migrator rides PostgresOptions.ConnectionString (AdvisoryAI:Storage / ConnectionStrings:Default / Database:ConnectionString) while the store rides KnowledgeSearchOptions.ConnectionString (AdvisoryAI:KnowledgeSearch). Wherever those point at different databases, EnsureSchemaAsync is the only migrator for the store’s database, and deleting it early strips a live service of its schema convergence. There is also no consolidated code path to retire it in, because D-AAI3-2 forbids anything that ships from referencing StellaOps.AdvisoryAI.Persistence until AAI-9.

AAI-9 gates 1–2 source result (2026-08-22): DONE atomically, not deployed. Both runtime option paths now resolve the one service-owned connection; EnsureSchemaAsync, every call to it, its private-ledger implementation, and the active legacy embedded resource were removed in the same changeset. The web host registers the gated consolidated migration authority before either indexer; the worker registers only the runtime DAL. OnlyOneMigrationLedgerExists_TheLegacyPrivateOneDoesNotCarry and the fresh 24+1 convergence suite pin the result. The maintenance-window data copy, configuration arm, image build, and live verification remain AAI-9 work.

2.3 Baseline decisions APPLIED (AAI-3, 2026-08-04)

src/AdvisoryAI/__Libraries/StellaOps.AdvisoryAI.Persistence/Migrations/001_advisoryai_consolidated_baseline.sql creates 24 + 1 tables under ONE ledger in advisoryai.schema_migrations. Beyond the §2 table dispositions:

2.4 Staging: how the fresh baseline stays off the shared database

main stays deployable at every commit, and all three family containers run live today (advisory-ai-web, advisory-ai-worker, opsmemory-web) against the shared platform database. Four discovery paths exist and none reaches the staged baseline:

PathScansWhy it cannot reach the baseline
advisory-ai-web / -workertypeof(AdvisoryAiDataSource).Assembly, prefix-free (AdvisoryAiCoreRuntimePersistenceExtensions.cs:64)binds the LEGACY assembly by type
opsmemory-webtypeof(IOpsMemoryStore).Assembly, prefix-free (OpsMemoryRuntimePersistenceExtensions.cs:102)binds the LEGACY assembly by type
platform-web central migratorAppDomain scan for IMigrationModulePluginalready discharged: PAC-6b removed BOTH AdvisoryAiMigrationModulePlugin and OpsMemoryMigrationModulePlugin plus the Platform.Database project references; this assembly declares no plugin. 2026-09-14 (SPRINT_20260722_021 PLT-4): the scan and the plugin contract are deleted outright
PostgresKnowledgeSearchStore.EnsureSchemaAsync (§2.2A)legacy assembly, .Storage.Migrations. resource pathnew assembly matches neither

Because the DC-26 central-migrator removal was already done for this family, AAI-3 inherits the Findings situation rather than the JobEngine one, and — like Findings — needs no .Consolidated suffix, since the D12 target DAL name was never taken.

The AAI-3-specific part. The three siblings could stage safely on the assembly name alone, because the hosts they created shipped nowhere. Here the design’s target host projects already exist and are live (§1), so the invariant is a reference question instead: nothing that ships may reference StellaOps.AdvisoryAI.Persistence at all, and the consolidated persistence is therefore not wired into either host’s Program.cs. Wiring it now would make the next rebuild of a live image resolve STELLAOPS_POSTGRES_ADVISORYAI_CONNECTION — which no compose file sets — and fail closed on startup. The wire-up belongs to the AAI-9 cutover, and the invariant is enforced by NoShippingProjectReferencesTheConsolidatedPersistence.

3. Build-boundary dispositions (2026-08-03 register)

State after AAI-4 (2026-08-05). Two of the eight are gone. excititor died with SPRINT_20260722_003 VULN-B1’s removal of the StellaOps.Excititor.Core reference (the provider it fed was inert), and findings-ledger died with the OpsMemoryDecisionHook deletion (D-AAI4-1). advisory-ai-web and advisory-ai-worker now carry SIX pins eachattestor, concelier, evidence-locker, opsmemory, policy, scanner — and opsmemory-web carries ZERO, all three of its pins having been reached through that same hook edge. The list below is the S0 inventory with its original dispositions; strike-through of resolved entries is noted inline.

Both keys carried the same EIGHT pins at S0 — the widest per-key foreign surface among the wave-2 programs, all inbound-to-AdvisoryAI-as-consumer:

4. What S1 must decide

  1. Merge confirmed per ADR-039 D14 (no deviation found by this S0). CLOSED — AAI-2, owner via D14 + round-88 ruling C.
  2. The RAG index budget (§2.1) — with measurements, not taste. OWNER RULING (2026-08-03, review item A9): benches approved (“surely, do benches”) — the pgvector-vs-app-side and one-FTS-config-vs-five decisions follow the measured numbers. STILL OPEN; owner is a named measurement window, NOT AAI-4 (corrected by AAI-4, D-AAI4-4: a bench is only meaningful against the real corpus, whose 130 MB of index lives on the live database — an owner-gated surface no implementation stage may touch — and synthetic data answers a different question). AAI-3 carried the shape verbatim so the benches have a fixed baseline to measure against (§2.3); the baseline is editable in place while unapplied. Carried is not decided.
  3. doctor_search_projection fate (with 009). RULED by AAI-4 (2026-08-05, D-AAI4-3): CARRY, with the writer retiring at AAI-10 and the table only after that, in a separate destructive window. The 009 coordination still has not happened — a repo grep of SPRINT_20260722_009 finds no mention of the table — so AAI-4 did not invent 009’s ruling; it supplied the missing evidence instead, and the evidence sharpens the finding: the table has one writer and zero readers. PostgresKnowledgeSearchStore.cs:1044 INSERTs on every index pass, there is no SELECT against it anywhere in src/, and IKnowledgeSearchStore exposes no doctor-read method. The doctor capability knowledge-search.md:38 describes is actually served from kb_chunk via KnowledgeOpenDoctorAction. So this is a write-only derived table — a P13 named-reader violation, not merely an uncoordinated carry. It carries anyway for safety, not merit: dropping it is a destructive action needing its own window (ADR-004), and the live writer survives until AAI-10, so removing it from the baseline would break advisoryai-web the moment AAI-9 repoints it. Same class as the JobEngine sibling’s D-JOB3-6 (scheduler.doctor_trends). Pinned by DoctorSearchProjection_IsCarriedLoudly_WithItsLiveWriter.
  4. Chat-lineage overlap — ANSWERED (§2.1: two roles, carry both with distinct P13 classes). CLOSED and enforced: the baseline stamps append-audit on the chat_* lineage and operational on conversations/turns, and a test asserts the two classes DIFFER, so a later edit cannot quietly collapse them into the “second full representation of one semantic fact” P13 forbids.
  5. (New, raised by AAI-3.) Retiring PostgresKnowledgeSearchStore.EnsureSchemaAsync and the legacy Storage/Migrations embedded resources — RULED by AAI-4 (2026-08-05, D-AAI4-2): owner is AAI-9, executed in the same commit that repoints the store. Rationale and the AAI-9 obligation in §2.2A.
  6. (New, raised by AAI-4.) IOpsMemoryClient / NullOpsMemoryClientRESOLVED by AAI-9 gate 9 (2026-08-22, D-AAI9-4). The unused service-shaped abstraction and local Null fallback are gone; OpsMemoryDataProvider consumes the in-process IOpsMemoryChatProvider, registered with the lifted store/playbook/chat services from the one owned AdvisoryAI DSN. No network seam or second migration authority was introduced. Amended 2026-09-07 (AAI-14): the data-provider registration is split by branch. Because IOpsMemoryChatProvider is registered only where the AdvisoryAI-owned DSN resolves, an unconditional registration of OpsMemoryDataProvider made a DSN-less local harness fail DI validation at host construction. AddAdvisoryChatDataProviders now keeps the concrete registration on the production branch — so a live host without OpsMemory storage still fails closed at construction — and on the local-harness branch (Development/Testing plus AdvisoryAI:RuntimeBindings:AllowLocalHarnessFakes=true) registers a factory that resolves the real provider when storage is present and NullOpsMemoryDataProvider when it is not. This does not reinstate the client seam D-AAI9-4 deleted: there is still no IOpsMemoryClient, no network indirection, and no production-path fallback. Verified-by: AdvisoryAiRuntimeBindingGuardTests.AdvisoryChat_OnTheProductionBranch_KeepsTheConcreteOpsMemoryRegistration and its two local-harness siblings.

Remaining verification CLOSED (2026-08-03):

5. S5/S6 — API surface, gateway and Console (AAI-5, 2026-08-05)

Currency note (2026-09-04) — the route swap described below is RETIRED, not pending. The 2026-08-28 owner ruling (“all microservices needs to autopublish no router configuration for microservices endpoints”) removed the routes this swap replaced; RAR-7 (8bebf33263) executed that and AAI-9 gate 6 was rewritten (F-AAI9-15). tools/scripts/aai5-swap-advisoryai-routes.ps1 now exits 2 on every path-Check and a bare run alike — because installing a static ^/api/advisoryai/v1(.*) group would out-rank the surface advisoryai-web already publishes. There is no -Check drift report to read any more. Confirm the end state with RouteSwapManifestStateTests; the operator procedure is docs/runbooks/advisoryai/advisoryai-aai9-data-move.md (step 11). The manifest is retained for its route-table findings. Everything below is the 2026-08-05 design record, kept for those findings, and is not an instruction to install the group.

The swap package was the deliverable, not an applied change: manifest (tools/scripts/aai5-advisoryai-route-swap.json), apply script (tools/scripts/aai5-swap-advisoryai-routes.ps1), guard test reading the SAME manifest (AdvisoryAiConsolidatedRouteConfigTests), and window runbook. Nothing was installed live — per D8/DC-19 this family gets ONE routing change and it belonged to AAI-9.

The consolidated group: ^/api/advisoryai/v1(.*)http://advisoryai.stella-ops.local/api/advisoryai/v1$1, Microservice, regex. Spelling is advisoryai, not the sprint’s advisory-ai (D-AAI5-1): every other target identifier in this program drops the hyphen — deployables advisoryai-web/-worker, database stellaops_advisoryai, env STELLAOPS_POSTGRES_ADVISORYAI_CONNECTION, container alias advisoryai.stella-ops.local, and the host’s own AddRouterMicroservice(serviceName: "advisoryai").

5.1 What the S0 route inventory did not record

5.2 No change owed to OpenApiAggregator or src/Api

The aggregator derives its route→service mapping from the route table (OpenApiAggregator.cs:606-630), so it follows the swap unaided. Its two hardcoded switches (ResolveServiceHosts, ResolveOpenApiPaths) contain no AdvisoryAI entry and need none: the alias override exists only where the HELLO ServiceName and the container alias disagree (today, one entry: findingsledgerfindings), and AdvisoryAI’s already agree. Unlike the Findings sibling, this family hands AAI-8 no aggregator re-keying. src/Api is likewise untouched — the family’s appearances in stella.yaml are scope names (advisory:read, advisory:ingest), not paths, and the scope catalog is unchanged by this stage.

5.3 Console (S6): six prefixes centralised, values unchanged

Twenty path literals across ten files now resolve through API_PATH_PREFIXES, so the AAI-9 Console work is a six-value edit. evidencePacks MOVED out of the “Evidence / attestation” block — it is served by advisory-ai-web (EvidencePackEndpoints.cs:41), not the Evidence family, and it flips with this group. Two new keys (advisoryAiSearch, advisoryAiApi, opsMemory) name surfaces the registry never carried.

The bypass class this family hides behind is not the interpolation the sibling guards hunt: three clients inject a full base-URL token that already resolves through the registry, so their call sites carry only a tail. What bypasses is the inject(TOKEN, {optional: true}) ?? '/v1/advisory-ai' fallback — registry-clean by inspection of the provider, and a hardcoded path that fires wherever the provider is absent. Two more of the same shape sit in app.config.ts’s own catch branches. tools/scripts/validate/check-console-advisoryai-prefix-literals.sh anchors on that shape and requires a delimiter after each surface name, because \b after advisory would also match Concelier’s advisory-sources.

/api/v1/chat has no Console consumer at all — only the CLI (ChatClient.cs, plus EvidenceCommandGroup.cs:1681 for evidence-pack export). The CLI repoint is handed to AAI-10 + the CLI owner: a shipped CLI must not target a route that does not exist until the window closes.

6. S7/S8 — doctor adoption, conformance and the staged stack (AAI-7/AAI-8, 2026-08-05)

6.1 Gated reference adoption and sole migration authority

Findings, JobEngine and the vulnerability hub all adopted doctor-check/v1 on hosts that ship nowhere. advisory-ai-web ships today (D-AAI3-1), and three things follow that no sibling had to handle:

  1. The doctor data source must not take the bare NpgsqlDataSource DI slot. The reference adopters register AddSingleton<NpgsqlDataSource> and let the SDK’s context pick it up. AAI-7 identified the collision that the OpsMemory fold would create. AAI-9 resolves it structurally: the store uses a private typed pool over the AdvisoryAI-owned DSN, Doctor keeps its explicit ServiceDoctorContext, and the bare slot remains free. Registration order is no longer part of the persistence boundary.
  2. Every runtime store and OwnDatabase bind STELLAOPS_POSTGRES_ADVISORYAI_CONNECTION (or Postgres:AdvisoryAI:ConnectionString) and nothing else. The legacy AdvisoryAI:Storage / AdvisoryAI:KnowledgeSearch connection values and generic ConnectionStrings:Default / Database:ConnectionString fallbacks cannot arm either runtime persistence or migrations. AAI-PA introduced the gated web reference; AAI-9 gates 1–2 then made AdvisoryAiOwnDatabaseConnection the standard runtime resolver and kept the consolidated resolver constants equal under test.
  3. Doctor registration cannot re-configure the host’s single auth client. A host gets exactly one StellaOpsAuthClientOptions, and with AdvisoryAi:Authority:Enabled=true (the live posture) the LIVEADAPTER-17-002 block owns it as stellaops-advisory-ai-internal with five internal HttpClients hanging off it. Copying the reference AddStellaOpsAuthClient would repoint every UnifiedSearch live adapter at the doctor client. The host now rides its existing identity and fails closed on a conflicting declared ClientId. Operator consequence: leave Doctor__Registration__ClientId unset and grant the existing client platform:doctor:register.

The worker uses the repointed runtime DAL but does not adopt the migration library. The JobEngine sibling’s stated reason (“a Worker host has no HTTP surface”) was checked and does not hold here — StellaOps.AdvisoryAI.Worker is a WebApplication already mapping MapWorkerHealthEndpoints(). The reason that does hold is duplication: both roles are replicas of one service over one database, and a second registration would be a second migration authority.

6.2 What the role-grant suite found: this baseline is the opposite of its siblings

AdvisoryAiRoleGrantTests proves the cross-owner refusal S7 asks for (a foreign role is rejected on read and write across both owned schemas and the migration ledger, with a granted-schema anti-vacuity twin; PUBLIC holds nothing; GRANT … ON ALL TABLES is a snapshot and this family has zero partitioned tables). It also found something AAI-9 must not inherit blindly:

The consolidated baseline creates NO database roles and enables NO row-level security, while 8+ of its tables carry a NOT NULL tenant_id. Findings ships FORCE RLS; JobEngine ships RLS plus a scheduler_admin role. So here there is no baseline role for AAI-9 to grant, and tenant isolation is application-enforced onlyapp.tenant_id / set_config appear nowhere in src/AdvisoryAI, which is why enabling RLS is a posture decision with an application cost rather than a one-line edit to a still-unapplied baseline. Measured from pg_roles, pg_class.relrowsecurity and information_schema.columns, so it fails loudly if a later migration changes any of it. AAI-9 owns the ruling.

6.3 S8 staging: what is staged, and the one thing that is deliberately not

devops/compose/docker-compose.advisoryai.yml stages advisoryai-web + advisoryai-worker outside the default config_files list, carrying the three advisory-ai-* volumes and their init step. Unlike the hub and JobEngine — and like Findings — this family gets no PostgreSQL cluster of its own: stellaops_advisoryai lives on the control-plane cluster. One DATABASE per service is the hard rule; one CLUSTER per service is not. Runbook: docs/runbooks/advisoryai/advisoryai-deploy-aai8-staged-stack.md.

No consolidated publish-helper or ownership-manifest keys, deliberately. Every sibling registered theirs at S8 and it was free, because their hosts were born new with zero violation pairs. These are continuation hosts: advisoryai-web would build from the same project as the live advisory-ai-web. Measured on the committed report — advisory-ai-web 5 violation pairs, advisory-ai-worker 5 — a second key over the same seed re-counts one source graph and turns those into ten register rows, five of them unpinned, which the shrink-only rule forbids. The rename rides AAI-9/AAI-10, where deleting the old key and adding the new one is net-zero and both files must move in one commit.

The D14 family fold DID execute here, as AAI-4’s manifest note and both register pins’ sunset: 013 S7/M2 gate scheduled: opsmemory-web joins the advisory-ai family as a third role, the three explicit opsmemory-owned ownership rules under src/AdvisoryAI are deleted (the surviving prefix rule covers them), and the two advisory-ai-* → opsmemory pins go with them. Measured 137 → 135 violation pairs, 0 unpinned / 0 stale / 0 grown, 53 keys; the report was regenerated once the tree went porcelain-clean (0010ba5995), where the AAI-7 doctor SDK also shows as +1 project and zero new violation pairs on advisory-ai-web — the SDK is domain-neutral-shared, which is what made adopting it on a live host free at the boundary.