sigelo — portable identity for AI agents

Draft: the wire may change; the keeper is experimental, stagenet only; unaudited. Wire sigelo/0, packages 0.1.0, nothing published to a registry yet. SPEC.md: "Nothing is stable until v1.0"; VERSIONING.md: sigelo/0 freezes at tag v0.2 after 30 days with no wire change. See versioning.

sigelo v0.1 — portable agent identity

Status: draft. Wire format sigelo/0. Nothing is stable until v1.0, which will ship sigelo/1. Scope: how an AI agent proves it is the same agent across worlds that share no infrastructure.

sigelo is a data format and a verification algorithm. There is no server, no registry, and no chain. Two parties who have never communicated can verify a sigelo bundle offline.


1. Design constraints

  1. No central authority. Any resolver or directory is convenience only. If every sigelo service disappeared, existing bundles must still verify.
  2. The agent's key is hot. It lives in a process that reads untrusted text all day. Assume it will be stolen. Design for recovery, not prevention.
  3. Verifiers decide. sigelo never adjudicates whether a claim is true or an issuer trustworthy. It proves who said what, and when.
  4. Adoption cost is the product. Integrating must take under ~100 lines and one dependency. Every feature is weighed against that.
  5. Payment is bound, never fused. Identity keys and spending keys are separate objects joined by a proof (§6).

1.1 Offline means self-contained

Everything a verifier needs is in the bundle: the subject's genesis, every rotation with its next genesis, and the genesis of every issuer (§8). A verifier with no prior knowledge of any party and no network verifies the same bundle to the same result as one with both.


2. Primitives

PurposeAlgorithm
SignaturesEd25519 (RFC 8032), pure, no prehash
HashingSHA-256
CanonicalizationJCS (RFC 8785), restricted per §3
Binary encodingmultibase z (base58btc)
Public keysmulticodec 0xed01 + 32 raw bytes, then multibase
Signatures on the wirethe 64 raw Ed25519 bytes, multibase. No multicodec prefix
DIDsdid:sigelo: + multibase of the raw 32-byte SHA-256 digest. No multicodec prefix
Noncesmultibase of raw bytes: z + base58btc, like keys (zJ6jjrKda7cWz17gvxJ4Fta), never z + hex; 16 random bytes for a genesis. A verifier MUST reject a genesis, binding or invoice nonce that is not z followed by 1 to 63 base58btc digits (at most 64 characters): <slot>: nonce is not z + base58btc (at most 64 characters), fatal in a genesis, per item in a binding (vectors fatal_genesis_nonce_z_hex, binding_nonce_not_multibase, binding_nonce_65_characters, binding_nonce_64_characters). The byte length is not checked. The §5.2 challenge nonce is exempt: opaque, the world's choice

