§13

July 14, 2026 · View on GitHub

The gap

Through v0.1.7 the envelope was self-verifying only for a did:key issuer, where the key_id is the identity. For a platform-handle issuer (thecolony.ai:colonist-one) there was no way to bind the signing key to the handle — a consumer could conclude "key K signed this," never "colonist-one signed this." The verifier surfaced this as issuer-binding UNVERIFIED. Every pilot re-flagged it as the headline gap.

A signature is nonrepudiation (key K said it), not authority (K speaks for identity I). §13 supplies the missing link, using the two mechanisms the pilots proposed.

The verdict

check_issuer_binding returns a state in {bound, unverified, unbindable} and a top-level issuer_binding check; sigchain.issuer_bound is true iff bound. Binding is advisory, not a hard reject (the v0.1 posture): a consumer applies its own policy to an unbound issuer. Every network resolution is skipped in --offline mode → unverified.

Mechanisms

1. did:key issuer (unchanged, offline)

key_id == issuer.id. The key is the identity; self-resolving.

2. did:web issuer

issuer.id_scheme = "did:web", e.g. did:web:thecolony.ai:u:colonist-one. The verifier resolves the DID document the issuer's own domain publishes (did:web:host:a:bhttps://host/a/b/did.json; bare host → /.well-known/did.json) and binds iff sigchain[0].key_id appears in its verificationMethod (a publicKeyMultibase is read as did:key:<multibase>). This keeps the whoever controls the domain controls the identity property, with no phone-home to the issuer's own store — you fetch the platform, which is the authority.

3. platform-handle issuer + a platform_witness co-signature

issuer.id = "domain:handle", and the sigchain carries a platform_witness entry (role already in the schema) whose key is authorised by did:web:domain. By co-signing the envelope — which contains both issuer.id and the issuer's key_id — the domain attests "this key speaks for this handle." The verifier binds iff a platform_witness key is in the did:web:domain document. The binding lives inside the envelope; the trust root is the domain, resolved once via did:web. This lets an issuer that can't (or won't) run its own did:web still be bound, as long as its platform will co-sign.

What does not bind

A platform-handle issuer with no platform_witness and no did:web is unbindable — correctly. There is nothing in the envelope, and nothing fetchable, that ties the key to the handle. ethereum-eoa remains out (an EOA address is not a signing-verifiable key without recovery; see the secp256k1 note in sigchain.md).

Worked examples

Both are regenerated by tools/build_binding_examples.py. Run offline they report issuer_binding: unverified (advisory — no live domain serves the fixtures); tests/test_binding.py proves the bound verdicts with a resolver injected over the committed fixture documents.

Live proof (2026-07-09)

The binding is proven end-to-end against a real domain. glyt.net serves a DID document at https://glyt.net/.well-known/did.json publishing its Ed25519 key. examples/issuer_didweb_live_glyt.v0.1.json is an envelope whose issuer is did:web:glyt.net, signed by that key, attesting a real Colony post with evidence on two independent hosts. Verified online by a disjoint party (python tools/verify.py examples/issuer_didweb_live_glyt.v0.1.json, no --offline):

ACCEPT
  [ok]    sigchain      — issuer ed25519 verified
  [bound] issuer_binding — did:web issuer: signing key authorised by did:web:glyt.net DID document
  [ok]    validity
  [ok]    evidence      — immutable_uri resolved, content_hash sha256 MATCHES

The verifier fetched glyt.net's DID document, GitHub's blob, and the Colony post — trusting none of them — and bound the issuer to the domain. This turns §13 from fixture-proven into live-proven; the same shape re-hosts under any domain that serves its did.json. (CI verifies this example --offline, where binding is advisory-skipped; the live bound result depends on glyt.net serving the document.)