Skip to content

feat(ledger): mint plan admissions natively via a two-phase storage api and broker composition (mint-control S2) - #315

Merged
SollanSystems merged 6 commits into
mainfrom
feat/mint-control-s2
Aug 24, 2026
Merged

SollanSystems merged 6 commits into
mainfrom
feat/mint-control-s2

Conversation

@SollanSystems

Copy link
Copy Markdown
Owner

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 native plan_admitted mint control; follows S1 (#314). Built conventionally per §9 — the control cannot gate its own construction.

What this delivers

Half 1 — bp-ledger storage API (sited beside the V5 admission pair, mirroring its shape):

  • record_plan_admission_v1 / seal_plan_admission_v1 / PlanAdmissionDispositionV1 — two-phase mint: one BEGIN IMMEDIATE, idempotency-identity-first resolution, §4.3 field derivation, self-canonicalized + kernel-signed event via the private insert pair, projection row, AwaitingCheckpoint → sealed.
  • plan_admissions projection table + migration: CHECK-constrained state vocabulary, UNIQUE admission event id, BEFORE DELETE no-delete + seal-only BEFORE UPDATE triggers (append-and-advance-once).
  • Every uncertainty (missing projection, concurrent checkpoint moving the prefix, empty prefix) → 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):

  • Injected seams (content resolver / ledger backend / fresh-snapshot verifier) per the dispatch_admission.rs template. No transport, no socket, no role, no bin, no config loader — Q1 staged; nothing production-side can reach the mint until S3.
  • Closed deny_unknown_fields request; re-derives input_digest (raw bytes it loaded), native plan_id, trusted_base (injected descriptor), decided_by (injected identity), decided_at (own clock). Caller-asserted plan_digest/idempotency_key recorded verbatim, documented non-authoritative (Q10/§4.3); requests without an asserted validation PASS are refused (Q10: advisory + required precondition), enforced at both composition and storage layers.
  • Outward dispositions: Sealed | ReconciliationRequired; nine individually-pinned refusal paths via a crate-private admit_detailed.

Consumer hardening (from the adversarial round): packages/kernel admitted-plan reader now exposes sealed from 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's plan_admitted reads 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)

  1. Kernel-signed plan_admitted on a real store + sealed projection row — plan_admission_mint.rs (14 tests).
  2. scripts/verify-signed-tape.mjs exits 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 committed plan-cycle fixture (§5.6 gaming hazard). ⚠️ This is the repo's first Rust-test-shells-to-node pattern — skips with an explicit message when node is absent; CI runs it with node present; mutation-verified that the node path really executes.
  3. All §6.2(2) negatives, each its own test with a named disposition (+ the Q10 refusal).
  4. §6.2(3) idempotent crash-recovery positive (injected seal failure → retry → identical sealed evidence, exactly one event).
  5. §6.2(4) fresh-snapshot postcondition (fresh durable connection re-verifies the seal).
  6. planforge-plan-admission.test.tszero diff vs origin/main (test 1 verbatim per NG3/Q9).
  7. Whole-workspace cargo test (no -p): 1316 passed, 105 suites (baseline 1277 + 39 new).

Also: typecheck clean; payload-variants.json byte-identical (doc-comment-only regen); changesets: @buildplane/ledger-client patch, @buildplane/kernel patch.

Disclosed deviations (full list in the ceremony record; the load-bearing ones)

  • plan_id is not natively re-derivable (contra spec §4.3): preview.ts fingerprints a parsed projection, not bytes. The mint derives a visibly-distinct pf-plan-native-<32hex> under its own domain separator. Verified harmless: the reader keys on tape event id.
  • Mint input_digest ≠ PlanForge's scheme (raw-bytes sha256 vs JSON-quoted-string sha256) — deliberate, per §4.2 "digest the bytes it loaded".
  • Fresh-snapshot verifier is a fresh-connection re-derivation through the storage verifier, NOT a bp-replay TrustedGovernedRecoverySnapshot (no plan-admission accessor exists) — weaker guarantee, stated in-code.
  • Single kernel signer (Q4) ⇒ no admission/checkpoint signer separation; the seal buys prefix verifiability only.
  • The M2 resume-rule conflict (spec §5.3) is documented, and now enforced on the TS consumer path, but CLAUDE.md's M2 crash-recovery contract text itself is unchanged.

Ceremony record (L0, 4-role)

  • Implementer: Opus, phased TDD; refusal branches and the round-trip node path mutation-verified.
  • Independent reviewer (fresh Opus): round 1 HOLD (HIGH: compatibility-matrix row falsified; MEDIUM: verifier vocabulary overclaim; 3 LOW) with five mutation probes (node-path panic, payload-guard arm deletion, Q10 neutralization all discriminate). Round 2 PASS at a9bbcb2, two further probes (gate neutralization, fail-open reader) discriminate; two new MEDIUMs resolved in the polish commit.
  • Adversarial (real Codex GPT-5.5 xhigh; sessions 01a02f24-4319-7f40-9f4e-ae44982322cc, re-check 01a02f46-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.
  • Acceptance verifier: independent 12-row checklist (§7-S2 seven criteria + five ceremony riders) with re-executed gates, at head 3cd5629OVERALL 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)

  1. Before S3: ratify (or amend) the pre-mint-tape consequence — every plan_admitted on a tape written before the mint reads as unsealed and confers no dispatch authority (fail-closed; currently unreachable). Wording at admitted-plan-reader.ts:35-53.
  2. Binding ordering constraint (recorded in the compatibility matrix): the mint must not gain a transport-reachable consumer before S4 lands the load-and-verify + checkpoint-coverage arm (Q13).

Known follow-ups (recorded, not silent)

  • S3: remove the module-wide #[allow(dead_code)] when the ingress wires the composition.
  • S3/S6: cross-language drift test binding the TS reader's hardcoded plan_admissions schema 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.

`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.
@mergify

mergify Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 2 of 2 protections blocking · waiting on 🙋 you

Protection Waiting on
🔴 require green CI on main 🙋 you
🔴 🚦 Auto-queue 🙋 you

🔴 require green CI on main

Waiting for

  • -draft
This rule is failing.
  • -draft
  • -conflict
  • check-success = Analyze (javascript-typescript)
  • check-success = GitGuardian Security Checks
  • check-success = verify

🔴 🚦 Auto-queue

Waiting for

  • label = buildplane:auto-merge
This rule is failing.

When all merge protections are satisfied and these conditions match, this pull request will be queued automatically.

  • label = buildplane:auto-merge
  • base = main

@SollanSystems
SollanSystems marked this pull request as ready for review August 24, 2026 01:21
Copilot AI lite review requested due to automatic review settings August 24, 2026 01:21
@SollanSystems
SollanSystems merged commit 3bdadf7 into main Aug 24, 2026
6 of 7 checks passed
@SollanSystems
SollanSystems deleted the feat/mint-control-s2 branch August 24, 2026 01:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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_v1 flow with a plan_admissions projection 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.

Comment on lines +158 to +160
// Column names and the state vocabulary mirror `plan_admissions` as
// created by `SqliteStore::init`
// (native/crates/bp-ledger/src/storage/sqlite.rs).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +22646 to +22648
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants