The contradiction today
Everything security-relevant in a Stack — permissions, grants, group membership, tokens, record.entityId, stack config — keys on entityId strings. But:
- The ID model says record IDs are unique within a stack only; cross-stack references need a
stackUrl to disambiguate. Entity IDs are record IDs of _entity records living in some stack, yet they're used as if they were global names. When Alice grants bob-entity-id access in her stack, nothing defines what makes that string globally meaningful, collision-free, or verifiable.
- In practice
entityId is an arbitrary string typed at init (LocalAdapter.initialize({ entityId: 'my-entity-id' }) in the README). The _entity record the spec says the config points to is never created by anything.
- Authentication bottoms out at "the server issued a bearer token for this string" — there is no way to verify that a requester is the entity a grant names.
Scope constraint (design intent): Stacks are for individuals and small groups with cohesive identity — not hundreds or thousands of principals in one stack. That scale changes the requirements: identity must be verifiable without a provider, but does not need global discovery infrastructure.
Design space
Once central providers are ruled out and domains are undesirable as a requirement (a domain is rented identity with a renewal-date failure mode), one primitive remains: cryptographic self-certification. An identity is a keypair; claims are signatures; anyone can verify without asking anyone. Every decentralized system that refused domains landed here (Scuttlebutt, Nostr, Hypercore) — and ATProto is a keypair system with a rotation directory bolted on.
DIDs are not an alternative to this — they're a standard envelope for it. The heavyweight reputation of DIDs comes from the resolution/document machinery of the fancier methods. One method, did:key, is nothing but a public key encoded as a string: zero infrastructure, zero resolution, zero registry.
Adopting the DID syntax means not having to pick a winner:
| Method |
What it is |
Role here |
did:key |
a public key, encoded |
the floor — mandatory to implement |
did:web |
a domain in DID clothing |
optional, for those who want domain identity |
did:plc |
ATProto's rotation directory |
optional, for the ATProto bridge (#15) |
All in the same field, all distinguishable by prefix, without the data model caring.
Proposed design
1. entityId becomes a DID; _entity records become local profiles (petnames)
Everywhere the system means "who" — Permission, GrantContent grantee, group membership associations, token store, record.entityId, _config.entityId — the value is a DID string.
The _entity record stays stack-local and becomes a profile card about a DID: content gains the required did field; name/handle are the stack owner's local labels for that identity. This is the petname pattern (Zooko's triangle: global + human-readable + decentralized — pick two; the escape is names local to the observer). The existing _entity type already is a petname record — it just lacks the stable key to anchor it. Two stacks holding different _entity cards with different display names for the same did:key:… is correct behavior: it's Alice's contact card for Bob.
type EntityContent = {
did: string; // the identity — e.g. "did:key:z6Mk…"
name: string; // local display name (petname)
handle?: string; // local short label
};
Cross-stack ID collisions become a non-issue mechanically — keys are globally unique by construction.
2. Authentication: challenge–response → bearer token
Token issuance stops being an out-of-band secret handoff. The server issues a nonce; the requester signs it with the key their DID encodes; the server verifies (for did:key, verification requires no lookup at all) and issues a session token. "Access granted to the holder of key X" is verifiable with no provider, no email loop, no OAuth.
StackTokenStore (#46) is unchanged structurally — it stores DIDs as its entityId — but gains a principled bootstrap. The wire spec gets a token-issuance endpoint sketch (nonce request / signed response); per-request HTTP signatures are a possible later alternative, out of scope here.
3. Owner bootstrap
initialize() generates (or accepts) a keypair, derives the owner DID, stores it as ownerEntityId, and creates the owner's _entity record — closing the gap where the config references an entity record that nothing creates. Key material handling (where the private key lives, encrypted backup) is an implementation/UX concern of apps and servers, not the storage layer; the spec should say only that the stack stores the public identity, never the private key.
4. Groups
"A group with cohesive identity" is anything that controls a key: a group gets its own keypair (held by admins), so it can be granted access, own a collaborative stack (stackUrl), and sign as itself. Membership associations list member DIDs. No new machinery.
The tax: key management (and why it's acceptable at this scale)
- Loss. With pure
did:key, identity is the key: lose it and you're a new identity. For a global social network that's severe; for individuals and small groups who actually know each other, it's a recoverable social event — "new key, it's me" over a trusted channel, contacts update their _entity cards. SSB and Nostr communities have operated this way for years. Mitigate with encrypted key-backup UX, not protocol machinery.
- Rotation & multi-device. Deferred (see below). For v1, multi-device works without rotation: the identity key bootstraps a session per device via challenge–response; devices hold revocable tokens, never the key.
Explicitly deferred: key rotation
Rotation is what did:plc's directory and KERI's event logs solve. When Haverstack wants it, there's a native answer worth sketching now so the design leaves room: the stack itself hosts the identity's key-event log — a signed chain of "key A hands off to key B" records. The stack is already the thing you own and take with you; making it the authority for your own key history fits the project's premise exactly, and verifiers need only the log, not a resolver. Designing that chain (record type, signature format, verification rules) is a separate future RFC. Nothing in this proposal blocks it: a rotated identity is a new DID documented by the log, or a method upgrade (did:key → stack-hosted method) for those who opt in.
Explicitly ruled out
- A bespoke identifier format —
did:key already is "just a key," with standard encoding and ecosystem adjacency.
- Requiring a domain.
- Anything requiring a blockchain or global directory — contradicts the premise, buys nothing at this scale.
Migration / impact
entityId is already an opaque string throughout the code, so nothing structural changes — what changes is what the string means, validation at boundaries, and the auth handshake:
- Spec: new "Identity" section;
EntityContent gains required did; permission/grant/group/config sections updated to say DID.
- Core: DID validation helpers (parse/validate
did:key, extract public key); initialize() bootstrap changes; owner _entity creation.
record-adapter-*: none beyond config content (identity is opaque to storage).
- Wire format: token-issuance (challenge–response) endpoint; discovery's
entityId becomes a DID.
- README quickstart: replace
entityId: 'my-entity-id' with generated-key flow.
Relation to #15: that RFC adds did?: string to EntityContent as optional metadata alongside ATProto support. This proposal inverts it: the DID is the identity, not an annotation on it. The ATProto mapping then becomes: entities whose DID method is plc/web are directly addressable in ATProto; did:key entities are Haverstack-native. ownerDid as a separate companion field (per #15) becomes unnecessary — ownerEntityId is a DID.
Open questions
- Signature suite floor: Ed25519 only (
did:key z6Mk…) to start? (Web Crypto's Ed25519 support is now broadly available; secp256k1 would need a dependency.)
- Should
handle remain in EntityContent at all, given it's a local label with no global meaning — or is that exactly what makes it fine?
- Anonymous/
null requester semantics are unchanged, but should a verified-but-ungranted DID be distinguishable from anonymous in server logs/errors? (Probably yes, trivially.)
- Does the
_config singleton's entityId → DID change warrant a stack format version marker for old files, or is best-effort detection (string starts with did:) enough?
Refs #15 (ATProto compatibility — reshaped by this), #45 (server topology — the auth handshake lives there), #46 (StackTokenStore stores DIDs), #48 (tokens/verification interplay).
The contradiction today
Everything security-relevant in a Stack — permissions, grants, group membership, tokens,
record.entityId, stack config — keys onentityIdstrings. But:stackUrlto disambiguate. Entity IDs are record IDs of_entityrecords living in some stack, yet they're used as if they were global names. When Alice grantsbob-entity-idaccess in her stack, nothing defines what makes that string globally meaningful, collision-free, or verifiable.entityIdis an arbitrary string typed at init (LocalAdapter.initialize({ entityId: 'my-entity-id' })in the README). The_entityrecord the spec says the config points to is never created by anything.Scope constraint (design intent): Stacks are for individuals and small groups with cohesive identity — not hundreds or thousands of principals in one stack. That scale changes the requirements: identity must be verifiable without a provider, but does not need global discovery infrastructure.
Design space
Once central providers are ruled out and domains are undesirable as a requirement (a domain is rented identity with a renewal-date failure mode), one primitive remains: cryptographic self-certification. An identity is a keypair; claims are signatures; anyone can verify without asking anyone. Every decentralized system that refused domains landed here (Scuttlebutt, Nostr, Hypercore) — and ATProto is a keypair system with a rotation directory bolted on.
DIDs are not an alternative to this — they're a standard envelope for it. The heavyweight reputation of DIDs comes from the resolution/document machinery of the fancier methods. One method,
did:key, is nothing but a public key encoded as a string: zero infrastructure, zero resolution, zero registry.Adopting the DID syntax means not having to pick a winner:
did:keydid:webdid:plcAll in the same field, all distinguishable by prefix, without the data model caring.
Proposed design
1.
entityIdbecomes a DID;_entityrecords become local profiles (petnames)Everywhere the system means "who" —
Permission,GrantContentgrantee, group membership associations, token store,record.entityId,_config.entityId— the value is a DID string.The
_entityrecord stays stack-local and becomes a profile card about a DID: content gains the requireddidfield;name/handleare the stack owner's local labels for that identity. This is the petname pattern (Zooko's triangle: global + human-readable + decentralized — pick two; the escape is names local to the observer). The existing_entitytype already is a petname record — it just lacks the stable key to anchor it. Two stacks holding different_entitycards with different display names for the samedid:key:…is correct behavior: it's Alice's contact card for Bob.Cross-stack ID collisions become a non-issue mechanically — keys are globally unique by construction.
2. Authentication: challenge–response → bearer token
Token issuance stops being an out-of-band secret handoff. The server issues a nonce; the requester signs it with the key their DID encodes; the server verifies (for
did:key, verification requires no lookup at all) and issues a session token. "Access granted to the holder of key X" is verifiable with no provider, no email loop, no OAuth.StackTokenStore(#46) is unchanged structurally — it stores DIDs as itsentityId— but gains a principled bootstrap. The wire spec gets a token-issuance endpoint sketch (nonce request / signed response); per-request HTTP signatures are a possible later alternative, out of scope here.3. Owner bootstrap
initialize()generates (or accepts) a keypair, derives the owner DID, stores it asownerEntityId, and creates the owner's_entityrecord — closing the gap where the config references an entity record that nothing creates. Key material handling (where the private key lives, encrypted backup) is an implementation/UX concern of apps and servers, not the storage layer; the spec should say only that the stack stores the public identity, never the private key.4. Groups
"A group with cohesive identity" is anything that controls a key: a group gets its own keypair (held by admins), so it can be granted access, own a collaborative stack (
stackUrl), and sign as itself. Membership associations list member DIDs. No new machinery.The tax: key management (and why it's acceptable at this scale)
did:key, identity is the key: lose it and you're a new identity. For a global social network that's severe; for individuals and small groups who actually know each other, it's a recoverable social event — "new key, it's me" over a trusted channel, contacts update their_entitycards. SSB and Nostr communities have operated this way for years. Mitigate with encrypted key-backup UX, not protocol machinery.Explicitly deferred: key rotation
Rotation is what
did:plc's directory and KERI's event logs solve. When Haverstack wants it, there's a native answer worth sketching now so the design leaves room: the stack itself hosts the identity's key-event log — a signed chain of "key A hands off to key B" records. The stack is already the thing you own and take with you; making it the authority for your own key history fits the project's premise exactly, and verifiers need only the log, not a resolver. Designing that chain (record type, signature format, verification rules) is a separate future RFC. Nothing in this proposal blocks it: a rotated identity is a new DID documented by the log, or a method upgrade (did:key→ stack-hosted method) for those who opt in.Explicitly ruled out
did:keyalready is "just a key," with standard encoding and ecosystem adjacency.Migration / impact
entityIdis already an opaque string throughout the code, so nothing structural changes — what changes is what the string means, validation at boundaries, and the auth handshake:EntityContentgains requireddid; permission/grant/group/config sections updated to say DID.did:key, extract public key);initialize()bootstrap changes; owner_entitycreation.record-adapter-*: none beyond config content (identity is opaque to storage).entityIdbecomes a DID.entityId: 'my-entity-id'with generated-key flow.Relation to #15: that RFC adds
did?: stringtoEntityContentas optional metadata alongside ATProto support. This proposal inverts it: the DID is the identity, not an annotation on it. The ATProto mapping then becomes: entities whose DID method isplc/webare directly addressable in ATProto;did:keyentities are Haverstack-native.ownerDidas a separate companion field (per #15) becomes unnecessary —ownerEntityIdis a DID.Open questions
did:keyz6Mk…) to start? (Web Crypto's Ed25519 support is now broadly available; secp256k1 would need a dependency.)handleremain inEntityContentat all, given it's a local label with no global meaning — or is that exactly what makes it fine?nullrequester semantics are unchanged, but should a verified-but-ungranted DID be distinguishable from anonymous in server logs/errors? (Probably yes, trivially.)_configsingleton'sentityId→ DID change warrant a stack format version marker for old files, or is best-effort detection (string starts withdid:) enough?Refs #15 (ATProto compatibility — reshaped by this), #45 (server topology — the auth handshake lives there), #46 (
StackTokenStorestores DIDs), #48 (tokens/verification interplay).