Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Canonical bytes are BCS with a domain tagbcs(("tn-xxx-v1", body)). JSON is display-only: never signed, never hashed.
  2. 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

  1. Architecture — the actors (employer, registrar, KYB attester, worker, verifier, mirrors) and what each is and isn’t trusted for.
  2. Objects — the tn-* object catalogue and the claim schemas.
  3. Log & epochs — the per-employer append-only log, epoch-scoped authority, delegations, and signed heads.
  4. Verification — the pure five-check predicate and the one-time import of a full log.
  5. Claim families — how exact/band/threshold variants are minted and retired together.
  6. Privacy — derive-then-purge storage, per-employer keys, and the public/private data split.
  7. 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

QuantityEncoding
IDsULIDs (strings)
Public keyslowercase hex
Signaturesbase64url, no padding
Hasheslowercase hex (empty string = none yet)
Moneyinteger cents (never floats)
Datesunix 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    └──────────────────────────┘
└─────────────┘
ActorCodeRole
Employercrates/tenure-signer, web/dashboardIssues 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.
Registrarcrates/tenure-registrarA 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 / holderweb/walletHolds per-employer keypairs, receives attestations + inclusion receipts, and signs ShareGrants — the consent record.
Verifierweb/verifier, web/verify-pageRuns 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:

  1. generates and stores the employer root key (encrypted at rest),
  2. imports unsigned action drafts the dashboard composed,
  3. renders them human-readably (“You authorize registrar tn1… to issue employment & income attestations for Acme LLC, max 500/day, epoch 1 from seq 1”),
  4. 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

ActorTrusted forNot trusted for
EmployerEverything 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 SignerFaithful display and signing of approved actions and batch manifests(Residual) its own build/update channel — mitigated by reproducible, signed, pinned releases
RegistrarLiveness, serialization, policy enforcement, delivery, fee collectionMinting outside epoch-scoped type/rate caps; rewriting witnessed history without producing a conflicting signed head; attestation validity (that chains to the employer key)
KYB attesterBinding 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 / holderHolding per-employer keys; signing ShareGrants; retaining receiptsAnything else
Verifier appCorrect chain verification (open-source and self-hostable precisely so this is checkable)Storing or re-sharing worker data beyond the grant
Mirrors / witnessesRetaining published headsCorrectness 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

ObjectTagSignerPurpose
EmployerDescriptortn-employer-v1EmployerDeclares the employer key, KYB reference, enabled attestation types, dispute & recovery policy, mirror URLs
KybAttestationtn-kyb-v1KYB attesterBinds employer_pk to a legal entity + jurisdiction, with verification methods and an expiry
EpochOpentn-epoch-v1EmployerOpens an authority epoch: names the registrar key valid from a given seq, chains to the prior epoch head
EpochClosetn-epoch-close-v1EmployerCloses an epoch at a final seq + head hash
Delegationtn-delegate-v1EmployerGrants the registrar the right to mint specified types up to a daily cap, within a seq and time window
BatchManifesttn-batch-v1EmployerCarries Signer-computed aggregates + the raw batch hash for one issuance run
Attestationtn-attest-v1RegistrarOne signed claim about a worker, under a covering delegation
StatusChangetn-status-v1RegistrarCloses or reopens an employment period
Revocationtn-revoke-v1Registrar / EmployerRevokes one attestation, with a reason
FamilySupersedetn-family-supersede-v1RegistrarOne log entry that atomically retires every variant of a claim family
Reissuetn-reissue-v1RegistrarRe-points attestations to a fresh subject key during recovery
ShareGranttn-share-v1HolderThe worker’s consent record: which attestations, audience, scope, expiry
GrantRevoketn-grant-revoke-v1HolderWithdraws a ShareGrant
LogHeadtn-loghead-v1RegistrarA signed witnessed head: employer, epoch, seq, head hash
Checkpointtn-checkpoint-v1RegistrarA 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:

