session 50: a seat can be taken — the effect that makes every drone lease possible (#50) #53

Merged
larandar merged 13 commits from tango/50-seat-taking into fastlane 2026-09-06 07:50:09 +00:00
Owner

Closes #50. Stacked on #51 — base is tango/47-private-service, not fastlane. Review #51 first; retarget this to fastlane when 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_tenures was 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_held and seat_not_held.
  • The tenure ledger (src/tenure.rs): append-only, sync_all per write, torn-tail tolerated and torn-middle refused — the journal's own discipline. It implements SeatLedger, TenureSource and ReplayGuard in one handle, so a seat command's freshness and a seat's holder are read from the same durable bytes.
  • The seat grant projection (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.
  • The route: POST /v1/lifecycle/seat-commands, same socket, same peer-uid authentication, same status mapping.
  • NoTenures remains 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 take reads current_holder from the same ledger a release writes, and there is no code path where current_holder becomes None without a durably recorded release. A distinct successor taking the seat before the predecessor's release has landed is denied seat_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, SeatFailure and TenureRecord have no field a bearer value could occupy, and three tests assert the serialized JSON never contains credential.

Evidence

devenv shell --: clippy -D warnings clean, cargo test --workspace green — 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 configuring peer_uids to my_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_at was added to SeatCommand, 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 from VEDANTA_PROJECTION:

{"seats":[{
  "grant": {"id":"swarm-alpha-queen-grant-1","version":1,"digest":"sha256:…"},
  "active": true,
  "assignment_id": "swarm-alpha/queen",
  "eligible_holders": [{
    "principal": {"subject_uuid":"…","service_account":"agent.teyla","state":"active"},
    "tenure_grant": {"id":"queen-teyla-tenure-1","version":1,"digest":"sha256:…"},
    "authorization_generation": 4,
    "forge_actor": "agent.teyla"}],
  "required_operations": ["forge-token:mint"],
  "account_mode": "queen_holder",
  "supported_actions": ["take","release"],
  "maximum_tenure_seconds": 3600
}]}

Refused at load: a duplicate assignment_id, empty supported_actions, empty eligible_holders, a non-positive maximum_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.nix here grows private.seatProjectionFile to receive it.

Closes #50. **Stacked on [#51](https://jo.et0.pw/Ting/Vedanta/pulls/51)** — base is `tango/47-private-service`, not `fastlane`. Review #51 first; retarget this to `fastlane` when that merges. ## Why Ruling D2b (Larandar, 2026-09-05), now ratified in the store ([contracts#11](https://jo.et0.pw/Ting/contracts/pulls/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_tenures` was 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_held` and `seat_not_held`. - **The tenure ledger** (`src/tenure.rs`): append-only, `sync_all` per write, torn-tail tolerated and torn-middle refused — the journal's own discipline. It implements `SeatLedger`, `TenureSource` **and** `ReplayGuard` in one handle, so a seat command's freshness and a seat's holder are read from the same durable bytes. - **The seat grant projection** (`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. - **The route**: `POST /v1/lifecycle/seat-commands`, same socket, same peer-uid authentication, same status mapping. - `NoTenures` remains 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 `take` reads `current_holder` from the same ledger a `release` writes, and there is no code path where `current_holder` becomes `None` without a durably recorded release. A distinct successor taking the seat before the predecessor's release has landed is denied `seat_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`, `SeatFailure` and `TenureRecord` have no field a bearer value could occupy, and three tests assert the serialized JSON never contains `credential`. ## Evidence `devenv shell --`: clippy `-D warnings` clean, `cargo test --workspace` green — **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 configuring `peer_uids` to `my_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_at` was added to `SeatCommand`**, 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 from `VEDANTA_PROJECTION`: ```json {"seats":[{ "grant": {"id":"swarm-alpha-queen-grant-1","version":1,"digest":"sha256:…"}, "active": true, "assignment_id": "swarm-alpha/queen", "eligible_holders": [{ "principal": {"subject_uuid":"…","service_account":"agent.teyla","state":"active"}, "tenure_grant": {"id":"queen-teyla-tenure-1","version":1,"digest":"sha256:…"}, "authorization_generation": 4, "forge_actor": "agent.teyla"}], "required_operations": ["forge-token:mint"], "account_mode": "queen_holder", "supported_actions": ["take","release"], "maximum_tenure_seconds": 3600 }]} ``` Refused at load: a duplicate `assignment_id`, empty `supported_actions`, empty `eligible_holders`, a non-positive `maximum_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](https://jo.et0.pw/lar.ad/nixops4-providers/pulls/47) and [flake-ops#426](https://jo.et0.pw/lar.ad/flake-ops/pulls/478), and `module.nix` here grows `private.seatProjectionFile` to receive it.
Ruling D2b (Larandar, 2026-09-05): provisioning makes a seat exist once;
allowing a principal to take it and releasing it are Vedanta's own bounded,
journaled effects — never a token, because taking a seat is authority
state. Add SeatCommand/SignedSeatCommand beside LifecycleCommand, in the
same signed envelope with its own signing domain, validated by
SeatValidator against a deployed seat grant projection
(DeployedSeatProjection) in the contract's stable order: peer, signature,
envelope freshness, replay, action, seat grant active/identity, holder
eligibility, tenure grant, generation, forge actor, then the seat's own
holder state (SeatAlreadyHeld / SeatNotHeld) and the tenure's duration
ceiling. SeatCommandService hands a validated command to a SeatLedger
(take/release); the durable ledger implementation lands in the next
commit. No result shape here (SeatSuccess/SeatFailure/SeatCommandResult)
has a field a credential could occupy.

The handover contract falls out of validation order rather than a special
case: a `take` denies SeatAlreadyHeld whenever the projection's
current_holder is Some — which is also exactly what a distinct successor's
`take` sees before a predecessor's `release` has landed, so there is no
coded interval where both could succeed.

Co-Authored-By: Claude Opus 5 <[email protected]>
SeatGrant / SeatGrantFile beside DeployedGrant / ProjectionFile: the seat's
own AssignmentGrant (assignment-grant.seat.example.yaml), every principal
provisioned as eligible to hold it (each at its own tenure grant and
generation), the operations and account mode the seat is for, and the
tenure duration ceiling. A distinct file, not a variant of ProjectionFile,
because a drone grant's claimant IS its principal while a seat's eligible
holders are never the seat's own claimant (ruling D2b).

SeatGrant::runtime_view joins the deployed half with current_holder — read
from Vedanta's own tenure ledger, never from this file, exactly the way
DeployedGrant::runtime_view already joins a lifecycle command's queen
tenure in from the journal rather than the static grant.

Co-Authored-By: Claude Opus 5 <[email protected]>
TenureLedger — append-only, fsync'd, replay-safe, beside operations.rs's
journal and built the same way: one JSON-lines file, every append the FULL
record for one operation, a torn tail tolerated and a torn middle refused.
Identifiers only (seat, holder, tenure grant, generation); no field
anywhere in TenureRecord a credential could land in, because a seat
command mints no credential (ruling D2b).

Implements both private_api::SeatLedger (the write side: take/release,
with its own defense-in-depth check against a race between the
validator's read and this write) and private_transport::TenureSource (the
read side: who currently holds a seat) — the SAME object serves both
roles once wired into the transport in the next commit, so a `release`
this ledger accepts is durable before ANY subsequent read can still see
the predecessor.

Co-Authored-By: Claude Opus 5 <[email protected]>
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]>
services.vedanta.private.seatProjectionFile renders VEDANTA_SEAT_PROJECTION,
the deployed seat grant a provider still has to compile and place — same
seam flake-ops#432 already owns for the drone projectionFile. The tenure
ledger, VEDANTA_TENURE_LEDGER, is fixed under StateDirectory beside
VEDANTA_JOURNAL rather than an option: it is Vedanta's own durable record,
not deployment-configurable input, and a per-host configurable path is a
ledger an operator can point at the wrong history — same reasoning the
journal's own path already follows.

Co-Authored-By: Claude Opus 5 <[email protected]>
CONTEXT.md gets a dated section for what #50 built: SeatCommand, the seat
grant projection, the durable tenure ledger, and the seat-commands route —
plus what still is not built (checkout.rs's own return/free cycle for the
seat side, and the deployment seam that renders the two new env vars).

AGENTS.md's perimeter item 8 ("Vedanta holds operation state, not
authority state") gets its one named exception spelled out: the tenure
ledger IS authority state, in ruling D2b's own words, and that is
deliberate, not a violation of the rule the rest of the perimeter still
holds.

Co-Authored-By: Claude Opus 5 <[email protected]>
larandar left a comment

Verdict: changes required.

  1. P1 — Tenure validity is dropped when persisted. src/tenure.rs:65–81,282–286: SeatCommand.valid_until explicitly bounds tenure lifetime, but no ledger field retains it and active() 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.
  2. P1 — Revoked seat eligibility leaves a usable recorded tenure. 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.
  3. P1 — Torn-tail recovery can lose acknowledged releases. 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.
  4. P2 — Stale release can remove a successor tenure. 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.
  5. P1 — Seat projection refresh repeats the cached-authority fallback. src/private_transport.rs:407–408 keeps prior eligibility indefinitely on failed refresh. Fail closed rather than authorizing from a stale successful read.

Validation rerun: nix develop --command just check passed, 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.

**Verdict: changes required.** 1. **P1 — Tenure validity is dropped when persisted.** `src/tenure.rs:65–81,282–286`: `SeatCommand.valid_until` explicitly bounds tenure lifetime, but no ledger field retains it and `active()` 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. 2. **P1 — Revoked seat eligibility leaves a usable recorded tenure.** `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. 3. **P1 — Torn-tail recovery can lose acknowledged releases.** `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. 4. **P2 — Stale release can remove a successor tenure.** `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. 5. **P1 — Seat projection refresh repeats the cached-authority fallback.** `src/private_transport.rs:407–408` keeps prior eligibility indefinitely on failed refresh. Fail closed rather than authorizing from a stale successful read. Validation rerun: `nix develop --command just check` **passed**, 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.
larandar force-pushed tango/50-seat-taking from 39364facc3 to 50db19d25a 2026-09-06 07:03:22 +00:00 Compare
Author
Owner

Rebased 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's ProjectionUnavailable and this PR's SeatAlreadyHeld/SeatNotHeld. Everything else merged clean.

Finding Commit
1 Tenure validity dropped when persisted 66926cc
3 Torn-tail recovery could lose a release 326586d
4 A stale release could remove a successor 3b3b184
5 Seat refresh repeated the cached-authority fallback 26c4cec
2 A revoked seat left a usable recorded tenure 31f72d4
6 Cross-change: invalidation after tenure release 50db19d

Finding 1's split is the interesting one. TenureSource now has two methods: an unfiltered active for occupancy, and a clock-checked active_authority for 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 Invalidate of an already-minted token can be authorized by the queen that actually minted it, even after that tenure is gone — while Mint and Rotate still 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

50db19d authorizes invalidation from recorded issuance but still requires the command to carry requested_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) sends assignment_authority: None, an empty GrantRef { id: "", version: 0, digest: "" }, empty repositories and operations, and auth_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 bao walk 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 Invalidate from 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 PrivateLifecycleService wired 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:

  1. TenureRecord's new valid_until_unix has 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.
  2. Finding 6 assumed OpenBao already holds the recorded queen from its own mint metadata. That assumption is wrong, as the section above shows — which is why the fix moves to Vedanta's side.
**Rebased 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's `ProjectionUnavailable` and this PR's `SeatAlreadyHeld`/`SeatNotHeld`. Everything else merged clean. | | Finding | Commit | |---|---|---| | 1 | Tenure validity dropped when persisted | `66926cc` | | 3 | Torn-tail recovery could lose a release | `326586d` | | 4 | A stale release could remove a successor | `3b3b184` | | 5 | Seat refresh repeated the cached-authority fallback | `26c4cec` | | 2 | A revoked seat left a usable recorded tenure | `31f72d4` | | 6 | Cross-change: invalidation after tenure release | `50db19d` | **Finding 1's split is the interesting one.** `TenureSource` now has two methods: an unfiltered `active` for occupancy, and a clock-checked `active_authority` for 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 `Invalidate` of an already-minted token can be authorized by the queen that *actually minted it*, even after that tenure is gone — while `Mint` and `Rotate` still 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 `50db19d` authorizes invalidation from recorded issuance **but still requires the command to carry `requested_by.assignment_authority`**, then compares it field for field against the recorded tenure. The plugin does not send one. [#52](https://jo.et0.pw/Ting/Vedanta/pulls/52)'s `build_invalidation_command` (`openbao-plugin/src/lease.rs:304–330`) sends `assignment_authority: None`, an empty `GrantRef { id: "", version: 0, digest: "" }`, empty `repositories` and `operations`, and `auth_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 `bao` walk 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 `Invalidate` from 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 `PrivateLifecycleService` wired 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: 1. `TenureRecord`'s new `valid_until_unix` has **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. 2. Finding 6 assumed OpenBao already holds the recorded queen from its own mint metadata. **That assumption is wrong**, as the section above shows — which is why the fix moves to Vedanta's side.
Defect (found by reading both sides, caught by no test on either):
finding 6's Invalidate exception still required the command to carry
requested_by.assignment_authority, then compared it field for field
against the recorded tenure, along with grant id/version/digest,
repositories, operations, and auth_generation. The OpenBao plugin's
build_invalidation_command (openbao-plugin/src/lease.rs, ~304-337)
sends none of that: the committed lease metadata (#12) carries no
grant, no scopes, and no tenure, so it sends assignment_authority:
None, an empty GrantRef, empty repositories/operations, an empty
principal name, and auth_generation: 0. Every real revocation sweep
from the real plugin was denied by the real Vedanta.

Fix, Vedanta side only: for Invalidate, stop requiring the caller to
restate authority it cannot know. Vedanta looks up what its own
journal recorded for the token being closed (projection.predecessor)
and authorizes from that. Dropped for Invalidate: the assignment_
authority requirement, the grant/principal-name/repositories/
operations/auth_generation comparisons against the live projection.
Kept unconditionally: peer identity, signature, time window, replay,
principal UUID, claimant, expiry, credential generation, and the
predecessor-ownership check (token id, lease id, principal uuid,
generation) — an Invalidate still addresses exactly one authenticated
token and infers no cascade. A command that DOES claim a queen for
Invalidate is still held to the recorded issuance, field for field —
a caller may not assert a different queen than the one that minted.
Mint and Rotate are unchanged and still require the live queen and
the full projection comparison; this never becomes a path that mints.

Tests added: the plugin's exact command shape now validates and
effects (the test whose absence caused the defect); a claimed queen
that disagrees with the recorded issuance is still denied; Mint and
Rotate carrying the same empty/zero fields are still denied; and the
predecessor-ownership negatives (wrong token, lease, principal,
generation) still hold against the plugin's real shape.

Co-Authored-By: Claude Opus 5 <[email protected]>
Author
Owner

The interop defect is fixed. a7f2ac6, pushed. 214 lib tests (206 + 8), workspace green, fmt and clippy clean — re-run by the queen. Only src/private_api.rs changed.

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 Invalidate now drops, and why each is safe

Grant match, principal.name match, repositories and operations scope, and auth_generation — all gated behind one explicit restates_grant_and_scope flag 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_fields helper, so a caller may not assert a different queen than the one that minted. Mint and Rotate take neither branch, and new tests prove they still deny at Grant when fed the same empty fields.

Eight tests, and the one that matters

Test 1 builds the command exactly as the plugin's build_invalidation_command does — empty grant, no queen, empty scope, zero generation, empty principal name — and drives it through the real PrivateLifecycleService. 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; Mint and Rotate with 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_command and build_command both send full grant, scope, queen and principal name — no mismatch. The renew path reuses rotate, so no separate shape. Invalidate was the only one. That is worth knowing precisely, because it bounds how much of this boundary is still guesswork.

Three things left standing

  1. The principal-name relaxation was not in the brief and may deserve its own look — it was forced by the real shape, not chosen.
  2. The pre-finding-6 fallback (a claimed queen with nothing recorded) is untouched and unverified against the plugin, which never claims a queen for Invalidate.
  3. 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 PrivateLifecycleService against the real plugin dispatch, writable only once #51, #53 and #52 sit on one branch.

**The interop defect is fixed.** `a7f2ac6`, pushed. **214 lib tests** (206 + 8), workspace green, fmt and clippy clean — re-run by the queen. Only `src/private_api.rs` changed. 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 `Invalidate` now drops, and why each is safe Grant match, `principal.name` match, repositories and operations scope, and `auth_generation` — all gated behind one explicit `restates_grant_and_scope` flag 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_fields` helper, so a caller may not assert a different queen than the one that minted. `Mint` and `Rotate` take neither branch, and new tests prove they still deny at `Grant` when fed the same empty fields. ### Eight tests, and the one that matters Test 1 builds the command **exactly as the plugin's `build_invalidation_command` does** — empty grant, no queen, empty scope, zero generation, empty principal name — and drives it through the real `PrivateLifecycleService`. 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; `Mint` and `Rotate` with 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_command` and `build_command` both send full grant, scope, queen and principal name — **no mismatch**. The renew path reuses rotate, so no separate shape. **`Invalidate` was the only one.** That is worth knowing precisely, because it bounds how much of this boundary is still guesswork. ### Three things left standing 1. **The principal-name relaxation was not in the brief** and may deserve its own look — it was forced by the real shape, not chosen. 2. The pre-finding-6 fallback (a claimed queen with nothing recorded) is untouched and **unverified against the plugin**, which never claims a queen for `Invalidate`. 3. `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 `PrivateLifecycleService` against the real plugin dispatch, writable only once #51, #53 and #52 sit on one branch.
larandar changed target branch from tango/47-private-service to fastlane 2026-09-06 07:45:17 +00:00
larandar merged commit 115dfc8b7d into fastlane 2026-09-06 07:50:09 +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!53
No description provided.