Runbook — moving Policy onto its own database (stellaops_policy)
Audience: operators performing the ADR-039 database-per-service move for policy-engine, and anyone standing up a NEW installation who wants to know why the gate projection is empty on day one.
Sprint: SPRINT_20260722_007 POL-F6. Standards: ADR-039, CODE_OF_CONDUCT §8.2, AGENTS.md §2.7 (auto-migration) and §2.11 (no fixture data in migrations).
STATUS 2026-08-15: this cutover is DONE on the local estate. The third attempt HELD, after two rollbacks on 2026-08-14.
policy-engineruns onstellaops_policy, the §0 prerequisite (SCR-3 tenant replication) is flipped and verified live, the corpus seed completed (10,499,248 issues derived into 10,499,248 decision rows, ~4h42m), and the KEV forcing function fired:CVE-2014-0160onpkg:deb/debian/openssl→ HTTP 403blockedBy: KevReachable, attested, WITH itspolicy.gate_decisionshistory row — the step attempt 2 never reached. Isolation 25/25, coverage 7/7.One verification step below is NOT satisfied and is knowingly outstanding: §5 step 3 (lag returns to 0). Measured 2026-08-15: the drain applies ~6,638 events/min while the hub produces ~6,421/min, so the ~490k backlog that accrued during the 4h42m seed is draining at a NET ~217/min — order of 37 hours, converging but slow. This is a drain-vs-hub throughput property, not a symptom of the database move: the checkpoint advances, rows agree with it exactly, and a lagging projection serves last-applied state by contract (
T8_WhileLagging_GatesServeLastAppliedState_AndCatchUpIsOrdered). Re-check before treating lag as evidence of anything else.Everything below remains the procedure for a NEW installation.
The one thing that will surprise you. A freshly converged
stellaops_policyhas the correct schema and cannot yet project anything, because the gate projection derives one decision row per (tenant, issue) and a fresh Policy database has no tenants. The tenant catalog is operator data. Until an operator creates the first policy pack, the projector correctly refuses to do anything and says so in the log. This is not a defect and there is nothing to repair — it is step 4 below.
0. Blocking prerequisite: SCR-3 tenant replication (SPRINT_20260722_027)
DISCHARGED — this prerequisite is met and the legacy path it describes no longer exists. policy-engine has resolved tenants from its own replica since 2026-08-15 (
catalog.replica.tenantsconverged instellaops_policy), and SCR-3 retiredPolicyStellaOpsTenantResolveron 2026-09-12; the host now registersAddStellaOpsTenantResolverReplicaOnly, so there is no branch left that could reachshared.tenants. The section is KEPT because the failure it documents is the reason the replica exists, and because anyone reading this runbook against an older image still needs it. Flag-off is now a refusal naming the flag, not a 42P01.
Historically, Policy resolved tenant slugs to UUIDs by reading Platform’s shared.tenants. PolicyStellaOpsTenantResolver issued SELECT … FROM shared.tenants, and that relation lives in stellaops_platform. It does not exist in stellaops_policy and must not be created there — it is Authority/Platform-owned data, and copying it would be the cross-service duplication ADR-039 exists to prevent.
Measured on the 2026-08-14 attempt: a gate evaluation against stellaops_policy resolved its baseline, materialized the target snapshot, computed the delta, decided Allow, and created an attested verdict — then failed with 42P01: relation "shared.tenants" does not exist inside GateDecisionHistoryRecorder.RecordAsync, so the caller got HTTP 500 and the decision history was never written. The identical sequence on stellaops_platform returns HTTP 200, which is what attributes the failure to the move rather than to the gate.
The designed exit already exists and is a config flip, not new code: Program.cs registers AddStellaOpsTenantResolverDualMode, so setting Catalog:Replication:Tenants:Enabled=true plus a FeedBaseAddress serves the same IStellaOpsTenantResolver seam from Policy’s own replica of Authority’s tenants catalog.
0.1 What SCR-3 landed on 2026-08-14, so you do not re-derive it
The flip is now a single staged overlay, devops/compose/docker-compose.policy-catalog-replica.yml — deliberately a separate file from docker-compose.policy-db.yml so you can apply it alone on the shared database first. Applying both at once gives a failure two candidate causes and no rollback that isolates either. It carries the three settings that were each a measured live failure at the SCR-3 proof: the unprefixed key form, the gateway https feed address, and AuthTenant: default.
Three prior items on this list are closed:
- The replica now lands in Policy’s own database by construction. The old warning here said the enabled path
TryAdds anNpgsqlDataSourcefrom the unnamedIOptions<PostgresOptions>and that you must verify where the inbox lands.AddPolicyPostgresStoragenow registers Policy’s single unkeyedNpgsqlDataSourcefromPolicyPersistenceConnectionbefore any seam can TryAdd a fallback, so every neutral library the host composes (the replica store, the eventing inbox/leases, both startup migrations) yields to it. Measured while landing this: the unnamed options on policy-engine already resolved to Policy’s own connection —PolicyDataSourceis built from that same object and its tables demonstrably converged instellaops_policy— so the earlier attribution of the split-brain to that options object was wrong. Pinned byPolicyPersistenceConnectionTests.ComposedOptions_ResolvePolicysOwnDatabase_EvenWithTheTransitionalKeyPresent. - A never-converged replica now fails LOUDLY. Previously both “unknown tenant” and “the drain never ran” were the same
null, which is how the 500 above arrived describing the tenant identifier instead of the missing relation.CatalogReplicaReaderkeeps the third state (contract §7) and the resolver throwsCatalogReplicaUnavailableException, whose message names the flag, the grant and the drain. Nothing is ever written on that path. - The bootstrap window is narrowed, not papered over. The drain runs once, bounded (30 s default), before host startup completes and after the startup migrations — so a cold start does not spend a poll interval refusing requests. Non-fatal by design: an already-converged replica must start with a dead producer.
0.2 What is still on YOU before the flip
The
catalog:replicategrant on the LIVE Authority. SCR-3 added it todevops/etc/authority/plugins/standard.yamlfor thepolicy-engineclient, but editing that file does not change a running estate — the live grant is a row inauthority.clients. Measured read-only 2026-08-14: the scope is catalogued (authority.permissionshascatalog:replicate; migrationS047is applied) and the grant is not —policy-engine’sallowed_scopesis{advisory:read,aoc:verify}. Without it every feed call isinvalid_scope, the replica never converges, and tenant resolution fails closed on every request.A plain
docker restart stellaops-authorityis enough, and that is verified from source rather than hoped for.StandardPluginBootstrapperrunsEnsureBootstrapClientsAsyncon every startup pass — it is not behindAUTHORITY_BOOTSTRAP_ENABLED(only the default-admin half is) — andStandardClientProvisioningStore.CreateOrUpdateAsyncmerges (unions) the config-default scopes with whatever the row already holds, so nothing an operator added by hand is lost. Then prove it, do not assume it:docker exec stellaops-postgres psql -U stellaops -d stellaops_authority -t \ -c "SELECT allowed_scopes FROM authority.clients WHERE client_id='policy-engine';" # must now contain catalog:replicateGateway route.
^/catalog-changes/(tenants|tenant_lifecycle)→ authority already exists indevops/compose/router-gateway-local.jsonand the router’sappsettings.json. Regenerate deployed gateway configs if yours are stale.
Flip and verify SCR-3 first, on the shared database, where a regression is visible and reversible. Then run §4.
1. What “the tenant catalog” actually is
IProjectionTenantCatalog is the projector’s answer to “which tenants must this decision be derived for”. The shipped implementation (PostgresProjectionTenantCatalog, in src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/VulnGate/VulnGateProjector.cs) reads Policy’s own tables and nothing else:
SELECT tenant_id FROM policy.packs
UNION
SELECT tenant_id FROM policy.vuln_tenant_overlay
A tenant with no policy pack has nothing to gate, which is what keeps the per-event fan-out proportional to real tenants rather than to every string that ever appeared in a tenant column.
So there are exactly two legitimate ways a tenant enters the catalog:
| Source | How it is populated | Who does it |
|---|---|---|
policy.packs | POST /api/policy/packs (scope policy:edit) → PostgresPolicyPackRepository | an operator, or any tooling holding policy:edit |
policy.vuln_tenant_overlay | DC-32 inbox materialization of Findings-ledger overlay events (tenant VEX decisions / trust overrides) | the tenant, through Findings |
And one illegitimate way, which is why this page exists: seeding a pack in a migration. AGENTS.md §2.11 forbids QA/demo/fixture rows in any runtime migration, and a policy pack is operator content — a migration that invented one would create a phantom tenant on every real installation, air-gap estate and CI database the moment the service started. There is deliberately no bootstrap flag that fabricates one either; *_BOOTSTRAP_ENABLED is explicitly not an exemption.
Therefore: at least one policy pack (or one tenant overlay) is a documented prerequisite of a meaningful gate projection — on a cutover and on a brand-new installation alike.
2. Preconditions
policy-engineimage contains the POL-F6 resolver (PolicyPersistenceConnection, built after 2026-08-11) and the first-fill ordering fix (built after 2026-08-14). An older image ignores the canonical variable and keeps writing to the shared database — the overlay looks applied and does nothing.stellaops_policy+ thepolicylogin role exist on the shared cluster, withREVOKE CONNECTapplied both ways. Verify withbash tools/scripts/deploy/postgres/probe-database-isolation.sh(see §6).STELLAOPS_POSTGRES_POLICY_CONNECTIONis set in the git-ignoreddevops/compose/.env. It has no fallback: an unset value must stop the stack rather than quietly leave a release gate on the shared database.- A from-empty converge is accepted. Auto-migration converges schema shape; it does not move data. Existing packs, exceptions, receipts, audit and risk-override rows do not follow a connection string. (Owner ruling 2026-08-14: windows create the database fresh and let startup migrations converge it — the same path a new installation takes.)
3. Order of operations, and why the order is load-bearing
The projection has two ways to fill: a one-shot corpus bulk seed (one ordered pass over the hub’s consensus-inputs section — minutes to hours) and the event drain (increments, event by event). On a fresh database the seed is what converges the ~10M-issue corpus; the drain would need days.
The seed’s only skip condition is “a durable checkpoint exists”, and the drain WRITES that checkpoint. So the two race for first fill, and the drain would always win — it runs continuously while the seed is attempted per lease acquisition. That race cost a real cutover window on 2026-08-14: the operator’s first pack appeared mid-loop, the running drain immediately had a tenant to derive for, consumed the small live backlog and checkpointed it, and the next start skipped the seed for “a checkpoint already exists” — leaving a projection holding 84,826 rows instead of 10.5M, behind a consumer that reported itself caught up.
Both halves are now enforced in code, so the sequence below is safe rather than merely recommended:
- the drain stands aside while no checkpoint exists and a corpus seed is available (
seedOwnsFirstFill), and says so in the log; - the seed refuses to run — before opening a 5 GiB stream — when there is no active tenant, and withholds its completion checkpoint whenever it wrote zero rows, so an empty projection stays retryable instead of becoming permanent;
- the host re-attempts the seed on its idle poll, so the operator action in step 4 is picked up without a service restart.
4. The cutover
Repoint. Append the staged overlay to the stack’s own
-fchain — read the chain off the live container, never hand-reconstruct it:cfg=$(docker inspect stellaops-policy-engine \ --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}') IFS=',' read -ra files <<< "$cfg"; args=(); for f in "${files[@]}"; do args+=(-f "$f"); done args+=(-f devops/compose/docker-compose.policy-db.yml) docker compose -p stellaops --project-directory devops/compose "${args[@]}" \ up -d --force-recreate --no-deps policy-engineOnce the cutover is permanent, the canonical variable lives in
docker-compose.stella-services.ymland the overlay is no longer needed.Confirm the schema converged in the NEW database (59 base tables + 7 views in
policy, 7 ineventing) and thatpolicy.schema_migrationslists every migration through006_exploit_evidence_projection.sql.Confirm the projector is refusing honestly, not failing:
[WRN] Gate projection has NO active tenants, so the corpus seed is a no-op and is being skipped: `policy.packs` and `policy.vuln_tenant_overlay` are both empty in THIS database …eventing.consumer_checkpointsmust be empty at this point. A checkpoint here would mean the seed’s window has already closed.If that warning appears on an estate that does have packs, the projection resolved a different database than the rest of the service. That is the split-brain defect this runbook’s precondition guards: check
PolicyPersistenceConnectionand the image version.Create the first policy pack — the operator action that establishes the tenant catalog. Any
policy:editholder can do it; the pack is real configuration, so name it for what it is:curl -sk -X POST https://<host>/api/policy/packs \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"packId":"default-release-gate","displayName":"Default release gate"}'Verify the row landed in
stellaops_policy.policy.packs(which also independently proves the write path repointed).Let the seed run. Within one poll interval the host re-attempts the seed and logs progress with both counts — issues derived and decision rows written. They must move together; that pairing exists because a version that reported only the issue count once claimed 10,504,824 issues derived while persisting nothing:
[INF] Corpus seed progress: 300000 issues derived across 1 tenant(s), 300000 decision writes.Expect roughly 500 issues/s (≈ 5–7 h for a ~10M-issue corpus). The completion checkpoint is written only after the last row commits, so a seed interrupted half way is resumed by re-running, never mistaken for a finished one.
Gate posture during the seed is fail-closed, by design. With
AdvisorySource=PolicyProjectionand no checkpoint yet, the findings lookup reports the source as unavailable rather than as “nothing affects this release”, andFindingsLookup:OnUnavailable=Denyblocks. Releases are refused during the window; nothing is certified against an empty projection.
5. Verifying it actually worked
A healthy container is not evidence. All five of these, in order:
The tenants replica converged (SCR-3, and the reason attempt 2 rolled back — check this BEFORE anything else, because everything downstream needs a tenant UUID):
docker logs stellaops-policy-engine 2>&1 | grep -i "tenants replica" # expect: "Tenants replica bootstrap drain applied N change(s) before startup completed."-- in stellaops_policy SELECT catalog, key, version, deleted FROM catalog_replica.replica_rows WHERE catalog = 'tenants'; SELECT consumer_id, stream, stream_epoch, seq FROM eventing.consumer_checkpoints WHERE consumer_id = 'catalog.replica.tenants';The checkpoint row is the load-bearing one, not the replica rows: it is what distinguishes “this tenant is unknown” from “this replica never drained”, and the resolver refuses to treat a miss as an answer without it. No checkpoint ⇒ every tenant-bound request fails with
CatalogReplicaUnavailableExceptionnaming the flag, thecatalog:replicategrant and the drain. That is correct behaviour — go fix the grant or the feed address, do not read it as a code bug.Rows exist:
SELECT count(*) FROM policy.vuln_gate_current> 0 instellaops_policy, and in the same order of magnitude as the corpus.The checkpoint agrees with the rows: exactly one row in
eventing.consumer_checkpointsforpolicy.vuln_gate, written after the seed finished. A checkpoint over an empty projection is the one state a release gate must never serve from.Lag returns to 0:
Gate projection lag: N hub events outstandingfalls to 0 and stays there.A real gate decision, not a health probe. The strongest available forcing function is the KEV gate: a release subject carrying a KEV-listed, reachable vulnerability must evaluate to HTTP 403 with
blockedBy: KevReachable, and a clean subject must Pass. Both verdicts are attested. Theis_kevsignal comes frompolicy.exploit_evidence, mirrored from the hub’s KEV catalog on the projection lease — check it is populated (~1.6k rows) rather than assuming it.Pick the subject from the live corpus, not from a previous run’s notes. The 2026-08-13 forcing function used
pkg:maven/org.apache.logging.log4j/log4j-core; the corpus was re-ingested from upstream in the topology window and no longer carries a maven product key forCVE-2021-44228at all, so that subject now yieldsunmatched— honest, and useless as a block proof. Query for a subject the projection actually holds:SELECT g.vulnerability_id, g.product_key FROM policy.vuln_gate_current g JOIN policy.exploit_evidence e ON e.vulnerability_id = g.vulnerability_id AND e.kind = 'exploited_in_wild' WHERE g.effective_status = 'affected' LIMIT 10;Verified working 2026-08-14 on the shared database:
CVE-2014-0160(Heartbleed) onpkg:deb/debian/openssl→ clean subject HTTP 200status 0, KEV subject HTTP 403blockedBy: KevReachable, conditionis_kev = true AND delta_reachable > 0.The chain that produces those two verdicts, in order:
POST /policy/orchestrator/jobs(scopepolicy:run, body carriesbatch_items[{component_purl, advisory_id}]) → wait forstatus: completed→POST /policy/ledger/export(scopepolicy:audit, body{"tenant_id":"default"}) →POST /api/v1/policy/gate/evaluatewithmissingBaselineMode: "bootstrap". Run it once with a subject the projection answersunmatched/not_affectedfor to bootstrap the baseline, then again with the KEV subject.The decision HISTORY row landed. This is the step attempt 2 skipped, and it is the difference between “the gate decided” and “the gate is auditable” — the 2026-08-14 rollback had a correct
Allow, an attested verdict, and no history row, because the audit write ran after the verdict and 500ed on its own.-- in stellaops_policy, after each evaluate above SELECT decision_id, tenant_id, gate_status, bom_ref, evaluated_at FROM policy.gate_decisions ORDER BY evaluated_at DESC LIMIT 5;One row per evaluation,
tenant_id= the canonical UUID the replica served (not null, not a synthetic value). An HTTP 200/403 with no row here means tenant resolution failed after the decision — re-read step 0.
6. Isolation — the boundary is the revoke, not the connection string
bash tools/scripts/deploy/postgres/probe-database-isolation.sh
Every role must reach exactly its own database. Read the printed role count: a family whose connection string is assembled in a compose file rather than .env is invisible to the probe, and the script’s coverage gate now refuses to exit 0 on a partial matrix rather than reporting a green boundary it never tested. Follow the recipe in the script header to include such a family (the vulnerability hub is one).
7. Rollback
The rollback is the connection string, and it is honest only while the old database still holds a consistent projection:
- remove the overlay (or restore the previous connection value) and
--force-recreatepolicy-engine; - the old database’s
policyschema is untouched by the cutover, so its projection and checkpoint remain in the state they were left in; - do not leave the estate pointing at a half-filled new database with a completion checkpoint over it. If the seed cannot finish, roll back and record why: a rolled-back window with a named blocker is a good outcome; a release gate serving from an empty projection is not.
Re-running the cutover after a rollback is safe. If a previous attempt left a drain-written checkpoint on the new database, the seed will skip; clear that consumer’s checkpoint and inbox rows (consumer_id = 'policy.vuln_gate') and the derived policy.vuln_gate_current rows so first fill is unambiguously the seed’s.
Rollback ORDERING with the catalog flip (measured 2026-08-17, routed from the SCR-3 lane): the catalog flip rolls back LAST, never first. Since the 08-15 window, Catalog__Replication__Tenants__Enabled: "true" is a literal in canonical compose and shared.tenants does NOT exist in stellaops_policy (to_regclass → NULL, verified live). Rolling back the catalog flip while Policy remains on its own database reintroduces the exact 42P01 → HTTP 500 audit-write failure that forced both 08-14 rollbacks — a gate that decides correctly and then fails its history write. The database-move rollback is safe in isolation (catalog_replica remains converged in stellaops_platform); the two rollbacks are NOT independent. Sequence: connection string first, catalog flip only after Policy is back on a database where shared.tenants resolves — or leave the flip in place entirely, which is always safe. Those three are all derived data — the packs and the exploit-evidence mirror are not, and must be kept.