Keys are points (fatal_genesis_key_all_zero, fatal_issuer_key_identity, fatal_rotation_recovery_key_y_ge_p, binding_ed25519_test_addr_all_zero_unproven). Every slot that holds a public key — a genesis key (the bundle's, a next_genesis's, an issuer's), a rotation's recovery_key, an ed25519-test binding's addr — MUST hold the canonical encoding (y < p; not x = 0 with the sign bit set) of a curve point that is not of small order: exactly the keys the §2 verification rule could ever accept a signature under. All-zero, the identity and the other small-order points, non-canonical spellings and non-points are malformed, key: not a valid Ed25519 point (non-canonical, off the curve or of small order), at the slot's severity: fatal for a genesis or rotation, per item for a binding (an unproven ed25519-test binding was accepted with an addr nothing could ever sign for). A point with a torsion component but not of small order is a valid key, as it is to the signature check.

Length before decoding (fatal_genesis_key_too_long, attestation_sig_too_long). Base58 decoding is quadratic in the length, so a verifier MUST bound a multibase value before decoding it: a public key (key, recovery_key, an ed25519-test addr) is at most 64 characters and a signature at most 100, z included (34 bytes spell at most 47 base58 digits, 64 bytes at most 88; the rest is margin). The alphabet is checked first (a linear scan, so the count is of ASCII characters): a non-digit is reported as such, then a value over its bound is rejected, multibase: longer than 64 characters, at the severity of the slot it sits in — fatal in a genesis or rotation, per item in an attestation or binding.

One suite. No negotiation, no agility, no downgrade surface.


3. Signing input and canonical form

signing_input = "sigelo\n" || JCS(body)

The prefix is exactly seven bytes, 73 69 67 65 6c 6f 0a (sigelo then LF), followed by the JCS serialization of the body, UTF-8, no trailing newline. The prefix separates sigelo signatures from any other protocol that signs raw JSON with the same key. Signatures are always detached — never inside the object they sign.

Type binding. Every signed body carries typ. Verifiers MUST check that typ matches the slot the object is presented in (an attestation presented as a binding is rejected even with a valid signature). typ inside the signed bytes is what prevents cross-type confusion.

No non-integer numbers. Signed objects MUST NOT contain floats, exponents, or integers outside ±2^53−1. Implementations MUST reject any number whose value is not an integer in that range. JCS number canonicalization is the largest source of cross-language interop failure; forbidding floats removes the class. Use strings ("0.15") or scaled integers. Integers are serialized as plain decimal digits with no fraction, exponent, or leading zeros; -0 is 0. Implementations that see the raw text MAY additionally reject non-canonical integer spellings such as 1.0 or 1e2; implementations working from parsed values cannot, and are not required to.

JCS, exactly. Three points of RFC 8785 that a sorted JSON.stringify or json.dumps(sort_keys=True) does not give you, and that the vectors exercise:

Duplicate keys. A signed object with the same key twice at any level has no canonical form. Parsers that keep the last value would silently sign different bytes than parsers that keep the first. Implementations MUST reject it. This means parsing with a duplicate-detecting hook, not the language default. A verifier reading bundle text treats a duplicate key or __proto__ (§3.1) anywhere in it as fatal, since the text has no single reading, but parses a forbidden number and leaves it to §9 step 2, so it sinks only the item that carries it.

Invalid UTF-8 (raw_invalid_utf8). JSON text is UTF-8 (RFC 8259 §8.1). A document that is not valid UTF-8 is rejected whole; an implementation MUST decode fatally. A lossy decode turns the bad bytes into U+FFFD and sinks only the item whose signature then fails, while a byte-level parser rejects the document: two verifiers, two answers. A leading byte-order mark is not JSON whitespace and is rejected the same way. This is not a vector, because test-vectors.json is itself UTF-8 text; a \ud800 escape is valid UTF-8 and stays per item (§3.1).

Nesting depth (raw_fatal_depth_513_in_claims, raw_depth_512_in_claims). A parser MUST reject a document whose arrays and objects, combined, nest deeper than 512 levels (the outermost container is level 1), as a parse error: the whole document is rejected, wherever the deep value sits — in claims too, where a malformed value would sink only its item — because the limit is on the text, like a duplicate key. The error is reported at the bracket that opens level 513, an empty container included. A canonicalizer MUST refuse a value nested deeper than 512 (no conforming parser returns one; an object built in memory can be one, and then it is malformed like a float, per item where it sits). Rationale: a recursive parser has a finite stack, and in Go exhausting it is a fatal runtime error that recover() cannot catch — a 1.6 MB document killed every process embedding the reference verifier, while an iterative parser answered. A bounded verifier must bound depth, and two verifiers that bound it at different places give two answers for the same bytes. 512 leaves a bundle's claims 507 levels.

Envelopes. Signed objects travel as:

{ "body": { … }, "sig": "z…" }

Bindings carry sig_id and optionally sig_addr instead of sig. Rotations additionally carry next_genesis. Nothing in the envelope outside body is signed, but the rules above apply to the whole envelope: a float anywhere in it makes the item malformed (§9 step 2), and so does a key outside its members (§3.1).

3.1 Fields, normatively

typrequiredoptional
genesisv typ key recovery created nonce—
attestationv typ iss sub iat exp ctx admission claimsadmission_by admission_cost
bindingv typ id method addr iat exp nonce—
rotationv typ id next iat reasonrecovery_key (required iff reason is recovery, forbidden otherwise)
challengev typ did ctx nonce—
invoicev typ did method addr iat exp nonceamount (string, atomic units) memo
bundlev typ genesis rotations bindings attestations issuers—

No other top-level keys. A body carrying a key not in its row is malformed. claims is the one free-form value and may hold anything JCS can serialize. Envelopes are part of the same rule, both ways: an envelope is exactly its defined members — a rotation envelope body, sig and next_genesis; a binding envelope body, sig_id and optionally sig_addr; an attestation envelope body and sig — with sig and sig_id strings. An envelope missing one, or carrying any other key, is malformed in the same class as its body (§9 step 2): fatal for a rotation (rotation: unknown envelope field "…"), the item discarded and counted for an attestation or binding (vectors attestation_envelope_extra_key_int, attestation_good_and_envelope_extra_keys, binding_envelope_extra_key, fatal_rotation_envelope_extra_key; binding_envelope_sig_id_only_unproven is the optional member absent). Nothing outside body is signed, so an extra member is data no signature authenticates, exactly like an unknown body field, and a verifier that ignored it would accept what a stricter one discards. A holder that receives an item with extra members (a world whose router adds a clock, say) keeps only the defined ones before bundling it; the signature does not cover the rest, so dropping them changes nothing that verifies. The bundle itself is the bundle row above; a §5.2 challenge answer or §6.3 invoice exchanged as {body, sig} follows the same rule. v is "sigelo/0"; iat, exp are Unix seconds; created is RFC 3339 UTC in exactly the form §4 fixes.

Field types. iat and exp are JSON integers in [0, 2^53−1], and exp > iat wherever both appear. Every other field in the table is a string, except recovery (string or null, §4), claims (free-form) and the bundle's own genesis and four arrays. A string anywhere in a signed object MUST be a sequence of Unicode scalar values: a lone surrogate (legal as a JSON \u escape) is malformed, since RFC 8785 requires I-JSON and an encoder that replaced it with U+FFFD would give two bodies one signing input. Noncharacters (raw_fatal_noncharacter_in_claims_value, raw_fatal_noncharacter_astral_in_claims_key): RFC 7493 §2.1, which RFC 8785 cites, also forbids them, so a parser MUST reject a string or key holding U+FDD0–U+FDEF or U+xFFFE / U+xFFFF in any plane, raw or escaped (an escaped surrogate pair counts as the character it spells), as a parse error — fatal to the document, reported at the string's opening quote: noncharacter U+FFFF in string at offset N. A canonicalizer MUST refuse one in a value built in memory (noncharacter U+FFFF in string), so a conforming library never signs a body no conforming parser will read. The object key __proto__ is malformed at any depth: a JavaScript parser that assigns it replaces the object's prototype instead of adding a key. A type failure is malformation like any other (§9 step 2), never an implementation error.


4. Genesis and identity

An identity is its genesis document. The DID is a hash of it, so nothing inside can be revised afterwards — including the recovery commitment.

{
  "v": "sigelo/0",
  "typ": "genesis",
  "key": "z6Mk…",
  "recovery": "sha256:2c5a92ed…",
  "created": "2026-09-07T00:00:00Z",
  "nonce": "z…"
}
FieldMeaning
keymultibase Ed25519 public key — the identity signing key
recoverysha256: + hex SHA-256 of the raw 32-byte recovery public key, or null. Exactly sha256: and 64 lowercase hex digits: a verifier MUST reject anything else (genesis: recovery is neither null nor sha256: + 64 lowercase hex), fatally, as in every genesis slot (vectors fatal_genesis_recovery_uppercase_hex, fatal_genesis_recovery_prefix_only). Uppercase hex is the same digest spelled so that no recovery key ever matches it: recovery silently off
createdRFC 3339 UTC, Z, second precision. Self-asserted and unverifiable; informational only. Its form is checked: see below
nonce16 bytes, multibase. Distinguishes genesis documents that share a key. Random by default; a world with a stable key MAY derive it from the key (e.g. the first 16 bytes of SHA-256 of the raw public key) so its genesis is reproducible without storage; then created MUST be pinned too, since every field is hashed. Uniqueness is what matters, not unpredictability: nothing secret is derived from it
DID = "did:sigelo:" + multibase_z( SHA-256( JCS(genesis) ) )

created, exactly (fatal_genesis_created_leap_second and its neighbours). A verifier MUST reject a genesis whose created is not exactly YYYY-MM-DDTHH:MM:SSZ: four-digit year, a real Gregorian date (30 April, 29 February only in a leap year), hours 00–23, minutes and seconds 00–59 (no leap second: nothing offline can check one), no fraction, no offset, uppercase T and Z. The reason is genesis: created is not RFC 3339 UTC (YYYY-MM-DDTHH:MM:SSZ). Every genesis slot defines an identity (§9 step 2), so this is fatal wherever a genesis sits: the bundle's own, a next_genesis, an issuer. The value is hashed into the DID and read by nobody, but a field no one checks is a field two implementations parse differently the day one of them starts to read it.

Verifiers MUST recompute the DID from the presented genesis and reject on mismatch. First step, every time, not optional. Verifiers MUST compare full DIDs — never prefixes — and UIs MUST NOT truncate them; base58 vanity grinding against a truncated display is cheap.

recovery: null is legal and means theft of the identity key is terminal. Libraries MUST warn at generation. Worlds MAY refuse attestations to such identities.

Recovery key handling. Generated offline, never loaded into an agent runtime. Only its hash is public until it is used (§7). An attacker with full agent compromise learns a hash.


5. Attestations

One world's signed statement about one identity.

{
  "v": "sigelo/0",
  "typ": "attestation",
  "iss": "did:sigelo:z…",
  "sub": "did:sigelo:z…",
  "iat": 1757203200,
  "exp": 1764979200,
  "ctx": "1f916.ai",
  "admission": "invite",
  "admission_by": "did:sigelo:z…",
  "claims": { "joined": "2026-04-02", "posts": 412, "standing": "citizen" }
}

claims is world-defined and opaque to sigelo. Verifiers interpret it according to how much they trust iss. admission_by is informational; verifiers are not required to resolve it. admission_by and admission_cost (§5.1) are optional; every other field shown is required.

The signature is verified against the key in the genesis whose DID is iss. A world that has itself rotated issues new attestations under its new DID; old attestations still verify against the old genesis. Verifiers do not walk issuer chains in v0.1. So, by design, a key a world rotated away from still makes attestations that verify under the old DID, whatever their iat, until each one's exp: nothing in a bundle says the old DID was retired, and a verifier that knows it was (from the world, out of band) stops trusting that DID itself (THREAT-MODEL §3.2).

exp is mandatory. There is no revocation list — freshness comes from reissuance. Recommended lifetime 30–90 days. To signal lost standing, reissue with claims changed.

5.1 Admission taxonomy

admission records what it cost the subject to enter. It is what makes a population count carry information.

ValueMeaning
openno barrier
captchaautomated challenge only
invitevouched by an existing member; admission_by names them
paymentmoney was paid; admission_cost MAY carry a string amount
humana human identity was verified by the issuer
stakea slashable bond is held

admission MUST be one of these six values; a verifier discards an attestation carrying any other as malformed (§9 step 2). New values arrive with a new wire version, not silently.

Issuers MUST NOT overstate. The only remedy for a lying issuer is that verifiers stop trusting it, which is the correct remedy. A verifier can then read a population of 2,000 as, say, 1,847 open and 153 invite — a signal, where a raw count was not.

5.2 Proof of control

Before a world attests to a DID it needs the agent to demonstrate it holds the key. Every world would otherwise invent its own handshake, so this one is fixed:

{ "v": "sigelo/0", "typ": "challenge", "did": "did:sigelo:z…", "ctx": "1f916.ai", "nonce": "…" }

These five fields and no others: an agent library MUST refuse a challenge carrying any additional key, so nothing can be smuggled into the signed bytes. The world chooses nonce (opaque to the agent, bound by the world to the requesting session and to a short lifetime) and ctx; the agent fills did with its current DID and signs the §3 signing input with the identity key; the world recomputes the DID from the presented genesis, compares full strings, and verifies the signature against genesis.key. A challenge never appears in a bundle and no bundle slot accepts typ: "challenge", so a challenge signature cannot be replayed into any other slot. Agent libraries SHOULD refuse to sign a challenge whose did is not their own. Vector challenge.


6. Payment bindings

Proves that an identity and a payment address share an operator. Cross-signed: both keys sign the identical signing input.

{
  "v": "sigelo/0",
  "typ": "binding",
  "id": "did:sigelo:z…",
  "method": "monero",
  "addr": "8Bx…",
  "iat": 1757203200,
  "exp": 1764979200,
  "nonce": "z…"
}

Envelope carries sig_id (identity key) and sig_addr (payment key, per-method format).

One-sided signatures are insufficient:

6.1 Proof status

Every binding the verifier returns carries exactly one of three statuses:

proofMeaning
provensig_id valid and sig_addr present and valid for method
unprovensig_id valid, sig_addr absent — the identity claims the address
unsupportedsig_id valid, sig_addr present, verifier has no routine for method

Where a payment method has no practical message-signing path, sig_addr MAY be omitted. The binding is then an unproven claim, whatever the method; unsupported applies only when a sig_addr is present. Verifiers MUST report the status in the return type, not as a boolean, and MUST NOT send funds to any address whose status is not proven. Keep exp short on unproven bindings.

A binding whose sig_id fails, or whose sig_addr is present but invalid for a method the verifier does support, is discarded, not downgraded. A bad proof is not the same thing as no proof.

6.1a Method ed25519-test

addr is a multibase Ed25519 public key (§2 encoding) and sig_addr is an ordinary §3 signature by that key. The method exists so the cross-signing rule can be exercised in the vectors without a wallet. Conformant implementations MUST support it. It carries no payment semantics and worlds SHOULD NOT accept it as a real binding. Its counterpart opaque-test is a method no implementation supports; it exists to exercise the unsupported status.

6.2 Monero

method: "monero". addr is a standard (base) address varint(prefix) ‖ B ‖ A ‖ checksum (prefix 18 mainnet, 24 stagenet, 53 testnet) or a subaddress varint(prefix) ‖ D ‖ C ‖ checksum (prefix 42 mainnet, 36 stagenet, 63 testnet), in Monero base58. Integrated addresses are not accepted as addr: the hash below does not cover the prefix or the payment id, so the base address's signature verifies for every integrated spelling of it and a payment id nobody signed for would ride along. sig_addr is a Monero message signature over the address's own two keys — (B, A) for a standard address, (D, C) for a subaddress:

h    = Keccak-256("MoneroMessageSignature\0" ‖ S ‖ V ‖ mode ‖ varint(len) ‖ signing_input)
sig  = c ‖ r,   c = H_s(h ‖ P ‖ kG),  r = k − c·x        (P, x) = (S, s) spend mode 0, (V, v) view mode 1
wire = "SigV2" ‖ monero_base58(sig)

standard:   (S, s) = (B, b)                  (V, v) = (A, a)
subaddress: (S, s) = (D, b + m)              (V, v) = (C, a·(b + m))
            m = H_s("SubAddr\0" ‖ a ‖ le32(major) ‖ le32(minor)),  D = B + mG,  C = a·D

where signing_input is §3's bytes, H_s(x) = sc_reduce32(Keccak-256(x)) with original Keccak padding, and verification recomputes R' = c·P + r·G and checks H_s(h ‖ P ‖ R') = c (monero src/wallet/wallet2.cpp get_message_hash, sign; src/crypto/crypto.cpp check_signature). Verifiers MUST accept either mode and SHOULD report which one. Legacy SigV1 is not accepted. Both keys in addr MUST decode as Monero's check_key decodes them (ge_frombytes_vartime: y < p, and not x = 0 with the sign bit set), or the binding is not proven, whichever mode signed.

(The subaddress row is wallet2::sign for a non-zero index.) A binding whose SigV2 verifies against its addr's own keys, in either mode, is proven; one whose signature was made with another address's keys — a subaddress signed with the base (B, A), say — is discarded.

View mode is the intended mode for a standard address. A view-only wallet holding (a, B) produces it (through monero-wallet-rpc sign with signature_type: "view" at index (0,0)), so an agent binds its wallet without ever holding a spend key. The signature proves "I can see this wallet", which is exactly what a payer needs the receiver to be able to do. For a subaddress, view mode does not imply a view-only signer: both of its secrets are derived from b, so a view-only wallet cannot produce either mode, and a subaddress binding is made by whoever holds the spend key (a keeper, MONERO.md §3).

The hash does not cover the network prefix. One key pair's mainnet, stagenet and testnet addresses verify the same signature. A verifier MUST check that addr decodes to the network it expects before treating the binding as proven.

6.3 Invoices

A binding names a wallet; an invoice names where to pay this time. Signed by the identity key, exchanged bilaterally, never in a bundle:

{ "v": "sigelo/0", "typ": "invoice", "did": "did:sigelo:z…", "method": "monero",
  "addr": "7…", "iat": 1757203200, "exp": 1757289600, "nonce": "z…", "amount": "150000000000" }

addr is a receive address of the bound wallet, for Monero a fresh subaddress per counterparty per invoice. The payer checks: the signature against the identity key of did, iat ≤ now < exp, and that did has a proven binding for the same method in a bundle it has verified. sigelo cannot prove a subaddress belongs to the bound wallet (that needs the view key); the identity's signature is the claim, the binding is its anchor, and a payment to an address the identity did not sign for is the payer's own mistake. amount is a string of atomic units or absent; memo is free text and, like claims, untrusted data.

Selective disclosure. Monero has no per-subaddress view key: one private view key covers every subaddress of a wallet, and disclosing it is retroactive and irrevocable. The primitives are per-payment proofs (get_tx_proof, get_reserve_proof) and one wallet per relationship whose view key is meant to be shared, all derived from one root seed. See MONERO.md. Libraries MUST NOT automate view-key disclosure; provide the primitive, make the caller invoke it deliberately. Bindings, invoices and proofs are what get automated.


7. Rotation and recovery

{
  "v": "sigelo/0",
  "typ": "rotation",
  "id":   "did:sigelo:<current>",
  "next": "did:sigelo:<new>",
  "iat": 1760227200,
  "reason": "voluntary",
  "recovery_key": "z6Mk…"
}

Envelope carries next_genesis; verifiers MUST check hash(next_genesis) == next. next MUST differ from id; a self-rotation is structurally invalid.

voluntary — signed by the current identity key. recovery_key absent. Constraint: next_genesis.recovery MUST equal the current recovery commitment. A voluntary rotation that changes the commitment is not a candidate (§7.4): it is never followed, and its presence does not by itself reject the chain. This is what stops an attacker with a stolen key from installing their own recovery key.

recovery — signed by the recovery key. recovery_key present; SHA-256(raw key) MUST equal the current recovery commitment (§7.2). next_genesis MAY carry a new commitment, or null, which retires recovery permanently: no later recovery rotation can validate.

7.1 Precedence

At any node, a valid recovery rotation supersedes any voluntary rotation, regardless of iat.

An attacker with a stolen key produces a perfectly valid voluntary rotation. The operator with the offline recovery key overrides it — even if the attacker's rotation is newer, even if it happened months earlier. Never reorder by timestamp. Vector rotation_recovery deliberately carries an earlier iat than both hostile rotations.

Two hostile vectors exist because two rules defend this node. rotation_hostile also changes the recovery commitment and is rejected by §7 before precedence is ever consulted. rotation_hostile_carried carries the commitment forward and is a fully valid voluntary rotation; only this rule defeats it. An implementation that skips precedence passes the first and fails the second (vector chain_precedence_only).

7.2 Where recovery authority lives

The governing commitment is the one in the most recent recovery-signed genesis in the chain, or the original genesis if there has been no recovery. Voluntary rotations carry it forward unchanged (enforced above). Only a recovery rotation can change it — so the operator can retire a recovery key, and an attacker never can.

7.3 Chain rules

Each DID in the chain is rotated from at most once. At a node:

  1. Collect rotations whose id is this node.
  2. Among them, the valid recovery rotations (signature, commitment, next hash all check; §7.4 lists what disqualifies one): if any, take the one with the latest iat — the operator controls all of them. If two share that latest iat → REJECT the chain; that is operator error and the fix is to reissue.
  3. Otherwise, the valid voluntary rotations (signature, next hash, commitment unchanged); invalid ones are not counted: exactly one → follow it. More than one → REJECT the chain. A fork under a single key is a compromise signal; the remedy is a recovery rotation, not verifier guesswork. Two entries are two candidates even when byte-identical: a bundle MUST NOT carry the same rotation twice, and a verifier does not deduplicate (vector negative.parity.fatal_duplicate_rotation_is_fork).
  4. None → chain ends here.
  5. Before following the chosen rotation: if next is already in the chain → REJECT the chain. A rotation back to an earlier DID is a cycle. Only a key holder can produce one, it has no legitimate meaning, and a verifier that follows it never terminates. Fail closed rather than loop (vector negative.cycle).

The chain is walked from the original genesis until step 4 ends it. Because every step consumes one rotation whose id is the current node and every next is new, the walk terminates in at most len(rotations) steps. Rotations that were valid but not chosen (a voluntary rotation superseded by a recovery) are simply not followed; the advice below on discounting attestations near a recovery is how a verifier accounts for them.

7.4 Two outcomes, two words

A rotation that fails a check is either not a candidate (ignored at its node; the walk continues as if it were absent) or it REJECTs the chain (fatal; the bundle does not verify). Nothing in between.

FailureOutcome
signature does not verifynot a candidate
hash(next_genesis) ≠ nextnot a candidate
voluntary, next_genesis.recovery ≠ current commitmentnot a candidate
recovery, SHA-256(recovery_key) ≠ current commitment (wrong or stale key)not a candidate
recovery while the current commitment is nullnot a candidate
body or next_genesis malformed, sig or next_genesis missing (§3.1)REJECT (structure, §9 step 2)
two valid voluntary rotations at one nodeREJECT (fork)
two valid recovery rotations sharing the latest iatREJECT (tie)
chosen next already in the chainREJECT (cycle)

A "not a candidate" rotation may still be a thief's artifact; that is what the advice on discounting is for. Vectors: negative.rotation_bad_sig, negative.recovery_key_mismatch, negative.stale_recovery_key and negative.voluntary_changes_recovery are all "not a candidate" cases and each states the chain that results.

Reputation follows the chain: attestations to any prior DID apply to the current one. After a recovery, the prior key was compromised for an unknown window. Verifiers SHOULD discount attestations to the compromised DID with iat near the recovery, and SHOULD treat an attestation issued to a DID after it was rotated away from as suspect. sigelo cannot determine when compromise began; only the issuing world can.

iat everywhere is signer-asserted. Time-based logic is advisory unless the signer is trusted.


8. Bundles

{
  "v": "sigelo/0",
  "typ": "bundle",
  "genesis": { … },
  "rotations": [ { "body": …, "sig": …, "next_genesis": … } ],
  "bindings":  [ { "body": …, "sig_id": …, "sig_addr": … } ],
  "attestations": [ { "body": …, "sig": … } ],
  "issuers": [ { …genesis… }, … ]
}

genesis is the original. Bundles are unsigned; every element carries its own signature. All four arrays are required and MAY be empty.

issuers carries the genesis documents of the worlds whose attestations appear in the bundle. It is what makes an attestation from a world the verifier has never heard of verifiable offline (§1.1). The array holds bare genesis documents, not a DID-keyed map: the verifier derives each DID by hashing, so there is no key/value pair that could disagree. A verifier MAY also know issuer genesis documents from elsewhere (its own, or a locally pinned set); the bundle's copies never override those. An attestation whose iss matches no presented and no known genesis is discarded in §9 step 4, not fatal to the bundle.


9. Verification algorithm

In this order. Fail closed. Every input ends in a result or in a rejection that names its check; running out of stack or arguments on a large but legal bundle is neither, and a verifier MUST NOT let one decide the outcome (§3 bounds nesting for the same reason).

Input: a bundle and now (Unix seconds). The verifier takes now as a parameter rather than reading a clock, so results are reproducible and testable.

  1. Genesis. Check bundle.genesis structurally and compute its DID. This is the root of the chain; nothing else in the bundle may name the identity except by this hash. The algorithm takes no expected DID: a caller that has one (a login claim) compares it, in full, against chain[0] or did of the result, and treats a mismatch as a failed claim. Vector negative.genesis_tampered is that comparison.
  2. Structure. A body is malformed if it has a non-integer number, a duplicate key, an unknown v, a missing required field, a top-level key outside its §3.1 row (or an envelope key outside its members, §3.1), a typ not matching its slot, or a field value outside its definition (an admission not in §5.1, a rotation whose next equals its id). Malformation in anything that defines the identity is fatal to the bundle: the bundle's own shape, genesis, every rotation body and next_genesis, every entry in issuers. Malformation anywhere in an individual attestation or binding (body or envelope, a non-integer number or lone surrogate included) discards that item and counts it in rejected, exactly as a bad signature would; the other items still verify. The presenter chose to include it, but an issuer wrote it; one world's bug must not sink its members' bundles.
  3. Chain. Apply §7.3 from the original genesis. Output: current DID, ordered chain of DIDs, governing recovery commitment. Reject on fork, on cycle, or any structural failure.
  4. Issuers. Hash each document in bundle.issuers to its DID. Add to the set of known issuer genesis documents; a locally known genesis for the same DID is kept in preference to the presented one (they are identical if both are honest).
  5. Attestations. Each: resolve iss to a known genesis (step 4), verify signature against its key, check iat ≤ now < exp, check sub ∈ chain. Discard failures individually — one bad attestation does not sink a bundle.
  6. Bindings. Verify sig_id against the identity key of the DID in id (must be ∈ chain). Check iat ≤ now < exp. Verify sig_addr if present, per method. Tag proof status per §6.1. Discard failures individually.
  7. Return the result below. No scores. No ranking. Weighting is the caller's job.

9.1 Result

{
  "did": "did:sigelo:<current>",
  "chain": [ "did:sigelo:<original>", …, "did:sigelo:<current>" ],
  "recovery": "sha256:…",
  "attestations": { "did:sigelo:<iss>": [ { …attestation body… }, … ] },
  "bindings": [ { "body": { …binding body… }, "proof": "proven" } ],
  "rejected": { "attestations": 2, "bindings": 0 }
}

attestations holds accepted attestation bodies verbatim, grouped by iss, in bundle order within each issuer. The same attestation presented twice is accepted twice and listed twice: a verifier does not deduplicate here any more than it does rotations (§7.3) — every copy verifies, and it reports what was presented — so a caller that counts attestations counts distinct ones. Key order of that object is informative only; conformance is compared by value, and an implementation whose objects are unordered is conformant. bindings holds accepted bodies with their proof status, in bundle order. recovery is the governing commitment, null if the identity has none. rejected counts what steps 2, 5 and 6 discarded. Implementations MAY attach per-item reasons alongside; the fields above are the conformance surface (vector bundle, compared as JSON).


10. Test vectors

test-vectors.json: real Ed25519 signatures from documented seeds. Positive groups include a four-node chain with two hostile rotations defeated by an earlier recovery, a recovery that changes the commitment followed by a second recovery under the new key, an attestation whose claims exercise the JCS rules of §3, and a full bundle with its expected §9.1 result at a fixed now. Negative cases include missing domain prefix, typ mismatch, stale recovery key, voluntary rotation changing the commitment, a fork, a cycle, a duplicate key, an integer outside ±2^53−1, an unknown top-level field, and a rotation with a bad signature. invoice and challenge are the two signed objects that never enter a bundle. A second bundle, bundle_minimal, is the smallest thing that verifies: one genesis with recovery: null, four empty arrays.

Conformant = every positive vector reproduced byte-for-byte, every vector carrying a bundle and expect reproduced as a §9.1 result compared by value, every negative rejected for the stated reason. The rotation_recovery versus rotation_hostile_carried pair is the one that matters.


11. Non-goals for v0.1

Trust scoring · revocation lists · encryption · discovery · personal-data handling in claims (see THREAT-MODEL §6) · algorithm agility · plugin systems.