lifecycle: the runtime half of a declared promotion or suppression #43

Merged
larandar merged 2 commits from tango/18-declared-lifecycle into fastlane 2026-09-04 21:35:54 +00:00
Owner

#18 and #19, one session, one PR. Complete — no WIP:. just check green: 127 tests, 14 of them new.

Both records said their bodies predate OpenSpec 85 and owed a refinement pass before a build. That pass is on the records (#18, #19); this is what it settled.

The gate is the directory, not a state file

Both records gate on "the provider-observed projection" and neither says how Vedanta reads one. It cannot — nixops4's state lives on whichever operator's machine ran the apply, and Vedanta is a service on another host.

It does not need to. nixops-kanidm publishes outputs read back from the directory after its write, never the declared values — that is its own stated rule, for exactly this reason: an output that echoed the declaration would report success for a write kanidm refused. So "what the provider observed" and "what the directory holds" are one fact, and only one of the two couples this service to a deployment tool it does not run.

It is also the stronger reading. A state file records what a tool believed at apply time. When the two disagree the directory is right, and the disagreement is drift — #11's to observe, not this module's to trust.

Inertness is judged, not read — and this is the sharpest thing here

#19's body asks this endpoint to gate on "the UUID tombstone". There is no tombstone attribute. The council's rendering emits accountexpire and nothing else (flake-ops#473): a second, estate-named attribute would have carried this estate's vocabulary into a provider published for anyone to consume, and would have collided with kanidm's recycled, which names entries that have been deleted.

So the gate cannot look for a marker. It asks whether the instant has passed, not whether the attribute is set.

That is not pedantry. An accountexpire of 2099 is a converged declaration and a fully live principal. A gate asking "is it set" would admit it and invalidate the credentials of an identity the directory still authorizes — a suppression complete from one side only, which is the exact failure #19's own gating paragraph names. It is a test:

a_future_expiry_is_refused_even_though_the_attribute_is_set

An unreadable expiry is a third answer, not folded into "live": the two need different fixes, and resolving it to the safe-looking one would cost the operator the reason.

Invalidate before mint

Fail-closed, and asserted as an order. Minting first and failing to invalidate leaves two live credentials, one carrying the authority the promotion moved away from. The reverse leaves none — a workflow that must ask again, which is a stoppage rather than an over-grant.

What Directory cannot do

Three methods: observe, invalidate one named token, mint one. There is no fourth.

  • No delete, disable, retire, or rotate. Vedanta's law assigns all four to Jostoph, and a method that exists is one the next contributor can find a reason to call. Same discipline as checkout.rs's Occupancy, where the missing method is the assertion.
  • No enumeration. Every invalidation names an exact token ID the caller supplies, so the account-wide cascade the contract forbids is not guarded against — it is unexpressible.

Verification

mutation assertion that fails
inertness read as "attribute present" an expiry that has not passed is not inert (2 tests)
mint before invalidate a failed mint leaves the prior credential already invalidated
role matched by bare prefix a role that is a prefix of another does not match it
role gate removed an unconverged promotion mints nothing and says why

Capture doubles throughout; nothing reaches a network.

Not here

Wiring these into private_api.rs as callable endpoints, and whatever supplies the token IDs a suppression names. Both want a caller, and no caller is declared yet.

Closes #18
Closes #19

[#18](https://jo.et0.pw/Ting/Vedanta/issues/18) and [#19](https://jo.et0.pw/Ting/Vedanta/issues/19), one session, one PR. Complete — no `WIP:`. `just check` green: **127 tests**, 14 of them new. Both records said their bodies predate [OpenSpec 85](https://jo.et0.pw/Ting/Ting/wiki/OpenSpec-85-stable-identity-promotion-and-suppression) and owed a refinement pass before a build. That pass is on the records ([#18](https://jo.et0.pw/Ting/Vedanta/issues/18#issuecomment-15975), [#19](https://jo.et0.pw/Ting/Vedanta/issues/19#issuecomment-15979)); this is what it settled. ## The gate is the directory, not a state file Both records gate on "the provider-observed projection" and neither says how Vedanta reads one. **It cannot** — nixops4's state lives on whichever operator's machine ran the apply, and Vedanta is a service on another host. It does not need to. `nixops-kanidm` publishes outputs read back **from the directory** after its write, never the declared values — that is its own stated rule, for exactly this reason: an output that echoed the declaration would report success for a write kanidm refused. So "what the provider observed" and "what the directory holds" are one fact, and only one of the two couples this service to a deployment tool it does not run. It is also the stronger reading. A state file records what a tool *believed* at apply time. When the two disagree the directory is right, and the disagreement is drift — [#11](https://jo.et0.pw/Ting/Vedanta/issues/11)'s to observe, not this module's to trust. ## Inertness is judged, not read — and this is the sharpest thing here #19's body asks this endpoint to gate on "the UUID tombstone". **There is no tombstone attribute.** The council's rendering emits `accountexpire` and nothing else ([flake-ops#473](https://jo.et0.pw/lar.ad/flake-ops/pulls/473)): a second, estate-named attribute would have carried this estate's vocabulary into a provider published for anyone to consume, and would have collided with kanidm's `recycled`, which names entries that have been *deleted*. So the gate cannot look for a marker. It asks **whether the instant has passed**, not whether the attribute is set. That is not pedantry. An `accountexpire` of 2099 is a **converged declaration and a fully live principal**. A gate asking "is it set" would admit it and invalidate the credentials of an identity the directory still authorizes — a suppression complete from one side only, which is the exact failure #19's own gating paragraph names. It is a test: ``` a_future_expiry_is_refused_even_though_the_attribute_is_set ``` An *unreadable* expiry is a third answer, not folded into "live": the two need different fixes, and resolving it to the safe-looking one would cost the operator the reason. ## Invalidate before mint Fail-closed, and asserted as an order. Minting first and failing to invalidate leaves two live credentials, one carrying the authority the promotion moved away from. The reverse leaves none — a workflow that must ask again, which is a stoppage rather than an over-grant. ## What `Directory` cannot do Three methods: observe, invalidate one named token, mint one. There is no fourth. - **No delete, disable, retire, or rotate.** Vedanta's law assigns all four to Jostoph, and a method that exists is one the next contributor can find a reason to call. Same discipline as `checkout.rs`'s `Occupancy`, where the missing method *is* the assertion. - **No enumeration.** Every invalidation names an exact token ID the caller supplies, so the account-wide cascade the contract forbids is not guarded against — **it is unexpressible.** ## Verification | mutation | assertion that fails | |---|---| | inertness read as "attribute present" | *an expiry that has not passed is not inert* (2 tests) | | mint before invalidate | *a failed mint leaves the prior credential already invalidated* | | role matched by bare prefix | *a role that is a prefix of another does not match it* | | role gate removed | *an unconverged promotion mints nothing and says why* | Capture doubles throughout; nothing reaches a network. ## Not here Wiring these into `private_api.rs` as callable endpoints, and whatever supplies the token IDs a suppression names. Both want a caller, and no caller is declared yet. Closes #18 Closes #19
Two endpoints over one gate, both duplicates of a provider path and neither
ever the only implementation of it.

THE GATE IS THE DIRECTORY, not nixops4 state Vedanta cannot reach. Both
records say 'the provider-observed projection' without saying how one is read;
they need not, because nixops-kanidm publishes outputs read back FROM the
directory rather than echoed from the declaration. The two are one fact, and
only one of them couples this service to a deployment tool it does not run.

INERTNESS IS JUDGED, NOT READ. There is no tombstone attribute — the council's
rendering emits accountexpire and nothing else, because a second estate-named
attribute would carry this estate's vocabulary into a provider anyone may
consume and would collide with kanidm's 'recycled', which names DELETED
entries. So the suppression gate asks whether the instant has passed, not
whether the attribute is set: an accountexpire of 2099 is a converged
declaration and a live principal, and admitting it would invalidate the
credentials of an identity the directory still authorizes.

INVALIDATE BEFORE MINT, fail-closed. Minting first and failing to invalidate
leaves two live credentials, one carrying the authority the promotion moved
away from. The reverse leaves none — a stoppage rather than an over-grant.

The Directory trait has three methods. It cannot delete, disable, retire or
rotate a principal, and it cannot ENUMERATE an account's tokens: the
account-wide cascade the contract forbids is not guarded against, it is
unexpressible. Every invalidation names an exact ID the caller supplies.

14 tests, capture doubles, no network. Mutation-tested: reading inertness as
'attribute present', minting before invalidating, matching a role by bare
prefix, or dropping the role gate each fails an assertion.

Co-Authored-By: Claude Opus 5 <[email protected]>
The suppression fixtures used the council record's own `+00:00`, which the
directory never produces. kanidm normalizes a datetime to UTC and renders it
with the time crate's RFC 3339 formatter, which spells a UTC offset as `Z` —
verified against 1.11.1's value.rs and by round-tripping the sentinel.

The gate already handled both, because it parses rather than compares strings.
The tests did not prove it. Now they do, over both spellings.

The provider had the same fact and did not survive it: its attribute
comparison was exact string equality, so it would have rewritten accountexpire
on every apply forever (nixops4-providers, kanidm/26-datetime-equality).

Co-Authored-By: Claude Opus 5 <[email protected]>
larandar merged commit b2c4818ccf into fastlane 2026-09-04 21:35:54 +00:00
larandar deleted branch tango/18-declared-lifecycle 2026-09-04 21:35:55 +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!43
No description provided.