Skip to content

feat(broker): authenticated ingress for the native plan_admitted mint (mint-control S3) - #316

Draft
SollanSystems wants to merge 24 commits into
mainfrom
feat/mint-control-s3
Draft

SollanSystems wants to merge 24 commits into
mainfrom
feat/mint-control-s3

Conversation

@SollanSystems

Copy link
Copy Markdown
Owner

mint-control S3 — authenticated ingress for the native plan_admitted mint (L0)

Lands the authenticated transport in front of the S2 mint composition: a dedicated protected authority host that a configured, SO_PEERCRED-authenticated client reaches over an FD-3 unix socket to cause a native plan_admitted mint. Completes the mint's ingress half; no transport-reachable consumer is wired (per the ratified §2.4 constraint — that lands at S4).

Base: origin/main 3bdadf7 (S2, #315). 19 commits.

What it adds

  • BrokerAuthorityRoleV1::PlanAdmission role + confinement wiring.
  • A closed, deny_unknown_fields plan-admission request wire struct.
  • Host binary buildplane-plan-admission-host + socket /run/buildplane/authority-host/plan-admission-v1.sock (FD 3) + config loader plan_admission_host_config.rs + systemd units.
  • Role verification before any frame byte is read; decided_by derived solely from the authenticated peer UID via config (request cannot name an identity — Q4).
  • Plan bytes staged over SCM_RIGHTS as a sealed file (all four seals: WRITE|SHRINK|GROW|SEAL), the broker as sole CAS writer (Q12c). Repo's first SCM_RIGHTS implementation.
  • trusted_base taken from the run's own kernel-signed run_started on the protected tape (attested, not independently re-derived — disclosed below).
  • Cross-language drift test binding the TS reader's plan_admissions schema to the Rust DDL.
  • Governed runbook section for the new host.

Review ceremony (L0, 4-role)

  • Implementer: TDD, mutation-verified refusal branches.
  • Independent Opus reviewer: HOLD → repair → PASS at 1174f85 (14 findings incl. one HIGH fd-leak on the truncated-cmsg path, all closed and re-derived).
  • Real Codex adversarial (gpt-5.5 xhigh): DEFEATED at 5b85689 → repaired → HELD at 9c0566a. Codex found two genuine authorization/evidence bypasses nothing else caught:
    • H1 — cross-run replay: the dispatch gate never bound the admission to the run; a sealed plan_admitted from run A could authorize dispatch in run B sharing one events.db. Fixed: AdmittedPlanRecord.runId exposed (inside the signed bytes), projection lookup + gate both bind the run. (latent — no production path can mint a sealed plan_admitted yet, Q7; the gate is S2-landed code, fixed here.)
    • H2 — signed response not run-bound: the plan-admission response bound only (request_id, plan_content_ref); a sealed response for run A verified for run B. Fixed: run_id added inside the signed payload + verifier comparison.
    • Plus a real latent shared-storage bug (M1): cas.rs used a deterministic .tmp-<hash> name with create_new, so a crash mid-write stranded a temp that wedged every retry forever — fixed with a unique-per-attempt name; the host now fails closed on CAS write error.
  • Independent acceptance verifier: PASS against the spec's literal §7-S3 / §6.2 criteria.

Disclosures (verified real)

  • trusted_base is an attestation, not an independent derivation. It comes off the run's own kernel-signed run_started (requires exactly one Verified event under the pinned keys + configured kernel signer) — strictly stronger than a config value, but it rests on the same kernel key the admission signature does; it is not a second independent source. Held honest today by a standing constraint (recorded in the resolver doc, compat matrix, and runbook): run_started must not become kernel-signable from caller-supplied input while it is the mint's trusted-base source. Pinned by a denylist-membership test.
  • CAS write-amplification residual (accepted, operator-owned). Every request/bytes-local disqualifier (asserted status, asserted-digest shape, idempotency-key shape, content-ref digest, and plan-bytes admissibility: empty/over-length/non-UTF-8) is now refused before staging. The only post-stage disqualifications left require the mint to consult the tape/projection (run-start attestation, idempotency conflict, fresh-snapshot rejection) — inherent to the Q12c shape, bounded at 1 MiB per authenticated connection. Reclamation of inert crash-stranded CAS temps (a periodic sweep, or none-by-design on the sequential host) is an operator/deployment call.
  • One-per-(run_id, request_id), not one-per-run. Cross-run authorization is closed by the per-admission run binding (H1), not an at-most-one-per-run rule; UNIQUE(run_id) would forbid the candidate-transaction model and is left for an operator gate.
  • Response verification half ships with no production caller (the future client), so a later client cannot invent a looser reading of the same bytes.

Constraints held

  • No transport-reachable consumer before S4 (nothing in TS drives the socket).
  • plan_admitted stays in serve.rs REJECTED_ONLY_WHEN_SIGNED (serve.rs logic unchanged; +30/−0 test-only).
  • OperatorRequested embargo untouched; test 1 of planforge-plan-admission.test.ts verbatim.
  • No new event kind / payload → no ledger fixture regeneration.

Gates

  • Whole-workspace cargo test --manifest-path native/Cargo.toml (no -p): 1371 passed / 107 suites.
  • pnpm typecheck, pnpm build, cargo fmt --check: clean.
  • git diff packages/ledger-client/{src/generated,fixtures}: clean.
  • Changeset: @buildplane/kernel: patch (the cross-run dispatch-binding fix).

L0 slice — not auto-merge eligible; admin-merge only.

… boundary

The role enum's own doc comment requires a new broker endpoint to extend it
and configure its worker identity boundary explicitly at host startup, so the
plan-admission ingress needs its own variant before it can have a policy.
The single kernel signer realm (Q4) leaves one deployment fact the mint cannot
derive on its own: which operator identity an authenticated client UID stands
for. That mapping is closed and injective, so an unmapped UID cannot reach the
mint and two peers can never collapse to one recorded decided_by.
…e byte

Peer verification is the first operation and repeats before every blocking
read, so a wrong-role, unconfigured, or broker-aliasing peer cannot cause even
a frame header read. The compiled plan bytes ride the same authenticated
channel as a sealed memfd passed by SCM_RIGHTS: the broker-owned CAS is 0700
and broker-uid-owned, so the requester structurally cannot stage its own
object, and a 16 KiB frame cannot carry a 1 MiB plan. Requiring the write,
shrink, grow, and seal seals is what makes the bytes the host hashes and the
bytes the host stages the same bytes.
…omain

The response binds the request id and the content reference the caller named,
and nothing the mint derived. A sealed outcome carries an admission event id
and a reconciliation outcome carries none; no other pairing can be signed, and
no refusal reason ever reaches the wire.
…host

The host owns one socket on the inherited listener, one role, one run, and one
kernel signer. It stages the sealed bytes the authenticated peer handed it into
the broker-owned CAS — the broker is the only principal that writes there —
derives decided_by from the peer's UID through the closed deployment identity
policy, and re-derives the trusted base from the run's own kernel-signed start
on the protected tape rather than from anything the request said.

The listener path validator is shared with the promotion-decision endpoint
rather than copied, so the two cannot drift apart on socket ownership or mode.
Non-Linux fails closed: the confinement boundary, the custody, and SCM_RIGHTS
delivery are all Linux-only, and there is no reduced mode to fall back to.

Nothing drives this ingress. The mint must not gain a transport-reachable
consumer before the approval arm verifies and requires checkpoint coverage of
the admission it names.
…eader

The kernel's admitted-plan reader opens this database over node:sqlite and
hardcodes four names. Renaming any of them on the Rust side resolved to
not-sealed and refused every governed dispatch with a misleading diagnosis,
with no failing test on either side. Both directions are now pinned, and the
state vocabulary is asserted against the CHECK itself rather than whichever
constraint happens to fire first.
…ity matrix

The row's claim of no transport-reachable writer stops being true with S3, so
it says so plainly and states what reaching the writer now costs: an enrolled
socket, a configured UID, and a peer check that runs before any frame byte.
The ordering constraint that no consumer may drive it before S4 is unchanged.
… authority hosts

The host reads its listener from inherited FD 3, so without a socket unit the
socket it validates can never exist and the ingress is unreachable in a real
deployment. The units mirror the operator-facing promotion-decision endpoint:
a root-owned 0660 socket in the operator group, and a hardened service whose
only writable paths are the ledger and the CAS it stages plan bytes into.
… truncated frame

MSG_CTRUNC does not mean nothing was delivered: Linux installs as many
descriptors as the control buffer holds and closes only the remainder.
Refusing before wrapping the installed ones in OwnedFd leaked them, and the
accept loop runs forever, so an authenticated peer could repeat one oversized
frame until the host's descriptor table was exhausted. Ownership now precedes
judgement on both the truncated and the malformed-length paths.
…ing its bytes

The CAS is the mint's trust root and shares a filesystem with the ledger, so
staging content that can never become an admission handed any authenticated
peer a write amplifier on an accept loop that never ends. The digest check and
the asserted-PASS precondition now both run before the stage, using the
composition's own constant so the two cannot drift.
The host never runs git. It takes git_head from the run's own start event and
establishes that the kernel signer attested to it, so the value originates with
whoever recorded the run start and is exactly as trustworthy as the key that
signs the admission — not an independent second source. Records the standing
constraint that run_started must not become kernel-signable from caller-supplied
input while it is the mint's trusted-base source, and pins its denylist
membership by name, since every other test there iterates whatever the arrays
happen to contain.
…gh the real host

The resolver's refusal branches were only ever exercised against S2's injected
fake, and the recorded operator was never proven to depend on the peer. Both
are now driven through the production host: five run-start variants each yield
reconciliation and mint nothing, and a lower-UID decoy client makes the
recorded decided_by discriminate between the peer that connected and the first
client in the config. Verified by mutation: deleting the signer comparison, the
verification check, or the peer-UID lookup each turns one of these red.
… claims

Request identifiers now require the canonical hyphenated spelling, matching
what the config and the signed response already demanded, so a request and its
own answer cannot disagree textually about one identity. The frame reader no
longer refuses a buffer that arrived complete exactly at the deadline. Domain
separation is tested by consequence — a same-key signature over the undomained
payload must fail — instead of restating that the prefix function prepends its
own constant, and the ingress content bound is pinned against the storage bound
it mirrors through a test-only accessor rather than a published constant.
Covers the socket and unit pair, the operator-created buildplane-plan-authority
identity the units name but nothing in-repo provisions, the closed
plan-admission-v1.json config, what an operator does on each of the three
diagnostics, and why a refused request is answered with silence. Records the
trusted-base standing constraint alongside the no-consumer-before-S4 one.
A request that clears the peer, run, identity, digest, and validation checks is
staged before the mint runs, so one the mint later declines has already written
its bytes. The matrix said otherwise; it now says what is actually true, and
the runbook records the residual — up to 1 MiB of self-named bytes per
connection on a CAS volume sharing a filesystem with the ledger, inherent to
resolving content through the broker-owned store, with quota and collection
left to the operator.

Also couples the truncation regression test to the ancillary capacity constant,
so widening the capacity keeps the test exercising MSG_CTRUNC instead of
silently retiring the branch, and names the read-path validator that makes the
payload-variant match unreachable.
The tape is one events.db per project, so a sealed plan_admitted minted for run
A satisfied signedByKernel, authorizedNextStep, and sealed identically when a
packet for run B named it as provenance_ref — and authorized the dispatch. The
reader already selected run_id but never exposed it. AdmittedPlanRecord now
carries runId, the projection lookup binds run_id as defence in depth, and the
gate refuses a wrong-run admission after the sealed check with a distinct
message naming both runs. Latent today (no production minting path yet, Q7),
but the bypass lived in the S2-landed gate that this slice is the adjacent
consumer of.
request_id is caller-controlled and unique only within a run — storage keys
(run_id, request_id) — so a sealed response for run A verified identically for
run B that reused the same request id and content reference. run_id is now part
of the response binding, the signed wire shape, the canonical payload, and the
verifier's expected-binding check; the host binds it to config.run_id, the run
already proven equal to the request's. Rust-internal signed wire, no
ledger-client fixture regeneration.
H3: the asserted plan-digest canonical form and idempotency-key shape are
request fields the mint refuses without loading the bytes back, so the ingress
now refuses them before staging — closing the deeper write amplification beyond
the digest/PASS pre-checks. The predicates are shared with the storage layer so
the pre-stage and mint-time checks cannot drift. The irreducible residual
(validations that need the CAS-resolved content) stays as the accepted,
operator-owned NEW-2 residual.

M1: the host stops discarding the CAS write result and fails the connection
closed on error, rather than proceeding into a mint that would resolve a
silently absent object and answer reconciliation. The CAS itself no longer
wedges on a stale temp: a crash between create and rename left a deterministic
.tmp-<hash> that made every retry fail AlreadyExists forever; the temp name is
now unique per attempt.

M2: document that the projection is one-admission-per-(run,request), not
one-per-run, and that a stricter UNIQUE(run_id) is an open product question for
the reviewer — not changed unprompted.
The dispatch gate binds each admission to its own run, but a run may still hold
several sealed admissions under distinct request ids; each authorizes only the
packet naming its event id. Records that a one-admission-per-run rule is an
open product question rather than an omission.
The mint's empty / over-length / non-UTF-8 checks on the plan bytes operated
only after the bytes were resolved back out of the CAS — i.e. post-stage — so a
request with a matching content-ref but non-UTF-8 bytes still wrote up to the
input bound into the trust-root CAS before the mint declined it. The three
checks are extracted into one shared bp-ledger predicate that
derive_plan_admission_content_v1 and the host pre-stage block both call, so the
mint-time and pre-stage checks cannot drift, and the host now refuses
inadmissible bytes before put_canonical_bytes. Empty and over-length were
already refused at the ingress descriptor layer; UTF-8 was not. The only
remaining post-stage disqualifications are the tape/projection-dependent ones
(run-start attestation, idempotency conflict, fresh-snapshot rejection) — the
irreducible NEW-2 residual, and the runbook now says so precisely.
The comment claimed a stranded temp is cleaned by the rename of a subsequent
write of nearby content. That is false: a rename moves its own temp into place
and never touches an abandoned one. Replaced with the truth — a crash between
create and rename strands a .tmp-<hash>-<pid>-<seq> that no code path reclaims —
and disclosed the residual honestly as low-rate and inert, with reclamation
left to the operator. No sweeper added.
Round-2 changed @buildplane/kernel runtime behavior (AdmittedPlanRecord gains
runId + the run-match gate) without a changeset. Declares @buildplane/kernel:
patch.
@mergify

mergify Bot commented Aug 24, 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

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.

1 participant