session 50: a seat can be taken — the effect that makes every drone lease possible (#50) #53
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Meta/Campaign
Meta/Epic
Meta/Session
Priority/Critical
Priority/High
Priority/Low
Priority/Medium
Reviewed/Confirmed
Reviewed/Curated
Reviewed/Duplicate
Reviewed/Invalid
Reviewed/Won't Fix
Scope/Campaign
Status/Abandoned
Status/Blocked
Status/Conflicted
Status/In Progress
Status/In Review
Status/Need Grooming
Status/Need More Info
Status/Ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Ting/Vedanta!53
Loading…
Reference in a new issue
No description provided.
Delete branch "tango/50-seat-taking"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #50. Stacked on #51 — base is
tango/47-private-service, notfastlane. Review #51 first; retarget this tofastlanewhen that merges.Why
Ruling D2b (Larandar, 2026-09-05), now ratified in the store (contracts#11): provisioning makes the SEAT available, and Vedanta CAN ALLOW a principal to take the seat. #51 built the consumer side of that — the projection names a seat and never its holder, and the service joins the one active tenure at command time. But nothing could write a tenure, so
active_queen_tenureswas empty by construction and every drone lease denied at the seat stage. Fail-closed and correct, and a wall.This is the effect that gets past it.
What lands
SeatCommand/SignedSeatCommand(src/private_api.rs) with its own signing domain, beside the lifecycle command and under the same envelope rules. Validation order: peer → signature → time window → replay → action supported → seat grant active and identical → holder eligibility → tenure grant identity → generation → forge actor → seat-holder state → tenure duration ceiling. Two new error classes,seat_already_heldandseat_not_held.src/tenure.rs): append-only,sync_allper write, torn-tail tolerated and torn-middle refused — the journal's own discipline. It implementsSeatLedger,TenureSourceandReplayGuardin one handle, so a seat command's freshness and a seat's holder are read from the same durable bytes.src/projection.rs): a second deployed file, distinct from the drone projection, because a seat's eligible holders are never the seat's own claimant. Shape below.POST /v1/lifecycle/seat-commands, same socket, same peer-uid authentication, same status mapping.NoTenuresremains as the documented fail-closed default; nothing in the production path constructs it any more.Handover order holds by construction, not by a special case. A
takereadscurrent_holderfrom the same ledger areleasewrites, and there is no code path wherecurrent_holderbecomesNonewithout a durably recorded release. A distinct successor taking the seat before the predecessor's release has landed is deniedseat_already_held. Proved twice: as an isolated validator test, and end to end over a real unix socket.A seat command mints nothing.
SeatCommandResult,SeatSuccess,SeatFailureandTenureRecordhave no field a bearer value could occupy, and three tests assert the serialized JSON never containscredential.Evidence
devenv shell --: clippy-D warningsclean,cargo test --workspacegreen — 195 lib tests, up from 152. Every required negative is proved one axis at a time, at the validator level and over the real socket: second take while held, take before release in handover, stale generation, inactive seat grant, replayed operation ID, tampered signature.The two things a reviewer must decide
1. One negative is weaker over the socket than it should be. The wrong-peer axis is proved in true isolation (
private_api::seat_tests::wrong_peer_is_an_independent_denial), but the socket-level seat test does not actually dial in as a different uid — the sandbox offers one uid, and the harness was built with real credentials for the handover tests. The existing lifecycle-command test solves this by configuringpeer_uidstomy_uid() + 1, and the seat harness could do the same with an optional override. The worker left it out under time pressure and said so rather than letting the test's name imply more than it proves. Small, mechanical, worth adding.2.
issued_atwas added toSeatCommand, which the issue's own spine did not list — without it there is no envelope time window to check. Flagged as an addition, not smuggled in.The seat grant projection — the provider must produce this
VEDANTA_SEAT_PROJECTION, separate fromVEDANTA_PROJECTION:Refused at load: a duplicate
assignment_id, emptysupported_actions, emptyeligible_holders, a non-positivemaximum_tenure_seconds. It never says who holds a seat — that lives only in the tenure ledger, joined at command time.This is new work for nixops4-providers#27 and flake-ops#426, and
module.nixhere growsprivate.seatProjectionFileto receive it.POST /v1/lifecycle/seat-commands, same peer/signature/window/freshness posture as /commands, validated against the deployed seat grant instead of the drone grant. Service now holds one TenureLedgerHandle (TenureSource + SeatLedger) instead of NoTenures — Service::open constructs a real TenureLedger from VEDANTA_TENURE_LEDGER and PrivateConfig grows VEDANTA_SEAT_PROJECTION, so GET .../projection/{grant}'s active_queen_tenures answers a real holder once one has taken the seat, by construction rather than a special case (ruling D2b: "the active tenure OpenBao resolves comes from Vedanta"). Every negative case the contract calls out is exercised over a real unix socket the way the existing lifecycle-command tests are: second take while held, take before release in a handover, stale generation, an inactive/unratified seat grant, a stranger peer, and a replayed operation id. FixedTenures (the existing lifecycle-only test double) grows trivial SeatLedger/ReplayGuard impls that always deny, since it is asked to implement TenureLedgerHandle now but never exercises the seat path. Co-Authored-By: Claude Opus 5 <[email protected]>Verdict: changes required.
src/tenure.rs:65–81,282–286:SeatCommand.valid_untilexplicitly bounds tenure lifetime, but no ledger field retains it andactive()checks no clock. Authority remains usable after expiry and restart. Persist and enforce authority validity. Expiring authority must not automatically free a slot: explicit release and reuse evidence remain required by the contract.src/private_transport.rs:370–371: lifecycle reads return the ledger tenure without joining the current seat-grant projection. Disabling a seat, removing/suspending its eligible holder, or advancing its authorization generation leaves the old tenure usable. Resolve against current deployed seat authority before mutation.src/tenure.rs:140–159: recovery skips a damaged final JSON line without truncating it. The next append joins onto that fragment; a subsequent restart drops the acknowledged operation or fails on middle-file corruption. A lost release can resurrect the former holder. Repair and sync the last verified boundary before accepting new writes.src/tenure.rs:231–237: the atomic check compares only holder UUID. If an old release validates, another request releases, and the same holder takes a new grant/generation before the old release writes, that stale release removes the successor. Compare the complete expected tenure under the lock.src/private_transport.rs:407–408keeps prior eligibility indefinitely on failed refresh. Fail closed rather than authorizing from a stale successful read.Validation rerun:
nix develop --command just checkpassed, with 195 library tests + 1 binary test, formatting and clippy clean. This is the diff on top of #51; retarget to fastlane only after #51 merges and verify the resulting combined head.Reviewed commit:
39364facc3f21a3018872696962b93e1a6bc8841.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.
39364facc3to50db19d25aRebased onto #51's fixes and all six findings fixed. Force-pushed with lease (rebase, as flagged when this PR opened). 206 lib tests, up from 195; fmt and clippy clean; re-run by the queen.
The rebase hit one conflict, in
ErrorCode— resolved by keeping both #51'sProjectionUnavailableand this PR'sSeatAlreadyHeld/SeatNotHeld. Everything else merged clean.66926cc326586d3b3b18426c4cec31f72d450db19dFinding 1's split is the interesting one.
TenureSourcenow has two methods: an unfilteredactivefor occupancy, and a clock-checkedactive_authorityfor usable authority. That is what lets an expired tenure stop granting authority without freeing the seat — the contract requires explicit release and reuse evidence, so expiry must not become an implicit release.Finding 6 records the minting queen on the journal record, so an
Invalidateof an already-minted token can be authorized by the queen that actually minted it, even after that tenure is gone — whileMintandRotatestill require the live queen. Cleanup is closed; mint authority is never reopened.An interop defect this exposes, found by reading both sides rather than by any test
50db19dauthorizes invalidation from recorded issuance but still requires the command to carryrequested_by.assignment_authority, then compares it field for field against the recorded tenure.The plugin does not send one. #52's
build_invalidation_command(openbao-plugin/src/lease.rs:304–330) sendsassignment_authority: None, an emptyGrantRef { id: "", version: 0, digest: "" }, emptyrepositoriesandoperations, andauth_generation: 0.So a real revocation sweep from the real plugin would be denied by the real Vedanta — at the queen stage, and again at grant and scope. Neither PR's tests catch it, because #52's live
baowalk drives a hand-rolled Vedanta double, and this PR's tests drive the validator directly with hand-built commands. Each side is proved against its own idea of the other.That is the third time this pattern has produced a defect in this run.
The fix belongs on this branch, not on #52: Vedanta should authorize an
Invalidatefrom its own recorded issuance without requiring the caller to restate the queen, grant, scope or generation — the caller supplies the lease and token IDs, and Vedanta looks up what it recorded. That is strictly less trust in the caller, not more, and the peer check and predecessor-ownership check still bind it. Dispatched.What neither branch can carry alone is the test that would have caught it: the real
PrivateLifecycleServicewired to the real plugin dispatch. Both crates live in one workspace, so it is writable — but only once #51, #53 and #52 are on one branch. Until then this boundary is unproven, and I would not call the lifecycle end-to-end proven on that basis.Two things the worker flagged for you:
TenureRecord's newvalid_until_unixhas no default, so a rolling deploy needs the tenure ledger empty or fully rewritten across this change. Nothing is deployed yet, so it costs nothing today — but it must be recorded before the first deploy, not discovered at it.The interop defect is fixed.
a7f2ac6, pushed. 214 lib tests (206 + 8), workspace green, fmt and clippy clean — re-run by the queen. Onlysrc/private_api.rschanged.The worker found one more mismatch than I named: the plugin also sends an empty
principal.name, because the committed lease metadata has no field for it. That would have blocked the real command too, and it was not in the brief — it was found by building the plugin's actual shape and watching the test fail.What
Invalidatenow drops, and why each is safeGrant match,
principal.namematch, repositories and operations scope, andauth_generation— all gated behind one explicitrestates_grant_and_scopeflag rather than scattered conditionals.Each is safe for the same reason: the predecessor-ownership check already binds the command to the exact journaled mint by token id, lease id and principal uuid. Closing a token that Vedanta itself recorded minting needs no fresh mint authorization. Nothing was loosened about which token dies.
Kept unconditionally: peer identity, signature, time window, replay, principal uuid, principal state, claimant, expiry, credential generation, and predecessor ownership.
The queen handling was restructured rather than merely relaxed. No claim → authorize purely from the recorded issuance. A claim → still compared field for field, through a shared
check_queen_fieldshelper, so a caller may not assert a different queen than the one that minted.MintandRotatetake neither branch, and new tests prove they still deny atGrantwhen fed the same empty fields.Eight tests, and the one that matters
Test 1 builds the command exactly as the plugin's
build_invalidation_commanddoes — empty grant, no queen, empty scope, zero generation, empty principal name — and drives it through the realPrivateLifecycleService. That is the test whose absence caused the defect, and it names in a comment where the shape came from.The rest: a claimed-but-wrong queen still denies;
MintandRotatewith the same empty fields still deny; and four predecessor-ownership negatives against the queen-less shape.The rest of the boundary, checked while in there
build_rotate_commandandbuild_commandboth send full grant, scope, queen and principal name — no mismatch. The renew path reuses rotate, so no separate shape.Invalidatewas the only one. That is worth knowing precisely, because it bounds how much of this boundary is still guesswork.Three things left standing
Invalidate.Vedanta.48's own suite was not re-run here; it is read-only reference on this branch.None of this replaces the integration test that would have caught it: the real
PrivateLifecycleServiceagainst the real plugin dispatch, writable only once #51, #53 and #52 sit on one branch.