Tenure documentation
Tenure is a confederal income and employment attestation protocol: employment verification where the employer’s keypair is the root of trust, the attestation is a portable signed credential the worker holds, and the verification bureau is a replaceable commodity service.
The documentation has three parts:
- Protocol specification — the cryptographic and architectural core: what Tenure is and how it works.
- API reference — the registrar’s HTTP surface and wire formats: how to talk to a registrar.
- Guides — task-focused walkthroughs for employers, workers, and verifiers.
Design invariants
Two rules underpin everything else:
- Canonical bytes are BCS with a domain tag —
bcs(("tn-xxx-v1", body)). JSON is display-only: never signed, never hashed. - The employer key is the root of trust. Every valid attestation chains to an employer-signed epoch chain; a registrar mint outside its delegation is invalid even though signed.
Protocol specification
This section specifies the Tenure protocol: the objects that travel on the wire, how authority is delegated and revoked, and how a credential is verified.
How the protocol fits together
- Architecture — the actors (employer, registrar, KYB attester, worker, verifier, mirrors) and what each is and isn’t trusted for.
- Objects — the
tn-*object catalogue and the claim schemas. - Log & epochs — the per-employer append-only log, epoch-scoped authority, delegations, and signed heads.
- Verification — the pure five-check predicate and the one-time import of a full log.
- Claim families — how exact/band/threshold variants are minted and retired together.
- Privacy — derive-then-purge storage, per-employer keys, and the public/private data split.
- Misbehavior & switching — conflicting-head proofs and the epoch transition that lets an employer change registrars.
What Tenure guarantees
Four properties hold everywhere; the rest of the protocol builds on them.
- The employer key is the root of trust. Every valid attestation chains back to an employer-signed epoch chain. The registrar only signs under delegations the employer issued, so a mint outside its delegation is invalid even though it carries a valid signature.
- One canonical byte format. The only bytes ever signed or hashed are
bcs(("tn-xxx-v1", body))— a BCS body tagged with its object kind. JSON is for display, never signed or hashed. All signatures are Ed25519. - The log is the source of truth. The event log and revocations are append-only and hash-chained; the readable tables are projections of it. Combined with externally witnessed heads, this makes any rewrite of past history detectable.
- Verification is a pure function. The chain, KYB attestation, consent grant, revocation set, and the current time all arrive as inputs, so the same check runs identically in the portal, the wallet, and the offline page.
How values are encoded
| Quantity | Encoding |
|---|---|
| IDs | ULIDs (strings) |
| Public keys | lowercase hex |
| Signatures | base64url, no padding |
| Hashes | lowercase hex (empty string = none yet) |
| Money | integer cents (never floats) |
| Dates | unix seconds |
The permitted cryptography is fixed: Ed25519 signatures, BLAKE3 hashing, and
age-style X25519 sealing for bundles — nothing else, and no JWT. The shared
implementation lives in
crates/confed-core;
the Tenure objects and predicate in
crates/tenure-core,
with a TypeScript twin in
packages/tenure-core-ts.
Architecture
Tenure is confederal: each employer is a sovereign island of authority, and the only infrastructure that touches an employer’s data is the one that employer hired. There is no global chain, no server-to-server gossip, and no consensus protocol. This page describes the actors and the trust boundaries between them.
The actors
┌──────────────────────────────┐ composes unsigned actions / manifests
│ EMPLOYER │◄───────────────┐
│ ├─ Dashboard (web, no keys) │ │
│ └─ SIGNER (offline-capable, │── signed ──┐ │
│ holds the root key) │ artifacts │ │
└──────────────────────────────┘ ▼ │
KYB ATTESTER ────────────► ┌──────────────────────────┐ Payroll
binds pk ↔ legal entity │ REGISTRAR (hired service)│◄── connector
│ axum + SQLite (WAL) │ (per-run
┌─────────────┐ claim link, │ · per-employer log (BCS)│ idempotent)
│ WORKER │◄──────────────────►│ · epoch-scoped authority│
│ wallet PWA │ attestations, │ · type+rate-capped mints│
│ per-employer│ receipts │ · checkpoints → mirrors │
│ keys │ └──────────┬───────────────┘
└──────┬──────┘ │ revocation commitments,
│ ShareGrant (expiring link/QR) ▼ signed heads
▼ ┌──────────────────────────┐
┌─────────────┐ sealed bundle │ MIRRORS / WITNESSES (≥2) │
│ VERIFIER │◄────────────────────│ + Signer + wallets hold │
│ portal / │ 5-check predicate, │ signed log heads │
│ self-hosted │ offline-capable └──────────────────────────┘
└─────────────┘
| Actor | Code | Role |
|---|---|---|
| Employer | crates/tenure-signer, web/dashboard | Issues attestations about its own workforce. The Signer holds the root key and approves every signed action; the dashboard composes unsigned drafts and holds no keys. |
| Registrar | crates/tenure-registrar | A hired service that serializes attest/revoke operations for an employer, enforces delegation caps, stores derived credentials, and publishes signed heads. Single writer per employer. |
| KYB attester | (external) | Signs a KybAttestation binding employer_pk to a legal entity, with the methods used. Named on every verdict card. |
| Worker / holder | web/wallet | Holds per-employer keypairs, receives attestations + inclusion receipts, and signs ShareGrants — the consent record. |
| Verifier | web/verifier, web/verify-page | Runs the pure predicate over a sealed bundle and renders a verdict card. The self-hosted page is dependency-free and retains nothing. |
| Mirrors / witnesses | (external, mirror_urls) | Retain published signed heads. Two conflicting heads at the same (employer, seq) are a portable misbehavior proof. |
The two-app employer pattern
The single most important structural decision: the dashboard never holds a key and never signs anything. Root-key operations run in an independent Employer Signer — an offline-capable app that:
- generates and stores the employer root key (encrypted at rest),
- imports unsigned action drafts the dashboard composed,
- renders them human-readably (“You authorize registrar
tn1…to issue employment & income attestations for Acme LLC, max 500/day, epoch 1 from seq 1”), - signs on approval.
For bulk issuance the Signer ingests the raw batch file itself, computes the
aggregates and a deterministic, hash-seeded sample of rows locally, and refuses
to accept a summary from the dashboard. The aggregates ride inside the signed
tn-batch-v1, and the registrar cross-checks the signed summary against the
batch it received. A manifest hash alone is not HR safety; the Signer seeing the
real numbers is.
Trust model
| Actor | Trusted for | Not trusted for |
|---|---|---|
| Employer | Everything about its own attestations (they are its statements) | Other employers; the truth of the world — an attestation proves the employer said it, displayed as such |
| Employer Signer | Faithful display and signing of approved actions and batch manifests | (Residual) its own build/update channel — mitigated by reproducible, signed, pinned releases |
| Registrar | Liveness, serialization, policy enforcement, delivery, fee collection | Minting outside epoch-scoped type/rate caps; rewriting witnessed history without producing a conflicting signed head; attestation validity (that chains to the employer key) |
| KYB attester | Binding employer_pk ↔ legal entity (named, with methods, on every verdict) | Anything about workers; attestation contents. v1 sole-attester centralization is admitted; the long-term design is an attester taxonomy with verifier-owned trust lists |
| Wallet / holder | Holding per-employer keys; signing ShareGrants; retaining receipts | Anything else |
| Verifier app | Correct chain verification (open-source and self-hostable precisely so this is checkable) | Storing or re-sharing worker data beyond the grant |
| Mirrors / witnesses | Retaining published heads | Correctness of content (heads are self-verifying) |
Equivocation defense
Operation receipts and checkpoints place signed heads with the Signer, the
wallets, and at least two independent mirrors. Two registrar-signed LogHeads
with equal (employer_id, seq) and different head_hash are a portable
misbehavior proof. For employment records this protection is
two-directional: an employer or registrar cannot quietly backdate a hire or
scrub a tenure period after it was witnessed, and a worker’s receipts
survive the employer’s infrastructure dying.
What the registrar can and cannot do
A registrar is a commodity. It can stop serving, throttle, or lie about the current head — and none of that forges a credential or destroys one already held. It cannot:
- mint an attestation outside its delegation’s allowed types or rate cap — the verifier re-checks this and rejects the mint even though it is signed;
- rewrite witnessed history without producing a conflicting head anyone can detect;
- read claim values after mint — derive-then-purge leaves it only subject-encrypted blobs, a recovery escrow, and commitments (see Privacy);
- prevent an employer from switching to a different registrar — that is a signed epoch transition, and every previously issued attestation stays valid.
This is what “fireable commodity service” means in practice.
Objects
Every protocol object is signed and transmitted in the same shape: the exact
canonical bytes plus an Ed25519 signature over them. The only bytes ever signed
or hashed are bcs(("tn-xxx-v1", body)) — a BCS body tagged with its object
kind. Because the tag is inside the signed bytes, a signature over one object
kind can never be replayed as another, and JSON renderings exist only for
display.
Verification always runs over the bytes as transmitted and only then decodes
them under the expected tag, so a single tampered byte fails the signature check
before the payload is ever interpreted. The authoritative definitions are in
crates/tenure-core,
with a TypeScript twin.
Object catalogue
| Object | Tag | Signer | Purpose |
|---|---|---|---|
EmployerDescriptor | tn-employer-v1 | Employer | Declares the employer key, KYB reference, enabled attestation types, dispute & recovery policy, mirror URLs |
KybAttestation | tn-kyb-v1 | KYB attester | Binds employer_pk to a legal entity + jurisdiction, with verification methods and an expiry |
EpochOpen | tn-epoch-v1 | Employer | Opens an authority epoch: names the registrar key valid from a given seq, chains to the prior epoch head |
EpochClose | tn-epoch-close-v1 | Employer | Closes an epoch at a final seq + head hash |
Delegation | tn-delegate-v1 | Employer | Grants the registrar the right to mint specified types up to a daily cap, within a seq and time window |
BatchManifest | tn-batch-v1 | Employer | Carries Signer-computed aggregates + the raw batch hash for one issuance run |
Attestation | tn-attest-v1 | Registrar | One signed claim about a worker, under a covering delegation |
StatusChange | tn-status-v1 | Registrar | Closes or reopens an employment period |
Revocation | tn-revoke-v1 | Registrar / Employer | Revokes one attestation, with a reason |
FamilySupersede | tn-family-supersede-v1 | Registrar | One log entry that atomically retires every variant of a claim family |
Reissue | tn-reissue-v1 | Registrar | Re-points attestations to a fresh subject key during recovery |
ShareGrant | tn-share-v1 | Holder | The worker’s consent record: which attestations, audience, scope, expiry |
GrantRevoke | tn-grant-revoke-v1 | Holder | Withdraws a ShareGrant |
LogHead | tn-loghead-v1 | Registrar | A signed witnessed head: employer, epoch, seq, head hash |
Checkpoint | tn-checkpoint-v1 | Registrar | A timestamped head mirrored externally; its published_at is the “not revoked as of” line |
Two consequences are worth calling out. Consent is the worker’s own signature:
ShareGrant and GrantRevoke are signed by the holder, not the registrar — not
a server toggle. And the batch manifest’s aggregates are computed by the
Signer, not the dashboard, so the registrar can cross-check them against the
raw batch it received.
Claim schemas
An Attestation carries a typed claims body and, for income, a basis. There
are seven claim types:
| Type | Claims |
|---|---|
employment_status | status (active / ended), start date, optional end date |
tenure_dates | start date, optional end date |
role_title | title, optional department |
income_exact | exact cents, basis |
income_band | floor cents, ceiling cents, basis |
income_threshold | “at least” cents, basis |
hours_class | class (full-time / part-time / variable) |
The basis qualifies income figures as an annual_salary, the
trailing_90d_annualized, or the trailing_12m. Income is never minted as a
lone exact value: the exact figure and its derived band and threshold variants
are minted together as one claim family.
What locates and bounds an attestation
Beyond its claims, an attestation carries the identifiers a verifier needs:
an attestation_id, the family_id shared by every variant of one fact, the
employer_id/epoch_no/log_seq that place it in the log, and the worker’s
per-employer subject_pk. as_of is the moment the claim describes;
valid_until is its optional expiry; supersedes_family points at the prior
family it replaces.
A registrar’s signature on an attestation only counts if a covering delegation
allows its type at that log_seq and its as_of falls inside the
delegation’s time window — see Verification.
Log & epochs
Each employer has exactly one append-only log, written by a single registrar at a time. This page covers how the log is chained, how authority over it is granted and rotated through epochs, and the signed heads that make its history witnessable.
The hash chain
Every log entry is a signed tn-* object, and entries are chained by hash:
each entry’s hash is BLAKE3(payload_bcs || prev_hash), where prev_hash is
the previous entry’s hash (empty for the first entry). Because each entry commits
to its predecessor, inserting, removing, or editing any entry changes every
later hash — and therefore the head.
The event log and revocations are append-only, enforced by database triggers. Those triggers guard against application bugs; the real defence against a malicious rewrite is the witnessed head, not the database.
Epochs: authority that can be rotated
An attestation is signed by the registrar, not the employer. The employer
authorizes a registrar by signing an EpochOpen that names the registrar’s
key and the sequence number its signatures become valid from, and chains to the
previous epoch by referencing that epoch’s final head. An EpochClose ends
an epoch at a definite sequence and head.
The chain of epochs verifies back to the first one, since each EpochOpen is
employer-signed and points at the prior epoch’s closing head. This is what lets
an employer change registrars without invalidating history: the new
registrar opens the next epoch pointing at the old epoch’s final head, and every
attestation from the previous epoch still verifies. See
Misbehavior & switching.
Delegations: what a registrar may mint
Naming a registrar is not enough; a Delegation bounds what it may do. It
lists the attestation types the registrar may mint, a daily mint cap, a
sequence range it is valid across (with an optional forward-only revocation
point), and a time window the claims’ as_of must fall in.
A registrar-signed attestation is valid only if some delegation for its epoch
allows its type, covers its sequence number, and contains its as_of in the
time window. The registrar enforces this, and every verifier re-checks it on
import — so a mint of a type the delegation never allowed is rejected even
though the signature on it is genuine. This is the “signed is not the same as
authorized” rule.
Signed heads & checkpoints
The head of a log is its latest sequence number and hash. It is published two ways:
- A
LogHeadis returned inside every operation receipt, so wallets keep a witnessed head for each attestation they hold. - A
Checkpointis the same head with a timestamp, mirrored to at least two independent locations at a regular cadence and at every epoch boundary. Its timestamp is exactly the “not revoked as of 14:02” line on the verdict card.
Because a head names a single (employer, seq, hash), two registrar-signed heads
for the same employer and sequence but different hashes are a self-verifying
conflicting-head proof that the registrar rewrote history. The
wallet, the Signer, the dashboard, and the verifier all run the same check.
Inclusion receipts
Each time the registrar appends an entry it returns an operation receipt — the entry’s sequence number and hash plus the signed head covering it. The wallet keeps it as independent proof that a specific attestation was in the log at a specific head, which survives even if the registrar later disappears or tries to disown the entry.
Verification
Verification is one pure function. The chain, the KYB attestation, the
consent grant, the revocation set, the current time, and the verifier’s list of
trusted attesters all arrive as arguments — the function does no I/O and reads no
clock. The identical logic runs in the verifier portal, the worker wallet, and
the dependency-free offline page, and is mirrored byte-for-byte in TypeScript.
The implementation is
crates/tenure-core/src/verify.rs.
Because the current time and the freshness window are inputs rather than globals, the same bundle yields a deterministic verdict for any chosen clock and recency policy — and the verifier owns its trusted-attester list the way a browser owns its root store.
The five checks
The predicate runs in order and returns the first failure, or Verified. (A
prerequisite step first confirms the employer descriptor is signed by the
employer key it declares.)
- KYB. A valid, unexpired KYB attestation binds the employer key to the displayed legal entity, and the attester that signed it is in the verifier’s trust list. A failure here always names which attester signed — even an untrusted one — so the verifier can decide for itself.
- Chain. The epoch chain verifies back to the employer key; each
attestation is signed by the registrar of its epoch, sits inside that epoch’s
sequence range, and is covered by a delegation that allows its type and
contains its
as_of. A mint outside every delegation is rejected here even though it is signed. - Consent. The share grant is signed by the worker’s per-employer key, is unexpired, names every presented attestation, and matches the audience presenting it.
- Freshness. If any presented attestation is in the revocation set, the
verdict is
Revokedregardless of head age. Otherwise, if the checkpoint is older than the freshness window, the verdict isStaleHead— evidence decays by design, so a saved bundle past the window can never readVerified. - Resolution. A claim verifies only if its family is the
latest unsuperseded family for its fact; a stale variant — exact, band, or
threshold — is rejected, as is any attestation whose own
valid_untilhas passed.
Verdicts
The verdict is explicit and never scored. Verified proves the employer
signed the statements — not that they are true.
| Verdict | Meaning |
|---|---|
Verified | All checks pass. Carries the employer’s legal name, the attester and its methods, the claims at the granted granularity, and the “not revoked as of” time and head age |
Revoked | A presented attestation or its family is revoked or superseded |
GrantExpired | The share grant has expired |
ChainInvalid | A structural failure — broken chain, mint outside delegation, tampered bytes — with a reason |
EmployerUnverified | KYB failed or the attester is not trusted; names the attester that signed |
StaleHead | The checkpoint is older than the freshness window |
The verdict card distinguishes an offline check (against a head from, say, 13:40) from a live recency check, and shows the head age explicitly.
Importing a full log
Verifying a presented bundle is the common path. Importing an entire log — a ragequit file, or a new registrar adopting an employer — is stricter and runs once: it verifies the descriptor, the KYB attestation, the epoch chain, and every delegation, then replays every entry (registrar signatures, hash-chain continuity, and type/rate-cap accounting), verifies the final signed head, and materializes the current state plus the revocation set. A new registrar runs exactly this before reporting its head, which the Signer then cross-checks against the heads it witnessed. See Misbehavior & switching.
Claim families
Tenure’s selective disclosure is signed-variant, not zero-knowledge. When
income is issued, the registrar mints the exact value and its derived band and
threshold variants together as one claim family sharing a family_id. The
worker later chooses which granularity each verifier sees, without re-asking the
employer. The implementation is
crates/tenure-core/src/family.rs.
Why families exist
Two problems motivate them:
- Disclosure should be the worker’s dial. A landlord asking for “income ≥ $3,200/mo” should see a threshold, not an exact salary — and the worker shouldn’t have to ask the employer for a fresh credential each time.
- Stale variants must not linger. If the exact, band, and threshold credentials were independent, a raise could update the band while an old threshold kept verifying. A family prevents that: supersession and revocation are atomic across all variants.
Minting a family
One underlying fact — an exact annual figure with a basis and as_of — produces
three attestations under one family_id:
| Variant | Type | Derived value |
|---|---|---|
| Exact | income_exact | the exact amount |
| Band | income_band | the $25,000-wide band the amount falls in |
| Threshold | income_threshold | the largest $5,000 step at or below the amount |
When income changes, a fresh family is minted that points back at the prior
period’s family via supersedes_family. Families are tracked per underlying fact,
so the income family and the employment-status family supersede on independent
schedules.
Resolving currency
At verification, each family resolves to one of three states by asking whether it is the latest unsuperseded family for its fact:
| State | Effect |
|---|---|
| Current | the latest family for its fact — its members may verify |
| Superseded | a later family or a supersede entry retired it — no member verifies, including a leftover threshold |
| Revoked | every member is revoked |
The key property: when a family is superseded, every variant retires at once. A verifier handed a leftover threshold from an old family resolves that family as superseded and rejects it — a stale threshold can never verify.
Atomic retirement
The atomicity primitive is a single FamilySupersede log entry. It names the
family and the member attestations being retired, optionally points at the
replacement family, and emits one revocation commitment per member — so there is
never a window in which the band has updated but the threshold has not. A
verifier only honours the entry if a registrar in the epoch chain signed it.
Not zero-knowledge
A band or threshold credential is a signed statement of a coarser fact, not a proof that cryptographically hides the exact value. The exact figure still exists as its own variant and the worker holds it; selective disclosure simply lets the worker present only the coarse variant. The product never implies otherwise — ZK-based disclosure is a later research track, not a current promise.
Privacy
Privacy in Tenure is structural, not policy. The two load-bearing mechanisms: after mint, no plaintext claim values exist registrar-side (derive-then-purge), and worker keys are per-employer so cross-employer correlation is impossible by construction. This page describes the data split and the honest limits of each promise.
The data split
| Visibility | Data |
|---|---|
| Public | Signed log heads, checkpoints, the epoch chain, delegation existence (type-level only), revocation commitments (BLAKE3(attestation_id)), and KYB attestations. Employer identity is public; workers never are. |
| Authorized-only | Claim payloads, the subject↔employer mapping, and share contents — visible to the employer, the subject, grant audiences, and authorized auditors |
| Holder-held | Inclusion receipts — witnessing without publishing the employment graph |
A revocation commitment is BLAKE3(attestation_id): a grant holder who knows
the attestation_id can check whether it is revoked, but the commitment is
unlinkable to anyone who doesn’t.
Derive-then-purge
The registrar must see exact values at mint — it derives the band and threshold variants of a claim family from the exact figure. The moment that is done, it purges the plaintext, retaining only:
- subject-encrypted blobs — the claim payloads sealed to the worker’s key,
- a recovery escrow exactly as strong as the descriptor’s recovery policy (no stronger), and
- commitments.
“Encrypted at rest with registrar-held keys” was the earlier posture and is explicitly rejected: it protects against disk theft, not against compromise or legal compulsion. Derive-then-purge means a compromised or compelled registrar has no plaintext to give up. The further hardening — the employer encrypts before submission, so the registrar never sees plaintext at all, and recovery becomes employer re-furnish — is a later commit-only storage path.
Per-employer keys
A worker has a fresh Ed25519 keypair per employer, generated silently when they claim a wallet. This is the privacy spine:
- there is no key that links a worker’s records across two employers;
- bundles are sealed to their audience;
- nothing about a worker is publicly enumerable.
Because correlation is impossible at the key layer, the product never aggregates, scores, or hints that two employers’ statements corroborate each other — triangulation is impossible by design, and the design leans into that.
Disclosure is consented, logged, and decaying
- Consented. A disclosure happens only when the worker signs a
ShareGrant. The wallet shows a preview of exactly what the verifier will see before the grant is signed. - Logged. Every fetch the registrar serves — link opens, bundle downloads, portal verifications — lands in the worker-visible access log. This is fetch-scoped: what happens after the bytes leave (screenshots, a saved bundle, the self-hosted page) is governed by grant terms and verifier policy — contract, not cryptography, and labeled as such in the UI. The self-hosted verify page structurally cannot feed the access log, and the protocol names which promise covers which event class rather than pretending both are absolute.
- Decaying. Revoking a grant stops future access through Tenure and makes
already-saved copies fail re-verification: past the freshness window a saved
bundle reads
StaleHead, notVerified. It does not delete copies already viewed or downloaded, and the copy says exactly that.
Monitoring carries no values
A scope = monitor grant lets a verifier receive event classes only —
employment_status_changed, attestation_superseded, grant_revoked — never
new claim values. This is enforced at the type level: the monitoring event enum
has no fields, so a push cannot carry a value. Learning what changed
requires a fresh share the worker consents to. Consent duration lives inside the
grant, and the consent copy is verbatim and explicit, including that revoking
“may affect your application.”
Misbehavior & switching
The registrar is a hired commodity, and two mechanisms make that literal: a
conflicting-head proof turns a rewritten log into portable, checkable
evidence, and an epoch transition lets an employer move to a different
registrar without invalidating a single previously issued attestation. The proof
machinery is in
crates/confed-core.
Conflicting-head proofs
A signed head is a registrar’s claim about the state of an employer’s log at a given sequence number. Because the log is hash-chained, there is exactly one honest head per sequence number. If a registrar ever signs two heads for the same employer and sequence but with different hashes, it has rewritten history that was already witnessed — and that pair of signed heads is the proof.
The proof is self-verifying: anyone can confirm that both heads carry a valid signature from the same key, name the same employer and sequence, and disagree on the hash. Nobody has to trust the party presenting it. The wallet, the Signer, the dashboard, and the verifier all run this check, and a detected conflict raises an unmissable alarm with one-tap export of the proof.
Witnesses
A proof only exists if both conflicting heads were witnessed, so Tenure spreads heads widely: every operation receipt hands a fresh head to the worker’s wallet, the Signer retains every head it has seen, and checkpoints are mirrored to at least two independent locations. This protection runs both ways — an employer or registrar cannot backdate a hire or scrub a tenure period after it was witnessed, and a worker’s receipts survive the employer’s infrastructure shutting down.
The registrar / payroll switch
Switching registrar (or payroll system) is a signed epoch transition that preserves every previously issued attestation and stops the old registrar from authorizing new state after the cutover:
- The old registrar freezes writes at a sequence number — or the employer declares one unilaterally if the registrar is unresponsive or hostile.
- The Signer closes the current epoch at that sequence, using its own witnessed head if the registrar’s claim looks suspect.
- The new registrar imports the ragequit file, replays it in full, and reports its computed head; the Signer cross-checks that head against the ones it witnessed.
- The Signer opens the next epoch, naming the new registrar and chaining to the old epoch’s final head, and issues a fresh delegation.
- The new artifacts are published to mirrors; wallets and verifiers confirm the employer signatures and re-point.
Because each epoch is employer-signed and chains to the previous one’s final head, a verifier accepts attestations from both the old and new epochs, while the old registrar can no longer author valid state. The one stated limitation: offline clients converge on the next sync — the switch is a signed cutover, not an instant global flip.
The ragequit file
The ragequit file is the employer’s full signed log plus epoch chain, exportable at any time. It is the substrate of the switch and of Tenure’s strongest claim: a credential still verifies after the original registrar is switched off entirely. A downloaded bundle verifies offline with no registrar in the loop, and a worker who witnessed the old head can validate the transition against it. The switch drill exercises the whole sequence end to end.
Failure modes
| Situation | Outcome |
|---|---|
| Employer goes out of business | Attestations still verify, with their as_of dates and an “employer record frozen” note once heads stop advancing |
| Registrar down | Verification proceeds from cached bundles and mirrors with a stale-head warning; issuance pauses; nothing already issued is affected |
| KYB attester key compromised | The attestation is revoked and affected employers downgrade to “identity unverified” pending re-attestation; HSM custody and attester plurality limit the blast radius |
| Worker device lost | Recovery re-points the worker’s attestations to a fresh key and re-delivers receipts |
| Fake-employer report | Investigated by the attester; a confirmed case revokes the KYB attestation, and every verdict that relied on it becomes invalid |
| Conflicting heads detected | Unmissable alarm everywhere, one-tap proof export, and the employer initiates a switch |
API reference
The registrar exposes a small HTTP surface. This page covers the conventions
common to every endpoint; the Registrar API lists each endpoint,
and Bundles & grants covers the share/verify exchange. The handlers
are thin wrappers over the registrar operations in
crates/tenure-registrar.
Transport
Requests and responses are JSON, but JSON is only the transport — never the signed form. Any signed object inside a request carries its canonical BCS bytes (base64url-encoded), and signatures are always verified over those bytes, not over the JSON around them.
Signed objects and envelopes
A signed protocol object travels as three fields: the canonical payload bytes, the signer’s public key, and the signature over those bytes. The registrar verifies the signature against the transmitted bytes and only then decodes them under the expected tag, so a tampered byte fails before the payload is interpreted.
Mutating calls from a signer (the employer Signer, a worker, and so on) add a nonce and a timestamp to that envelope, which authenticate the call itself. Nonces are single-use per signer, the timestamp must be within five minutes of the registrar’s clock, and share-grant nonces are single-use per audience — together these defeat replay.
The public read endpoints — heads, checkpoints, revocation commitments — and share-link fetches carry no envelope; in production they are rate-limited and proof-of-work gated.
Operation receipts
Every call that appends to the log returns an operation receipt: the new entry’s sequence number and hash, plus the signed head covering it. Wallets keep these as inclusion receipts — their independent proof that an attestation was in the log at a given head.
Errors
Errors are a JSON object with an error message and a status code:
| Status | Condition |
|---|---|
401 Unauthorized | Envelope verification failed — bad signature, stale timestamp, or replayed nonce |
404 Not Found | Unknown employer, grant, or entity |
422 Unprocessable Entity | Authenticated but rejected by policy — e.g. a mint outside delegation, or a batch that fails the cross-check |
500 Internal Server Error | Storage or internal failure |
Registrar API
Every route on the registrar, grouped by actor. See the wire format
for envelopes, signed objects, and receipts. Routes are defined in
crates/tenure-registrar/src/http.rs.
Employer / onboarding
| Method & path | Body | Returns |
|---|---|---|
POST /onboard | OnboardRequest (descriptor + KYB + EpochOpen(1) + delegation, each signed) | { receipts } |
POST /invite | { employer_id, email, payroll_ref } | { claim_token } |
POST /batch | { manifest: Envelope, raw_batch_b64 } | { status: "processed", receipts } or { status: "skipped" } |
POST /epoch/close | { close: Signed } (tn-epoch-close-v1) | { ok: true } |
POST /checkpoint/:employer_id | — | the published Checkpoint |
GET /export/:employer_id | — | the ragequit file |
POST /import | { file, epoch_open: Signed, delegation: Signed, contact_email } | { employer_id } |
Notes:
/batchis the bulk-issuance endpoint. Themanifestenvelope wraps the Signer-signedtn-batch-v1(whose aggregates the Signer computed itself), andraw_batch_b64is the raw batch file — never a summary. The registrar cross-checks the signed aggregates against the file it received, and therun_idmakes the call idempotent: a replayed run returns{ status: "skipped" }./importis how a new registrar adopts an employer during a switch. It runs full import-time verification over the ragequit file before accepting the new epoch.
Worker / holder
| Method & path | Body / query | Returns |
|---|---|---|
POST /claim | { token, subject_pk } | { employer_id } |
GET /wallet/:subject_pk | — | { attestations } (sealed payloads + receipts) |
POST /grants | { grant: Signed, sealed_bundle_b64 } | { grant_id } |
POST /grants/revoke | { revoke: Signed } (tn-grant-revoke-v1) | { ok: true } |
GET /access_log/:grant_id | ?holder_pk=… | { access_log } |
Notes:
/claimredeems an invite token and binds a freshly generated per-employersubject_pkto the worker./grantsstores a holder-signedShareGranttogether with the pre-sealed verification bundle./grants/revokewithdraws it — killing future fetches and recency, never deleting copies already seen./access_logis fetch-scoped and worker-visible;holder_pkauthorizes the read.
Verifier
| Method & path | Body / query | Returns |
|---|---|---|
GET /share/:grant_id | ?verifier_account_id=… | { sealed_bundle_b64 } |
POST /verifier_accounts | { org_name, email } | { verifier_account_id } |
POST /verifications | { grant_id, verifier_account_id, verified_head_seq } | { billed, amount_cents } |
POST /monitors | { grant_id, verifier_account_id } | { monitor_id } |
GET /monitors/events | ?verifier_account_id=… | { events: [{ grant_id, event_class }] } |
Notes:
/share/:grant_idreturns the sealed bundle; the fetch lands in the worker’s access log. No account is needed to view; an account is needed to bill./monitors/eventsreturns event classes only —employment_status_changed,attestation_superseded,grant_revoked— never claim values. The payload type cannot carry a value.
Public (unauthenticated)
| Method & path | Returns |
|---|---|
GET /public/:employer_id/head | the latest signed LogHead |
GET /public/:employer_id/checkpoint | the latest Checkpoint |
GET /public/:employer_id/revocations | { commitments } — the public revocation commitments |
These are the data a verifier needs for a live recency check and the inputs
to the freshness check in the verify predicate.
They expose heads, checkpoints, and BLAKE3(attestation_id) commitments — never
worker identity or claim values.
Bundles & grants
The share/verify exchange is the product’s central moment. This page covers the share grant (the worker’s consent record), the sealed verification bundle (everything a verifier needs, offline), and how the two move between wallet, registrar, and verifier.
The share grant
A share grant is signed by the worker’s per-employer key — it is the consent
record, not a server setting. It names a grant_id, the exact attestations being
disclosed, the audience (a known verifier key, or a link), the scope (view or
monitor), and an expiry.
The granularity dial lives here: a threshold-only share lists only the threshold attestation, and the bundle built for it contains no bytes of the exact variant at all. Selective disclosure is enforced by what is in the bundle, not by hiding fields. A matching holder-signed revocation withdraws a grant — killing future fetches and recency, though it cannot delete copies already viewed.
The verification bundle
The bundle is self-contained, enough for fully offline verification:
| Field | Signed by | What |
|---|---|---|
| employer descriptor | Employer | the root employer record |
| KYB attestation | KYB attester | binds the employer key to a legal entity |
| epoch chain | Employer | the epochs back to the first |
| delegations | Employer | the delegations covering the granted mints |
| attestations | Registrar | only the granted variants |
| supersede entries | Registrar | family-supersession evidence, when it exists |
| revocation commitments | — | the public commitments as of the checkpoint |
| latest checkpoint | Registrar | the freshness anchor (“as of”) |
| grant | Holder | the consent record |
| inclusion receipts | Registrar | optional extra witness evidence |
The verifier presents this bundle together with a presentation context — who it has authenticated as, and whether it wants a view or a monitor. That context is an input to the verify predicate, which checks the grant’s audience against it, so a bundle sealed for one verifier cannot be replayed by another.
Sealing
The bundle is sealed (age-style: X25519 + ChaCha20-Poly1305) to its audience. When the verifier’s key is known, it is sealed to that key; otherwise it is sealed to a key derived from the link secret. The capability URL carries the secret while the stored grant carries only a hash of it, so the registrar never holds the secret itself.
The exchange
- The wallet signs the grant, seals the bundle to the audience, and stores both
with the registrar (
POST /grants). - The worker shares a link or QR code. Opening it fetches the sealed bundle
(
GET /share/:grant_id); that fetch lands in the worker’s access log. - The verifier unseals the bundle and runs the predicate. For a live recency check it also pulls the public checkpoint and revocation list; for an offline check it uses only what the bundle carries, and the verdict shows the head age either way.
- A billing call (
POST /verifications) records the verdict and issues a receipt.
Monitoring
A monitor-scope grant additionally authorizes a subscription. Polling it returns event classes only — that employment status changed, an attestation was superseded, or the grant was revoked — never the new values. Learning what changed requires a fresh share the worker consents to, and the subscription dies with the grant.
Guides
Short, task-focused walkthroughs for working with Tenure. For the conceptual model read the protocol specification; for the wire surface read the API reference.
- Quick start — clone, build, test, and regenerate the cross-language vectors. Start here.
- Employer guide — onboard an employer, understand the two-app Signer pattern, and issue attestations.
- Worker guide — claim a wallet, hold credentials, and share them at chosen granularity.
- Verifier guide — verify a credential online and fully offline, and read a verdict card.
Each guide assumes you have a working checkout per the quick start.
Quick start
This guide gets the workspace building, the tests passing, and the cross-language vectors regenerated.
Prerequisites
- Rust (stable, 2021 edition) with
cargo. - Node.js (with
npm) for the TypeScript twin and the web surfaces.
The permitted cryptography is fixed — ed25519-dalek v2, blake3, x25519 +
age-style sealing, getrandom — and pinned in the workspace Cargo.toml. You
do not need to install anything cryptographic yourself.
Build & test the Rust workspace
cargo build --workspace
cargo test --workspace # includes the red-team suites for every phase
The workspace has four crates:
| Crate | Role |
|---|---|
confed-core | Shared protocol layer: domain-tagged BCS signing, hash chains, signed heads, misbehavior proofs, sealing |
tenure-core | tn-* objects, claim families, the pure 5-check verify predicate |
tenure-registrar | The axum registrar binary (SQLite WAL) |
tenure-signer | The employer Signer (CLI skeleton) |
The red-team tests are the real exit criteria — they assert the negative properties (a mint outside delegation is rejected, a superseded family cannot verify, a tampered bundle fails, derive-then-purge leaves no plaintext). Run them before claiming anything works.
Cross-language vectors
Rust writes the vectors; TypeScript re-verifies them byte-for-byte. This is how the two implementations are kept honest.
cargo run -p tenure-core --bin gen_vectors # Rust writes /vectors
cd packages/tenure-core-ts && npm install && npm test # TS re-verifies parity
Every signed object has a /vectors entry, plus a full claim family, a family
supersession, a revocation, a ShareGrant, a 30-entry employer log, and a full
bundle with its expected verdict. If you change any object’s BCS layout, both
the Rust writer and the TS twin must agree, or npm test fails.
Run a registrar
cargo run -p tenure-registrar -- <db_path> <key_file> <port> [mirror_dir...]
db_path— the SQLite database (created if missing).key_file— a 32-byte hex seed; generated and written if the file is absent. The registrar prints its public key on startup.port— binds127.0.0.1:<port>.mirror_dir...— optional directories that receive published checkpoints.
See the Registrar API for the endpoints it serves.
The drills & audits
scripts/switch_drill.sh # epoch-transition drill (spec §3.9): registrar A → B
scripts/sr1_check.sh # audit built web bundles for forbidden signing code
switch_drill.shruns the full registrar/payroll switch end-to-end and prints a transcript, asserting that a credential still verifies after the original registrar is switched off. See Misbehavior & switching.sr1_check.shgreps the builtweb/*bundles to prove the dashboard has no signing capability at all, the wallet cannot sign employer-root actions, and the self-hosted verify page retains nothing (nolocalStorage,indexedDB, cookies, or network calls). Build the web surfaces first.
The web surfaces
cd web/wallet && npm install && npm run dev # worker PWA
cd web/verifier && npm install && npm run dev # verifier portal
cd web/dashboard && npm install && npm run dev # employer dashboard (no keys)
web/verify-page is a dependency-free static page — open index.html
directly, or serve the directory with any static file server. It is the
self-hostable verifier that makes “you don’t have to trust our portal” literal.
Employer guide
An employer issues attestations about its own workforce. The defining constraint: the dashboard never holds a key and never signs anything. Every signed action is approved in an independent Employer Signer that holds the root key. This guide walks through onboarding and issuance.
The two-app pattern
| App | Holds keys? | Role |
|---|---|---|
Dashboard (web/dashboard) | No | Composes unsigned action drafts and batch files; shows verification traffic; runs the switch flow |
Signer (crates/tenure-signer) | Yes (root key) | Renders unsigned actions human-readably, then signs on approval |
The dashboard prepares; the Signer approves. The
sr1_check.sh audit proves on the built bundle
that the dashboard contains no Ed25519 code at all.
Onboarding
- KYB. A KYB attester verifies the legal entity (EIN/registry match, domain
control, and — with a payroll connector — a bank micro-match) and signs a
KybAttestationbindingemployer_pkto the entity, with the methods used and an expiry. This is what the verdict card later names. - Generate the root key. The Signer generates the employer root key and an encrypted backup. This key never leaves the Signer and never appears in the dashboard.
- Compose the onboarding bundle. The dashboard composes three unsigned
drafts: an
EmployerDescriptor,EpochOpen(1), and aDelegation(the allowed attestation types and a daily mint cap). - Review & sign. The Signer renders the bundle in plain language — “You
authorize registrar
tn1…to issue employment & income attestations for Acme LLC, max 500/day, epoch 1 from seq 1” — and signs on approval. - Activate. The signed bundle is posted to
POST /onboard; the registrar activates the employer and returns operation receipts. TheKybAttestationis published alongside the descriptor.
See the Registrar API for the endpoints.
Issuing attestations
Bulk (the common path)
- The dashboard composes or uploads the roster batch file.
- The Signer ingests the raw batch file itself, computes the
entries_hashand the human-readable aggregates locally, and renders a deterministic, hash-seeded sample of rows plus red-flag heuristics (termination spikes, income outliers, new-worker surges). It does not accept a summary from the dashboard. - On approval the Signer signs a
BatchManifest(tn-batch-v1) whose aggregates ride inside the signed payload. - The dashboard posts
POST /batchwith the signed manifest envelope and the raw batch file. The registrar cross-checks the signed aggregates against the file it received, then mints one claim family per fact per worker (exact + band + threshold under onefamily_id). - The
run_idmakes the call idempotent — a replayed batch returns{ status: "skipped" }.
Income changes & corrections
- Income refresh. A new family is minted with
supersedes_familypointing at the prior period’s family; the supersession atomically retires every variant of the old one. A stale threshold can never linger valid. - Corrections are append-only. A wrong title is fixed by issuing a superseding attestation plus a revocation of the old one. Both stay in the log forever — the dispute trail is the log. Nothing is ever edited in place.
- End of employment. A
StatusChangecloses the employment period with an end date. History is preserved as a dated period; “currently employed” checks then fail honestly.
Switching registrar or payroll
Switching is a signed epoch transition,
not a migration: the Signer closes the current epoch, the new registrar imports
the ragequit file and reports its
computed head, the Signer cross-checks it against witnessed heads and opens the
next epoch with a new delegation. Every previously issued attestation stays
valid; the old registrar cannot author new state after the cutover. Exercise the
whole sequence with scripts/switch_drill.sh.
What the employer is and isn’t claiming
An attestation proves the employer said it — not that it is true. The verdict card says exactly that. Tenure never claims ground truth, never aggregates two employers’ statements, and never scores anyone.
Worker guide
A worker (the holder) receives attestations into a wallet and discloses them
on their own terms. Workers never see a key, a seed phrase, or the word
“crypto.” This guide describes claiming a wallet and sharing credentials. The
wallet is web/wallet (a PWA).
Claiming a wallet
- The employer invites the worker; the registrar issues a one-time claim token delivered by a work-email magic link.
- Opening the link redeems the token via
POST /claimwith a freshly generated per-employer public key. The keypair is generated silently in the wallet — no password ceremony. - Attestations and their inclusion receipts appear as plain-language cards: “Employed at Acme since Mar 2024 · income band $50–75k.”
Per-employer keys are the privacy spine. A second employer means a second, unrelated key. There is no key that links your records across employers, and nothing about you is publicly enumerable. See Privacy.
Sharing a credential
Sharing is consent, expressed as a signature. The share composer:
- Pick the ask. Choose which fact the verifier needs — e.g. “income ≥ $3,200/mo, currently employed.”
- Turn the granularity dial. Dates only → Band → Threshold → Exact. A preview shows exactly what the verifier will see before anything is signed. A threshold-only share carries only the threshold variant — the exact value’s bytes are not in the bundle at all. See Claim families.
- Set expiry (grants default-expire within 30 days) and choose the
scope —
viewfor a one-time verdict,monitorfor ongoing change-notification. - Sign. The wallet signs a
ShareGrantwith the per-employer holder key — the consent record — and seals the verification bundle to the audience. A link or QR is produced.
The access log
Every fetch the registrar serves — link opens, bundle downloads, portal verifications — appears in your access log: “Lena’s Property Mgmt viewed this Tue 14:02.” This is fetch-scoped: it covers what Tenure serves, not what someone does with bytes after they leave (a screenshot, a saved bundle). The UI labels that boundary honestly rather than promising “every view, anywhere, forever.”
Revoking a share
One tap revokes any grant. Revocation:
- stops future access through Tenure, and
- makes already-saved copies fail re-verification — past the freshness
window a saved bundle reads
StaleHead, neverVerified.
It does not delete copies already viewed or downloaded, and the wallet says exactly that.
Monitoring consent
If you grant monitor scope, the verifier can be notified that something
changed — employment_status_changed, attestation_superseded, or
grant_revoked — but never the new values. Seeing the new values takes a
fresh share you consent to. The consent screen is explicit, including that
revoking “may affect your application,” and the subscription dies when the grant
expires.
Recovery
If you lose your device, recovery follows the employer’s recovery policy
(default: email verification + employer approval + a 24-hour delay). Recovery
re-points your attestations to a fresh per-employer key via a public Reissue
entry, revokes the old credential, and notifies it. This is auditable
credential rotation, not key recovery — the change appears in the employer’s
public record.
Verifier guide
A verifier checks a credential and renders a verdict. The check is the pure
5-check predicate — the same logic in the hosted
portal (web/verifier) and the dependency-free static page (web/verify-page).
This guide covers verifying online and offline, and reading the verdict.
Verifying from a share link
- Open the link (or scan the QR). The portal fetches the sealed bundle via
GET /share/:grant_id— the fetch lands in the worker’s access log. - The portal unseals the bundle and runs
verify_bundle(bundle, presentation, trusted_attesters, now, freshness_window). - For a live recency check it also pulls
GET /public/:employer_id/checkpointand/revocationsand feeds them in. - A verdict card renders in seconds.
No account is needed to view; an account is needed to bill. Billing is
recorded with POST /verifications, which returns a receipt.
Verifying offline
The bundle is self-contained. Download it and verify on the self-hosted
page — no network, no dependencies, nothing retained (no localStorage, no
indexedDB, no cookies, no fetch). This is a trust feature: you don’t have to
trust the hosted portal, because you can run the verifier yourself.
The verdict distinguishes an offline check (against the bundle’s bundled checkpoint — “verified offline against head from 13:40”) from a live recency check, and always shows the head age.
Reading a verdict
Verified means the employer signed these statements — not that they are
true. A verified card shows:
- the employer’s legal name and key,
- the attester and its methods — “identity attested by Tenure KYB via EIN + domain + payroll feed” — backed by your own trust list,
- the claims at the granted granularity (and nothing finer), and
- “not revoked as of 14:02 · head age 3m.”
The red states are explicit and never ambiguous; nothing is ever scored:
| Verdict | Meaning |
|---|---|
Verified | All checks passed |
Revoked | An attestation or its family is revoked/superseded |
GrantExpired | The share grant expired |
ChainInvalid | Structural failure — broken chain, mint outside delegation, tampered bytes (carries a reason) |
EmployerUnverified | KYB failed or the attester isn’t in your trust list (names the attester) |
StaleHead | The checkpoint is older than your freshness window |
Trust lists
You own your accepted-attester set, the way a browser owns its root store.
Tenure ships a default list; the self-hosted page makes it editable. The
card always names the attester and the methods so you can price evidence
strength yourself — a live payroll feed, for instance, is strong
employer-existence evidence. An attester not in your list yields
EmployerUnverified, naming the attester that signed so you can decide.
What you may not do
The verdict is evidence; the decision is yours. Tenure presents signed evidence and never scores, ranks, or recommends — deliberate FCRA distance. As a verifier you must not store or re-share worker data beyond the grant’s terms; the grant is the boundary of consent.