Plan: chain-v2 substrate — schema, KAT vectors, restricted roles, empty-genesis install (#1246, epic &73)
On this page
- Status
- Successor issues (filed at 0a)
- Context (recon facts — verified
file:line) - D-CANON — canonicalization (resolved)
- D1 — the
canopy-chaincrate (new workspace crate) - D2 — schema (dormant until cutover; exact DDL in MR-2, constraints pinned here)
- D3 — union uniqueness (honest layering; AC1 revised at 0a)
- D4 — hash storage
- D5 — shard counts
- D6 — identity split, roles, functions (C8)
- D7 — empty-genesis install (operator-driven; two-phase; credentials via env)
- D8 — anchors: local record + DTOs here; the async authority is #1278
- D9 — anchor signing
- D10 — the KAT corpus (repo precedent; independently seeded)
- Field-coverage tables (authoritative copy in ADR-014 Amendment 6)
- Scope — explicitly OUT
- Verification
- Files touched (by MR)
- Decisions ratified at sign-off (2026-07-30)
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 ( |
Done (2026-07-30) — !1046 merged 129a5572 (impl 30d7d27d) |
2 |
MR-2 database/roles ( |
Done (2026-07-30) — !1047 merged d2c80eac (impl 9e6d849f; 28 tests; J-review clean) |
3 |
MR-3 genesis/bootstrap/ops ( |
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
ExternalAnchorAuthoritytrait + canopy-store impl with append-only hardening (conditional create, receipt/version capture — today’sputdiscards theobject_storePutResult,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 +
_v2index rename/rebuild + re-creating the family-named append functions against renamed tables + #1197 EXPLAIN retarget; runtime-URL swap + explicit per-object v1-surface grants (neverALTER 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
statecolumn, 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
canopylogin 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 atbootstrap.rs:103-111BEFORE 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 ALLarchives (20260326000000_create_security_tables.sql,20260402000001_add_hash_chain.sql; tanf20260325000001+20260425000000; medicaid copies). No version column (Amendment 1’shash_versiondropped by20260624130000). 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);typhardcodedcanopy-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.1at seven call sites incl. the audit chain (services/canopy-security/src/store/mod.rs:48-72) with PG-JSONB normalizationSELECT $1::jsonb(:112-121). -
EphemeralSchema: per-test
test_<hex>schemas created/owned bycanopy,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-5shells 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\bvs!), 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 atodo!()panic path onarbitrary_precisionnumbers (ser.rs:166-171). -
serde_json_canonicalizer0.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 typedChainError. 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 inserde_json::Value(wildcard event payloads — theAuditChainInputs.metadataprecedent,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
ChainEnvelopepreimage 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 v1SELECT $1::jsonbpattern); 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 — noarithmetic_side_effectshazard). Routing id = the immutable event id: the bus envelope id for#-queue events; for direct ingest the RECOMMENDATION is the server-minted envelopeevent_id(api/mod.rs:240,265already 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’sprevious_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’sprevious_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, pluscurrent_epoch INT NOT NULLwith 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_activateand the future closure fn maintain it; belt: a partial unique onchain_epochs (chain_instance_id) WHERE state IN ('installing','active')makes two-open-epochs unrepresentable). A reset retires the pointer (setsretired_aton the old instance, repoints); the rerun predicate andchain_head_lockkey by family alone because of this uniqueness. chain_epochs-
Per (instance, family, epoch):
shard_count SMALLINT(CHECK 1..32767),routing_version,state(CHECK ininstalling/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 requiresstate='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_seqCHECKBETWEEN 0 AND 9007199254740991,last_hash BYTEACHECKoctet_length=32,archived_throughCHECK0 ⇐ 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-shardlast_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_v2twins)-
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_seqCHECK as the BIGINT LITERALBETWEEN 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_versionCHECK= 2,previous_hash/event_hashBYTEA(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, plusUNIQUE (…, 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) + thechain_anchor_appendCAS 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 whilepending, CHECK-tied to state),anchor_kind(CHECK ingenesis/periodic_tip/epoch_closure/archive_watermark/purge_boundary— genesis gets its OWN kind, recorded in Amendment 6),notarization_state(pending→submitted→confirmed, or →failed; one-way transition fn;confirmedimmutable), 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). Supersedesfti_chain_verificationsat 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_appruntime logins (canopy_security_appetc.). -
Grant matrix (pinned):
_app— EXECUTE its family’s append fns + EXECUTEchain_head_lock(part of the append flow — the only path tolast_hashfor Rust-side hashing) + SELECT own family tables + SELECT instances/topology/epochs/sources (epoch + shard-count discovery; no chained data) + SELECTchain_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_objectORunique_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’ssqlx_migrationsbookkeeping 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) andGRANT USAGE, CREATE ON SCHEMA <current_schema()> TO <owner_role>(ALTER OWNER requires the new owner hold schema CREATE; SECURITY DEFINER bodies need USAGE —publicmasks USAGE via PUBLIC’s default, EphemeralSchematest<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 EXISTINGmigratecommand —xtask/src/cmd/migrate.rscarries 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 inconfiguration-reference.adoc, tested in MR-3): true means bootstrap runs NO migrator (the job owns migrations from then on; refused inCANOPY_ENV=developmentunless 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 devstackchain-migration-splitprofile) — 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 atbootstrap.rs:103-111before the migrator runs at:117-126); an injected settings seam for tests (no env mutation). The xtaskmigrate applyjob carries the full guard posture on its URL (validate_database_name+DbPool::connect_withTLS — never barePgPool::connect). -
Functions (SECURITY DEFINER, owner-role-owned, created via
DO … EXECUTE format(…, current_schema())with a per-function interpolatedSET search_path = <schema>, pg_temp):-
chain_head_lock(family, epoch, shard) → (instance, last_seq, last_hash)— lock order pinned: epoch rowFOR SHAREFIRST, then head rowFOR UPDATE(closure will take the epoch rowFOR UPDATEfirst then touch heads; identical epoch→head ordering on both sides prevents the inverted-order deadlock — recorded as the protocol lock order #1280 MUST follow). TheFOR SHAREvsFOR UPDATEconflict 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 carriescanopy-security, etc.) exists inchain_sourcesfor the active instance — no runtime caller-supplied source, no session_user inference; epochactive+ routing-version match (the C4 fence);seq > archived_through; contiguity fromlast_seq+1; whole-batch interior linkage: EVERY row’sprevious_hash= the prior row’sevent_hash(the first row’s = the head’slast_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 pinnedunhashedingress object ({request_id, ip_address, success}FTI /{ip_address}audit),created_at/received_atserver-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. Therows[]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;confirmedimmutable);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_v2per family): the hashed business fields as typed columns + position/hash columns +canonical_event_payload; NEVER an excluded column. The minimality test asserts the exactinformation_schemacolumn 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_instancesrow + the topology pointer + thechain_sourcesrow(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-gatedchain_epoch_activateafter external confirmation) + all heads (last_seq=0, KAT’d empty-head hashes). Identity + clock are injected into the pureGenesisPlan. -
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 viachain_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_sourcesrows 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-genesisis never legitimate against an activated chain; the message nameschain_epoch_activateas 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_boundary →
purge_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 carriesanchor_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. TheVECTOR_CORPUS_SHA256const 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
matchhelpers): 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;confirmedimmutable; 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_MIGRATIONSsemantics; the full battery per MR (LOCALcargo xtask validatebefore 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_canonicalizerinto[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+ theapplyarm inxtask/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), theservices.adocpointer,CHANGELOG.adoc.
Decisions ratified at sign-off (2026-07-30)
-
D-CANON:
serde_json_canonicalizer0.3.2 + the recursive I-JSON validation layer (research-verified; alternatives rejected as recorded above). -
The Step-0 process shape (sign-off → issue reshaping → plan-commit MR → confirmatory review at 0c).
-
D8 first-impl authority recommendation (canopy-store/S3-Object-Lock; Garage caveat) — final selection ratified in #1278 via a further ADR-014 amendment.
-
Non-binding shard-count defaults (audit 8 / FTI 2); binding selection at the #1279 gate.
-
The successor-issue scope boundaries (#1278–#1281 as filed).