session 20: the private lease contract end to end — validate, journal, mutate, reconcile, issue, rotate/revoke (#22 #8 #21 #23 #12 #24) #27

Merged
larandar merged 7 commits from tango/22-authenticate-lifecycle-commands into fastlane 2026-09-02 19:28:38 +00:00
Owner

Completes Session #20's leaf set — this PR is the one interface-compatibility review OpenSpec 20 declares ("one PR carries six isolated Deliverables for a single interface-compatibility verdict"), grown on the branch that started as #22's completion.

What the added commits do

  • kanidm: bounded credential mutations (#8) — the narrow mutation adapter Vedanta owns (mint with explicit integer expiry returning value/token-ID/expiry/subject once; successor rotation that never deletes — caller-selected grace; exact token-ID invalidation with the observed-before distinction; terminal unrelated-subject and denial semantics). Wire shapes verified against kanidm v1.11.1 upstream source; the live narrow-manager proof stays flake-ops#123 per #8's delivery class. 10 capture-double tests.
  • operations: the durable journal (#21) — fsync'd write-ahead, one-operation-one-mutation, restart-safe replay, torn-tail tolerated / torn-middle fatal, metadata-only by type construction. module.nix gains StateDirectory=vedanta + fixed-path VEDANTA_JOURNAL. 8 tests.
  • reconcile: uncertain delivery meets committed lease state (#23) — matching lease recovers the ack with nothing delivered; missing/mismatched orphans are invalidated by exact journaled token ID with the reason kept; compensation forces fresh operation IDs via the journal's replay guard; refused invalidations reported, never swallowed. 6 tests.
  • openbao-plugin: authorize in order, commit after mint, wrap once (#12) — new workspace member vedanta-openbao-plugin: the thirteen-stage ordered authorization table (first failure names its stage), the full private-contract command build + Ed25519 signing, and the issue flow where a transport failure or refusal yields neither a committed lease nor credential material, a lease that cannot commit never wraps, and the credential exists in exactly one scope — mint result into one-shot five-minute wrapper. The queen authorizes; only the drone receives. 23 tests.
  • lease: the non-issue state machine (#24) — metadata-only ReadLease; RotateLease re-runs the authorization table against the current projection and mints a distinct successor generation with the predecessor kept for grace; RevokeLease saves revocation_pending before any dispatch (deny use immediately) and completes only when every outstanding token — current and still-valid predecessors — invalidates. Transitions never recover an old secret: there is no path that could.

Delivery shape

Per OpenSpec 20's delivery decomposition (task 5.7): six isolated Deliverables, one coherent PR, one reviewer verdict over the public lease contract, private command ABI, mutation adapter, and lost-response semantics. just check gates every commit; the workspace-wide gate (cargo clippy/test --workspace) is what actually exercises the new member — the root-only gate silently skips it, which is worth knowing for future multi-crate work here.

Verification (run, not reasoned)

  • nix develop gate green at every commit: fmt stable, clippy -D warnings clean, full workspace suite.
  • nix build .#default green with the workspace present — the sandboxed package build is unchanged.
  • Final state: 135 workspace tests green — 72 vedanta lib + 1 bin; 32 in the plugin (23 issue-path, 9 lease-transition); the rest the substrate's own.

Still open (not this PR's to decide)

  • #22's flagged divergence for reviewer confirmation: unsupported_action and predecessor_mismatch error classes vs the canonical 0.1.4 table ("a ruling may fold them") — unchanged.
  • #20 Q1 (missing Forgejo-adapter leaf) remains an owner decision on the Session record; this PR delivers exactly the six leaves the dependency graph names.

Refs #20

Closes #22
Closes #8
Closes #21
Closes #23
Closes #12
Closes #24

Completes Session #20's leaf set — this PR is the one interface-compatibility review OpenSpec 20 declares ("one PR carries six isolated Deliverables for a single interface-compatibility verdict"), grown on the branch that started as #22's completion. ## What the added commits do - `kanidm: bounded credential mutations (#8)` — the narrow mutation adapter Vedanta owns (mint with explicit integer expiry returning value/token-ID/expiry/subject once; successor rotation that never deletes — caller-selected grace; exact token-ID invalidation with the observed-before distinction; terminal unrelated-subject and denial semantics). Wire shapes verified against kanidm v1.11.1 upstream source; the live narrow-manager proof stays flake-ops#123 per #8's delivery class. 10 capture-double tests. - `operations: the durable journal (#21)` — fsync'd write-ahead, one-operation-one-mutation, restart-safe replay, torn-tail tolerated / torn-middle fatal, metadata-only by type construction. `module.nix` gains `StateDirectory=vedanta` + fixed-path `VEDANTA_JOURNAL`. 8 tests. - `reconcile: uncertain delivery meets committed lease state (#23)` — matching lease recovers the ack with nothing delivered; missing/mismatched orphans are invalidated by exact journaled token ID with the reason kept; compensation forces fresh operation IDs via the journal's replay guard; refused invalidations reported, never swallowed. 6 tests. - `openbao-plugin: authorize in order, commit after mint, wrap once (#12)` — new workspace member `vedanta-openbao-plugin`: the thirteen-stage ordered authorization table (first failure names its stage), the full private-contract command build + Ed25519 signing, and the issue flow where a transport failure or refusal yields neither a committed lease nor credential material, a lease that cannot commit never wraps, and the credential exists in exactly one scope — mint result into one-shot five-minute wrapper. The queen authorizes; only the drone receives. 23 tests. - `lease: the non-issue state machine (#24)` — metadata-only ReadLease; RotateLease re-runs the authorization table against the current projection and mints a distinct successor generation with the predecessor kept for grace; RevokeLease saves `revocation_pending` before any dispatch (deny use immediately) and completes only when every outstanding token — current and still-valid predecessors — invalidates. Transitions never recover an old secret: there is no path that could. ## Delivery shape Per OpenSpec 20's delivery decomposition (task 5.7): six isolated Deliverables, one coherent PR, one reviewer verdict over the public lease contract, private command ABI, mutation adapter, and lost-response semantics. `just check` gates every commit; the workspace-wide gate (`cargo clippy/test --workspace`) is what actually exercises the new member — the root-only gate silently skips it, which is worth knowing for future multi-crate work here. ## Verification (run, not reasoned) - `nix develop` gate green at every commit: fmt stable, clippy `-D warnings` clean, full workspace suite. - `nix build .#default` green with the workspace present — the sandboxed package build is unchanged. - Final state: 135 workspace tests green — 72 vedanta lib + 1 bin; 32 in the plugin (23 issue-path, 9 lease-transition); the rest the substrate's own. ## Still open (not this PR's to decide) - #22's flagged divergence for reviewer confirmation: `unsupported_action` and `predecessor_mismatch` error classes vs the canonical 0.1.4 table ("a ruling may fold them") — unchanged. - #20 Q1 (missing Forgejo-adapter leaf) remains an owner decision on the Session record; this PR delivers exactly the six leaves the dependency graph names. Refs #20 Closes #22 Closes #8 Closes #21 Closes #23 Closes #12 Closes #24
Bind the OpenBao peer and Ed25519 signature to one typed command, then validate freshness, deployed authority, principal state, generations, expiry, and predecessor ownership in stable order. Opaque validated commands are the only values the future journal and mutation adapters may accept.
Separate principal lifecycle from private credential effects, replace the pre-minted queen handoff with OpenBao-owned drone leases, and document exact-token invalidation plus journal metadata without turning Vedanta into an authority store.
CPU handoff checkpoint. This intentionally preserves an unverified and potentially non-compiling review repair in progress; result envelopes, RFC3339 wire types, queen-tenure evidence, and expanded fixtures are partially reconciled, but the fixture conversion and dependency lock verification are not complete.
larandar force-pushed tango/22-authenticate-lifecycle-commands from 2292559ce1 to 9750b29187 2026-08-31 15:47:00 +00:00 Compare
larandar changed title from WIP: authenticate and validate private lifecycle commands to private-api: complete #22 — lock the wire-repair dep, cover the null queen-evidence axis 2026-09-02 09:01:32 +00:00
9750b29 added time = { version = "0.3", features = ["formatting", "parsing"] } to Cargo.toml and left the lock stale; this is that resolution and nothing else.
The ordered validator denies a command whose assignment_authority is absent, but no fixture exercised that branch — the one axis in the table without its own negative. Add it; fmt also settles the last non-canonical lines carried by the handoff checkpoint.
Vedanta owns IDM mint_api_token, rotate_api_token, and explicit
invalidate_api_token (owner ruling, 2026-08-26); this is the adapter those
rulings named, delivered as #8's agent-unit with capture-double tests.

The wire is kanidm v1.11.1's, verified against upstream source —
server/core/src/https/v1.rs, proto/src/internal/token.rs, and
server/lib/src/idm/serviceaccount.rs — not against the live instance: the
live narrow-manager proof stays flake-ops#123 per #8's delivery class, and
the adapter says so in its own doc comment rather than inheriting the file's
live-verified header.

The rulings this encodes:

- Mint names explicit expiry (a plain i64 — the request cannot be built
  without one; gate I03) and target account by UUID, never by label, and
  returns value, token ID, expiry, and subject exactly once. The value
  crosses this boundary one hop: mint result to caller to OpenBao's
  immediate result. Nothing here stores or logs it.
- Rotation mints a distinct successor — a token returned with the
  predecessor's ID is refused — and issues no DELETE: grace is
  caller-selected, so the predecessor stays valid until explicitly
  invalidated, and the overlap is bounded by the caller's policy, not by
  the adapter deciding when a credential dies.
- Invalidation addresses exactly one authenticated token ID. Absence is
  external success with the distinction kept (observed_before), per the
  2026-08-26 ruling; a kanidm refusal stays a final Error.
- Unrelated-subject mint and kanidm denial are terminal. No retry, no
  second account, no broader credential — the capture doubles assert call
  counts, not just outcomes.

The kanidm module moves from the binary into the library beside
private_api: the private lifecycle service's mutation adapter consumes the
same bounded calls the CLI mint path does, and in a bin crate the
unconsumed-public-API lint would otherwise read as dead code — the wiring
that silences it honestly is the Session's, not a stub.

just check green: fmt stable, clippy -D warnings clean, 59 tests
(10 new capture-double cases: wire shape incl. integer expiry and
compact=false, bearer presentation, distinct successor, no-delete
rotation, in-place refusal, exact-path invalidation, absent-token
distinction, final 403, metadata-only list).
No mutation reaches Kanidm before a durable record of its intent exists;
one operation ID maps to at most one mutation; a restart re-derives
everything from one file. That is the whole of #21, and it is now
src/operations.rs.

The rulings this encodes:

- Write-ahead: begin() appends a pending record and sync_all()s it before
  the service may mutate. A crash between the two leaves a pending
  record — exactly the evidence #23's reconciliation needs to tell
  "never minted" from "minted, delivery unacknowledged".
- One operation, one mutation: a begin for an operation ID the journal
  already holds is refused, whatever its state. The validator's
  freshness check normally catches replays first; one that reaches
  begin means two writers or torn history, and the answer is no.
- Metadata only, by type: JournalRecord carries identifiers, ownership,
  generations, outcome, and acknowledgement state. There is no field a
  credential could land in, so the one-hop value rule is enforced by the
  type system, not by a convention tests have to police — though the
  tests grep the durable bytes for the fixture secret anyway.
- Status and ack are non-secret by the same construction:
  OperationStatus maps off the record, acknowledge() is idempotent
  (re-acknowledging a committed lease is recovery, per the ack-timeout
  contract), and acknowledging an unknown operation is refused.
- Torn tail tolerated, torn middle fatal: a crash mid-append can only
  tear the last line, which loads as never-begun (fresh, re-beginnable);
  corruption between durable records refuses to load — deny-biased,
  loudly. Loading also propagates read errors instead of defaulting
  them: on unix a directory at the journal path opens fine and then
  answers every read with EISDIR forever, and a loader that mapped that
  to empty strings would collect them without end. Found the honest way
  — the first version of this module did exactly that and hung its own
  test.

module.nix grows StateDirectory=vedanta and VEDANTA_JOURNAL (fixed path,
not an option — a per-host configurable journal location is a journal an
operator can point at the wrong history), and its "Vedanta holds no state
of its own" comment, true when written, is corrected with them.

private_api grows a #[cfg(test)] ValidatedCommand::for_tests constructor
so the journal's tests record already-validated commands without a
bypass existing in any non-test build.

Gate: just check green — fmt stable, clippy -D warnings clean, 67 tests
(8 new: write-ahead + restart, freshness flip, once-per-operation,
metadata-only completion/ack/status with byte-level secret absence,
unknown-ack refusal, stable failure class, torn tail vs torn middle,
unopenable-journal denial). alejandra clean on module.nix.
A mint whose acknowledgement never arrived is ambiguous between "OpenBao
committed the lease and lost the ack" and "the delivery never landed, and
a credential no lease owns is live in the directory" — and the two demand
opposite actions. src/reconcile.rs resolves the ambiguity against
OpenBao's committed lease state before touching anything:

- Matching lease — same operation, same token — recovers the
  acknowledgement in the journal and delivers NOTHING. Acknowledgement
  loss is never evidence of lease loss, and the one-shot value is long
  gone besides: there is nothing to redeliver and nowhere a second
  delivery would be legal.
- A missing lease, or one naming a different operation or token, makes
  the journalled mint an ORPHAN: invalidated by exact token ID — the
  journaled token, never the committed lease's — with the reason kept
  (lease_missing vs operation_mismatch vs token_mismatch), the
  compensation durably recorded, and the invalidation's own
  observed-before distinction preserved.
- Replacement on a FRESH operation ID is not a new mechanism: the
  compensated operation's ID stays replay in the journal forever, which
  is the rule enforced by a fact the journal already keeps.

Only minted-and-unacknowledged records are delivery-uncertain — a
pending record's mutation never completed and has no token to reconcile.
A refused invalidation is reported per-selection and journalled as no
compensation at all: the next sweep must see an uncompensated orphan,
not a success-shaped hole.

The invalidation effect rides a one-method trait the Kanidm client
implements; tests capture it. Journal gains an optional
orphan_compensation record (serde default, so pre-existing journals load
unmodified).

Gate: just check green — 73 tests (6 new: ack recovery delivers nothing,
missing-lease orphan invalidates by exact ID, mismatch orphans OUR token
not the committed one, compensation survives restart and forces fresh
operation IDs, sweep scope excludes acknowledged/pending/failed, refused
invalidation reported not swallowed).
A new workspace member, vedanta-openbao-plugin: the OpenBao side of the
identity-lease contract (OpenSpec 20/61, 0.1.4). OpenBao is the only
workload-facing lease authority; this crate is its issue path — the
thirteen-stage ordered authorization table, the durable pre-mutation
intent, the signed private mint dispatch to Vedanta, the commit, and the
one-shot wrapped delivery.

authorize() runs the contract's canonical order — caller authentication,
claimant, seat tenure (exactly one active runtime-seat tenure; zero and
many both deny), grant validity including exact grant binding, principal
state, assignment, isolation, resource, operation, audience, requested
TTL against the 30-minute pilot ceiling, active lease limit, projection
freshness — and returns the FIRST failure: the stage a denial names is
observable contract behavior, which is why no check is reordered or
folded into a neighbour, and why the error carries nothing about any
other principal.

build_command() emits the full private-contract LifecycleCommand (Mint,
credential_generation 1, no predecessor, 60-second command window) and
Ed25519Signer seals it into the envelope vedanta's Ed25519Verifier
accepts; the seed never leaves the signer.

IssueService::issue() walks the flow the contract words exactly:
a denial touches nothing; an unrecordable intent never dispatches; a
transport failure or a returned refusal yields NEITHER a committed lease
NOR credential material; a lease that cannot commit never wraps (the
orphan is Vedanta-side #23's to compensate, not ours to paper over with
a wrapper for a lease that does not exist); a refused wrap reports
WrapFailed against a committed lease — retryable, because durable state
is consistent and only delivery was lost. The credential value exists in
exactly one scope of the flow — the mint result on its way into the
wrapper — and no outcome, store, or committed lease has a field it could
land in. The queen authorizes the seat claim but only the drone
receives: the committed lease's subject is the claimant, never the
tenure holder.

Scaffolding and the authorization table were drafted by a delegated
agent session; the service flow and its capture doubles were finished
and corrected in review. Root Cargo.toml gains only the [workspace]
section; Cargo.lock's additions are the new member plus the jostoph
build-time transitives that workspace-wide resolution now pins.

Gate (workspace-wide — the root-only gate silently skips new members):
fmt stable, clippy -D warnings clean, 95 workspace tests (23 here:
14 stage negatives incl. first-fault ordering and envelope verification,
9 flow tests incl. denial-touches-nothing, intent-before-dispatch,
refusal/transport-without-lease, commit-failure-never-wraps,
queen-never-receives).
The non-issue state machine of the lease contract, in
openbao-plugin/src/lease.rs — everything after the issue path #12 opened.

- ReadLease is metadata-only BY CONSTRUCTION: no credential field exists
  to fill, and none could — past the wrap window there is no value to
  recover, which is a property of the system, not of the method's
  discipline.
- RotateLease re-runs the thirteen-stage table against the CURRENT
  projection before anything: the grant may have moved to a new version
  or generation, and a rotation authorized against yesterday's world is
  an unauthorized mint. The successor command carries
  credential_generation = stored + 1 and predecessor = the live token;
  the predecessor is NOT invalidated — caller-selected grace, the #8
  ruling — and lands on the still-valid-predecessor list revocation
  later sweeps.
- RevokeLease FAILS FORWARD: the FIRST durable act is saving
  revocation_pending (deny use immediately — the status IS the denial,
  and any read between save and completion refuses), then every token
  in the invalidation set — current plus still-valid predecessors — is
  invalidated by exact signed command. The first unverified deletion
  leaves the outcome RevocationPending with the outstanding suffix
  named; only all-verified closes to Revoked, idempotently. A revoke
  that cannot prove its lease Active denies rather than sweeps.

Retryability diverges from the issue path deliberately, in-code:
SuccessorCommitFailed/SuccessorUndeliverable are NOT retryable because
rotate has no journal replay-guard — a blind re-rotate is refused by
Vedanta's expected-generation check, so recovery by exact token ID
(#23) is the path, not another mint under the same command.

Invalidate commands carry only what stored metadata can prove — token,
lease, principal — and never invent grant or tenure content; the
generation simplification (all tokens at the stored generation) matches
what Vedanta's predecessor-ownership stage actually checks for
Invalidate. Drafted by a delegated agent session; reviewed, corrected,
and gated here.

Gate (workspace-wide): fmt stable, clippy -D warnings clean, 135
workspace tests (32 in the plugin: 9 lease-transition tests incl. exact
save-before-dispatch event ordering, partial-invalidation pending,
idempotent completion, and no-old-secret-recovery).
larandar changed title from private-api: complete #22 — lock the wire-repair dep, cover the null queen-evidence axis to session 20: the private lease contract end to end — validate, journal, mutate, reconcile, issue, rotate/revoke (#22 #8 #21 #23 #12 #24) 2026-09-02 19:23:32 +00:00
larandar left a comment

LGTM

LGTM
larandar merged commit f09b6d20fc into fastlane 2026-09-02 19:28:38 +00:00
larandar deleted branch tango/22-authenticate-lifecycle-commands 2026-09-02 19:28:38 +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!27
No description provided.