Motivation
As Haverstack moves toward federation and multi-stack interop, ATProto (the protocol behind Bluesky) is the most architecturally aligned target. Both are record-centric, identity-scoped, and schema-namespaced. This issue proposes a set of additive, non-breaking changes to @haverstack/core that would make a future @haverstack/adapter-atproto straightforward to implement.
Proposed changes
1. Content addressing — add cid to StackRecord
ATProto's foundational property is content addressing: every record has a CID (Content Identifier) derived from its content. Without this, Haverstack can't participate in ATProto's signed commit log or verify record integrity across stacks.
// types.ts
export type StackRecord = {
id: RecordId;
cid?: string; // SHA-256 CID of the content blob, computed on write
// ...existing fields
};
The stack should compute and store cid on create and update. ATProto adapters can use this to build a proper commit chain.
2. Surface previousCid on update()
ATProto records are immutable at a given CID — updating means writing a new CID and recording the transition. The current update() return type should surface this:
async update(id: string, content: ...): Promise<StackRecord & { previousCid?: string }>
This is largely an adapter-level concern, but exposing it in the return type keeps the door open without requiring a future breaking change.
3. at:// URI support
ATProto identifies records by URI: at://<did>/<nsid>/<rkey>. Haverstack IDs are within-stack only (this is already noted in the README). Two additions:
A helper method on Stack:
getAtUri(record: StackRecord): string {
return `at://${this.ownerEntityId}/${record.typeId}/${record.id}`;
}
atUri on RelationshipAssociation for cross-stack references:
export type RelationshipAssociation = {
kind: 'relationship';
label: string;
recordId: RecordId;
stackUrl?: string; // already implied — make explicit
atUri?: string; // ATProto native reference form
};
4. DID support on EntityContent
ATProto identity is built on DIDs (did:plc:... or did:web:...). Handles are resolved to DIDs; DIDs are the stable identity. EntityContent should support this natively:
export type EntityContent = {
name: string;
handle?: string;
did?: string; // e.g. "did:plc:abc123" or "did:web:example.com"
};
Stack.ownerEntityId is currently a local RecordId. A companion ownerDid?: string on the Stack class (read from adapter config) would make ATProto stack identity unambiguous.
5. unlisted permission + tombstone on StackRecord
ATProto distinguishes tombstoned records (URI remains valid, content is gone) from deleted ones. It also has no direct equivalent of { access: 'public' } without a concept of "accessible by URI but not indexed."
export type Permission =
| { access: 'public' }
| { access: 'unlisted' } // accessible via URI, not surfaced in feeds/indexes
| { access: 'entity'; entityId: RecordId; read: boolean; write: boolean }
| { access: 'group'; groupId: RecordId; read: boolean; write: boolean };
export type StackRecord = {
// ...
tombstone?: boolean; // URI remains valid, content is redacted — distinct from deletedAt
};
6. lexiconId on StackType
ATProto Lexicons use reverse-DNS without a version suffix in the $type field (e.g. app.bsky.feed.post). Haverstack's com.example.myapp/note@1 format doesn't need to change, but storing an optional Lexicon ID alias lets an ATProto adapter map outbound records to the correct $type without apps having to change their internal naming:
// types.ts
export type StackType = {
// ...existing fields
lexiconId?: string; // e.g. "app.bsky.feed.post"
};
// stack.ts
export type DefineTypeOptions = {
migratesFrom?: TypeId;
lexiconId?: string;
};
Summary
| Change |
Breaking? |
cid on StackRecord |
No — optional field |
previousCid on update() return |
No — additive to return type |
getAtUri() + atUri on relationships |
No |
did on EntityContent |
No — optional field |
unlisted + tombstone |
No |
lexiconId on defineType / StackType |
No |
All changes are additive. They lay the groundwork for @haverstack/adapter-atproto without requiring any migration of existing stacks or changes to app code.
Motivation
As Haverstack moves toward federation and multi-stack interop, ATProto (the protocol behind Bluesky) is the most architecturally aligned target. Both are record-centric, identity-scoped, and schema-namespaced. This issue proposes a set of additive, non-breaking changes to
@haverstack/corethat would make a future@haverstack/adapter-atprotostraightforward to implement.Proposed changes
1. Content addressing — add
cidtoStackRecordATProto's foundational property is content addressing: every record has a CID (Content Identifier) derived from its content. Without this, Haverstack can't participate in ATProto's signed commit log or verify record integrity across stacks.
The stack should compute and store
cidoncreateandupdate. ATProto adapters can use this to build a proper commit chain.2. Surface
previousCidonupdate()ATProto records are immutable at a given CID — updating means writing a new CID and recording the transition. The current
update()return type should surface this:This is largely an adapter-level concern, but exposing it in the return type keeps the door open without requiring a future breaking change.
3.
at://URI supportATProto identifies records by URI:
at://<did>/<nsid>/<rkey>. Haverstack IDs are within-stack only (this is already noted in the README). Two additions:A helper method on
Stack:atUrionRelationshipAssociationfor cross-stack references:4. DID support on
EntityContentATProto identity is built on DIDs (
did:plc:...ordid:web:...). Handles are resolved to DIDs; DIDs are the stable identity.EntityContentshould support this natively:Stack.ownerEntityIdis currently a localRecordId. A companionownerDid?: stringon theStackclass (read from adapter config) would make ATProto stack identity unambiguous.5.
unlistedpermission +tombstoneonStackRecordATProto distinguishes tombstoned records (URI remains valid, content is gone) from deleted ones. It also has no direct equivalent of
{ access: 'public' }without a concept of "accessible by URI but not indexed."6.
lexiconIdonStackTypeATProto Lexicons use reverse-DNS without a version suffix in the
$typefield (e.g.app.bsky.feed.post). Haverstack'scom.example.myapp/note@1format doesn't need to change, but storing an optional Lexicon ID alias lets an ATProto adapter map outbound records to the correct$typewithout apps having to change their internal naming:Summary
cidonStackRecordpreviousCidonupdate()returngetAtUri()+atUrion relationshipsdidonEntityContentunlisted+tombstonelexiconIdondefineType/StackTypeAll changes are additive. They lay the groundwork for
@haverstack/adapter-atprotowithout requiring any migration of existing stacks or changes to app code.