Prose specification. VAID. Implements ADR-0001. Revision 2, incorporating Phase 0 findings of 27 July 2026.
Terminology follows the repo: a VAID is the document (Vaid); lineage is the
child to parent ancestry (parent_vaid, lineage_hash); mint, attenuate and
verify are used as in the crates.
Companion document.
encoding.mdtakes the opposite posture: it is normative and specifies what is inside the conformance surface — the encoding rules that decide the canonical bytes. Read it for what conformance requires; read this for where it stops.
Revocation is outside the VAID conformance surface.
Conformance is defined over the minting, attenuation and verification of VAID documents, and is fixed by the frozen conformance vectors. Conformance does not extend to:
- revocation list format
- revocation distribution or transport
- revocation check semantics
- revocation storage or durability
An implementation MAY provide no revocation mechanism and remain conformant. Two conformant implementations MAY differ arbitrarily in revocation behaviour.
This exclusion is deliberate. It is stated here rather than left implicit because a specification that omits revocation without saying so is indistinguishable from one that overlooked it.
VAID's central claim is that a conforming implementation in any language produces and verifies documents that agree byte for byte with every other conforming implementation. That claim is worth making only if the conformance surface is small enough to be verified exhaustively by frozen vectors and stable enough to build against.
Revocation does not currently meet either condition. Its semantics are coupled to deployment topology in ways the document format is not. A single-process mint, a multi-instance service, an offline verifier, and a cross-organisation verifier that does not trust the issuing mint's availability have materially different requirements. Specifying one as normative would either exclude the others from conformance or produce a specification loose enough to be meaningless.
Freezing a revocation format now would freeze a design that no implementer outside the authoring organisation has built against.
Revocation is expected to move inside the conformance surface in a future version, once there is implementation evidence for the normative shape. That change will be made by amendment, with vectors, and announced as a conformance-surface change.
Excluding revocation from the conformance surface does not make it optional in production.
VAIDs are bearer documents. A deployment that issues them with no means of invalidating an outstanding VAID before expiry has no response to key compromise, leakage, or a misbehaving agent other than waiting out the TTL.
Implementations intended for production SHOULD provide revocation. Section R.4 defines a common shape so that independent implementations converge voluntarily ahead of any normative requirement.
Non-normative: an implementation departing from this remains conformant. It is specified in detail because no conformance vector covers revocation, and there is therefore nothing else preventing independent implementations from diverging.
Two distinct jobs, and conflating them is the principal design error available here.
The verifier assembles the lineage. It resolves the ordered ancestry of the VAID under verification, root first, leaf last. The check does not perform lookups and is not given the means to.
The check answers about a lineage it is handed. It receives an ordered list of VAID identifiers and reports revocation status for that list.
This split keeps the check implementable against any backing store without that store needing knowledge of lineage resolution.
A VAID carries parent_vaid, which is one hop, and lineage_hash, which is one-way
and encodes only the immediate parent. The full lineage is not recoverable from the
VAID itself. Assembly requires a resolver, and the reference implementation's
resolver is the issuer's in-process lineage map.
Assembly therefore has three outcomes, and all three MUST be representable:
| Outcome | Condition |
|---|---|
| Complete | Every hop resolved from leaf to a VAID with parent_vaid absent. |
| Incomplete | Some parent_vaid present but unresolvable. |
| Trivially complete | Leaf has no parent_vaid. It is its own root. |
An incomplete lineage MUST be reported to the caller as Unavailable under R.4.3.
It MUST NOT be presented to the check as though it were the whole lineage, and the
resulting verification MUST NOT succeed.
This requirement exists because of a specific failure. The reference resolver is an in-memory map that is empty after process restart. A child VAID verified against an empty map resolves nothing, and a naive implementation returns a single-element lineage containing only the leaf. That result is indistinguishable from a legitimately rootless VAID and passes verification. The revocation of every ancestor is silently discarded.
An implementation that cannot distinguish "this VAID has no parent" from "I could not resolve this VAID's parent" does not satisfy this section.
The check returns exactly three states:
| State | Meaning |
|---|---|
NotRevoked |
The check completed against a complete lineage. Nothing in it is revoked. |
Revoked |
At least one VAID in the lineage is revoked. |
Unavailable |
Status could not be determined. |
Unavailable covers both failure modes: the revocation store could not be consulted,
and the lineage could not be completely assembled. The caller MUST be able to
distinguish Unavailable from NotRevoked.
An interface returning a boolean does not satisfy this section. Neither does one that
expresses Unavailable only as an error the caller may discard.
A VAID is revoked if any VAID in its lineage is revoked. Revoking a parent revokes every VAID attenuated from it, transitively.
An implementation checking only the presented leaf leaves revocation bypassable by minting a child, and does not satisfy this section.
Verification MUST fail closed on Unavailable. A VAID whose revocation status cannot
be determined is rejected.
An implementation MAY offer fail-open as an explicit configuration option for deployments prioritising availability. Where offered:
- it MUST NOT be the default
- it MUST be named to state what it does rather than obscure it
- selecting it MUST be recorded in verification output for every verification performed under it
A verifier operating fail-open is, for that period, a verifier without revocation. That is a legitimate deployment choice and an illegitimate silent default.
Revocation state and lineage state that do not survive process restart do not provide
revocation. A store that is empty after restart reports NotRevoked for every VAID
ever revoked, and does so without any indication that anything is wrong.
Implementations providing non-durable stores MUST make state loss detectable, so it
surfaces as Unavailable rather than as NotRevoked. An in-memory store that cannot
distinguish "initialised and empty" from "never populated" does not satisfy this
section.
The reference implementation currently ships non-durable in-memory stores for both revocation and lineage. See R.6.
Durability is two stores, and half of it is an outage rather than a hole. The requirement above binds the revocation store and the lineage resolver, and the two fail in opposite directions:
| Persisted | Child VAID | Root VAID |
|---|---|---|
| both | verifies | revocation in force |
| lineage only | verifies | verifies — the revocation is gone |
| revocation only | Unavailable |
revocation in force |
Persisting the revoked set alone leaves every VAID carrying a parent_vaid
unresolvable after restart, which is Incomplete under R.4.2, which is
Unavailable under R.4.3, which fails closed under R.4.5. Every rootless VAID is
unaffected, because it is trivially complete and never consults the resolver. The
result is a failure that is total for delegated credentials, invisible for root
ones, arrives at restart rather than at deploy, and — since nothing was revoked —
is first diagnosed as a signing or clock problem. This is the specification working
as intended: an outage is the correct choice over a child that masquerades as
rootless and discards its ancestors' revocations. It is stated here because the
symptom is not derivable from the requirement, and the diagnosis is not derivable
from the symptom.
An implementation offering an injection point for one store and not the other therefore offers its users only the configuration that produces this outage. Implementations SHOULD make the two durable stores replaceable together, so that the half-configuration cannot be reached by omitting an argument. It remains a legitimate deployment choice for a single process that mints and verifies and is never restarted, and where offered it SHOULD require that choice to be named rather than defaulted into.
Sequencing, for a deployment migrating to durable stores: make the resolver
durable first, or both in the same change. The revoked set first is the ordering
that produces the outage. An intermediate posture this section already blesses —
holding the revocation store in absent state, so it reports Unavailable and
verification fails closed, until state has been loaded — is the safe way to deploy
either half: a deliberate, brief, visible outage rather than a silent window of
unrevoked credentials.
Consulted during verification.
MAY additionally be consulted during attenuation, so a revoked parent cannot mint a child. Implementations that do not are not deficient: the child carries its parent in its lineage and fails verification under R.4.4 regardless.
Not consulted during minting of a root VAID. A VAID that does not yet exist cannot be revoked.
VAIDs carry a short time to live and implementations enforce it strictly.
TTL bounds the window in which a compromised VAID remains usable. It does not close that window on demand and it is not a revocation mechanism. It is defence in depth and is described as such throughout.
An implementation relying on TTL expiry alone has no response to compromise other than waiting.
Stated plainly because it is the current state and an evaluator will determine it from the source within minutes.
| 0.1.2 (superseded) | 0.2 onward (current: 0.11.0) | |
|---|---|---|
| Seam present | Yes, Rust and Python | Yes, Rust, Python and TypeScript |
| Return type | Boolean | Three-state per R.4.3 |
| Lineage-aware | No, leaf identifier only | Yes, per R.4.2 and R.4.4 |
| Assembly failure detected | No | Yes |
| Behaviour on store failure | Not representable | Fail closed per R.4.5 |
| Revocation store durability | In-memory only | In-memory only — unchanged, see note |
| Lineage store durability | In-memory only, issuer-local | In-memory only, issuer-local — unchanged, see note |
| Both stores replaceable together | No | Yes, since 0.9.0 (RevocationBackend) |
| Reference default posture | Vouching | Vouching until 0.7.0; absent, fails closed, since 0.9.0 |
The shipped seam satisfies R.4. The three-state, lineage-aware seam landed in
vaid-mint 0.2.0 on 2026-07-27 as a deliberate breaking change with no compatibility
shim, and it has been the only seam since; it is published on crates.io, PyPI and npm.
The right-hand column is therefore what you get by installing the package, not a
roadmap. The rows did not all change on one date, and the table says so rather than
implying a single flip:
- Return type, lineage-awareness, assembly-failure detection and store-failure
behaviour changed at 0.2.0 (2026-07-27), when the boolean leaf-only check was
replaced wholesale. ADR-0001 records the decision to ship no shim: two traits named
RevocationCheckwith different safety properties was the outcome being avoided. - Seam present in all three languages completed at 0.3.0 on npm (2026-07-30), when TypeScript became the third reference implementation. Before that date the right-hand column was accurate for Rust and Python only.
- Both stores replaceable together, and the default posture changed at 0.9.0
(2026-08-23), in one release and in all three languages at once. Two breaking
changes: the single-half injection point (
with_revocation_check) was removed in favour ofRevocationBackend, which has no single-half constructor; and the reference default flipped from vouching to absent. See ADR-0008.
The two durability rows did not change, and are not scheduled to. Both stores are still in-memory in every published version. This is the one place where the earlier edition of this table was right and remains right, and it is called out because the rest of the row set moving could otherwise be read as durability having moved with it.
What changed at 0.9.0 is not durability but replaceability and posture: the reference stores are as non-durable as they ever were, and the two new rows record that a host can now replace both together, and that the non-durable revocation store no longer vouches. Read the durability rows and the posture row as separate claims; conflating them is how "the default fails closed" becomes the false belief that the reference now persists anything.
0.1.2 is retained above as history, not as deployment advice. That seam accepted a single identifier and returned a boolean, so revocation of a parent did not affect an attenuated child and a failed check was indistinguishable from a clean one. It has not been the current release since 2026-07-27 and no supported deployment runs it; the left-hand column exists so that a reader holding an older copy of this document can see what changed and when.
Durable backing stores are a host-application responsibility in all versions — both of them: durable revocation and durable lineage resolution. The reference implementation ships in-memory stores for development. R.4.6 governs how those stores must behave when their state is absent, not whether the reference implementation must become durable.
Note on the reference default (0.9 onward). The reference issuer's revocation
store starts absent: it has not been populated, cannot vouch for anything, and
reports Unavailable, so verification fails closed (R.4.5) until revocation state is
loaded into it. This is the posture R.4.6 already blessed as the safe way to run a
non-durable store, now taken as the default rather than offered as an alternative.
A caller who wants a fresh issuer to verify immediately asks for that by name —
assuming_nothing_revoked() in Rust and Python, assumingNothingRevoked() in
TypeScript — which installs the vouching store described below. That is fail-open,
and R.4.5 permits it precisely in this form: as an explicit configuration, named to
state what it does, never as a default.
Note on the reference default, 0.2 through 0.7 (historical). Until 0.9.0 the
reference issuer defaulted its revocation store to a vouching posture (the
constructor is named assume_nothing_revoked, not for its empty state but for what
it does): it answered NotRevoked over an empty set so a fresh issuer verified out
of the box. Being non-durable, it could not detect its own restart — after a restart
it was reconstructed empty and again vouched NotRevoked, so a VAID revoked before
the restart verified clean. It was a deliberate trade of restart-detection for
out-of-the-box usability in development, disclosed here from the start, and it was
argued legitimate under R.4.6 (which requires only that an absent store report
Unavailable, and that absent be distinguishable from vouching — both of which
held) and explicitly not under R.4.5 as a configuration: it was not a fail-open
verifier setting, it was the reference's development default, and it was named to be
unmisreadable.
That argument was sound, and it was a carve-out — one that existed only because the posture was a default. Removing the default removes the need for it, which is the reason the flip is an improvement to this specification's position rather than merely to the package's. R.4.5 now applies to the reference implementation on its own terms: the fail-open posture exists, it is not the default, and it is named.
Verifying a VAID answers two different questions, and they must not be collapsed:
- Authenticity — was this document genuinely issued under a given kernel key, and
is it internally consistent? This needs only the issuer's public key. It is
answered by
verify_vaid_authenticity(checks the signature-scheme version, the kernel signature over the canonical document, andlineage_hashconsistency), and it is available to any third party — cross-organisation, offline, or after the fact — because a signature needs only the public key to check. - Standing — may this VAID be used right now? That is policy: at minimum expiry (a temporal check) and revocation (this section). Standing is evaluated on top of authenticity, by the party that holds the relevant state.
verify_vaid_authenticity deliberately answers authenticity only. It does not check
expiry, and — the load-bearing point for this specification — it does not consult
revocation. Gating authenticity on revocation would reintroduce the R.4.2 problem in
a new place: a resolver-less verifier cannot perform the lineage/revocation lookup, so
every third-party verification would fail closed, and the portable, publicly-checkable
authenticity that is the point of a VAID would be lost. Revocation status is therefore
reported on a separate path (RevocationCheck / the reference's
revocation_status), or not at all where the verifier has no revocation state — never
as a precondition of confirming the document is real.