Plan: chain-v2 substrate — schema, KAT vectors, restricted roles, empty-genesis install (#1246, epic &73)

On this page
NOTE

Child of the chain-v2 rollout plan (#1236). The contract is ADR-014 Amendment 5 C1–C8 as revised by Amendment 6 (this MR) — a contextless implementer reads those first; this plan owns the byte-level design the ADR delegates. Consumers: the verifier children (#1205/#1206), the append transport (#1207), the archive/purge children (#1208/#1247), and the successor issues #1278 (anchor authority), #1279 (cutover), #1280 (epoch closure/rollover), #1281 (repo-wide serde_jcs conformance). Nothing in this plan’s MRs changes live write paths: every v2 artifact lands dormant until the #1279 coordinated-downtime cutover.

Provenance: v2 of this plan (seven internal contextless review rounds) was rejected at external review 2026-07-30 (~18 blockers, ~25 high-severity findings — process shape, rollout order, RFC 8785 conformance of the canonicalizer, source-identity binding, credential isolation, and more); v3 dispositioned every finding, absorbed three further fresh review rounds, and was approved 2026-07-30. The full disposition appendix lives in the review record; the design below is the post-disposition state.

Status

Step Description Status

0a

Issue reshaping: file #1278/#1279/#1280/#1281 with epic &73 + blocker links; revise #1246 (AC1 union clause → layered structural discharge; AC6 → self-certification/notarization split; epoch-state vocabulary; four-MR delivery shape); claim.

Done (2026-07-30) — #1278–#1281 filed + linked; #1246 revised + claimed

0b

Plan-commit MR (this MR): this plan + nav + parent-plan amendment (rollout reorder, Issues table, open-decisions correction, Step 1/3 statuses) + ADR-014 Amendment 6 + CHANGELOG.

Done (2026-07-30) — !1045 merged b40c2ea9

0c

Confirmatory external review of the committed artifact BEFORE implementation begins.

Done (2026-07-30) — reviewed; green light given

1

MR-1 protocol/KAT (feature/1246-chain-proto, Relates to #1246): crates/canopy-chain + independently-seeded KAT corpus + canopy-signing anchor-JWS vectors.

Done (2026-07-30) — !1046 merged 129a5572 (impl 30d7d27d)

2

MR-2 database/roles (feature/1246-chain-db, Relates to #1246): the three substrate migrations (schema, FKs/CHECKs, functions incl. CAS transitions, roles/grants) + integration/security suites against the real fixed roles.

Done (2026-07-30) — !1047 merged d2c80eac (impl 9e6d849f; 28 tests; J-review clean)

3

MR-3 genesis/bootstrap/ops (feature/1246-chain-genesis, Closes #1246): xtask chain-genesis + migrate apply, bootstrap SKIP_MIGRATIONS + ordering fix, docs.

Done (2026-07-30) — this MR (closes #1246)

4

Post-merge bookkeeping: parent Step 3 → Done; blocker-link verification across #1278–#1281.

Done (2026-07-30) — #1246 closed (!1048 merged 1123a9da); parent flipped; this commit

Epic: &73
Issue: #1246 (critical; revised at 0a) — blocked by #1236 (Done); blocks #1205/#1206/#1207/#1208/#1247 and #1278/#1279/#1280
Branches: feature/1246-chain-v2-plan (this MR), then feature/1246-chain-proto, feature/1246-chain-db, feature/1246-chain-genesis
Discipline: local cargo xtask validate runs BEFORE each MR’s first push.

Successor issues (filed at 0a)

  • #1278 — anchor authority (critical, w5): the async ExternalAnchorAuthority trait + canopy-store impl with append-only hardening (conditional create, receipt/version capture — today’s put discards the object_store PutResult, crates/canopy-store/src/store.rs:93-95 —, version-aware reads, split credentials, configuration attestation, outage/replay tests); production anchor-signing key identity + RFC 7638 JWK-thumbprint kid (chain-purpose/source namespace); strict anchor-JWS verification wiring; emission cadence/SLO; the final authority selection RATIFIED by a further ADR-014 amendment. #1279 depends on it.

  • #1279 — coordinated-downtime cutover (critical, w8): v1 truncate (guard-GUC, recorded ADR-016 exception) → drop → v2 rename + _v2 index rename/rebuild + re-creating the family-named append functions against renamed tables + #1197 EXPLAIN retarget; runtime-URL swap + explicit per-object v1-surface grants (never ALTER DEFAULT PRIVILEGES); BYTEA reader adaptation before reopen; old-owner handoff; credential activation (NOLOGIN→LOGIN); genesis + external notarization + epoch activation; binding shard-count selection; the executable go/no-go gate + recovery-matrix dry-run.

  • #1280 — epoch closure/rollover (high, w5): the C4/C5 crash-resumable cross-database transition executor (the substrate ships the fenced state column, the append-side fence, and the epoch→head lock-order protocol ONLY).

  • #1281 — serde_jcs conformance, repo-wide (high, w3): migrate the seven legacy canonicalization call sites off the nonconforming serde_jcs 0.1 (they produce RFC-compatible bytes only by accident of ASCII snake_case keys + in-bounds integers). Not in #1246’s scope.

Corrected rollout order (parent plan amended in this MR; pinned by Amendment 6): substrate (this plan) → #1207 writers (dormant) + #1205/#1206 verifiers (dormant) + #1278 delivered → #1279 cutover LAST, depending on all of them. The prior sequence put cutover before any v2 writer existed — at cutover direct DML becomes forbidden and v1 writers cannot call the v2 functions, so nothing could write. Fixed by ordering, not code.

Context (recon facts — verified file:line)

  • No KAT precedent exists. Insta snapshots deliberately REDACT hashes (services/canopy-security/tests/security_test.rs:350-352); only single-primitive well-known digests exist (e.g. crates/canopy-policy/src/source.rs:194-200). This plan defines the repo pattern.

  • The canopy login is simultaneously devstack cluster superuser (POSTGRES_USER: canopy, docker-compose.yml:43,156-224), boot-time migration runner (crates/canopy-api/src/bootstrap.rs:117-126 — and the app pool is created at bootstrap.rs:103-111 BEFORE the migrator runs), and runtime identity (docker-compose.yml:845,960,1012). Zero role/GRANT DDL exists in any migration. The #624 append-only guard is trigger+GUC precisely because the owner bypasses GRANT/REVOKE (services/canopy-security/migrations/20260603120000_audit_events_append_only_guard.sql:9-11); C8’s mandate is to remove that constraint structurally.

  • v1 DDL: nullable TEXT hex hashes, no UNIQUEs on chain columns, LIKE … INCLUDING ALL archives (20260326000000_create_security_tables.sql, 20260402000001_add_hash_chain.sql; tanf 20260325000001 + 20260425000000; medicaid copies). No version column (Amendment 1’s hash_version dropped by 20260624130000). Current migration max: 20260904000000.

  • Signing: ECDSA P-256 detached JWS over JCS bytes, kid + dual-key rotation (crates/canopy-signing/src/{signer,envelope,verifier}.rs); typ hardcoded canopy-determination+jwt (signer.rs:55); the verifier validates no header fields strictly (verifier.rs:36-47); p256 is RFC 6979 deterministic (signature KATs are reproducible).

  • Object store: Garage devstack / S3 production via canopy-store (crates/canopy-store/src/lib.rs:5-9); the current API is overwrite-capable and discards put results — hence #1278’s hardening ACs.

  • JCS today: serde_jcs = 0.1 at seven call sites incl. the audit chain (services/canopy-security/src/store/mod.rs:48-72) with PG-JSONB normalization SELECT $1::jsonb (:112-121).

  • EphemeralSchema: per-test test_<hex> schemas created/owned by canopy, options=-c search_path=<schema>,public (crates/canopy-test-lib/src/db.rs:260-274); the sqlx migrator holds a per-database advisory lock (same-DB runs serialize; cross-DB runs on a shared cluster do not).

  • xtask has no PG driver by design (xtask/src/psql.rs:3-5 shells out); its dispatcher is synchronous. (D7 deliberately reverses the no-driver stance for the genesis installer / migration job — the first xtask commands that must own transactions.)

D-CANON — canonicalization (resolved)

Adopt serde_json_canonicalizer 0.3.2 + a canopy recursive I-JSON validation layer.

  • Research verified serde_jcs 0.1 is nonconforming beyond the external review’s claim: it sorts SERIALIZED key bytes (quotes + escapes included), which flips ordering even on plain-ASCII keys ("a" vs "a b" — space 0x20 < closing-quote 0x22; the escape class \b vs !), plus the non-BMP UTF-16 divergence and verbatim i64 emission (serde_jcs-0.1.0/src/entry.rs:5, ser.rs:348-358,221-226), and a todo!() panic path on arbitrary_precision numbers (ser.rs:166-171).

  • serde_json_canonicalizer 0.3.2 (MIT, 2026-02 release, ~2.5M recent downloads, three-crate footprint, ryu-js 1.0.1) is conforming on BOTH axes — raw-key UTF-16 code-unit sort, ES6 number emission — and bundles the official cyberphone/json-canonicalization test corpus (tests/resources/testdata/), which with RFC 8785 §3.2.3 + Appendix B seeds canopy’s KAT corpus INDEPENDENTLY of our implementation (killing the KAT-circularity finding). Alternatives rejected: serde_jcs 0.2 (conforming but ryu-js 0.2, no bundled vectors, thin adoption), json-canon (numbers still nonconforming, dormant), json-syntax (code-point sort), vendoring (audit surface without benefit).

  • The validation layer is mandatory regardless of crate: conforming implementations SILENTLY ROUND integers beyond ±(2^53−1) through f64 — two distinct i64s canonicalize to identical bytes, a semantic collision an audit chain must refuse. canopy-chain therefore recursively validates the ENTIRE value tree (payload, manifest, every integer — not just chain_seq) before hashing: reject non-finite numbers and INTEGERS with |n| > 2^53−1 with a typed ChainError. Non-integer (float) values PASS — arbitrary event payloads legitimately carry them, and the conforming serializer emits ES6 shortest-round-trip floats which a strict verifier reproduces; only the integer-collision class is refused. Keys need no restriction.

  • multiple-versions = "warn" in deny.toml tolerates the transient ryu-js 0.2.2+1.0.1 dual; #1281 retires 0.2.2.

D1 — the canopy-chain crate (new workspace crate)

Bytes-only home for every byte-level rule; no DB, no signing, no async. Deps: serde, serde_json, serde_json_canonicalizer (NEW — the one new external dep, per D-CANON), sha2, uuid, chrono, thiserror. It depends on NEITHER canopy-db NOR canopy-common (both consume it; avoids the fti_chain_lock_id cycle-duplication precedent, crates/canopy-common/src/fti_audit.rs:30-36).

  • Validated newtypes (invalid states unrepresentable): ShardCount (1..=32767 — the SMALLINT-lossless cap), ShardId (< count), Epoch (>=0), EventSeq (1..=2^53−1), HeadSeq (0..=2^53−1), RoutingVersion, ChainInstanceId (UUIDv7: separate mint vs fallible decode paths), HashFormulaVersion (const 2; fallible DB/wire decode), EventHash([u8;32]) (hex only at edges), ChainFamily (Audit/Fti/EleGrant), ChainSource (canopy-security/canopy-tanf/canopy-medicaid — the C1 service/database identity, see D2), ChainError (the thiserror enum — every canonicalization/validation/genesis fn is fallible). Carve-out stated up front: the payload traffics in serde_json::Value (wildcard event payloads — the AuditChainInputs.metadata precedent, store/mod.rs:48-62), annotated per the house rule.

  • Typed per-family payload builders with CLOSED field sets (the field-coverage tables below) — the only production way to construct a canonical_event_payload; excluded columns are structurally unplaceable. The SQL side re-validates exact keys/types (D6).

  • Exact ChainEnvelope preimage JSON (JCS-ordered; formula-bearing, pinned):

    {"chain_epoch": 0, "chain_family": "audit|fti|ele_grant",
     "chain_instance_id": "<uuid-lowercase>", "chain_seq": 1,
     "chain_source": "canopy-security|canopy-tanf|canopy-medicaid",
     "domain_tag": "canopy-chain-v2", "hash_formula_version": 2,
     "payload": {}, "previous_hash": "<64-hex-lowercase>", "shard_id": 0}

    Hashes are BYTEA at rest, lowercase hex in preimages. Normalization: UUIDs lowercase-hyphenated; timestamps %Y-%m-%dT%H:%M:%S%.6fZ; canonical bytes = canonicalize(the REFETCHED PG-JSONB value) — JSONB stores a semantic value, not bytes; insert and verify sides both canonicalize the same refetched value (the corrected reading of the v1 SELECT $1::jsonb pattern); arrays order-preserving; absent → null. The D-CANON validation layer runs on every tree before hashing.

  • Independent protocol versions (a vector change bumps ITS OWN version, never the others): event_hash_formula_version=2, routing_version=1, genesis_version=1, anchor_manifest_version=1, anchor_signing_version=1.

  • shard_for(routing_id: Uuid, shard_count: ShardCount) → ShardId — first 8 bytes of SHA-256("canopy-chain-v2/routing/v1" ‖ 16 raw UUID bytes) as u64 BE, mod count (NonZero-backed — no arithmetic_side_effects hazard). Routing id = the immutable event id: the bus envelope id for #-queue events; for direct ingest the RECOMMENDATION is the server-minted envelope event_id (api/mod.rs:240,265 already mints it; the row PK is append-minted and excluded) — the binding pin is #1207’s per C3, and Amendment 6 corrects the ADR’s "row id" wording. Shard placement is NOT re-derived in-database (no pgcrypto dependency): the SCRUB recomputes and validates placement fail-closed (#1205/#1206), and #1207 tests the write side.

  • Genesis rules (C3 "all specified"): the empty-head hash = SHA-256 of the JCS of {"chain_epoch": <int>, "chain_family": …, "chain_instance_id": …, "domain_tag": "canopy-chain-v2/genesis", "genesis_version": 1, "shard_id": <int>}; a first row’s previous_hash = that hash — never NULL, never a "GENESIS" sentinel. Epoch N>0 adds "previous_epoch_anchor_hash": "<64-hex>" (C4 linkage). Epoch-0 root = the topology row’s instance id (no prior anchor; the zero sentinel appears only in the genesis anchor’s previous_anchor_hash).

  • GenesisPlan::compute(instance_id, minted_at, family, source, shard_count) → GenesisPlan — PURE (identity and clock INJECTED; xtask mints them), returns every row to insert + the genesis manifest bytes; verify_genesis_state(fetched_rows) → Result<(), ChainError> — the pure post-install check (D7).

D2 — schema (dormant until cutover; exact DDL in MR-2, constraints pinned here)

Families/DBs: canopy_security=audit, canopy_tanf=fti, canopy_medicaid=fti (+ele_grant at #1248). All v2 objects live in the service’s normal schema with _v2-suffixed names (indexes too — index names are schema-global and v1 holds the canonical names, create_security_tables.sql:65-71; #1279 renames).

chain_instances

IMMUTABLE registry of every instance ever minted: (chain_instance_id PK, chain_family, chain_source, minted_at, genesis_version, retired_at NULL). History is never deleted.

chain_topology

The ACTIVE pointer: UNIQUE (chain_family), FK → instances, plus current_epoch INT NOT NULL with a composite FK (chain_instance_id, current_epoch) → chain_epochs — "the topology pointer’s current epoch" the fences reference is THIS column (genesis sets 0; chain_epoch_activate and the future closure fn maintain it; belt: a partial unique on chain_epochs (chain_instance_id) WHERE state IN ('installing','active') makes two-open-epochs unrepresentable). A reset retires the pointer (sets retired_at on the old instance, repoints); the rerun predicate and chain_head_lock key by family alone because of this uniqueness.

chain_epochs

Per (instance, family, epoch): shard_count SMALLINT (CHECK 1..32767), routing_version, state (CHECK in installing/active/closing/anchored/closed), closure-anchor ref; UNIQUE (chain_instance_id, epoch) (the declared target of topology’s composite FK). FK → chain_instances(chain_instance_id) — the IMMUTABLE registry; an FK to the repointable topology row would either be uncreatable or turn every reset repoint into an FK violation against retired instances' epochs. Fence shipped HERE: append requires state='active' AND epoch = the topology pointer’s current epoch — nothing more; transitions are #1280’s (plus the single installing→active activation CAS used at #1279).

chain_heads

PK (instance, family, epoch, shard_id); last_seq CHECK BETWEEN 0 AND 9007199254740991, last_hash BYTEA CHECK octet_length=32, archived_through CHECK 0 ⇐ archived_through AND archived_through ⇐ last_seq, plus the C7 columns: movement_generation, purge_boundary_seq, trusted_boundary_seq, boundary-manifest refs. FK → epochs. Pre-created at genesis (lazy-create is a fork race).

chain_sources

The trusted source→instance registry (C1 binding): (chain_source, chain_family, chain_instance_id FK) — the append functions verify their baked source literal against it.

chain_status_v

The minimal _app-readable status VIEW (per chain-owning DB): family, current epoch + state, per-shard last_seq/archived_through — no hashes, no payloads, no verification-state. The handler-facing surface until #1205/#1206 define the real status DTOs. Column list pinned in MR-2; included in the minimality test set.

audit_events_v2 / fti_audit_log_v2 (+ _archive_v2 twins)

Business columns (hashed ones DERIVED from the payload; unhashed via the pinned ingress; legitimately-nullable stay nullable) + chain columns — ALL chain columns NOT NULL with CHECKs: chain_family (CHECK = the table’s family literal — the AC1 tuple is LITERAL), chain_source, chain_instance_id, chain_epoch, shard_id, chain_seq CHECK as the BIGINT LITERAL BETWEEN 1 AND 9007199254740991 (in PG, ^ is the float-typed operator — don’t make the implementer prove float rounding; a table CHECK, not only fn-enforced), hash_formula_version CHECK = 2, previous_hash/event_hash BYTEA(32), canonical_event_payload JSONB. FK (instance, family, epoch, shard_id) → chain_heads — shard validity is structural (shard_id < count holds because heads are pre-created exactly for 0..N−1). UNIQUE (chain_instance_id, chain_family, chain_epoch, shard_id, chain_seq) on live AND archive, plus UNIQUE (…, event_hash); a (shard_id, chain_seq) range index.

chain_anchors + chain_anchor_heads

canopy_security ONLY, all families (the anchor-emitting authority’s home — #1247 assigns canopy-security the external boundary-manifest authority; writing anchors into program DBs would break the cross-DB read-only posture). chain_anchor_heads (per instance+family: last_anchor_seq, last_anchor_hash) + the chain_anchor_append CAS fn make sequencing STRUCTURAL (no gaps/forks — seq=last+1 and previous_anchor_hash=last hash, or refuse). Anchors: manifest canonical bytes + hash, JWS + kid (NULL only while pending, CHECK-tied to state), anchor_kind (CHECK in genesis/periodic_tip/epoch_closure/archive_watermark/purge_boundary — genesis gets its OWN kind, recorded in Amendment 6), notarization_state (pendingsubmittedconfirmed, or →failed; one-way transition fn; confirmed immutable), external receipt fields, verifier-confirmation timestamp.

chain_verification_checkpoints

canopy_security ONLY, all families. PK (instance, family, epoch, shard, loop_kind tail/scrub); target_seq, target_hash, verified_through_seq, verified_through_hash, trusted_manifest_ref, lease_owner, lease_expires_at, fence, updated_at. Mutated ONLY via the lease/fence CAS fn.

chain_verification_runs

canopy_security ONLY: id, identity, started/finished, outcome, error, rows_verified, aggregate_manifest JSONB. INSERT-only.

chain_incidents

canopy_security ONLY: id, identity, detected_at, kind, evidence JSONB, state (latched/resolved) + resolution (actor, reason, evidence_ref, revalidation_run_id). Latch via fn; resolution via a SEPARATE authorized fn (D6). Supersedes fti_chain_verifications at cutover (#1206 owns the swap).

Copy discipline: verbatim across the three migrations — the role DO-blocks, instances/topology/epochs/heads/sources DDL, chain_head_lock. Family-parameterized at AUTHORING time (rendered into each file; never runtime table-name dispatch) — the family-NAMED append fns (chain_append_rows_audit / chain_append_rows_fti; medicaid’s future _ele_grant separate — no family argument exists to mismatch), the *_v2 DDL, the projection views. Anchor + verification-state tables only in canopy_security’s file. Migration versions sort after 20260904000000. crates/canopy-test-lib/src/db.rs is touched (EphemeralSchema pickup).

D3 — union uniqueness (honest layering; AC1 revised at 0a)

PostgreSQL cannot express one constraint across live+archive. Layers, each tested: identical five-tuple UNIQUEs on both; writes confined to the D6 functions; the archived_through watermark (insert refuses seq <= archived_through; the C7 movers advance it in-tx); the verifier rejects overlap/gap as breach. The five-tuple clause is LITERAL per table (chain_family is a column); only the cross-table-union clause was reworded at 0a.

D4 — hash storage

BYTEA(32) with octet_length CHECKs; hex only at wire/API edges. (Reader adaptation for the v1→v2 flip is #1279’s AC.)

D5 — shard counts

Genesis data, not code. NON-BINDING defaults recorded (audit 8 / FTI 2); the binding selection happens at the #1279 gate, after #1207’s throughput evidence exists (kills the circular dependency the external review flagged).

D6 — identity split, roles, functions (C8)

Timing: machinery now, activation at cutover — during dormancy the v2 tables are empty, so an early runtime swap buys zero protection while breaking every v1 query under a new principal. #1279 owns the swap + the v1-surface grants.

  • Roles (per cluster; created by migrations; NOLOGIN until cutover credential activation — passwordless-LOGIN is NOT dormant, peer/trust/cert auth could still bind): service-qualified owners canopy_chain_owner_security / _tanf / _medicaid (ADR-001: one cluster-global owner would give members ownership power across co-located service DBs in shared-cluster mode); canopy_chain_verify (privilege bundle, never a connection identity); canopy_security_verify (the LOGIN principal for ALL verifier pools, member of the bundle, created on all three clusters); canopy_chain_maintenance (EXECUTE on archive/purge fns; granted to nothing until #1208/#1247); canopy_chain_incident_admin (EXECUTE on the incident-RESOLUTION fn only — background verification carries NO resolution authority; wired to the admin surface at #1205/#1206); the _app runtime logins (canopy_security_app etc.).

  • Grant matrix (pinned): _app — EXECUTE its family’s append fns + EXECUTE chain_head_lock (part of the append flow — the only path to last_hash for Rust-side hashing) + SELECT own family tables + SELECT instances/topology/epochs/sources (epoch + shard-count discovery; no chained data) + SELECT chain_status_v; NO DML on any chain table, no chain-role membership. canopy_chain_verify — program DBs: SELECT projection views (live + archive) + instances/topology/epochs/heads/sources, strictly read-only; canopy_security additionally: EXECUTE the checkpoint lease/fence CAS fn, INSERT on runs, EXECUTE the incident-latch fn and the anchor append/transition fns (NO table UPDATE grants anywhere — every state change goes through a guarded fn). PUBLIC EXECUTE is REVOKED on every function at creation (PostgreSQL defaults it on — without the revoke, any login could call the SECURITY DEFINER appends). Explicit CONNECT + schema USAGE for every real principal; tests assert PUBLIC/app/verify lack schema CREATE.

  • Provisioning: in MIGRATIONS (init scripts run once per volume; the program containers have no init mounts, compose:169-201; production never sees them). Idempotent DO-blocks catch duplicate_object OR unique_violation (the concurrent loser gets 23505 off pg_authid; the real race is cross-database — tanf+medicaid on one shared cluster; same-DB runs are serialized by sqlx’s per-DB migrator lock). On exists: reconcile, fail closed — assert exact LOGIN/SUPERUSER/CREATEROLE/REPLICATION/BYPASSRLS/password-null/membership state against pg_roles/pg_auth_members (role squatting or stale config refuses the migration).

  • Ownership mechanism: create-then-transfer with the enabling grants. No SET ROLE (it persists → sqlx’s sqlx_migrations bookkeeping would run as the owner role and abort). Between the role block and the transfers: GRANT <owner_role> TO current_user (PG16+ CREATEROLE grants the creator ADMIN OPTION, not membership — membership is required for ALTER OWNER; records a membership row, harmless for superusers) and GRANT USAGE, CREATE ON SCHEMA <current_schema()> TO <owner_role> (ALTER OWNER requires the new owner hold schema CREATE; SECURITY DEFINER bodies need USAGE — public masks USAGE via PUBLIC’s default, EphemeralSchema test<hex> schemas do not). Both statements are dynamic SQL in DO blocks (GRANT takes no expression). Transfers cover tables, functions, VIEWS, and composite types. Runbook: the production migration credential needs CREATEROLE + schema ownership (or CREATE WITH GRANT OPTION); the rotation wrinkle is documented (a rotated CREATEROLE credential lacks ADMIN on roles it didn’t create — the reconciliation step surfaces this rather than half-applying).

  • The migration JOB (the runtime process must never hold the privileged credential): migrations for the three chain services run via cargo xtask migrate apply --service <svc> (nested under the EXISTING migrate command — xtask/src/cmd/migrate.rs carries snapshot/rollback today) as a deploy-time step — devstack: a compose one-shot service per chain service (dormant until #1279 activates the split); production: a deploy-job in the runbook. Bootstrap keeps its embedded migrator for ALL services until cutover; at cutover the three chain services set {PREFIX}SKIP_MIGRATIONS (boolean env, default false — listed in configuration-reference.adoc, tested in MR-3): true means bootstrap runs NO migrator (the job owns migrations from then on; refused in CANOPY_ENV=development unless the compose one-shot is configured — realized as a second documented boolean, {PREFIX}MIGRATIONS_JOB_CONFIGURED, set only by deployments that wire the job (the devstack chain-migration-split profile) — so a dev cannot silently strand a schema), the app pool connects as _app, and NO privileged URL exists in the runtime environment — the compromise-resistant form an in-process migration-URL design could not give (a closed pool does not un-know a credential). The second bootstrap fix landing in MR-3, benefiting every service: the ordering fix — migrate FIRST over a max-one short-lived pool, closed on every path, THEN build the app pool (today the app pool is created at bootstrap.rs:103-111 before the migrator runs at :117-126); an injected settings seam for tests (no env mutation). The xtask migrate apply job carries the full guard posture on its URL (validate_database_name + DbPool::connect_with TLS — never bare PgPool::connect).

  • Functions (SECURITY DEFINER, owner-role-owned, created via DO … EXECUTE format(…, current_schema()) with a per-function interpolated SET search_path = <schema>, pg_temp):

    • chain_head_lock(family, epoch, shard) → (instance, last_seq, last_hash)lock order pinned: epoch row FOR SHARE FIRST, then head row FOR UPDATE (closure will take the epoch row FOR UPDATE first then touch heads; identical epoch→head ordering on both sides prevents the inverted-order deadlock — recorded as the protocol lock order #1280 MUST follow). The FOR SHARE vs FOR UPDATE conflict gives append-vs-close mutual exclusion; concurrent appenders' `FOR SHARE`s coexist. Re-checks epoch = the topology pointer’s current epoch under the lock. v2 needs NO advisory locks ON THE APPEND PATH (the head row lock is the serialization point; D7’s installer lock is a different, operator-time scope).

    • chain_append_rows_{family}(epoch, shard, expected_routing_version, rows JSONB[]) — validates: the SOURCE LITERAL baked into this service’s rendered function body at migration-authoring time (canopy_security’s fn carries canopy-security, etc.) exists in chain_sources for the active instance — no runtime caller-supplied source, no session_user inference; epoch active + routing-version match (the C4 fence); seq > archived_through; contiguity from last_seq+1; whole-batch interior linkage: EVERY row’s previous_hash = the prior row’s event_hash (the first row’s = the head’s last_hash); exact payload key/type validation per the family’s closed field set (extra/missing members refuse); batch size ≤ the pinned max (default 500 rows — a config-free function constant in MR-2, revisited by #1207 with throughput evidence); then inserts — hashed BUSINESS columns POPULATED from the payload (jsonb_populate_record / →> extraction; JSON arrays → TEXT[] and ISO strings → timestamptz coerce directly; the BYTEA hash columns are decoded explicitly, decode(elem→>'previous_hash', 'hex') — a bare hex string through record-population would land as 64 ASCII bytes and loud-fail the length CHECK; divergence stays unrepresentable), unhashed columns from the pinned unhashed ingress object ({request_id, ip_address, success} FTI / {ip_address} audit), created_at/received_at server-clocked, the audit row PK server-minted — and advances the head in the same statement. Refusal = exception, never partial advance. Malformed-JSON/lock-timeout behavior pinned in MR-2 tests. The C3 multi-shard FTI determination carve-out (compute all shards first, lock heads in shard-id order inside one caller tx) is RECORDED here; its primitive + deadlock tests are #1207’s. Caller flow: canonicalize/validate/normalize BEFORE locking (the JSONB round-trip too — v1 normalizes in-lock, don’t copy); one tx per shard spanning lock → hash-in-Rust → append. The rows[] element encoding is pinned: {chain_seq, previous_hash: <hex>, event_hash: <hex>, canonical_event_payload: <object>, unhashed: <object>} — hashed business columns are NOT in the element (the function derives them).

    • chain_checkpoint_advance(identity, fence, …) — lease/fence CAS; chain_incident_latch / chain_incident_resolve (separate grants); chain_anchor_append + chain_anchor_transition (one-way graph; confirmed immutable); chain_epoch_activate(family) — the single installing→active CAS, #1279-gated; EXECUTE granted to NO runtime or verify role — the cutover operator runs it under the migration/owner credential (pinned in the runbook); chain_archive_prefix_{family}(epoch, shard, through_seq, movement_generation) RETURNS bigint / chain_purge_prefix_{family}(…) — signatures pinned, interim bodies RAISE not-implemented (the objects exist; the negative-EXECUTE tests cover them; semantics land in #1208/#1247).

  • Projection views (live _v2 + archive _v2 per family): the hashed business fields as typed columns + position/hash columns + canonical_event_payload; NEVER an excluded column. The minimality test asserts the exact information_schema column list. The verify bundle’s duties (D3 layer 4, archive-awareness, orphan detection via instances/topology) map onto its SELECT set above.

D7 — empty-genesis install (operator-driven; two-phase; credentials via env)

cargo xtask chain-genesis --service <svc> [--shard-count N] (tokio block_on inside the sync dispatcher; xtask gains sqlx + canopy-chain + canopy-db deps). URLs come from CANOPY_CHAIN_GENESISTARGET_URL / ANCHOR_URL via the secret-provider seam — never argv (process listings / shell history); both URLs pass validate_database_name + TLS guards independently; the anchor DB MUST be canopy_security; the principals the two URLs carry are pinned in the runbook/config-reference (the migration/owner-capable credential — devstack canopy: genesis INSERTs into owner-role-owned tables on the target DB and EXECUTEs chain_anchor_append on the anchor DB); devstack defaults resolve per-service (the psql.rs/seed.rs routing precedent).

Service→DB→family mapping pinned: canopy-security→canopy_security→audit; canopy-tanf→canopy_tanf→fti; canopy-medicaid→canopy_medicaid→fti. The installer cross-checks current_database() against the mapped name and refuses actionably (including the migrations-not-applied case). A per-(database, family) advisory installer lock — taken on the TARGET DB, so the two FTI installs are independent — guards the whole run (key = first 8 bytes of SHA-256 over canopy-chain-genesis/{schema}/{family}; production runs in one fixed schema so this IS the per-(database, family) key, and folding the schema in additionally isolates the EphemeralSchema test harness). The migrate apply job’s URL rides CANOPY_MIGRATE__DATABASE_URL (same never-argv rule).

  • Rerun predicate (three arms, ALWAYS validating before any refusal): no topology → fresh install; topology + genesis anchor absent → RESUME Phase B (after proving heads untouched: all last_seq=0, hashes = the genesis values); topology + anchor present → run the FULL Phase-C validation and only then report "already installed" (a crash after B can still complete C); any supplied param differing from installed state → hard error, never silently ignored.

  • Phase A (one tx, TARGET DB only): insert the chain_instances row + the topology pointer + the chain_sources row(s) binding this DB’s source(s) to the new instance (without this, every post-cutover append refuses against an empty registry — and no dormant-phase test would catch it) + the epoch-0 row (state='installing' — the chain is NOT appendable until the #1279-gated chain_epoch_activate after external confirmation) + all heads (last_seq=0, KAT’d empty-head hashes). Identity + clock are injected into the pure GenesisPlan.

  • Phase B (idempotent, ANCHOR DB = canopy_security): the genesis manifest (anchor_kind='genesis', anchor_seq=1, previous_anchor_hash = the 64-zero-hex sentinel) is a pure function of installed Phase-A state — rebuilt from re-fetched rows on any rerun; inserted via chain_anchor_append (same key + identical bytes → no-op; different bytes → hard error). Precondition checked with an actionable error: canopy_security’s substrate migration applied.

  • Phase C: re-fetch everything, independently REBUILD the expected manifest and byte-compare (never merely re-hash stored bytes), and run verify_genesis_state: instances/topology/chain_sources rows present and mutually consistent (exact source, family, instance binding); exact shards 0..N−1; last_seq=0; archived_through=0; routing/version/state values; a UUIDv7 check on the instance id; anchor kind/seq/sentinel/pending; ZERO rows in the live and archive tables. Any mismatch fails the command. (Post-activation reruns hit the "already installed" arm’s validation against genesis-shaped state and fail corruption-shaped — correct: chain-genesis is never legitimate against an activated chain; the message names chain_epoch_activate as the boundary.) Fault-injection tests: kill after A, after B, concurrent installers, corrupt state, target/anchor outage, same-key/different-bytes.

D8 — anchors: local record + DTOs here; the async authority is #1278

canopy-chain pins the DTOs (AnchorSubmission, AnchorReceipt{authority_id, external_ref, version_or_etag, stored_at}) and the exact AnchorManifest JSON (JCS-ordered):

{"anchor_kind": "genesis|periodic_tip|epoch_closure|archive_watermark|purge_boundary",
 "anchor_manifest_version": 1, "anchor_seq": 1, "chain_epoch": 0,
 "chain_family": "...", "chain_instance_id": "...", "chain_source": "...",
 "domain_tag": "canopy-chain-v2/anchor", "hash_formula_version": 2,
 "previous_anchor_hash": "<64-hex>", "routing_version": 1, "shard_count": 8,
 "shards": [{"last_hash": "<64-hex>", "last_seq": 0, "shard_id": 0}]}

shards is an ARRAY ordered by shard_id, complete over exactly 0..N−1 (completeness
duplicate rules validated); all hashes lowercase hex. The boundary kinds bind the C7 columns (archive_watermark → per-shard archived_through + movement_generation; purge_boundarypurge_boundary_seq + trusted boundary) — semantics recorded with the columns, movers in #1208/#1247. The async trait, authority impl, IAM, cadence/SLO: #1278 (cutover-blocking). First-impl recommendation: canopy-store/S3 with Object Lock in production; Garage devstack is functional-not-adversarial (the protocol checks carry dev; the production configuration discharges C5) — with #1278 noting that the final authority/credential selection is RATIFIED by a further ADR-014 amendment (C5 says the amendment pins the authority — a crate-level decision cannot discharge that).

D9 — anchor signing

ES256 detached JWS over the canonical manifest bytes, as a DISTINCT protocol surface: protected header {"alg":"ES256","kid":<thumbprint-kid>,"typ":"canopy-chain-anchor+jws"} with STRICT verification (alg/typ/kid all validated — the determination path’s lax verifier and hardcoded typ are not reused as-is); kid = RFC 7638 JWK thumbprint under a chain-purpose/source namespace. Production key identity + registry wiring = #1278. MR-1 ships the strict verify FUNCTION itself in canopy-signing (provisional header form) plus TEST-ONLY-key vectors in its test suite (an explicit canopy-chain dev-dependency — canopy-common’s runtime dep arrives only with #1207, and canopy-chain deps nothing of signing, so no cycle), marked PROVISIONAL until #1278 freezes the header — the anchor_signing_version=1 bump discipline covers any change.

D10 — the KAT corpus (repo precedent; independently seeded)

  • Files: crates/canopy-chain/tests/vectors/canonicalizer_rfc8785/ (the official cyberphone testdata + RFC 8785 §3.2.3 + Appendix B samples — INDEPENDENT provenance, documented per file), event_hash_audit.json, event_hash_fti.json, event_hash_ele_grant.json (the closed enum is fully covered NOW; the family’s tables are deferred to #1248 — these vectors pin the ENVELOPE level via a TEST-ONLY raw-payload constructor with a designated placeholder payload; the closed-builder rule governs production construction and #1248 adds the real builder), normalization.json, genesis.json, routing.json, anchor_manifest.json; canopy-signing carries anchor_signature.json (TEST-ONLY key). Chain-specific vectors are seeded by an independent derivation (documented manual/second-implementation computation for a seed subset), then extended by the generator — provenance recorded in each file header.

  • Adversarial cases pinned: non-BMP + escaped keys; numeric edges (±2^53−1, −0, rejection cases: non-finite/oversize integers); malformed values; UUID version/case; max shard count; strict-JWS negatives (wrong alg/typ/kid); rotation; test-key exclusion.

  • Generator: a cargo example (--example generate_vectors) that refuses overwrite (no #[ignore] test). Freeze discipline: changing a vector = bumping THAT surface’s protocol version + an ADR-014 amendment. The VECTOR_CORPUS_SHA256 const forces a visible source diff on regeneration — review discipline, not claimed as a mechanical gate.

  • Property tests (fixed seeds, bounded counts, explicit tolerances; enum coverage via exhaustive match helpers): canonicalization determinism/idempotence over arbitrary payload trees (validation-layer-filtered), hash shape, routing stability + bounded-count full-range reachability, manifest roundtrip byte-stability.

Field-coverage tables (authoritative copy in ADR-014 Amendment 6)

FTI (fti_audit_log_v2): hashed = id (row id; also the payload’s routing-relevant id), accessed_at, accessed_by, purpose_code, data_elements_accessed, originating_system, action, resource_type, resource_id, plus the C1 tuple and chain_source. Excluded-with-rationale (via the unhashed ingress; never in projections) = request_id, ip_address, success. Server-side = created_at/received_at. Forbidden in payload = every excluded column (builder + SQL validated).

Audit (audit_events_v2): hashed = event_id (envelope id), event_type, event_timestamp, user_id, user_role, action, resource_type, resource_id, source_service, household_id, metadata, plus the tuple and chain_source. Excluded = ip_address. Server-side = the row id (append-minted PK), received_at, created_at.

Scope — explicitly OUT

Writers/staging (#1207, incl. the multi-shard primitive + the routing-id binding + throughput evidence); verifier loops / status DTOs / citation coverage (#1205/#1206); archive/purge mover bodies + ruleset retention keys (#1208/#1247); the ele_grant family (#1248); cutover execution + go/no-go (#1279); the epoch closure/rollover executor (#1280); the anchor authority + production signing (#1278); the repo-wide serde_jcs migration (#1281).

Verification

  • MR-1: cargo build -p canopy-chain, cargo clippy -p canopy-chain --all-targets — -D warnings, cargo nextest run -p canopy-chain; the RFC 8785 official vectors green through the D-CANON stack; the full KAT + coverage + property suites; the canopy-signing vector tests.

  • MR-2 (EphemeralSchema + devstack; suites live in the three services' tests/ with canopy-chain dev-deps): append happy path; refusals (linkage incl. whole-batch interior, contiguity, epoch not-active, routing-version, watermark, payload exact-key, batch-max, source-registry); rollback consistency (no partial advance); the concurrency category — two parallel same-shard txs serialize with contiguous seq + a single head advance, and different-shard txs proceed in parallel (no global serialization); the append-vs-epoch-close race; the CAS graphs (illegal transitions refused; confirmed immutable; resolution denied to the verify principal); real-fixed-role assertions — exact pg_roles/pg_auth_members/owners/ACLs; owner-bypass (direct DML refused for _app-shaped + real roles; appends via the fns succeed); PUBLIC-EXECUTE negatives; projection minimality (live + archive) against the pinned lists; the production-shaped NON-superuser-migrator scenario + rotated-migrator + concurrent tanf/medicaid migrations on one cluster (shared-db); reconciliation fail-closed (a pre-created conflicting role refuses).

  • MR-3: the two-phase genesis incl. all three rerun arms, param-mismatch, the fault-injection points, installer-lock contention, both-guard URL validation; the bootstrap ordering test via the injected seam (migration pool first, closed on all paths); SKIP_MIGRATIONS semantics; the full battery per MR (LOCAL cargo xtask validate before each first push); the Antora build renders this plan + ADR Amendment 6.

Files touched (by MR)

  • This MR (plan-commit): this plan + nav.adoc + the parent plan + ADR-014 Amendment 6 + CHANGELOG.adoc.

  • MR-1: crates/canopy-chain/ (new) + workspace members + Cargo.toml (serde_json_canonicalizer into [workspace.dependencies]); crates/canopy-signing/ (dev-dep + vectors + the strict-header verify function).

  • MR-2: the three services/*/migrations/<post-20260904>_chain_v2_substrate.sql; crates/canopy-test-lib/src/db.rs (touch); the services' tests/ + their `Cargo.toml`s (canopy-chain dev-deps).

  • MR-3: xtask/src/cmd/chain_genesis.rs + the apply arm in xtask/src/cmd/migrate.rs + registration + xtask/Cargo.toml (sqlx, canopy-chain, canopy-db); crates/canopy-api/src/bootstrap.rs; docker-compose.yml (one-shot migration services, dormant); docs — configuration-reference.adoc (the new env vars), the xtask catalog, data-models/canopy-{security,tanf,medicaid}.adoc, shared-crates.adoc, security.adoc/security-operations.adoc (runbook incl. the migration job + rotation wrinkle), testing.adoc (the KAT pattern), the services.adoc pointer, CHANGELOG.adoc.

Decisions ratified at sign-off (2026-07-30)

  1. D-CANON: serde_json_canonicalizer 0.3.2 + the recursive I-JSON validation layer (research-verified; alternatives rejected as recorded above).

  2. The Step-0 process shape (sign-off → issue reshaping → plan-commit MR → confirmatory review at 0c).

  3. D8 first-impl authority recommendation (canopy-store/S3-Object-Lock; Garage caveat) — final selection ratified in #1278 via a further ADR-014 amendment.

  4. Non-binding shard-count defaults (audit 8 / FTI 2); binding selection at the #1279 gate.

  5. The successor-issue scope boundaries (#1278–#1281 as filed).

Edit this page · default