TypeClaims
employment_statusstatus (active / ended), start date, optional end date
tenure_datesstart date, optional end date
role_titletitle, optional department
income_exactexact cents, basis
income_bandfloor cents, ceiling cents, basis
income_threshold“at least” cents, basis
hours_classclass (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 LogHead is returned inside every operation receipt, so wallets keep a witnessed head for each attestation they hold.
  • A Checkpoint is 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.)

  1. 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.
  2. 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.
  3. 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.
  4. Freshness. If any presented attestation is in the revocation set, the verdict is Revoked regardless of head age. Otherwise, if the checkpoint is older than the freshness window, the verdict is StaleHead — evidence decays by design, so a saved bundle past the window can never read Verified.
  5. 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_until has passed.

Verdicts

The verdict is explicit and never scored. Verified proves the employer signed the statements — not that they are true.

VerdictMeaning
VerifiedAll 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
RevokedA presented attestation or its family is revoked or superseded
GrantExpiredThe share grant has expired
ChainInvalidA structural failure — broken chain, mint outside delegation, tampered bytes — with a reason
EmployerUnverifiedKYB failed or the attester is not trusted; names the attester that signed
StaleHeadThe 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:

  1. 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.
  2. 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:

VariantTypeDerived value
Exactincome_exactthe exact amount
Bandincome_bandthe $25,000-wide band the amount falls in
Thresholdincome_thresholdthe 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:

StateEffect
Currentthe latest family for its fact — its members may verify
Supersededa later family or a supersede entry retired it — no member verifies, including a leftover threshold
Revokedevery 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

VisibilityData
PublicSigned 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-onlyClaim payloads, the subject↔employer mapping, and share contents — visible to the employer, the subject, grant audiences, and authorized auditors
Holder-heldInclusion 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:

  1. subject-encrypted blobs — the claim payloads sealed to the worker’s key,
  2. a recovery escrow exactly as strong as the descriptor’s recovery policy (no stronger), and
  3. 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, not Verified. 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 onlyemployment_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:

  1. The old registrar freezes writes at a sequence number — or the employer declares one unilaterally if the registrar is unresponsive or hostile.
  2. The Signer closes the current epoch at that sequence, using its own witnessed head if the registrar’s claim looks suspect.
  3. 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.
  4. The Signer opens the next epoch, naming the new registrar and chaining to the old epoch’s final head, and issues a fresh delegation.
  5. 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

SituationOutcome
Employer goes out of businessAttestations still verify, with their as_of dates and an “employer record frozen” note once heads stop advancing
Registrar downVerification proceeds from cached bundles and mirrors with a stale-head warning; issuance pauses; nothing already issued is affected
KYB attester key compromisedThe attestation is revoked and affected employers downgrade to “identity unverified” pending re-attestation; HSM custody and attester plurality limit the blast radius
Worker device lostRecovery re-points the worker’s attestations to a fresh key and re-delivers receipts
Fake-employer reportInvestigated by the attester; a confirmed case revokes the KYB attestation, and every verdict that relied on it becomes invalid
Conflicting heads detectedUnmissable 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:

