feat(ledger): mint plan admissions natively via a two-phase storage api and broker composition (mint-control S2) - #315
Conversation
`plan_admitted` is refused on every public append path since S1, and this is the dedicated native control the ingest guard names. `record_plan_admission_v1` and `seal_plan_admission_v1` mirror the V5 admission pair: one BEGIN IMMEDIATE, identity-first idempotent resolution, event construction + canonicalize inside the control, the narrow monotonic-ordinary-id re-assertion that stands in for the generic validator, private insert_event/insert_event_signature, an append-and-advance-once projection, then a separate exact checkpoint seal. A plan admission has no signed predecessor to replay, so the control derives what it can from the exact content-addressed bytes it was handed: input_digest over those bytes (refusing any object that does not hash to its own cas:// reference), a byte-addressed plan id, and decided_at from its own clock. The PlanForge plan_digest and idempotencyKey are recorded verbatim as caller assertions and never become the identity a retry resolves against. Two disclosed departures from the V5 template: - Single kernel signer. PlanAdmittedV1 records the operator as a payload field and is kernel-signed, so the realm configures one identity for both the admission and its sealing checkpoint. The seal therefore buys prefix verifiability, not separation of duties. - The natively derived plan id is deliberately NOT PlanForge's pf-plan-<fingerprint>. That fingerprint hashes an insertion-order JSON.stringify over a parsed projection of the plan (preview.ts), not the compiled bytes, so re-deriving it would mean porting the TypeScript compiler. The mint marks its own id visibly distinct so the two cannot be confused. The record is recovery evidence, not effect authority, matching the V5 receipt.
…nt-control S2) The broker half of the mint: a private composition over injected content, repository-descriptor, authenticated-identity, ledger, and fresh trusted-recovery seams. No transport, socket, role, bin, or config loader — the authenticated ingress is the next slice, so nothing production-side calls this yet. The closed request shape is a request id, a run id, one cas:// content reference, and the advisory block, under serde deny_unknown_fields, with canonical UUID parsing before any seam is touched. There is deliberately no wire slot for input_digest, plan_id, trusted_base, decided_by, or decided_at: the composition derives the digest from the bytes the content seam returned and holds them to the reference the caller named, takes the trusted base from the retained repository descriptor (refusing one bound to another run), and takes decided_by from the authenticated-identity seam. Per the ratified gate, a request that does not assert validation PASS is refused, and the status is still recorded as caller-asserted and non-authoritative. Outward the composition speaks only Sealed(proof) | ReconciliationRequired. The negatives are asserted against a crate-private `admit_detailed` because a suite that only asserted the outward disposition would stay green with five of the six checks deleted; a dedicated test pins the collapse itself, so the non-disclosure property is covered without weakening the negatives. Both the composition's refusals and the storage layer's are exercised: the production backend and fresh-snapshot verifier seal against a real store, and the verifier reopens a FRESH durable connection (canonical-path bound) rather than reading back through the handle that performed the write. Every refusal branch was mutation-checked; the pass found two branches with no discriminating test (a blank operator identity and empty content) and both now have one.
…ard (mint-control S2) Three things the S2 acceptance and the carried S1 ceremony obligations require. The mint round trip. A Rust integration test drives record + seal against a real temp store, exports the run with `export_signed_tape`, and shells to `node scripts/verify-signed-tape.mjs`, asserting exit 0 and — from the verifier's own JSON report — that the minted `plan_admitted` is reported `verified` rather than `unsigned` and that the sealing checkpoint's tape root recomputes. This is the repo's FIRST Rust-test-shells-to-node pattern and is flagged as such in the file header: S2 has no transport, so no TypeScript harness can reach the mint, and putting a signing path behind a CLI verb (even a cfg-gated one) is refused by the standing invariant. It skips with an explicit message when `node` is absent so a bare `cargo test` stays green, and it deliberately never touches the committed `test/fixtures/signed-tape/` corpus, which already contains a signed plan_admitted and would make the criterion vacuous. The payload-variant guard, generalized. S1 scoped its kind/payload-smuggling fix to `plan_admitted` and left the rest to this slice. `validate_external_append` now classifies by BOTH clauses for the whole always-blocked set, so a `governed_dispatch_v5_admission_recorded_v1`, `promotion_reconciliation_resolved`, or `tape_checkpoint` payload is refused under any envelope label — bp-replay dispatches on the payload variant and never reads `event.kind`. The drift guard. Nothing pinned the storage always-blocked set's membership; the guard has no analogue of serve.rs's disposition-table coverage test. A new table-driven test walks every EventKind serde can enumerate and asserts the set is exactly its pinned membership, plus a payload case per blocked variant. The kind cases carry an unrelated payload and the payload cases an unrelated kind, so neither clause can be deleted without a failure. Also corrects the `PlanAdmittedV1` documentation per the ratified gate: the type doc no longer calls the payload "the dispatch authority", and three field docs now say where their values actually come from now that a second producer exists. Regenerated the typeshare TS; `fixtures/payload-variants.json` is byte-identical (documentation carries no value), and a patch changeset covers the published `@buildplane/ledger-client` doc change.
A `plan_admitted` event that is validly kernel-signed but whose admission was never sealed granted dispatch authority. The mint commits the event, its signature, and an `awaiting_checkpoint` projection row in one transaction, and only a later successful seal advances that row, so a crash in between leaves a validly signed event on the tape whose admission was never completed. The reader consulted only `events` and `event_signatures`, so a signature check could not tell the two apart. `AdmittedPlanRecord` now carries `sealed`, read from the mint's `plan_admissions` projection in the same ledger database and failing closed on a missing table, a missing row, an unexpected state, or a failed query. The orchestrator gate requires it and names the unsealed state in its refusal, so an operator learns that an admission exists but is incomplete rather than that none was found. Every tape written before the mint existed carries no projection table and is therefore reported unsealed. That is the ratified behaviour.
Four documentation claims did not match the code they describe. The trust-spine compatibility matrix still said the mint "is not built" and called its own row "slice S1". S2 landed the mint, so the row now records what exists (`record_plan_admission_v1` / `seal_plan_admission_v1` plus the broker-private composition), that S2 shipped no transport, socket, role, bin, or config loader so no lane can reach it, that both generic-ingest lanes stay closed, that consumption is sealed-only, and the ordering constraint that the mint must gain no transport-reachable consumer before S4 lands load-and-verify plus checkpoint coverage. `PlanAdmittedV1::plan_digest` and `idempotency_key` read as canonical values under a canonical-sounding lead sentence while both are caller assertions the mint records verbatim and never verifies. Their docs now lead with that and name the authoritative identity instead: the natively derived `input_digest` plus the projection's native idempotency identity. Regenerated bindings carry the doc change only; payload values are byte-identical. `TrustedPlanAdmissionSnapshotVerifier` and its seam trait borrowed the V5 analogue's "trusted recovery" vocabulary. It re-derives through a fresh connection to the same storage-layer verification, not through `bp_replay::TrustedGovernedRecoverySnapshot`, because `bp-replay` exposes no plan-admission accessor; both docs now say so and name the weaker guarantee. `validate_external_append`'s clause (b) said `plan_admitted` joins the always-blocked set "ahead of its own mint control", and `admit_detailed` claimed six refusal paths against nine pinned `PlanAdmissionRefusal` variants. Both corrected.
… schema coupling Rewrites the pre-mint-tape rationale in admitted-plan-reader to state what the ratification record actually covers: the sealed-only direction is operator-ratified (gates Q12b/Q13, PR #306), the consumer gate lands one slice early because S2 opens the unsealed crash window, and the blanket pre-mint-tape reclassification is a fail-closed choice pending explicit operator confirmation before S3. Adds a back-reference on the plan_admissions DDL naming the four identifiers the kernel reader binds to over node:sqlite, notes the drift test as an S3/S6 follow-up, marks the decorative unsealed row in orchestrator-admission, and supersedes the stale canonical-digest trust wording on the quarantined legacy builder and the archived M2 spec. No behaviour changes.
Merge Protections🔴 2 of 2 protections blocking · waiting on 🙋 you
🔴 require green CI on mainWaiting for
This rule is failing.
🔴 🚦 Auto-queueWaiting for
This rule is failing.When all merge protections are satisfied and these conditions match, this pull request will be queued automatically.
|
There was a problem hiding this comment.
🔵 Needs a closer look
It modifies L0-adjacent ledger minting/trust semantics across Rust + TypeScript consumers, which warrants final human review despite only minor nits found in this pass.
Pull request overview
Implements mint-control S2 for native plan_admitted minting: adds a two-phase bp-ledger storage API + projection, composes a broker-private plan-admission mint over injected seams, and hardens the TypeScript consumer path to require a sealed admission before any dispatch effect.
Changes:
- Add native
SqliteStore::record_plan_admission_v1/seal_plan_admission_v1flow with aplan_admissionsprojection table and extensive Rust tests (including a Node-based verifier round trip). - Introduce broker-private plan-admission composition (
bp-authority-broker) that re-derives authoritative identity fields and emits only sealed evidence or reconciliation. - Extend the kernel admitted-plan reader + orchestrator gate to fail-closed on unsealed admissions, and update TS tests/docs accordingly.
File summaries
| File | Description |
|---|---|
| packages/planforge/src/admit.ts | Documents that the legacy digest-trust model is superseded by the native mint’s caller-asserted advisory fields. |
| packages/ledger-client/src/generated/index.ts | Updates PlanAdmittedV1 field/type docs to match native mint semantics (recovery evidence; advisory vs derived fields). |
| packages/kernel/test/orchestrator.test.ts | Updates orchestrator unit test stubs to include sealed in admitted-plan records. |
| packages/kernel/test/orchestrator-admission.test.ts | Extends admission table-driven tests to carry sealed and clarifies sealed-gate coverage location. |
| packages/kernel/test/candidate-evidence-flow.test.ts | Adds explicit test that signed-but-unsealed admissions are refused before any worker/candidate effect. |
| packages/kernel/test/admitted-plan-reader.test.ts | Adds projection seeding and asserts the reader reports sealed based on projection state / missing projection. |
| packages/kernel/src/orchestrator.ts | Enforces “sealed-only admission authorizes dispatch” in the admission gate and emits a specific refusal message for unsealed admissions. |
| packages/kernel/src/admitted-plan-reader.ts | Adds sealed to AdmittedPlanRecord and reads it from plan_admissions projection (fail-closed on uncertainty). |
| native/crates/bp-ledger/tests/plan_admission_mint.rs | Adds durable mint + projection behavior tests for record/seal/idempotency/crash windows. |
| native/crates/bp-ledger/tests/plan_admission_mint_round_trip.rs | Exports a tape from a real store and runs scripts/verify-signed-tape.mjs via Node to prove verifier compatibility. |
| native/crates/bp-ledger/tests/external_append_denylist.rs | Adds drift guard coverage for the storage always-blocked kind/payload denylist. |
| native/crates/bp-ledger/src/payload/plan_lifecycle.rs | Aligns Rust payload docs with the new mint-control semantics and advisory field interpretation. |
| native/crates/bp-ledger/src/error.rs | Adds plan-admission specific error variants used by the mint control. |
| native/crates/bp-authority-broker/src/plan_admission.rs | Implements broker-private plan-admission composition over injected seams (content/repo/identity/backend/fresh snapshot). |
| native/crates/bp-authority-broker/src/plan_admission_contract_tests.rs | Adds detailed refusal-path tests + outward nondisclosure collapse + production seam integration tests. |
| native/crates/bp-authority-broker/src/lib.rs | Wires the new module (dead-code allowed until transport lands) and enables its tests. |
| native/crates/bp-authority-broker/src/candidate_approval_contract_tests.rs | Formatting-only import consolidation. |
| docs/superpowers/specs/2026-05-29-planforge-m2-admit-cycle.md | Adds a supersedence notice clarifying the native mint’s updated identity/trust model. |
| docs/operations/trust-spine-compatibility-matrix.md | Updates plan_admitted row to reflect S2 mint existence and sealed-only consumption behavior. |
| .changeset/tall-hounds-govern.md | Records patch bumps and summarizes the sealed-gate + documentation corrections. |
Review details
- Files reviewed: 20/21 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Column names and the state vocabulary mirror `plan_admissions` as | ||
| // created by `SqliteStore::init` | ||
| // (native/crates/bp-ledger/src/storage/sqlite.rs). |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3cd5629605
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .get(eventId), | ||
| ["state"], | ||
| ); | ||
| return row?.state === PLAN_ADMISSION_SEALED_STATE; |
There was a problem hiding this comment.
Verify the sealing checkpoint instead of trusting its cache
When a valid kernel-signed admission is present but its local projection has been altered or has become inconsistent—for example, an awaiting_checkpoint row is changed to sealed without a valid checkpoint—this literal alone makes read() return sealed: true, and the orchestrator proceeds to dispatch. The state column is unsigned local projection data and this query never verifies that the referenced checkpoint exists, is kernel-signed, or covers the admission, even though SqliteStore::resolve_sealed_plan_admission_v1 performs those checks. Thus anyone able to supply or modify events.db can upgrade an otherwise reusable signed-but-unsealed event into dispatch authorization; the gate should consume verified checkpoint evidence rather than this cache bit.
Useful? React with 👍 / 👎.
| if signer.actor_id != expected.actor_id | ||
| || signer.key_id != expected.key_id | ||
| || expected.public_key_hash.as_deref() != Some(actual_public_key_hash.as_str()) |
There was a problem hiding this comment.
Bind the supplied signer reference to the configured key hash
When the correct signing key and actor/key IDs are supplied but signer.public_key_hash is absent or differs from the configured signer, this validation succeeds because it compares only the configured hash to the key material. sign_event then persists the malformed supplied signer reference, so record_plan_admission_v1 reports success and commits an admission that verify_stored_plan_admission cannot verify; sealing and every retry consequently require reconciliation. Compare signer.public_key_hash with both the configured hash and actual_public_key_hash before writing.
Useful? React with 👍 / 👎.
Mint-control S2 — the plan-admission mint: storage API + broker-private composition
Implements §7 S2 of
docs/superpowers/specs/2026-08-17-plan-admitted-native-mint-control-design.md(ratified via #306). Second slice of the dedicated nativeplan_admittedmint control; follows S1 (#314). Built conventionally per §9 — the control cannot gate its own construction.What this delivers
Half 1 —
bp-ledgerstorage API (sited beside the V5 admission pair, mirroring its shape):record_plan_admission_v1/seal_plan_admission_v1/PlanAdmissionDispositionV1— two-phase mint: oneBEGIN IMMEDIATE, idempotency-identity-first resolution, §4.3 field derivation, self-canonicalized + kernel-signed event via the private insert pair, projection row,AwaitingCheckpoint→ sealed.plan_admissionsprojection table + migration:CHECK-constrained state vocabulary,UNIQUEadmission event id,BEFORE DELETEno-delete + seal-onlyBEFORE UPDATEtriggers (append-and-advance-once).ReconciliationRequired, never authority — stricter than the V5 analogue in one arm (noted in-code).Half 2 — broker-private composition (
bp-authority-broker/src/plan_admission.rs):dispatch_admission.rstemplate. No transport, no socket, no role, no bin, no config loader — Q1 staged; nothing production-side can reach the mint until S3.deny_unknown_fieldsrequest; re-derivesinput_digest(raw bytes it loaded), nativeplan_id,trusted_base(injected descriptor),decided_by(injected identity),decided_at(own clock). Caller-assertedplan_digest/idempotency_keyrecorded verbatim, documented non-authoritative (Q10/§4.3); requests without an asserted validationPASSare refused (Q10: advisory + required precondition), enforced at both composition and storage layers.Sealed|ReconciliationRequired; nine individually-pinned refusal paths via a crate-privateadmit_detailed.Consumer hardening (from the adversarial round):
packages/kerneladmitted-plan reader now exposessealedfrom the mint's projection (fail-closed on missing table/row/state/error) and the orchestrator dispatch gate requires it — enforcing the ratified Q12b ruling ("an unsealed admission authorizes nothing") at the only consumer. This lands the consumer half of the S4-scheduled gate one slice early because S2 itself opens the unsealed crash window (the §7-S1 front-loading rationale). The blanket consequence — every pre-mint tape'splan_admittedreads as unsealed — is a deliberate fail-closed choice pending explicit operator confirmation before S3 (see "Operator gates" below).Carried S1 obligations: payload-variant guard generalized from
plan_admitted-only to the whole always-blocked set; behavioral drift-guard test pinning the denylist membership; payload↔reader coverage rejoined via the round trip.Acceptance (§7 S2, all seven)
plan_admittedon a real store +sealedprojection row —plan_admission_mint.rs(14 tests).scripts/verify-signed-tape.mjsexits 0 over a tape exported from the store the mint wrote — the first time PlanForge criterion 5 has ever been reachable.plan_admission_mint_round_trip.rs; never touches the committedplan-cyclefixture (§5.6 gaming hazard).nodeis absent; CI runs it with node present; mutation-verified that the node path really executes.planforge-plan-admission.test.ts— zero diff vs origin/main (test 1 verbatim per NG3/Q9).cargo test(no-p): 1316 passed, 105 suites (baseline 1277 + 39 new).Also: typecheck clean;
payload-variants.jsonbyte-identical (doc-comment-only regen); changesets:@buildplane/ledger-clientpatch,@buildplane/kernelpatch.Disclosed deviations (full list in the ceremony record; the load-bearing ones)
plan_idis not natively re-derivable (contra spec §4.3):preview.tsfingerprints a parsed projection, not bytes. The mint derives a visibly-distinctpf-plan-native-<32hex>under its own domain separator. Verified harmless: the reader keys on tape event id.input_digest≠ PlanForge's scheme (raw-bytes sha256 vs JSON-quoted-string sha256) — deliberate, per §4.2 "digest the bytes it loaded".bp-replayTrustedGovernedRecoverySnapshot(no plan-admission accessor exists) — weaker guarantee, stated in-code.CLAUDE.md's M2 crash-recovery contract text itself is unchanged.Ceremony record (L0, 4-role)
a9bbcb2, two further probes (gate neutralization, fail-open reader) discriminate; two new MEDIUMs resolved in the polish commit.01a02f24-4319-7f40-9f4e-ae44982322cc, re-check01a02f46-d67d-7ac2-840f-0823414b75f3): round 1 DEFEATED — [HIGH][CONFIRMED] signed-but-unsealed admissions satisfied the TS dispatch gate; [MEDIUM][CONFIRMED] caller-asserted digests signed under canonical-sounding docs. Post-repair re-check: finding 1 CLOSED [CONFIRMED] (bypass hunt, SQL-forgery, and second-dispatch-path attacks all held); finding 2 payload/projection/broker surfaces fixed, two stale legacy doc surfaces then annotated in the polish commit.3cd5629— OVERALL PASS, 12/12. Full libtest output was re-derived by running the compiled test binaries directly to confirm the round-trip test executed the node path rather than skipping.Operator gates opened by this slice (do not silently pass)
plan_admittedon a tape written before the mint reads as unsealed and confers no dispatch authority (fail-closed; currently unreachable). Wording atadmitted-plan-reader.ts:35-53.Known follow-ups (recorded, not silent)
#[allow(dead_code)]when the ingress wires the composition.plan_admissionsschema to the Rust DDL (back-reference comments exist on both sides).MAX_PLAN_ADMISSION_INPUT_BYTES(1 MiB) is a chosen bound; revisit if real plans approach it.L0 slice — draft PR, NOT auto-merge eligible. Operator admin-merge only.