session 47: the tested contract becomes the running one — a private socket, a read-only projection, and no pool bring-up (#47) #51

Merged
larandar merged 12 commits from tango/47-private-service into fastlane 2026-09-06 07:45:06 +00:00
Owner

Closes #47. Records ruling D2 on #45 (closed) and D2b on #46. Files #50 for the half this leaves undone.

Why

#20 closed with the private command contract, the journal, both native adapters and the lost-response semantics written and tested — and nothing serving them. vedanta serve mounted jostoph-rs's evaluate router and stopped there; private_api, operations, kanidm, forgejo and reconcile were library modules no process instantiated. The delivery run for Ting/Ting#60 found that as the first Vedanta delta.

The same run found a boundary mismatch the capture doubles had hidden: PR #38 reserved slot names, created principals, pinned UUIDs and rendered grants. contracts.identity.openbao-lease-backend forbids exactly that, and the freeholder ruled it (2026-09-05): "Vedanta MUST NOT — this is a provider job."

What lands

  • The private lifecycle listener (src/private_transport.rs) on a unix socket. The caller is the kernel-reported peer uid, mapped to OpenBao's configured user; any other uid is unauthenticated at the validator's first stage, before the journal and before the body is believed. There is no header a caller could set to become OpenBao. Routes: POST /v1/lifecycle/commands, GET …/operations/{id} (metadata only), POST …/operations/{id}/acknowledge, GET …/projection/{grant}, GET …/health.
  • The deployed grant projection as read-only input (src/projection.rs): loaded at start, re-read per command, refused when it says what the contract forbids (a lease ceiling above 1800 s, a claimant that is not the principal, a duplicate grant). runtime_view joins it with the journal so a rotate names its true predecessor and a second mint on a live lease is refused.
  • The native issuers (src/native.rs): repositories named → a Forge capability on the queen holder's account, scopes mapped exactly (repository:readread:repository; an unknown operation is refused, never widened), deleted by exact ID. No repositories → a Kanidm api-token. Either issuer may be absent and answers native_unavailable rather than pretending.
  • The journal records outcomes, not only intent (OperationJournal::{complete,fail}), with two new error classes: native_refused (final) and native_unavailable (retryable).
  • Pool bring-up is gone. src/pool.rs keeps the slot types, the realized mapping as a read (Realized::load; there is no save) and the ratification gate; Provisioner, Ledger, Namer and PoolDirectory are removed.
  • Ruling D2b applied to the shape. A file rendered at provisioning cannot know who took a seat later, so DeployedGrant carries a SeatDelegation — which seat, which operations its holder must be entitled to, which account mode — and the service joins the seat's one active tenure in at command time from a TenureSource. Two records claiming a seat are no holder, as are none.
  • module.nix gains services.vedanta.private.*; AGENTS.md and CONTEXT.md say what runs.

Evidence

devenv shell -- : cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace — green on 2026-09-06. 152 lib tests (was 128), 1 bin, 32 in openbao-plugin, 3 doctests.

The listener is exercised over a real unix socket, not a mock: OpenBao's uid mints once and the journal keeps the token ID; a stranger's uid is refused with no journal write and no mutation; a replayed operation ID does not mint twice; status never carries the value; a tampered signature, an unknown grant, an unheld seat and a doubly-claimed seat are each refused independently.

Both producers of the projection file — the provider library (nixops4-providers#27) and the deployment (flake-ops#426) — have their actual rendered output vendored under fixtures/ and parsed by the real parser, so a drift in either fails a test here instead of denying in production.

Nothing here has spoken to a live Kanidm or Forgejo. sa-vedanta does not exist until flake-ops#98 runs.

What a reviewer must check

  1. The peer-uid model: /etc/passwd resolution at startup means OpenBao's unit needs a static user — flake-ops#426 switches it off DynamicUser, and that migration moves a raft store.
  2. runtime_view's choice for a rotate or invalidate with nothing minted: the expected generation comes from the command, so the denial lands on the predecessor stage, which is the stage that is wrong.
  3. The success path journals complete after the mutation and still answers success if that write fails — the operation stays pending for #23's sweep rather than telling a caller "failed" about a token that is live. Argued in the commit body.
  4. active_queen_tenures is empty until #50 exists, so every drone lease denies at the seat stage. Fail-closed and deliberate.
  5. contracts.identity.openbao-lease-backend still forbids what D2b permits; the delta is Ting/contracts#10, unratified.
Closes #47. Records ruling D2 on #45 (closed) and D2b on #46. Files #50 for the half this leaves undone. ## Why [#20](https://jo.et0.pw/Ting/Vedanta/issues/20) closed with the private command contract, the journal, both native adapters and the lost-response semantics written and tested — and **nothing serving them**. `vedanta serve` mounted jostoph-rs's evaluate router and stopped there; `private_api`, `operations`, `kanidm`, `forgejo` and `reconcile` were library modules no process instantiated. The delivery run for [Ting/Ting#60](https://jo.et0.pw/Ting/Ting/issues/60) found that as the first Vedanta delta. The same run found a boundary mismatch the capture doubles had hidden: PR #38 reserved slot names, created principals, pinned UUIDs and rendered grants. `contracts.identity.openbao-lease-backend` forbids exactly that, and the freeholder ruled it (2026-09-05): *"Vedanta MUST NOT — this is a provider job."* ## What lands - **The private lifecycle listener** (`src/private_transport.rs`) on a unix socket. **The caller is the kernel-reported peer uid**, mapped to OpenBao's configured user; any other uid is `unauthenticated` at the validator's first stage, before the journal and before the body is believed. There is no header a caller could set to become OpenBao. Routes: `POST /v1/lifecycle/commands`, `GET …/operations/{id}` (metadata only), `POST …/operations/{id}/acknowledge`, `GET …/projection/{grant}`, `GET …/health`. - **The deployed grant projection as read-only input** (`src/projection.rs`): loaded at start, re-read per command, refused when it says what the contract forbids (a lease ceiling above 1800 s, a claimant that is not the principal, a duplicate grant). `runtime_view` joins it with the journal so a rotate names its true predecessor and a second mint on a live lease is refused. - **The native issuers** (`src/native.rs`): repositories named → a Forge capability on the queen holder's account, scopes mapped exactly (`repository:read` → `read:repository`; an unknown operation is refused, never widened), deleted by exact ID. No repositories → a Kanidm api-token. Either issuer may be absent and answers `native_unavailable` rather than pretending. - **The journal records outcomes**, not only intent (`OperationJournal::{complete,fail}`), with two new error classes: `native_refused` (final) and `native_unavailable` (retryable). - **Pool bring-up is gone.** `src/pool.rs` keeps the slot types, the realized mapping as a **read** (`Realized::load`; there is no save) and the ratification gate; `Provisioner`, `Ledger`, `Namer` and `PoolDirectory` are removed. - **Ruling D2b applied to the shape.** A file rendered at provisioning cannot know who took a seat later, so `DeployedGrant` carries a `SeatDelegation` — which seat, which operations its holder must be entitled to, which account mode — and the service joins the seat's **one** active tenure in at command time from a `TenureSource`. Two records claiming a seat are no holder, as are none. - `module.nix` gains `services.vedanta.private.*`; `AGENTS.md` and `CONTEXT.md` say what runs. ## Evidence `devenv shell --` : `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` — green on 2026-09-06. **152 lib tests** (was 128), 1 bin, 32 in `openbao-plugin`, 3 doctests. The listener is exercised over a **real unix socket**, not a mock: OpenBao's uid mints once and the journal keeps the token ID; a stranger's uid is refused with no journal write and no mutation; a replayed operation ID does not mint twice; status never carries the value; a tampered signature, an unknown grant, an unheld seat and a doubly-claimed seat are each refused independently. Both producers of the projection file — the provider library ([nixops4-providers#27](https://jo.et0.pw/lar.ad/nixops4-providers/issues/27)) and the deployment ([flake-ops#426](https://jo.et0.pw/lar.ad/flake-ops/issues/426)) — have their **actual rendered output** vendored under `fixtures/` and parsed by the real parser, so a drift in either fails a test here instead of denying in production. **Nothing here has spoken to a live Kanidm or Forgejo.** `sa-vedanta` does not exist until [flake-ops#98](https://jo.et0.pw/lar.ad/flake-ops/issues/98) runs. ## What a reviewer must check 1. The peer-uid model: `/etc/passwd` resolution at startup means OpenBao's unit needs a static user — flake-ops#426 switches it off `DynamicUser`, and that migration moves a raft store. 2. `runtime_view`'s choice for a rotate or invalidate with nothing minted: the expected generation comes from the command, so the denial lands on the predecessor stage, which is the stage that is wrong. 3. The success path journals `complete` **after** the mutation and still answers success if that write fails — the operation stays `pending` for #23's sweep rather than telling a caller "failed" about a token that is live. Argued in the commit body. 4. `active_queen_tenures` is empty until #50 exists, so every drone lease denies at the seat stage. Fail-closed and deliberate. 5. `contracts.identity.openbao-lease-backend` still forbids what D2b permits; the delta is [Ting/contracts#10](https://jo.et0.pw/Ting/contracts/issues/10), unratified.
Ruling D2 (Larandar, 2026-09-05, in-session, delivery run for
Ting/Ting#60): Vedanta MUST NOT reserve slot names, create or adopt
pool principals, pin UUIDs, or author grants; that is the provider's
job, driven by flake-ops#427. The ratified contract already said so
(contracts.identity.openbao-lease-backend,
the-realized-drone-pool-is-read-only-input-to-vedanta) and Session #30
/ PR #38 crossed it behind capture doubles.

What stays: Slot, SlotName (now also parsed back from a mapping, and
refused when malformed), Grant, RealizedSlot/Realized as a READ input
(Realized::load; there is no save), and the ratification Gate. What
goes: Namer, Account, PoolDirectory, Reservation, Ledger, Provisioner,
and their tests. checkout.rs is untouched and stays provisionally
pending #46 / D2b.

Refs: Ting/Vedanta#47, #45 (closed as re-homed), #46.
The served path needs two things the library contract left implicit.
First, OperationJournal only claimed intent; the minted token ID, expiry
and native event — the handles every later invalidate and reconcile
address — were recorded by nobody. The trait now carries complete and
fail, the durable Journal implements them, and the service records the
outcome before it answers. A record that cannot be written does not
un-mint the token: the operation stays pending, which is exactly the
delivery-uncertain shape #23's sweep reconciles against OpenBao's
committed lease.

Second, a native issuer's own refusal and its unreachability are not
any of the existing validation classes. native_refused is final under
the same command (never retried with wider authority);
native_unavailable is retryable, because nothing is known to have
changed.

Refs: Ting/Vedanta#47.
The projection is the file the deployment renders from the ratified
grant set (providers#30 compiles it, flake-ops#427 realizes and
ratifies it, #432 places it). Vedanta loads it, refuses what the
contract forbids — a lease ceiling above 1800 s, a claimant that is not
the principal, a duplicate grant — and never writes it. runtime_view
joins the deployed half with the journal's minted records so a rotate
names its true predecessor, an invalidate addresses the exact token it
names, and a second mint on a live lease is refused at the predecessor
stage.

native.rs is the production MutationAdapter. Repositories named → a
Forge capability on the queen holder's account (agent.teyla), scopes
mapped exactly and unknown ones refused, deleted by exact ID; no
repositories → a Kanidm api-token for the principal. Forgejo cannot
encode expiry, so the result echoes the lease's requested expiry — the
bound OpenBao enforces by exact deletion. Either issuer may be absent
and answers native_unavailable rather than pretending.

Refs: Ting/Vedanta#47.
Until now vedanta serve mounted jostoph-rs's evaluate router and nothing
else; the private command contract, journal and adapters were library
modules no process ran. serve now also binds the private surface on a
unix socket when VEDANTA_PRIVATE_SOCKET is set, and fails the unit when
the surface is configured incompletely.

Peer identity comes from the transport: the kernel-reported peer uid is
mapped to OpenBao's configured user, and any other uid is
unauthenticated at the validator's first stage — before the journal,
before the body is believed. There is no header a caller could set to
become OpenBao. Routes: POST /v1/lifecycle/commands, GET
/operations/{id} (metadata only), POST /operations/{id}/acknowledge,
GET /projection/{grant}, GET /health. The tests exercise all of it over
a real socket: a stranger's uid, a replayed operation ID, a tampered
signature, an unknown grant, and that status never carries the value.

axum is named as a direct dependency; it is the same axum jostoph-rs
already brings, so the closure gains no second HTTP stack.

Refs: Ting/Vedanta#47.
peerUser is the OpenBao unit's user, resolved to a uid at startup;
openbaoIdentity is the name the validator and the audit call it;
projectionFile and verificationKeysFile are the deployment's inputs
(read, never written); commandWindowSeconds bounds a signed command's
life. The socket lives in RuntimeDirectory=vedanta (0755 so OpenBao's
unit can traverse; the socket is 0660 and the peer-uid check is what
decides). Rendering the two files is flake-ops#432's seam.

Refs: Ting/Vedanta#47.
CONTEXT.md gains a dated section for Session #47 and rows for the
private listener, the projection, the native issuers and the retired
pool bring-up; the test count is the measured one. AGENTS.md's
perimeter records ruling D2 and the rule that the private surface is a
unix socket authenticated by peer uid, never a TCP listener or a header.

Refs: Ting/Vedanta#47.
Ruling D2b (Larandar, 2026-09-05): provisioning makes the SEAT exist
once; allowing a principal to take it is Vedanta's effect. So the
tenure OpenBao resolves at its seat-tenure stage is Vedanta's to serve,
not a field of the static grant projection — a grant's queen field says
whom the seat is delegated to, not that anyone holds it.

GET /v1/lifecycle/projection/{grant} now answers the deployed grant
flattened beside active_queen_tenures. Nothing records a tenure yet
(the seat-taking command is #50), so the list is empty by construction,
which is the fail-closed answer: zero holders denies every drone lease
until the seat can actually be taken. checkout.rs stays as the
drone-slot half of allocation; AGENTS.md and CONTEXT.md say so, and the
contract's 'MUST NOT allocate pool slots' is amended by Ting/contracts#10.

Refs: Ting/Vedanta#46, #47, #50, Ting/contracts#10.
Ruling D2b, applied to the shape (drone A found the conflict): a
projection rendered at provisioning cannot know who took a seat later,
so DeployedGrant no longer carries a QueenAuthorityRef. It carries a
SeatDelegation — which seat, which operations its holder must be
entitled to, which account mode — and the service joins the seat's ONE
active tenure in at command time from a TenureSource. Two records
claiming the seat are no holder, as are none; the validator now denies
an unheld seat at its queen-holder stage on Vedanta's own evidence, and
a tenure cannot widen what the seat is for, because the delegation's
operations and account mode win over the tenure's.

NoTenures is the production source until the seat-taking command (#50)
writes a ledger; the projection route serves active_queen_tenures from
the same source, so OpenBao and Vedanta deny from one fact.

Refs: Ting/Vedanta#46, #47, #50, Ting/contracts#10.
lar.ad/nixops4-providers#27 now compiles the Vedanta projection in the
shape src/projection.rs defines (branch tango/27-assignment-grant,
b962920). That file is vendored here and parsed by a test, so the
producer and this consumer cannot drift without one of the two suites
saying so. It carries a seat delegation and no holder — the D2b shape
on both sides.

Refs: Ting/Vedanta#47, lar.ad/nixops4-providers#27.
The provider library (nixops4-providers#27) and the deployment
(flake-ops#426) each render this file, from different sources, in
different languages. Neither can be trusted to have stayed in shape
because a reviewer read both — so each renderer's ACTUAL output is
vendored here and parsed by ProjectionFile::parse, and a drift in
either is a failing test in this repository rather than a denial in
production.

flake-ops' render is its adapter fixture grant evaluated through
flake.identity.renderedProjection at e2fe3af. Both files carry a seat
delegation and no holder, which is ruling D2b on all three sides.

Refs: Ting/Vedanta#47, lar.ad/flake-ops#426, lar.ad/nixops4-providers#27.
Author
Owner

Reviewer note: item 5 of "what a reviewer must check" is now settled. The freeholder ratified and merged the D2b delta (Ting/contracts#11, issue #10 closed, 2026-09-06). contracts.identity.openbao-lease-backend no longer forbids what this PR implements: the prohibition is on creating seats and authoring grants, and seat taking is named as Vedanta's bounded effect, with OpenBao resolving the active tenure from Vedanta's occupancy rather than from a provisioned file — which is exactly what src/projection.rs's SeatDelegation and src/private_transport.rs's TenureSource do here.

One caveat while it lasts: the materialized projection at openspec/specs/contracts.identity.openbao-lease-backend/spec.md still shows the old clause, because archiving the change into the specs is the store's separate after-ratification step and has not run yet. A reviewer reading the published spec rather than the merged change will see the pre-ruling wording. That archive is in progress; this note will not be edited when it lands, so check the store if the distinction matters to your verdict.

The other four items stand unchanged.

**Reviewer note: item 5 of "what a reviewer must check" is now settled.** The freeholder ratified and merged the D2b delta ([Ting/contracts#11](https://jo.et0.pw/Ting/contracts/pulls/11), issue #10 closed, 2026-09-06). `contracts.identity.openbao-lease-backend` no longer forbids what this PR implements: the prohibition is on *creating* seats and authoring grants, and seat taking is named as Vedanta's bounded effect, with OpenBao resolving the active tenure from Vedanta's occupancy rather than from a provisioned file — which is exactly what `src/projection.rs`'s `SeatDelegation` and `src/private_transport.rs`'s `TenureSource` do here. One caveat while it lasts: the **materialized** projection at `openspec/specs/contracts.identity.openbao-lease-backend/spec.md` still shows the old clause, because archiving the change into the specs is the store's separate after-ratification step and has not run yet. A reviewer reading the published spec rather than the merged change will see the pre-ruling wording. That archive is in progress; this note will not be edited when it lands, so check the store if the distinction matters to your verdict. The other four items stand unchanged.
larandar left a comment

Verdict: changes required.

  1. P1 — Failed authority refresh continues authorizing from cached grants. src/private_transport.rs:345–355: when the deployed projection becomes unreadable or invalid, refreshed_projection() returns the last good active copy indefinitely. A failed revocation deployment therefore retains authority. Refuse mutations when authoritative input cannot be read or validated.
  2. P2 — One listener failing does not fail the unit. src/main.rs:212–217: tokio::join! waits for both futures. If one bind fails while the other server keeps running, the error never reaches systemd. Use failure-propagating concurrency that terminates the sibling listener.

Cross-change concern with #53: the existing validator requires the current matching queen even for exact invalidation (src/private_api.rs:556). Releasing or replacing that tenure can make cleanup of its outstanding tokens impossible. Validate bounded cleanup against recorded issuance ownership without restoring mint authority, retaining the original native issuer identity. This validator condition is not itself newly introduced here; the running listener and seat-release path expose the interaction.

Validation rerun: nix develop --command just check passed, with 152 library tests + 1 binary test, formatting and clippy clean. These findings are outside the existing positive tests. Review used the merged D2b ruling and contracts#12’s materialization; the earlier wording discrepancy is not treated as a defect in this PR.

Reviewed commit: 6a25a763cd89324ec38a5777b0c15428d76e484e.

Agent review performed by Codex at the user’s request. Posted through the PR author’s account; this records review evidence, not an independent collaborator approval or a new owner ruling.

**Verdict: changes required.** 1. **P1 — Failed authority refresh continues authorizing from cached grants.** `src/private_transport.rs:345–355`: when the deployed projection becomes unreadable or invalid, `refreshed_projection()` returns the last good active copy indefinitely. A failed revocation deployment therefore retains authority. Refuse mutations when authoritative input cannot be read or validated. 2. **P2 — One listener failing does not fail the unit.** `src/main.rs:212–217`: `tokio::join!` waits for both futures. If one bind fails while the other server keeps running, the error never reaches systemd. Use failure-propagating concurrency that terminates the sibling listener. Cross-change concern with #53: the existing validator requires the current matching queen even for exact invalidation (`src/private_api.rs:556`). Releasing or replacing that tenure can make cleanup of its outstanding tokens impossible. Validate bounded cleanup against recorded issuance ownership without restoring mint authority, retaining the original native issuer identity. This validator condition is not itself newly introduced here; the running listener and seat-release path expose the interaction. Validation rerun: `nix develop --command just check` **passed**, with **152 library tests + 1 binary test**, formatting and clippy clean. These findings are outside the existing positive tests. Review used the merged D2b ruling and contracts#12’s materialization; the earlier wording discrepancy is not treated as a defect in this PR. Reviewed commit: `6a25a763cd89324ec38a5777b0c15428d76e484e`. Agent review performed by Codex at the user’s request. Posted through the PR author’s account; this records review evidence, not an independent collaborator approval or a new owner ruling.
Finding 1 (P1, review at 6a25a763): refreshed_projection() fell back to
the last good in-memory copy indefinitely whenever a re-read failed, so
a failed revocation deployment (an unreadable or invalid projection
file) left the previous grants in force forever — the opposite of what
a revocation is for.

The command route (POST /v1/lifecycle/commands) is the only place
authority is actually exercised, so it is the only place that must fail
closed: it now uses a new refreshed_projection() that returns Err on a
read/validation failure instead of the cache, and the handler turns
that into a new ErrorCode::ProjectionUnavailable denial — a new
retryable class (like NativeUnavailable: nothing mutated, a fixed
redeploy makes the same operation ID succeed on retry), not an
overload of GrantDigestMismatch, which means something materially
different ("the projection loaded fine and does not name this grant").
As with the existing unknown-grant branch, peer identity is checked
first, so a stranger still only ever learns "unauthenticated".

The two GET routes (/projection/{grant}, /health) are deliberately left
on the old best-effort behaviour (now cached_projection()): they are
introspection for OpenBao's own operator tooling, not the enforcement
point — a revocation is enforced at the command route regardless of
what these report — so degrading to the last good copy on a transient
read failure is preferable to taking down health/observability along
with it.

Test: a_projection_that_goes_unreadable_after_a_good_start_refuses_the_next_command
starts a service, lets one mint succeed, corrupts the projection file in
place, and asserts the next command is refused 503
projection_unavailable with no further mutation — and fails against the
old code, which would mint again from the cached copy. It also checks
the GET routes keep answering, and that repairing the file lifts the
projection_unavailable denial on the next attempt.
Finding 2 (P2, review at 6a25a763): serve() raced the webhook router
and the private lifecycle listener with tokio::join!, which polls both
futures to completion regardless of outcome. If one failed to bind (or
otherwise exited), the other kept serving indefinitely and the failure
never propagated to the caller — a unit that looks healthy to systemd
while actually half-deaf, with no restart ever triggered.

Replaced with race_to_first_exit(), built on tokio::select!: the first
future to resolve — success or failure — wins, its Result is returned,
and select! drops the other future without polling it again. Both
listeners here are a bare `axum::serve(...).await` with the listener
owned by the future itself (no detached tokio::spawn underneath, per
jostoph-rs's service.rs and private_transport::serve), so dropping the
losing future closes its socket immediately rather than leaving it
half-listening after serve() returns. tokio::try_join! would in fact
also short-circuit and drop the loser the same way; select! is used
instead to make that guarantee the explicit point of the call rather
than something incidental to try_join!'s Result-flattening, and because
it lets each exit be logged by name for whichever restart lands next to
it in the unit's journal.

Tests (in src/main.rs, exercising race_to_first_exit directly, not the
real listeners): a_failing_listener_terminates_a_sibling_that_would_otherwise_serve_forever
races a future that errors immediately against one that is deliberately
Pending forever and only sets a flag on Drop, and asserts under a
timeout that the function returns the error AND the flag was set — i.e.
the sibling was actually terminated, not left running. This fails
against plain tokio::join! (which would hang on the never-resolving
future and never return at all).
a_graceful_exit_on_either_side_still_stops_the_other checks the same
termination holds for an Ok exit, not only a failing one.
Author
Owner

Both findings fixed. 878b2bf and f989809, pushed on top of the reviewed 6a25a763. No rebase — #53 is stacked here.

1 — Fail closed when the projection cannot be re-read

refreshed_projection() is split. The command route now uses a fail-closed version that returns a Result and propagates the failure; the two GET routes keep a best-effort cached_projection().

New error class ProjectionUnavailable, retryable, HTTP 503 — the same bucket as NativeUnavailable. It was not folded into GrantDigestMismatch, which means something else entirely ("the projection loaded fine, and your grant is not it"). Peer-first ordering is preserved, so a stranger still learns only unauthenticated.

The split is a judgment, and here is its defence. POST /commands is where authority is exercised, so it must refuse; GET /projection/{grant} and /health are introspection, and serving the last good read there does not authorize anything. If OpenBao builds a view from a momentarily stale projection read and then submits a command, Vedanta still refuses it — the enforcement point is the mutation, and it is closed. Argue it the other way and the fix is one line.

Test: a_projection_that_goes_unreadable_after_a_good_start_refuses_the_next_command corrupts the file after a successful mint and proves the next command is refused 503 with no second mutation. It fails against the old code.

2 — One listener's exit now terminates the other

tokio::join! replaced with race_to_first_exit() over tokio::select!, which drops the losing future the moment either listener exits. try_join! was considered and rejected: it propagates the error but leaves the sibling running, which is the actual defect — a unit that looks alive with one live listener.

The worker checked jostoph-rs's service.rs to confirm neither listener hides a detached tokio::spawn, so dropping the future genuinely closes its socket. Two tests use a "never resolves, flags on Drop" future to prove termination, not merely error propagation; both would hang forever under join!.

One honest note it left: with no hidden spawns in today's code, select! over try_join! is defensive rather than strictly required. It is recorded in case a future refactor introduces one.

Measured

cargo fmt --check clean, clippy --workspace --all-targets -- -D warnings clean, cargo test --workspace green: 153 lib (152 + 1), 3 bin (1 + 2), 32 plugin. Re-run independently by the queen.

module.nix and openbao-plugin/** untouched — neither finding needed them.

The cross-change concern you raised with #53 (the validator requiring a matching current queen even for exact invalidation, so releasing a tenure could strand its outstanding tokens) is not addressed here and is not this PR's to address. It is carried to #53.

**Both findings fixed.** `878b2bf` and `f989809`, pushed on top of the reviewed `6a25a763`. No rebase — #53 is stacked here. ### 1 — Fail closed when the projection cannot be re-read `refreshed_projection()` is split. The **command route** now uses a fail-closed version that returns a `Result` and propagates the failure; the two GET routes keep a best-effort `cached_projection()`. New error class `ProjectionUnavailable`, retryable, HTTP 503 — the same bucket as `NativeUnavailable`. It was **not** folded into `GrantDigestMismatch`, which means something else entirely ("the projection loaded fine, and your grant is not it"). Peer-first ordering is preserved, so a stranger still learns only `unauthenticated`. **The split is a judgment, and here is its defence.** `POST /commands` is where authority is exercised, so it must refuse; `GET /projection/{grant}` and `/health` are introspection, and serving the last good read there does not authorize anything. If OpenBao builds a view from a momentarily stale projection read and then submits a command, Vedanta still refuses it — the enforcement point is the mutation, and it is closed. Argue it the other way and the fix is one line. Test: `a_projection_that_goes_unreadable_after_a_good_start_refuses_the_next_command` corrupts the file after a successful mint and proves the next command is refused 503 with no second mutation. It fails against the old code. ### 2 — One listener's exit now terminates the other `tokio::join!` replaced with `race_to_first_exit()` over `tokio::select!`, which drops the losing future the moment either listener exits. **`try_join!` was considered and rejected**: it propagates the error but leaves the sibling running, which is the actual defect — a unit that looks alive with one live listener. The worker checked jostoph-rs's `service.rs` to confirm neither listener hides a detached `tokio::spawn`, so dropping the future genuinely closes its socket. Two tests use a "never resolves, flags on Drop" future to prove **termination**, not merely error propagation; both would hang forever under `join!`. One honest note it left: with no hidden spawns in today's code, `select!` over `try_join!` is defensive rather than strictly required. It is recorded in case a future refactor introduces one. ### Measured `cargo fmt --check` clean, `clippy --workspace --all-targets -- -D warnings` clean, `cargo test --workspace` green: **153 lib** (152 + 1), **3 bin** (1 + 2), 32 plugin. Re-run independently by the queen. `module.nix` and `openbao-plugin/**` untouched — neither finding needed them. The cross-change concern you raised with #53 (the validator requiring a matching current queen even for exact invalidation, so releasing a tenure could strand its outstanding tokens) is **not addressed here** and is not this PR's to address. It is carried to #53.
larandar merged commit f278b4392b into fastlane 2026-09-06 07:45:06 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Ting/Vedanta!51
No description provided.