Evidence EVD-9 data move and cutover
Audience: Evidence implementer, Database Operations, live-deploy operator, rollback owner, and the owner approving the SPRINT_20260722_011 EVD-9 maintenance window.
Purpose: move the live attestor, proofchain, and evidence_locker schemas from stellaops_platform into the Evidence family’s own stellaops_evidence database, prove canonical row parity and both integrity chains, start the digest-pinned successor roles, perform the one gateway/Console repoint, revoke predecessor access, and soak without dropping the source schemas.
ROUTE LEG RETIRED — 2026-09-04. The gateway route-swap parts of this procedure must not be run. The owner ruling of 2026-08-28 (“all microservices needs to autopublish no router configuration for microservices endpoints”) was executed by
SPRINT_20260809_001RAR-5/RAR-7 (81e276c2a3,83bfc9359f,f4b1774cf6). All fifteen rows intools/scripts/evd5-evidence-route-swap.jsonare gone from both route tables, which now hold 22 routes total; Evidence is served by the auto-published layer. A static gateway entry out-ranks publication, so reinstating those fifteen rows — by applying the EVD-5 swap, or by “reconciling” the manifest until-Checkpasses and then applying — would claw the Evidence plane back off the published layer: a regression, not a no-op. (Concretely today the scripts cannot do this on their own — with nothing to match they write nothing, then falsely report a successful apply and exit 0; see the window steps.)Since
ced8a2d286pwsh tools/scripts/evd5-swap-evidence-routes.ps1refuses on both invocations —-Checkand a bare run each report the swap as RETIRED and exit 2, measured 2026-09-04 at HEAD. Section 7 step 3’s “exact fifteen predecessor routes” NO-GO gate is therefore unsatisfiable, and section 8 step 3’s apply no longer runs.Do instead: confirm the auto-published layer serves
/api/evidence/v1/**through the gateway (authorized 2xx, unauthenticated 401/403) and that the two Policy false friends (/api/v1/attestor/policies,/api/v1/attestor/reports) still resolve to Policy. Everything in this runbook that is not the route swap — the data move, both integrity chains, parity, revocation and soak — still stands. Model for the retired shape:offlinekit-cutover-ok6-route-swap.md.
This is an operator procedure, not authorization to execute it. EVD-5, EVD-8, and EVD-9 are one window. Stop at the first failed gate. Do not improvise a temporary build-boundary pin, a partial route swap, a second data copy, or a schema drop.
Non-negotiable boundaries
- The source database and all three source schemas remain intact through rollback retention. Old schema drops require a separate destructive approval.
- Signer is outside this family.
stellaops-signerand its database, keys, grants, routes, and containers are not touched. stellaops-attestor-tileproxyandstellaops-tsaremain unchanged roles. They are not database writers and are not stopped by the writer fence.- The data move, target start, route group, overlay override replacement, Console prefix change, predecessor stop, and reviewed grant revocation are one atomic window. Do not deploy a subset.
- Run from the repository root in Bash. Put receipts under the gitignored
tmp/evd9-<window-id>/directory. Never put a secret in a command, transcript, checked-in file, or Docker label. - A same-tag image is not evidence of the intended build. Both target roles use reviewed immutable digests, and their running image IDs are recorded.
docker-compose.evidence.ymlis the cutover overlay and intentionally names the carried live volumes. A disposable rehearsal must layerdocker-compose.evidence.scratch.ymllast, require a uniqueEVIDENCE_SCRATCH_RESOURCE_PREFIX, and supply throwaway PostgreSQL/Valkey services from an uncommitted override. The base file still requires its connection variable during interpolation; use the documented invalid render sentinel and let the last-file scratch overlay replace it. A Compose project name alone does not isolate explicit physical names.
Scratch rehearsal namespace preflight (read-only)
Run this gate before every disposable Evidence up, including a repeat with a prefix that was used earlier. Compose can require a non-empty variable, but it cannot reject compose* or stellaops*, and -p does not replace explicit physical names. A collision is NO-GO: choose a new prefix. Do not delete or rename the resource that made the gate red; it may belong to the standing estate or an interrupted rehearsal.
The checker is read-only. Its self-test uses an injected inventory and does not contact Docker. The normal check rejects standing-name prefixes case-insensitively, then requires all exact derived web/worker/database/cache container names, Evidence/ProofChain/database/cache volume names, the network name, and every container/volume/network carrying the same Compose project label to be absent. The injected self-test drives each of those 14 exact name/project-label probes red in turn.
export EVIDENCE_SCRATCH_RESOURCE_PREFIX="evd8scratch-$(date -u +%Y%m%d%H%M%S)-$$"
export EVD8_SCRATCH_OVERRIDE="tmp/scratch/${EVIDENCE_SCRATCH_RESOURCE_PREFIX}/docker-compose.throwaway.yml"
pwsh tools/scripts/validate/check-evidence-scratch-resources.ps1 -SelfTest
pwsh tools/scripts/validate/check-evidence-scratch-resources.ps1 \
-Prefix "${EVIDENCE_SCRATCH_RESOURCE_PREFIX}"
test -s "${EVD8_SCRATCH_OVERRIDE}"
: "${EVIDENCE_IMAGE_TAG:?build the two successor images under a unique scratch tag first}"
: "${EVIDENCE_SCRATCH_POSTGRES_PASSWORD:?load a scratch-only database password}"
: "${POSTGRES_IMAGE:?set an audited digest-pinned local PostgreSQL image}"
: "${VALKEY_IMAGE:?set an audited digest-pinned local Valkey image}"
: "${KESTREL_CERT_PASSWORD:?load the development certificate password from its local source}"
: "${STELLAOPS_DEFAULT_REGION:?set the installation residency sentinel for this rehearsal}"
: "${EVIDENCE_AUTHORITY_CLIENT_ID:?set the reviewed scratch Evidence client id}"
: "${EVIDENCE_AUTHORITY_CLIENT_SECRET:?load the scratch client secret}"
: "${STELLAOPS_EVIDENCELOCKER_CAPSULE_ED25519_SEED_B64:?load a scratch-only capsule signing seed}"
: "${STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY:?load the estate identity-envelope signing key from its local source}"
# The base file interpolates before last-file replacement, so use an invalid non-secret sentinel.
export STELLAOPS_POSTGRES_EVIDENCE_CONNECTION='SCRATCH_OVERLAY_MUST_REPLACE_THIS'
EVD8_SCRATCH_COMPOSE=(
-p "${EVIDENCE_SCRATCH_RESOURCE_PREFIX}"
--env-file /dev/null
-f devops/compose/docker-compose.evidence.yml
-f "${EVD8_SCRATCH_OVERRIDE}"
-f devops/compose/docker-compose.evidence.scratch.yml
)
docker compose "${EVD8_SCRATCH_COMPOSE[@]}" config --quiet
# Re-run immediately before creation; the earlier clean inventory is not a reservation.
pwsh tools/scripts/validate/check-evidence-scratch-resources.ps1 \
-Prefix "${EVIDENCE_SCRATCH_RESOURCE_PREFIX}"
docker compose "${EVD8_SCRATCH_COMPOSE[@]}" up -d
The throwaway override must publish no ports, use the prefixed network only, and give its disposable database/cache containers the exact physical names <prefix>-db / <prefix>-cache and its disposable volumes <prefix>-postgres-data / <prefix>-cache-data. Those are the names the preflight proves absent. The checked-in last file labels every declared service, volume, and network with com.stellaops.scratch=true and com.stellaops.scratch.resource-prefix=<prefix> for attribution. Teardown only the exact scratch project, then reuse the same checker as the residue gate:
docker compose "${EVD8_SCRATCH_COMPOSE[@]}" down --volumes --remove-orphans
pwsh tools/scripts/validate/check-evidence-scratch-resources.ps1 \
-Prefix "${EVIDENCE_SCRATCH_RESOURCE_PREFIX}"
This preflight authorizes no EVD-9 data move, route swap, predecessor stop, grant change, or live Compose activation. Those remain inside the owner-attended atomic window below.
Rehearsal record 2026-09-03 and the absent-predecessor variant
The procedure below was rehearsed on 2026-09-03 (SPRINT_20260722_011 EVD-5, receipt docs/implplan/_evidence/20260903-evd5-scratch-forcing-and-evd9-rehearsal.md) on an isolated scratch project with the live PostgreSQL used read-only. What it measured changes how the window must be planned on this estate:
- Sections 3, 4 (count snapshots), 6 and 7 execute as written. Full and fenced dumps taken with
pg_dump18.1, the fenced dump restored into a fresh consolidated target with--exit-on-error --data-only --no-owner --no-privileges, and all 31 domain tables reached count and canonical-row SHA-256 parity.pg_dumpwarns about the circular foreign key onevidence_locker.evidence_bundles(supersedes/superseded_by); the restore still succeeded on the lab data because no row supersedes another. A future estate with supersession chains needs--disable-triggersor the two-pass restore the warning names; treat the warning as a gate, not noise. - The chain and spine gates are vacuous on this estate today.
attestor.verdict_ledgerandproofchain.spinesboth hold 0 rows, so sections 7.2 and 7.3 report 0 breaks andchecked=0. A green result there is not evidence of the recomputation; record the row counts beside it. - The three predecessor writers are absent, so section 4’s fence has nothing to stop. On this host
stellaops-attestor,stellaops-evidence-locker-webandstellaops-evidence-locker-workerdo not exist (containers, images and both carried data volumes are gone; D-EVD9-5). Section 2 step 5 and section 4 therefore run in the absent-predecessor variant: record the absence withdocker psanddocker volume lsreceipts instead of fencing, keep the two count snapshots 30 s apart (they still prove nothing is writing), and accept that the rollback path in “Rollback” step 4 cannot restart what does not exist. Rollback on this estate is: stop both targets, restore the gateway/Console blobs, and leavestellaops_platformuntouched, which still serves nothing for this family, exactly as before the window. - Section 8 step 2 is proved on the scratch target. With the staged overlay’s resource-server posture (HTTPS discovery through the router, audience
stellaops, tenantdefault, the predecessors’ bypass set, the CA bundle mount, the identity-envelope key) the successor read the tenants feed with the reconciledstellaops-evidence-webclient, converged a durablecatalog.replica.tenantscheckpoint at the feed head, answered 401 with a Bearer challenge on every scoped route, stored and retrieved a capsule with an identical root hash, verified it, and a bundle copied from the live source verifiedtrusted=truethrough the successor API. - Verification is a root-hash comparison, not a signature check (
EvidenceSnapshotService.VerifyAsync), so a copied bundle verifies even under a different capsule seed. Do not read that as proof the successor carries the predecessor’s key: the window still supplies the installation’sSTELLAOPS_EVIDENCELOCKER_CAPSULE_ED25519_SEED_B64so newly sealed capsules keep the sameevidence-locker-capsule-keyidentity the published JWKS verifies. - The 8 copied bundle headers have no payloads. Every
storage_keypoints under the deleted/data/evidencevolume; the forcing function “an evidence capsule stores and retrieves with digest intact” is discharged only by a NEW capsule stored through the successor, never by carried data. has_database_privilege('PUBLIC', …)fails on PostgreSQL 18.1 (role "PUBLIC" does not exist); the acceptance query in section 5 now uses the lowercase pseudo-role, which returnsfalsefor the revoked database andtruefor an unrevoked control database.
Required approvals and receipts
The window is NO-GO until every field names a real, reviewed artifact. A blank value, TBD, stale report, or verbal substitution is a failure.
| Required input | Receipt recorded before the window |
|---|---|
| Approved start/end, change owner, DB operator, gateway operator, Console operator, rollback owner | change/window ID and approver |
| Current build-boundary report | 0 unpinned, 0 stale, 0 grown, 0 activation mismatches, 0 impure client SDKs |
| EVD-5 source ownership | check-evidence-p19-transition.ps1 -RequirePost reports the coherent post-state: the four Evidence-to-predecessor implementation pairs are gone without temporary pins and all three active predecessor host projects have left the supported source tree |
| Tenant and default-region resolution | local catalog-replica forcing test/commit for tenant slug-to-UUID; setup receipt naming the installation’s STELLAOPS_DEFAULT_REGION and the tested fail-closed source commit; both Authority Standard descriptors plus tests pin stellaops-evidence-web to default tenant, stellaops, client_credentials, and exactly catalog:replicate platform:doctor:register; live token/feed/checkpoint receipts contain no secret |
| Direct callers and SQL readers | reviewed repoint manifest, every row closed or owner-excluded; its SQL-reader section must contain the exact two rows in consolidation design §4.3 and show both foreign database authorities removed |
| EVD-6 Console change | tested commit and Console image digest; /policies and /reports remain Policy-owned |
| Target roles | web/worker image digests, provenance receipts, runtime render, authenticated rehearsal. The render must carry the Attestor half the retired attestor definition carried (SPRINT_20260722_011 D-EVD5-17): attestor__signer__baseUrl, attestor__rekor__primary__{url,publicKeyBase64}, attestor__transparency__local__*, attestor__signing__keys__0__* with the PEM mount, attestor__verdictTrust__keys__0__*, attestor__security__signerIdentity__mode__*, attestor__security__mtls__requireClientCertificate, attestor__rekorQueue__enabled. Missing keys mean product defaults: mTLS-only writes and checkpoint_public_key_missing on every local-log entry (measured 2026-09-05, _evidence/20260905-evd9-attestor-half-carry-over.md). Assert with docker compose … config --format json before section 8, not after the forcing function fails. |
| Backup | full source-schema dump SHA-256 and isolated restore-test receipt |
| Permissions | reviewed forward and rollback grant plans; no ad-hoc role names |
| Rollback | predecessor image IDs, exact compose lists, source config blobs, named restart commands |
| Soak | duration, query owner, success thresholds, abort thresholds |
The pre-window reviewer signs this table in the window record. That signature does not waive a live measurement below.
The initial source census found two direct SQL consumers. Sprint 021 PLT-1A closed Platform’s legacy-capsule erasure row, and sprint 026 CM-5 closed the CLI local-transparency row. The current production-source census therefore has zero foreign direct SQL readers of the carried schemas: Platform’s SQL adapter and silent no-refusal binding are deleted, while the CLI has no selectable PostgreSQL mode or local connection string and instead verifies the proof returned by the authenticated owner API. Canonical Evidence configuration wins at composition; invalid canonical configuration and owner failure fail closed without predecessor, database, or Rekor-v2 fallback.
Those source closures are not live cutover receipts. The window still captures the configured owner origin and proves each request crosses the selected predecessor or target route. The same receipt must enumerate and close the remaining direct HTTP bindings; a zero-row SQL census is not a substitute for that caller inventory. CM-5 also does not close the CLI’s seven remaining direct Attestor-family ProjectReference edges, which are source-boundary debt outside this database-reader repoint.
The reviewed HTTP inventory is consolidation-design.md §4.4. caller-repoints.tsv must carry every ID from EVD-HTTP-00 through EVD-HTTP-20, including rows classified as stopped, on-demand, dormant or unwired; those rows need an owner-signed exclusion and the command/config evidence that proves the classification at the window. Active rows need old and new origin, exact method/path, authentication mode, forced request ID, status and response digest. The source/compose guard EvidenceDirectHttpCallerConformanceTests must be green on the reviewed window commit; it reads the per-binding census in consolidation-design.md §4.5, so a binding this window repoints or retires is recorded there rather than in the test. Do not replace this with a hostname grep: several active callers inherit their old origin from source defaults, and CLI EvidenceCommandGroup uses the canonical Evidence variable with mixed legacy paths.
1. Establish the window workspace
Use opaque identifiers; do not put credentials in these variables.
set -euo pipefail
export EVD9_WINDOW_ID='<approved-window-id>'
export EVD9_ROOT="tmp/evd9-${EVD9_WINDOW_ID}"
export EVD9_PG_CONTAINER='stellaops-postgres'
export EVD9_PG_SUPERUSER='stellaops'
export EVD9_SOURCE_DB='stellaops_platform'
export EVD9_TARGET_DB='stellaops_evidence'
export EVD9_TARGET_ROLE='evidence'
export EVD9_PROJECT='stellaops'
mkdir -p "${EVD9_ROOT}"/{preflight,backup,parity,forcing,rollback,soak}
test -n "${EVD9_WINDOW_ID}"
git rev-parse HEAD | tee "${EVD9_ROOT}/preflight/repository-head.txt"
git status --short | tee "${EVD9_ROOT}/preflight/repository-status.txt"
Explain every status row before continuing. Do not clean or reset the shared checkout. The window change set must be a reviewed commit, not loose edits.
2. Run the fail-closed preflight
Record the build-boundary verdict. The command must exit 0 with unpinned, stale, grown, activation mismatch, and impure client SDK all at zero.
pwsh tools/scripts/build-boundary/generate-build-boundary-report.ps1 -Check \ 2>&1 | tee "${EVD9_ROOT}/preflight/build-boundary.txt"Prove the Evidence ownership transition is complete rather than hidden. The self-test first proves the checker rejects a mixed project set, a temporary pin, and a family reclassification while the supported predecessor hosts remain.
-RequirePostmust then report the coherent post-state. A coherent pre-state is still NO-GO; it needs the owner-approved EVD-9/EVD-10 transition decision, not a pin.pwsh tools/scripts/build-boundary/check-evidence-p19-transition.ps1 -SelfTest pwsh tools/scripts/build-boundary/check-evidence-p19-transition.ps1 -RequirePost \ 2>&1 | tee "${EVD9_ROOT}/preflight/evidence-p19-transition.txt"Verify the route package without writing.Retired 2026-09-04 (banner above): the script now refuses and exits 2, and that is the correct end state, not a NO-GO to reconcile. Do not run it. Verify instead that the auto-published Evidence surfaces answer through the gateway and that the Policy false friends still resolve to Policy.-Checkmust report the exact fifteen predecessor routes, ten overlay keys to replace with one, both Policy false friends, and the Platform/api/v2/evidenceroute. Any other drift is NO-GO.# RETIRED 2026-09-04 -- do not run. The script reports the swap as retired and exits 2. # pwsh tools/scripts/evd5-swap-evidence-routes.ps1 -Check # 2>&1 | tee "${EVD9_ROOT}/preflight/route-swap-check.txt"Attach the reviewed caller/SQL-reader manifest, EVD-6 receipt, backup restore proof, image provenance, permission plans, approval, and Evidence tenant-replica identity receipts. Both checked-in Authority Standard descriptors now define
stellaops-evidence-web, and their focused test pins its exact default tenant,stellaopsaudience,client_credentialsgrant, environment secret reference, and the two-scope setcatalog:replicate platform:doctor:register. That is source preparation only.docker-compose.evidence.ymldeliberately retains:?guards for the explicit client ID and operator-owned secret; the live row must be reconciled and token/feed probes must prove the intended claim set without recording the secret. This assertion rejects absent or empty files.for receipt in \ caller-repoints.tsv console-evd6.txt backup-restore-proof.txt \ authority-evidence-descriptors.txt authority-evidence-tests.txt \ authority-evidence-token-claims.txt authority-evidence-feed.txt \ evidence-web-image.txt evidence-worker-image.txt grants-forward.sql grants-rollback.sql \ window-approval.txt; do test -s "${EVD9_ROOT}/preflight/${receipt}" || { echo "NO-GO: missing preflight receipt ${receipt}" >&2 exit 1 } done grep -Eq '@sha256:[0-9a-f]{64}$' "${EVD9_ROOT}/preflight/evidence-web-image.txt" grep -Eq '@sha256:[0-9a-f]{64}$' "${EVD9_ROOT}/preflight/evidence-worker-image.txt"Re-measure the live topology. The expected serving writers are
stellaops-attestor,stellaops-evidence-locker-web, andstellaops-evidence-locker-worker; tileproxy and TSA are recorded but not fenced. Neither target role may already be running. If a writer is absent (measured on 2026-09-03: all three, plus their images and both carried volumes), thedocker inspectloop below fails for it; record the absence as the receipt for that container and run section 4 in the absent-predecessor variant described above.docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' \ | sort | tee "${EVD9_ROOT}/preflight/containers-before.tsv" for container in \ stellaops-attestor stellaops-evidence-locker-web stellaops-evidence-locker-worker \ stellaops-attestor-tileproxy stellaops-tsa; do docker inspect --format '{{index .Config.Labels "com.docker.compose.project.config_files"}}' \ "${container}" > "${EVD9_ROOT}/rollback/${container}.config-files.txt" docker inspect --format '{{.Config.Image}} {{.Image}}' "${container}" \ > "${EVD9_ROOT}/rollback/${container}.image.txt" docker inspect --format '{{json .Mounts}}' "${container}" \ > "${EVD9_ROOT}/rollback/${container}.mounts.json" docker inspect --format '{{json .NetworkSettings.Networks}}' "${container}" \ > "${EVD9_ROOT}/rollback/${container}.networks.json" done test -z "$(docker ps -q --filter name='^/stellaops-evidence-web$')" test -z "$(docker ps -q --filter name='^/stellaops-evidence-worker$')"Record database/role state and source estimates. Historical sprint counts are context, not acceptance values.
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \ -U "${EVD9_PG_SUPERUSER}" -d postgres -c \ "SELECT datname, datdba::regrole AS owner, datallowconn FROM pg_database WHERE datname IN ('${EVD9_SOURCE_DB}','${EVD9_TARGET_DB}') ORDER BY datname;" \ | tee "${EVD9_ROOT}/preflight/databases-before.txt" docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \ -U "${EVD9_PG_SUPERUSER}" -d "${EVD9_SOURCE_DB}" -At -F $'\t' -c \ "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables WHERE schemaname IN ('attestor','proofchain','evidence_locker') ORDER BY schemaname, relname;" \ | tee "${EVD9_ROOT}/preflight/source-estimated-counts.tsv"
Stand down if the target database already contains domain data, a target role is running, or any required receipt/checker is red.
3. Capture a restorable source backup
Take the full schema+data backup before the writer fence, then the exact transfer snapshot after the fence. The first is disaster recovery; the second is the frozen dataset to copy.
docker exec -i "${EVD9_PG_CONTAINER}" pg_dump \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_SOURCE_DB}" -Fc \
--schema=attestor --schema=proofchain --schema=evidence_locker \
--schema=evidence_locker_app \
> "${EVD9_ROOT}/backup/source-domain-full.dump"
test -s "${EVD9_ROOT}/backup/source-domain-full.dump"
sha256sum "${EVD9_ROOT}/backup/source-domain-full.dump" \
| tee "${EVD9_ROOT}/backup/source-domain-full.dump.sha256"
pg_restore --list "${EVD9_ROOT}/backup/source-domain-full.dump" \
> "${EVD9_ROOT}/backup/source-domain-full.list"
grep -q 'SCHEMA.*attestor' "${EVD9_ROOT}/backup/source-domain-full.list"
grep -q 'SCHEMA.*proofchain' "${EVD9_ROOT}/backup/source-domain-full.list"
grep -q 'SCHEMA.*evidence_locker' "${EVD9_ROOT}/backup/source-domain-full.list"
evidence_locker_appis not optional (found 2026-09-05, window20260905-evd9). Everyevidence_lockerRLS policy callsevidence_locker_app.require_current_tenant(), andpg_dependshows it is the only foreign schema the three families’ policies and functions reference. A dump of the three domain schemas alone loads its data and then fails at the firstCREATE POLICYon a standalone restore (schema "evidence_locker_app" does not exist) - while every row count still matches, which is how the gap hides. The 2026-09-03 rehearsal never hit it because it restore-tested the fenced data-only dump into a converged target that already carried the functions. Restore-test the FULL dump into an empty database with--exit-on-errorand require exit 0, not just matching counts.
Compare PostgreSQL versions and command shape with the restore-tested receipt. A newly created dump has not itself been restored; a version or command drift requires a new isolated restore test.
4. Fence the writers and freeze the source
Only the three database-writer containers stop. Do not stop Signer, tileproxy, or TSA.
docker stop \
stellaops-attestor \
stellaops-evidence-locker-web \
stellaops-evidence-locker-worker \
| tee "${EVD9_ROOT}/preflight/writer-fence-stop.txt"
for container in stellaops-attestor stellaops-evidence-locker-web stellaops-evidence-locker-worker; do
test "$(docker inspect -f '{{.State.Running}}' "${container}")" = 'false'
done
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_SOURCE_DB}" -c \
"SELECT pid, usename, application_name, client_addr, state
FROM pg_stat_activity WHERE datname = '${EVD9_SOURCE_DB}' ORDER BY pid;" \
| tee "${EVD9_ROOT}/preflight/source-sessions-after-fence.txt"
The DB operator compares that list with the captured writer identities and signs that none remain. Then capture exact counts twice, at least 30 seconds apart. The files must be byte-identical.
snapshot_counts() {
local database="$1" output="$2"
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${database}" -At -F $'\t' <<'SQL' > "${output}"
SELECT format(
'SELECT %L, count(*) FROM %I.%I;',
schemaname || '.' || tablename, schemaname, tablename)
FROM pg_tables
WHERE schemaname IN ('attestor','proofchain','evidence_locker')
AND tablename NOT IN ('schema_migrations','schema_migration_reconciliations')
ORDER BY schemaname, tablename
\gexec
SQL
}
snapshot_counts "${EVD9_SOURCE_DB}" "${EVD9_ROOT}/parity/source-counts-a.tsv"
sleep 30
snapshot_counts "${EVD9_SOURCE_DB}" "${EVD9_ROOT}/parity/source-counts-b.tsv"
cmp "${EVD9_ROOT}/parity/source-counts-a.tsv" "${EVD9_ROOT}/parity/source-counts-b.tsv"
Create the frozen data-only transfer dump. Predecessor migration ledgers are excluded because the target’s fresh Evidence baseline remains its migration authority. Derive the ledger list from the source, do not copy it from here: the EvidenceLocker predecessor kept its ledger under the non-standard name evidence_locker.evidence_schema_version, which the 2026-09-05 window found only when the data-only restore died mid-way on a table the target never creates (the target was then partially loaded and had to be re-truncated). Any table present in the source but absent from the converged target is a ledger or residue to exclude, and the parity denominator is source tables - excluded ledgers (33 - 2 = 31 on that window), not a constant.
# Every source table the converged target does not have is the exclusion list: derived, not assumed.
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -At -U "${EVD9_PG_SUPERUSER}" -d "${EVD9_SOURCE_DB}" -c \
"SELECT schemaname||'.'||tablename FROM pg_tables WHERE schemaname IN ('attestor','proofchain','evidence_locker') ORDER BY 1;" \
> "${EVD9_ROOT}/parity/source-tables.txt"
# Compare with the target's table list captured in section 5; each source-only name becomes an --exclude-table below.
docker exec -i "${EVD9_PG_CONTAINER}" pg_dump \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_SOURCE_DB}" -Fc --data-only \
--schema=attestor --schema=proofchain --schema=evidence_locker \
--exclude-table=attestor.schema_migrations \
--exclude-table=attestor.schema_migration_reconciliations \
--exclude-table=proofchain.schema_migrations \
--exclude-table=proofchain.schema_migration_reconciliations \
--exclude-table=evidence_locker.schema_migrations \
--exclude-table=evidence_locker.schema_migration_reconciliations \
--exclude-table=evidence_locker.evidence_schema_version \
> "${EVD9_ROOT}/backup/source-domain-fenced-data.dump"
test -s "${EVD9_ROOT}/backup/source-domain-fenced-data.dump"
sha256sum "${EVD9_ROOT}/backup/source-domain-fenced-data.dump" \
| tee "${EVD9_ROOT}/backup/source-domain-fenced-data.dump.sha256"
pg_restore --list "${EVD9_ROOT}/backup/source-domain-fenced-data.dump" \
> "${EVD9_ROOT}/backup/source-domain-fenced-data.list"
5. Provision and converge the target
Load the Evidence role password from the approved secret store into PGPASSWORD_SERVICE without echoing it, then use the repository’s idempotent provisioner.
test -n "${PGPASSWORD_SERVICE:-}" || {
echo 'NO-GO: PGPASSWORD_SERVICE was not loaded from the approved secret store' >&2
exit 1
}
bash tools/scripts/deploy/postgres/provision-service-database.sh \
"${EVD9_TARGET_DB}" "${EVD9_TARGET_ROLE}" \
--container "${EVD9_PG_CONTAINER}" --superuser "${EVD9_PG_SUPERUSER}" \
| tee "${EVD9_ROOT}/preflight/provision-target.txt"
unset PGPASSWORD_SERVICE
Verify ownership/isolation before a target process starts:
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d postgres -c \
"SELECT d.datname, d.datdba::regrole AS owner,
has_database_privilege('public', d.datname, 'CONNECT') AS public_connect,
r.rolsuper, r.rolbypassrls
FROM pg_database d JOIN pg_roles r ON r.oid = d.datdba
WHERE d.datname = '${EVD9_TARGET_DB}';" \
| tee "${EVD9_ROOT}/preflight/target-ownership.txt"
Acceptance is owner evidence, public_connect = false, rolsuper = false, and rolbypassrls = false.
Load the reviewed STELLAOPS_POSTGRES_EVIDENCE_CONNECTION, certificate password, Evidence service identity, installation region, and capsule signing seed into the process environment. Do not print them. Use only the Evidence overlay and reviewed digest-pin override, with --no-deps; do not reconstruct or recreate the live stack. These checks intentionally name the required inputs without printing their values.
test -s "${EVD9_ROOT}/preflight/evidence-digest-pin.override.yml"
test -n "${STELLAOPS_POSTGRES_EVIDENCE_CONNECTION:-}"
test -n "${KESTREL_CERT_PASSWORD:-}"
test -n "${STELLAOPS_DEFAULT_REGION:-}"
test -n "${EVIDENCE_AUTHORITY_CLIENT_ID:-}"
test -n "${EVIDENCE_AUTHORITY_CLIENT_SECRET:-}"
test -n "${STELLAOPS_EVIDENCELOCKER_CAPSULE_ED25519_SEED_B64:-}"
test -n "${STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY:-}"
EVD9_COMPOSE=(
-p "${EVD9_PROJECT}"
--env-file devops/compose/.env
-f devops/compose/docker-compose.evidence.yml
-f "${EVD9_ROOT}/preflight/evidence-digest-pin.override.yml"
)
docker compose "${EVD9_COMPOSE[@]}" up -d --no-deps --force-recreate evidence-web
until docker inspect -f '{{.State.Health.Status}}' stellaops-evidence-web | grep -qx healthy; do
sleep 2
done
docker logs stellaops-evidence-web > "${EVD9_ROOT}/preflight/evidence-web-converge.log" 2>&1
docker stop stellaops-evidence-web
Prove the baseline shape before clearing seed data. Expected shape is 10 Attestor tables, 8 ProofChain tables plus artifact_canonical_records, 13 EvidenceLocker tables, one fresh Evidence migration ledger, and 13 EvidenceLocker tables with both RLS and FORCE RLS. Any difference is NO-GO; do not edit a migration in the window.
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" -c \
"SELECT n.nspname AS schema_name, c.relkind, count(*)
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname IN ('attestor','proofchain','evidence_locker','evidence')
AND c.relkind IN ('r','m')
GROUP BY n.nspname, c.relkind ORDER BY n.nspname, c.relkind;
SELECT count(*) FILTER (WHERE relrowsecurity) AS rls,
count(*) FILTER (WHERE relforcerowsecurity) AS forced
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'evidence_locker' AND c.relkind = 'r';" \
| tee "${EVD9_ROOT}/preflight/target-baseline-shape.txt"
6. Copy the frozen data
This clears only the newly converged target domain tables. All target roles and predecessor writers must still be stopped.
for container in \
stellaops-attestor stellaops-evidence-locker-web stellaops-evidence-locker-worker \
stellaops-evidence-web stellaops-evidence-worker; do
test -z "$(docker ps -q --filter name="^/${container}$")"
done
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" <<'SQL'
SELECT format('TRUNCATE TABLE %I.%I CASCADE;', schemaname, tablename)
FROM pg_tables
WHERE schemaname IN ('attestor','proofchain','evidence_locker')
ORDER BY schemaname, tablename
\gexec
SQL
docker exec -i "${EVD9_PG_CONTAINER}" pg_restore \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" \
--exit-on-error --data-only --no-owner --no-privileges \
< "${EVD9_ROOT}/backup/source-domain-fenced-data.dump" \
2>&1 | tee "${EVD9_ROOT}/backup/target-restore.txt"
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" -c \
'REFRESH MATERIALIZED VIEW proofchain.artifact_canonical_records;' \
| tee "${EVD9_ROOT}/backup/target-materialized-view-refresh.txt"
7. Prove row parity and integrity
7.1 Per-table count and canonical-row SHA-256
For every ordinary table, compare count and SHA-256 of C-collated canonical JSONB rows. Migration ledgers are intentionally excluded. An empty table still has a stable digest.
mapfile -t EVD9_TABLES < <(
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -At \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_SOURCE_DB}" -c \
"SELECT format('%I.%I', schemaname, tablename)
FROM pg_tables
WHERE schemaname IN ('attestor','proofchain','evidence_locker')
AND tablename NOT IN ('schema_migrations','schema_migration_reconciliations')
ORDER BY schemaname, tablename;"
)
table_count() {
local database="$1" table="$2"
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -At \
-U "${EVD9_PG_SUPERUSER}" -d "${database}" -c "SELECT count(*) FROM ${table};"
}
table_digest() {
local database="$1" table="$2"
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -qAt \
-U "${EVD9_PG_SUPERUSER}" -d "${database}" -c \
"COPY (SELECT to_jsonb(t)::text FROM ${table} AS t
ORDER BY to_jsonb(t)::text COLLATE \"C\") TO STDOUT" \
| sha256sum | awk '{print $1}'
}
printf 'table\tsource_count\ttarget_count\tsource_sha256\ttarget_sha256\tverdict\n' \
> "${EVD9_ROOT}/parity/table-parity.tsv"
for table in "${EVD9_TABLES[@]}"; do
source_count="$(table_count "${EVD9_SOURCE_DB}" "${table}")"
target_count="$(table_count "${EVD9_TARGET_DB}" "${table}")"
source_hash="$(table_digest "${EVD9_SOURCE_DB}" "${table}")"
target_hash="$(table_digest "${EVD9_TARGET_DB}" "${table}")"
verdict=PASS
test "${source_count}" = "${target_count}" || verdict=FAIL
test "${source_hash}" = "${target_hash}" || verdict=FAIL
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"${table}" "${source_count}" "${target_count}" "${source_hash}" "${target_hash}" "${verdict}" \
| tee -a "${EVD9_ROOT}/parity/table-parity.tsv"
done
! grep -q $'\tFAIL$' "${EVD9_ROOT}/parity/table-parity.tsv"
Compare the refreshed materialized view separately. Both count/hash pairs must match; a stale source view is a finding, not permission to refresh the frozen source.
for database in "${EVD9_SOURCE_DB}" "${EVD9_TARGET_DB}"; do
printf '%s\t%s\t%s\n' "${database}" \
"$(table_count "${database}" proofchain.artifact_canonical_records)" \
"$(table_digest "${database}" proofchain.artifact_canonical_records)"
done | tee "${EVD9_ROOT}/parity/artifact-canonical-records.tsv"
7.2 Exhaustive verdict-ledger linkage
The first row per tenant has a null prev_hash; every later row names the preceding verdict_hash. The result must be 0 on source and target.
for database in "${EVD9_SOURCE_DB}" "${EVD9_TARGET_DB}"; do
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -At -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${database}" -c \
"WITH ordered AS (
SELECT tenant_id, verdict_hash, prev_hash,
lag(verdict_hash) OVER (
PARTITION BY tenant_id ORDER BY created_at, ledger_id) AS expected_prev,
row_number() OVER (
PARTITION BY tenant_id ORDER BY created_at, ledger_id) AS rn
FROM attestor.verdict_ledger
)
SELECT count(*) FROM ordered
WHERE (rn = 1 AND prev_hash IS NOT NULL)
OR (rn > 1 AND prev_hash IS DISTINCT FROM expected_prev);"
done | tee "${EVD9_ROOT}/parity/verdict-chain-breaks.txt"
test "$(sort -u "${EVD9_ROOT}/parity/verdict-chain-breaks.txt" | tr -d '[:space:]')" = '0'
7.3 Exhaustive proof-spine recomputation
This gate mirrors DeterministicMerkleTreeBuilder: UTF-8 leaves, global bytewise lexical sort, SHA-256 per leaf, duplicate the final leaf hash to the next power of two, then SHA-256 of left || right until one lowercase 64-hex root remains. It checks every target spine against its stored bundle_id.
Create tmp/evd9-<id>/parity/verify-spines.py from this reviewed body:
import hashlib
import json
import sys
failures = 0
checked = 0
for raw in sys.stdin:
if not raw.strip():
continue
row = json.loads(raw)
leaves = [str(row["entry_id"])]
leaves.extend(sorted(str(value) for value in row["evidence_ids"]))
leaves.append(str(row["reasoning_id"]))
leaves.append(str(row["vex_id"]))
encoded = sorted(value.encode("utf-8") for value in leaves)
level = [hashlib.sha256(value).digest() for value in encoded]
target = 1
while target < len(level):
target <<= 1
while len(level) < target:
level.append(level[-1])
while len(level) > 1:
level = [
hashlib.sha256(level[index] + level[index + 1]).digest()
for index in range(0, len(level), 2)
]
actual = level[0].hex()
expected = str(row["bundle_id"]).lower()
checked += 1
if actual != expected:
failures += 1
print(f"FAIL\t{row['entry_id']}\t{expected}\t{actual}")
print(f"SUMMARY\tchecked={checked}\tfailures={failures}")
raise SystemExit(1 if failures else 0)
Run it and retain the output:
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -qAt -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" -c \
"COPY (
SELECT json_build_object(
'entry_id', entry_id, 'evidence_ids', evidence_ids,
'reasoning_id', reasoning_id, 'vex_id', vex_id, 'bundle_id', bundle_id)::text
FROM proofchain.spines ORDER BY entry_id
) TO STDOUT" \
| python "${EVD9_ROOT}/parity/verify-spines.py" \
| tee "${EVD9_ROOT}/parity/proof-spines.txt"
7.4 Artifact foreign key
Count dangling evidence_artifacts.bundle_id values after the copy. A non-zero result is NO-GO and needs an owner disposition; do not add the constraint NOT VALID, because new inserts are still checked in that form.
orphan_count="$(docker exec -i "${EVD9_PG_CONTAINER}" psql -X -At -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" -c \
"SELECT count(*)
FROM evidence_locker.evidence_artifacts a
LEFT JOIN evidence_locker.evidence_bundles b ON b.bundle_id = a.bundle_id
WHERE b.bundle_id IS NULL;")"
printf '%s\n' "${orphan_count}" | tee "${EVD9_ROOT}/parity/evidence-artifact-orphans.txt"
test "${orphan_count}" = '0'
docker exec -i "${EVD9_PG_CONTAINER}" psql -X -v ON_ERROR_STOP=1 \
-U "${EVD9_PG_SUPERUSER}" -d "${EVD9_TARGET_DB}" <<'SQL'
DO $do$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_artifacts_bundle'
AND conrelid = 'evidence_locker.evidence_artifacts'::regclass
) THEN
ALTER TABLE evidence_locker.evidence_artifacts
ADD CONSTRAINT fk_artifacts_bundle
FOREIGN KEY (bundle_id)
REFERENCES evidence_locker.evidence_bundles(bundle_id)
ON DELETE CASCADE;
END IF;
END $do$;
SQL
Re-run the target table receipt after adding the constraint. Data counts/hashes must not change.
8. Start the successor and perform the one repoint
Start both target roles from the same immutable override. Use
--no-deps.docker compose "${EVD9_COMPOSE[@]}" up -d --no-deps --force-recreate \ evidence-web evidence-worker until docker inspect -f '{{.State.Health.Status}}' stellaops-evidence-web | grep -qx healthy; do sleep 2 done test "$(docker inspect -f '{{.State.Running}}' stellaops-evidence-worker)" = 'true' docker inspect --format '{{.Config.Image}} {{.Image}}' \ stellaops-evidence-web stellaops-evidence-worker \ | tee "${EVD9_ROOT}/forcing/target-images-running.txt"Probe the successor directly before exposing it.
/health/readymust be 200; an anonymous scoped endpoint must be 401/403; an authorized read must succeed; all Evidence doctor checks must be healthy. Also provecurrent_database()is exactlystellaops_evidence, bothcatalog_replica.replica_rowsandeventing.consumer_checkpointsexist there, thetenantsconsumer has a durable checkpoint, and a known active slug resolves while a measured unknown slug does not. Store results underforcing/; never record a token or secret.Apply the reviewed window change set containing all three inseparable changes:
the route script’s fifteen-to-one group in both route sources— retired; omit this part entirely (banner above). Nothing is applied to the route sources in this window;- deletion of the manifest’s ten old overlay keys plus
^/api/evidence/v1(.*) -> https://evidence.stella-ops.local/api/evidence/v1$1; - the EVD-6 Console prefix/image change, preserving Policy’s
/api/v1/attestor/policiesand/api/v1/attestor/reportsroutes.
Do not hand-edit a partial state. Re-run the checker after the route and overlay change.
# RETIRED 2026-09-04 -- do not run either command. Both invocations report the swap as # retired and exit 2 since ced8a2d286; neither writes anything. # pwsh tools/scripts/evd5-swap-evidence-routes.ps1 # 2>&1 | tee "${EVD9_ROOT}/forcing/route-swap-apply.txt" # pwsh tools/scripts/evd5-swap-evidence-routes.ps1 -Check # 2>&1 | tee "${EVD9_ROOT}/forcing/route-swap-postcheck.txt"Recreate only gateway and Console using their exact captured live config-file lists and reviewed image pins. Never run broad
up -dagainst a reconstructed subset. Record commands before use.Verify first-match routing for the consolidated group, both Policy false friends, and the Platform
/api/v2/evidenceroute before functional forcing.
9. Run the forcing functions
Each must cross the gateway and identify the successor build in response metadata or correlated logs. Record request ID, tenant, actor, result, persisted identifiers, digest, and timestamps; never record tokens or sensitive payloads.
Configuration precondition (EVD-13, added after the 2026-09-05 window). FF-1 and FF-3 below are only executable once the running render carries the consolidated host’s Attestor transparency and write keys. The 2026-09-05 window ran without them and both gates failed on configuration rather than on the data: assert them in the live render BEFORE running the forcing functions, not after interpreting a failure.
for role in stellaops-evidence-web stellaops-evidence-worker; do
docker inspect "$role" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -cE '^attestor__' # expect 28
docker exec "$role" test -f /app/etc/secrets/attestor-signing.pem && echo "$role KEY-MOUNTED"
done
docker inspect stellaops-evidence-web --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^attestor__security__mtls__requireClientCertificate=' # must be false
If any is missing the host is running on product defaults that differ from what this estate ran: the write leg answers 403 attestor.validation.client_cert_required to every caller on every network (this host serves plain HTTP and never calls ConfigureAttestorKestrel, so no client certificate can ever be presented — true here is a kill switch, not a posture), and every entry that reaches the checkpoint stage fails checkpoint_public_key_missing. EvidenceConsolidationComposeConformanceTests pins the same key set in the COMMITTED overlay, so a render mismatch here means the running container is stale, not that the file drifted.
- Attestation (FF-1). Identity: a bearer client holding the canonical scope
attest:create(the ASP.NET policy name isattestor:write). Path:POST /api/evidence/v1/rekor/entriesthrough the gateway. Prove, in order: submission accepted with a rekor uuid;GET /api/evidence/v1/attestations/{uuid}200 from the successor build;POST /api/evidence/v1/rekor/verifywith that uuid returnsok:true; and the RFC 3161 leg via the unchanged TSA role (FF-2 exercises the same TSA and may be cited). Scope negative — read this before recording one.attestor:writeis aRequireAssertionpolicy of “attest:createOR trusted-network service bypass”, and the gateway forwards from 172.19.0.0/16, which IS a bypass network. A no-scope negative run through the gateway therefore proves nothing about scope: it is admitted by address. Either run the negative from outside the bypass set, or record explicitly that the scope gate is not provable through the gateway (ROA-1 class,SPRINT_20260904_003). Do not record a gateway 403 from the certificate gate as a scope result — that was the 2026-09-05 window’s ambiguity. - Evidence capsule: store an approved non-sensitive payload, record its SHA-256, retrieve it through
/api/evidence/v1/evidence/**, and prove the returned bytes re-hash identically. - Proof/Console (FF-3). Run this on a subject attested by FF-1’s own submission, not on a carried subject. Open it in deployed Console, render the chain, run “Verify all”, and require PROVEN. A direct API 200 without Console rendering does not satisfy this gate. Two Console behaviours decide whether you see anything at all: the custody list filter is an EXACT match on artifact sha256 / image digest / subject URI and Console sends the
sha256:-prefixed digest, so only animageDigest-bearing attestation is found; and only kinds in the timeline’sSCAN_KINDS/DEPLOY_KINDS/BUILD_KINDS/VERDICT_KINDSlight a step — areportrenders nothing. Expected residual:witness_missing(withpolicy_warn:transparency) stays on every entry and is a NON-blocking warn —Verification.RequireWitnessEndorsementdefaults false and no witness aggregator is configured on this estate or was on the predecessor. It must not render as TAMPERED. Do NOT gate this on the carried entries, and do not reach for the SER-5b proof backfill to “fix” them. Measured 2026-09-05 with every key above in place (docs/implplan/_evidence/20260905-evd9-attestor-half-carry-over.md): all 26 carried entries verifyok:falseBY DATA, on any configuration.- 23 rows are UIF-8 demo fixtures on
log_backend='stellaops-transparency', a backend and log URL no writer insrc/produces; their checkpoint names a log that does not exist and their proofs carry unoriented hex hashes.POST /rekor/entries/proof-backfill?maxEntries=1000returned 200{"refreshed":31}and the md5 of those 23 rows’ proof JSON was byte-identical before and after — nothing re-derives a proof for a log that was never written.proof_leafhash_mismatch+proof_path_orientation_missingis the CORRECT verdict on them. - 3 rows are real local-log entries whose checkpoint notes were signed by the dev key KEY-2R retired (
f11c76a5d8, 2026-08-21) because it had been committed. With the current key they move tocheckpoint_signature_invalid. Making them pass would mean trusting a retired key. Record this as a data-provenance fact, not a configuration failure. Establish what wrote a row before concluding the host is misconfigured — that inversion cost the 2026-09-05 window a cycle.
- 23 rows are UIF-8 demo fixtures on
- Repeat one protected endpoint anonymously. It must remain 401/403 and persist no row.
Unavailable or ambiguous ownership, stale timestamp, digest change, or Policy-owned path handled by Evidence triggers rollback.
10. Revoke predecessor access and soak
Only after parity and all forcing functions are green:
- Apply reviewed
grants-forward.sqlagainst the exact database named in its header. It revokes only recorded predecessor access; it does not drop a schema, role, or database. - Prove the reviewed inverse remains available in
grants-rollback.sql. - Verify the Evidence owner remains
NOSUPERUSER NOBYPASSRLS, all 13 EvidenceLocker RLS tables remain forced, both targets are running, and all Evidence doctor checks are healthy. - Keep predecessor writers stopped but present. Retain their images, compose definitions, source schemas, and volumes through rollback retention.
- Soak for the approved duration. Capture target/gateway/Console/TSA/tileproxy errors, permission denials, queue backlog, retries, latency, and forcing identifiers. Any unexplained increase or predecessor write is an abort.
Close EVD-9 only after a signed soak receipt. EVD-10 and old-schema deletion remain separate work.
Rollback
Rollback is available because the source schemas are frozen and unchanged. Repoint back; do not restore over an intact source database.
- Declare the abort and keep the writer fence. Stop both targets.
- Restore the exact route, overlay, and Console blobs/image captured under
rollback/; recreate only gateway and Console with their captured config-file lists. Verify the fifteen predecessor routes, wildcard-backed Console Attestor paths, both Policy false friends, and/api/v2/evidencebefore restarting writers. - Apply reviewed
grants-rollback.sqland verify predecessor access equals the pre-window receipt. - Restart
stellaops-attestor,stellaops-evidence-locker-web, andstellaops-evidence-locker-workerusing captured image IDs and exact compose lists. Do not restart Signer, TSA, or tileproxy unless their own health requires it. - Repeat predecessor health, authorization, capsule digest, attestation/timestamp, and Console proof checks. Confirm source counts still equal
source-counts-b.tsv. - Leave
stellaops_evidenceand its failed-window data intact for investigation unless the owner separately approves destruction. Do not drop the database or role as cleanup.
Restore source-domain-full.dump only if the source itself was unexpectedly modified or lost, and only through the separately approved migration recovery procedure. That is disaster recovery, not normal EVD-9 rollback.
Window closeout record
The signed record contains:
- window ID, operators, approver, start/end, repository commit;
- predecessor/successor image references, runtime image IDs, exact compose lists and commands;
- full/fenced dump SHA-256 values and restore-test receipt;
- per-table count/digest TSV, materialized-view parity, verdict chain, spine recomputation, orphan count, and FK catalog proof;
- route pre/post checks and first-match results for the group and three false friends;
- all four forcing receipts (three authorized paths plus anonymous denial);
- forward/rollback permission-plan results, soak duration, and log/metric verdict;
- final decision: committed, rolled back, or held, with owner signature.
An absent receipt means the window remains open or rolls back; it never becomes a prose exception.