StatusCondition
401 UnauthorizedEnvelope verification failed — bad signature, stale timestamp, or replayed nonce
404 Not FoundUnknown employer, grant, or entity
422 Unprocessable EntityAuthenticated but rejected by policy — e.g. a mint outside delegation, or a batch that fails the cross-check
500 Internal Server ErrorStorage 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 & pathBodyReturns
POST /onboardOnboardRequest (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_idthe published Checkpoint
GET /export/:employer_idthe ragequit file
POST /import{ file, epoch_open: Signed, delegation: Signed, contact_email }{ employer_id }

Notes:

  • /batch is the bulk-issuance endpoint. The manifest envelope wraps the Signer-signed tn-batch-v1 (whose aggregates the Signer computed itself), and raw_batch_b64 is the raw batch file — never a summary. The registrar cross-checks the signed aggregates against the file it received, and the run_id makes the call idempotent: a replayed run returns { status: "skipped" }.
  • /import is 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 & pathBody / queryReturns
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:

  • /claim redeems an invite token and binds a freshly generated per-employer subject_pk to the worker.
  • /grants stores a holder-signed ShareGrant together with the pre-sealed verification bundle. /grants/revoke withdraws it — killing future fetches and recency, never deleting copies already seen.
  • /access_log is fetch-scoped and worker-visible; holder_pk authorizes the read.

Verifier

Method & pathBody / queryReturns
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_id returns 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/events returns event classes onlyemployment_status_changed, attestation_superseded, grant_revoked — never claim values. The payload type cannot carry a value.

Public (unauthenticated)

Method & pathReturns
GET /public/:employer_id/headthe latest signed LogHead
GET /public/:employer_id/checkpointthe 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:

FieldSigned byWhat
employer descriptorEmployerthe root employer record
KYB attestationKYB attesterbinds the employer key to a legal entity
epoch chainEmployerthe epochs back to the first
delegationsEmployerthe delegations covering the granted mints
attestationsRegistraronly the granted variants
supersede entriesRegistrarfamily-supersession evidence, when it exists
revocation commitmentsthe public commitments as of the checkpoint
latest checkpointRegistrarthe freshness anchor (“as of”)
grantHolderthe consent record
inclusion receiptsRegistraroptional 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

  1. The wallet signs the grant, seals the bundle to the audience, and stores both with the registrar (POST /grants).
  2. 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.
  3. 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.
  4. 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:

CrateRole
confed-coreShared protocol layer: domain-tagged BCS signing, hash chains, signed heads, misbehavior proofs, sealing
tenure-coretn-* objects, claim families, the pure 5-check verify predicate
tenure-registrarThe axum registrar binary (SQLite WAL)
tenure-signerThe 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 — binds 127.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.sh runs 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.sh greps the built web/* 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 (no localStorage, 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

AppHolds keys?Role
Dashboard (web/dashboard)NoComposes 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

  1. 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 KybAttestation binding employer_pk to the entity, with the methods used and an expiry. This is what the verdict card later names.
  2. 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.
  3. Compose the onboarding bundle. The dashboard composes three unsigned drafts: an EmployerDescriptor, EpochOpen(1), and a Delegation (the allowed attestation types and a daily mint cap).
  4. 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.
  5. Activate. The signed bundle is posted to POST /onboard; the registrar activates the employer and returns operation receipts. The KybAttestation is published alongside the descriptor.

See the Registrar API for the endpoints.

Issuing attestations

Bulk (the common path)

  1. The dashboard composes or uploads the roster batch file.
  2. The Signer ingests the raw batch file itself, computes the entries_hash and 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.
  3. On approval the Signer signs a BatchManifest (tn-batch-v1) whose aggregates ride inside the signed payload.
  4. The dashboard posts POST /batch with 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 one family_id).
  5. The run_id makes the call idempotent — a replayed batch returns { status: "skipped" }.

Income changes & corrections

  • Income refresh. A new family is minted with supersedes_family pointing 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 StatusChange closes 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

  1. The employer invites the worker; the registrar issues a one-time claim token delivered by a work-email magic link.
  2. Opening the link redeems the token via POST /claim with a freshly generated per-employer public key. The keypair is generated silently in the wallet — no password ceremony.
  3. 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:

  1. Pick the ask. Choose which fact the verifier needs — e.g. “income ≥ $3,200/mo, currently employed.”
  2. 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.
  3. Set expiry (grants default-expire within 30 days) and choose the scopeview for a one-time verdict, monitor for ongoing change-notification.
  4. Sign. The wallet signs a ShareGrant with 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, never Verified.

It does not delete copies already viewed or downloaded, and the wallet says exactly that.

If you grant monitor scope, the verifier can be notified that something changedemployment_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.

  1. 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.
  2. The portal unseals the bundle and runs verify_bundle(bundle, presentation, trusted_attesters, now, freshness_window).
  3. For a live recency check it also pulls GET /public/:employer_id/checkpoint and /revocations and feeds them in.
  4. 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:

VerdictMeaning
VerifiedAll checks passed
RevokedAn attestation or its family is revoked/superseded
GrantExpiredThe share grant expired
ChainInvalidStructural failure — broken chain, mint outside delegation, tampered bytes (carries a reason)
EmployerUnverifiedKYB failed or the attester isn’t in your trust list (names the attester)
StaleHeadThe 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.