Integrations deploy — INT-5 staged own-database repoint
Sprint: SPRINT_20260722_024 INT-5 (recipe S7+S8). Historical: the two PolicyStorage__* / ScannerStorage__* rows in the environment table below substituted the generic estate connection, which SPRINT_20260913_001 ORP-3 and SPRINT_20260722_021 PLT-3 retired on 2026-09-14 — integrations-web now reads Policy through its owner API and Scanner through the authenticated projection feed. Do not re-create those keys. Historical window overlay: devops/compose/docker-compose.integrations-consolidated.yml (retired from source by INT-7 after the successful window). Route swap (separate, INT-6): integrations-cutover-int4-route-swap.md.
This runbook covers the compose half of the INT-6 window: repointing integrations-web onto stellaops_integrations. It stages nothing live — the overlay is deliberately outside the default config_files list — and it is written to be read before the window opens, because two of its preconditions are not fixable inside the window.
Post-window canonical state (INT-7, 2026-08-24). The procedure below is retained as the executed/rollback record; do not add the retired overlay to a new compose command.
docker-compose.stella-services.ymlnow carries the own Integrations DSN, Scanner retained-feed/auth configuration, Doctor settings, and the explicit separate Policy DSN.integrations-webresolves the canonical own-database variable directly andStellaOps.Integrations.Persistenceis its only migration assembly. Rollback still uses the recorded predecessor images and database snapshot; no legacy schema or database was dropped by INT-7.
What the overlay changes
Applied, the overlay adds environment to the existing integrations-web key:
| Key | Value | Why |
|---|---|---|
ConnectionStrings__IntegrationsDb | ${STELLAOPS_POSTGRES_INTEGRATIONS_CONNECTION:?} | the query plane |
ConnectionStrings__Default | same | the host’s second resolution hop |
STELLAOPS_POSTGRES_INTEGRATIONS_CONNECTION | same | so the doctor can compare intent against reality |
ScannerStorage__Postgres__ConnectionString | ${STELLAOPS_POSTGRES_CONNECTION:?} | keeps the Scanner reads on the shared database (D-INT4-4) |
PolicyStorage__Postgres__ConnectionString | ${STELLAOPS_POSTGRES_CONNECTION:?} | keeps the Policy + tenant-catalog reads there (D-INT7-1) |
Doctor__Registration__Enabled | ${INTEGRATIONS_DOCTOR_REGISTRATION_ENABLED:-false} | default-off doctor registration |
It creates no new deployable, no new image, and no new publish key: the OD-2 re-scope (ADR-041 D2) moved the registry token service to the Registry family with its own database birth, so this family has exactly one compose key and it already exists.
Read this before opening the window
1. Setting the canonical variable does NOT repoint this service
integrations-web resolves ConnectionStrings:IntegrationsDb, then ConnectionStrings:Default, then a compiled-in Host=localhost;Database=stellaops_integrations literal. It never reads STELLAOPS_POSTGRES_INTEGRATIONS_CONNECTION — only the consolidated DAL does, and nothing deployed may reference that assembly before the window (D-INT3-1).
So a window that exports the canonical variable and stops there leaves the EF context, the startup migrations and the KEK control plane on the shared platform database, with a clean startup and a green route suite. That is why the overlay maps one variable into both ConnectionStrings keys.
The compiled-in literal is the second half of the same trap: with no configuration at all this host starts and points at a localhost PostgreSQL that does not exist inside a container. GET /doctor/integrations/checks reports both states distinctly — doctor.integrations.database.own-database-cutover fails critical either when the canonical variable is set and the query plane did not move, or when no surface resolved at all.
2. Two foreign read planes follow the repoint, and both fail SOFT
This is the item that costs the window if it is missed, and it is two defects with one shape:
| Plane | Resolution chain in Program.cs | On failure |
|---|---|---|
Scanner (scanner.*) | ScannerStorage:Postgres:ConnectionString → ConnectionStrings:Default → ConnectionStrings:IntegrationsDb | caught, logged at Warning/Debug; every registry image reads unevaluated |
Policy (policy.gate_decisions + shared.tenants) | PolicyStorage:Postgres:ConnectionString → Policy:Postgres:ConnectionString → ConnectionStrings:PolicyDb → ?? connectionString | caught, logged at Warning; every gate verdict comes back empty |
Neither key has ever been set for this service, so today both planes ride the Integrations DSN and work only because scanner.*, policy.* and shared.tenants are co-located in the shared database. Repointing the query plane takes both with it and the reads start throwing 42P01 at request time — caught, soft, and byte-identical to a correctly configured empty estate. Set both keys before the split, not after.
The policy plane is the worse of the two and it is the one nobody had named:
- it has no null-provider branch. The Scanner side at least has an honest “no DSN configured → policy-only provider” state;
PostgresRegistryImagePolicyDecisionProvideris always constructed, becauseProgram.csends its chain with?? connectionString; - it resolves the tenant slug through
shared.tenantsfirst, so losing that schema fails every lookup before any policy table is touched. An operator told to checkpolicy.gate_decisionswould be looking in the wrong place — which is whydoctor.integrations.policy-plane.dsn-attribution(historical — retired 2026-09-14 by ORP-3; the live check isdoctor.integrations.policy-decisions.owner-api) names the tenant catalog first when both are missing.
3. The discovery-outbox bridge cannot be staged in compose at all
Scanner.Worker’s RegistryImageDiscoveredBridgeService reads integrations.registry_image_discoveries by cross-schema SQL over Scanner’s own data source — no ProjectReference, no ownership-matrix entry. The moment the repoint applies, that FROM stops resolving. The bridge already swallows unavailability and retries, so registry auto-scan quietly stops bridging discovered images to scans, and a row-count parity check still passes.
No environment variable on this service fixes that, and pointing Scanner at the Integrations database would invert the ownership the window exists to establish. The cure is the discovery event (DC-15), which is why INT-6 depends on INT-3b. Until it lands, doctor.integrations.discovery-bridge.scan-plane-visibility is the alarm — and it deliberately reports Warning, not healthy, when this host has no Scanner DSN to measure the bridge’s plane through: the bridge may well be broken and the deployment simply cannot tell.
4. The crypto slice is a data move, and row counts will not catch it
integrations.integrations.credential_kek_version references crypto.kek_versions.version. The crypto schema is shared-library-owned rather than family-owned and the original design inventory missed it (D-INT3-5), so a window that copies only the integrations schema produces a database full of credentials that cannot be opened — with matching row counts on both sides.
doctor.integrations.credential-custody.kek-resolvable is the forcing function. It matches on (kek_id, version), not version alone: the ledger is keyed by both, so a destination whose crypto slice was seeded from another service’s ledger can hold version 1 under the wrong kek_id, and a version-only probe would call that resolvable while the credential stays unopenable.
5. The advisory catalog is already broken, and this overlay must not “fix” it
The SPRINT_20260722_008 G1 cutover moved the vuln schema to stellaops_vuln on its own container, so per-digest severity counts already return empty and log at DEBUG (D-INT3-6) — a live regression, independent of this window. The overlay deliberately sets no advisory DSN, and the conformance lint asserts that: the fix is the DC-30 v2 repoint onto the corpus artifact / hub read API. A connection string here would “fix” the symptom by re-creating the cross-database read the vuln-plane separation removed.
Preconditions (all must hold before the window)
- INT-3b’s discovery event has landed (item 3). Otherwise registry auto-scan silently stops the moment the repoint applies.
STELLAOPS_POSTGRES_INTEGRATIONS_CONNECTIONis set in the operator env file. The overlay uses:?on it, so compose refuses rather than starting the container against an empty connection string.stellaops_integrationsexists, has converged the consolidated baseline, and carries thecryptoslice (item 4).- The gateway route swap is sequenced with this repoint, not before it (DC-19) — see the INT-4 runbook.
- Doctor registration stays OFF unless this service’s Authority client already holds
platform:doctor:register. Flipping it early is a 403 loop against Platform.
Procedure
# 1. Rebuild the image from working-tree code (the doctor SDK is a new payload).
devops/docker/build-service-publish.sh integrations-web
# 2. Apply the overlay. A same-tag image does NOT swap on `up -d` — --force-recreate
# is mandatory, every time.
docker compose \
-f devops/compose/docker-compose.stella-infra.yml \
-f devops/compose/docker-compose.stella-services.yml \
-f devops/compose/docker-compose.integrations-consolidated.yml \
up -d --force-recreate --no-deps integrations-web
# 3. Expect the service to re-send HELLO over Valkey. A gateway
# 503 "no instances available" right after the swap is fixed by one restart.
docker restart stellaops-integrations-web # only if the gateway 503s
# 4. Verify the swap took: fresh image id, healthy, clean logs.
docker inspect --format '{{.Image}} {{.State.Health.Status}}' stellaops-integrations-web
config_files labels are heterogeneous across this stack: recreate each service with its OWN -f list, never with a list assembled for a different service.
Forcing function — what to check, and what not to accept
GET /doctor/integrations/checks under an ops.health-scoped token is the whole verification surface. A healthy container is not evidence here: every failure this window can produce is soft, and the service stays healthy through all of them.
Required after the repoint:
| Check | Expected | If it fails |
|---|---|---|
…database.own-database-cutover | Info, database.connected = stellaops_integrations, consolidated baseline applied, crypto.kek-ledger = present | the repoint did not take, or the crypto slice did not travel |
…scan-plane.dsn-attribution | Info, source = ScannerStorage:Postgres:ConnectionString | the explicit key was dropped — registry image security state is silently gone |
…policy-plane.dsn-attribution (historical; retired 2026-09-14, successor …policy-decisions.owner-api) | Info, source = PolicyStorage:Postgres:ConnectionString | same, for policy verdicts and tenant resolution — this posture no longer exists; see ORP-3 |
…discovery-bridge.scan-plane-visibility | Info | registry auto-scan has stopped bridging (item 3) |
…credential-custody.kek-resolvable | Info, credentials.kek-unresolvable = 0 | inline-sealed credentials are unopenable |
…severity-plane.advisory-reachable | Warning is CORRECT pre-DC-30-v2 | do not “fix” it with a wider DSN (item 5) |
…registry-auth.credential-readiness | Warning while any active registry has credential_backend = 'none' | provision credentials before the S7 bearer-auth flip on the interim zot |
Plus the two data-plane forcing functions INT-6 owns: an inline-sealed credential round-trip (proves the crypto slice landed) and a discovered image bridging to a scan (proves D-INT3-3 was cured rather than silently broken).
2026-08-23 INT-6 executed receipt
The reversible window completed. The durable, secret-free receipt is 2026-08-23-integrations-int6-live. It is authoritative for the executed topology where INT-3b replaced the two raw-SQL planes described above with retained owner feeds.
Operational conclusions that must survive future reruns:
- Build only
integrations-web,scanner-web, andscanner-worker. Authority and Router were recreated on their unchanged image digests to reconcile clients and load already-published config; neither is a fourth source-image build. - Reconcile Authority once with both
INTEGRATIONS_AUTHORITY_CLIENT_SECRETandFINDINGS_NIS2_LEDGER_AUTHORITY_CLIENT_SECRETmapped from the ignored operator env file. Never print either value. The accepted claims were exact: integrations-web audiencestellaops, scopesplatform:doctor:register scanner:read; the Findings NIS2 client audiencenotify, scopenotify.operator. - Copy the classified
integrations:credentials:v1KEK row by explicit column name. The accepted source/target canonical hash was407e89c25b9508cd10dde27fa61a3969; a row-count-only check remains insufficient. /api/integrations/v1/secret-authority/targetsis a SecretsManager-only view. A registry credential is not supposed to appear there. The window therefore used a separate credential-free Vault SecretsManager target and accepted its truthfulFailedstate while Vault was absent, then disabled it without deletion.- The existing CLI automation client could not mint
registry:read; the registry browse call correctly returned 403. Do not widen that service-account grant merely to make an operator browse check green. The retained Scanner owner feed plus exact database/checkpoint/projection parity is the accepted proof for this window. - Disable canaries; never delete them for rollback. After disable, wait at least one Scanner poll interval and require the owner stream epoch/head, outbox sequence, Scanner checkpoint/inbox, runtime scan, and Integrations projection to remain fixed.
Doctor-SDK prune trap
The image now ships StellaOps.Doctor.Plugin.Abstractions.dll, which matches the *.Plugin.*.dll prune pattern by name. Verified for this stage against devops/docker/build-service-publish.sh: is_host_runtime_plugin_payload allowlists it for every key, so the image keeps it. If integrations-web ever crashes at boot with a FileNotFoundException for that assembly, that allowlist regressed.
Rollback
Drop the overlay from the -f list and force-recreate. The canonical file’s integrations-web key is untouched by this stage, so the rollback is the pre-window configuration exactly — but the DATA move is not rolled back by compose (ADR-004 is forward-only; recovery is a PostgreSQL snapshot restore, see migration-recovery.md).
The executed window preserved these exact reversible artifacts:
| Surface | Accepted rollback |
|---|---|
| integrations-web | Recreate from stellaops/integrations-web:int6-pre-bf7731500e (sha256:efe0890788714b780ca826a6eec48a4ebb86006f328c6f4ee5730245f5b2396f) after dropping the image/own-DB overlays. |
| scanner-web | Recreate from stellaops/scanner-web:int6-pre-bf7731500e (sha256:00a9a0f97b7102e920f429470935786cf4843038de18da70babce04acc03e6a4). |
| scanner-worker | Recreate from stellaops/scanner-worker:int6-pre-bf7731500e (sha256:95b0bbc909697592c075b0f87be39090eff66a3a2d48d77754a861d0561ac228), drop the runtime plugin override, and remount the byte-exact predecessor bundle snapshot (1,910 files; aggregate SHA256 dcf436c2fe4f21bd04ecdc8120a8e44e4127cf0a675b6ba6b3403da9f07b1f91). |
| Authority | Drop the INT-6 client override and recreate once from the exact predecessor compose chain. Image stays sha256:226a98d1699c4c74837b5e237e3943865a11fc3dc032babef7a590e09da72fdb. |
| Router/Console | Drop the route override and recreate from the snapshotted pre-window compose chain. Router image stays sha256:ff4a61b7224831ef6b20ceb9ec2e87caf8cd8ef760246098eca559bb1cd079e3; the committed source/config can be re-applied later without rebuilding Router. |
| PostgreSQL | The old source database and rows were retained; no drop/delete occurred. Repoint the service to the old DSN for application rollback. Schema downgrade still requires snapshot restore under ADR-004. |
| Canaries | Both records are retained in Disabled state with is_deleted = false; do not delete them. |
The ignored runtime bundle paths are operator-local evidence, not source publication. Before closeout, all generated files under devops/plugins/scanner/** were restored byte-for-byte to HEAD; the accepted worker mounted only the ignored coherent bundle (1,909 files, aggregate SHA256 e4a2d1dc90cb1c7f46bdac7da9819f260331e9ceb851ffd0fb1424c7711f0df3).
