Living document. Captures design decisions made so far.
A Stack is a structured, portable personal or organizational data store. It provides a unified API for reading and writing Records regardless of the underlying storage backend. Apps integrate a single library and don't need to know or care how data is stored.
The spec is split into focused documents:
| Document | Covers |
|---|---|
| Data model | Records, IDs, associations, types, schemas, migrations, queries |
| Identity | DIDs, entities, apps, groups, authentication, key rotation |
| Access control | Record-level permissions, type-level grants, ScopedStack enforcement |
| App installs | Manifests, the _install record, and migration by an installed app |
| Unlisted records | Withholding a Record from enumeration, orthogonal to who may read it |
| Refusals & disclosure | Which refusal a Record answers with, and what a refusal is allowed to reveal |
| Versioning & deletion | Version history, restore, optimistic concurrency, soft delete/purge |
| Change journal | The durable log of what each change moved, and the deltas nothing else keeps |
| Attachments | Content-addressed binary storage, metadata records, garbage collection |
| Change events | Subscribing to record changes, event shape, permission scoping, delivery |
| Adapters | Adapter contract, backends, capabilities, concurrency & storage ownership |
| Wire format | The HTTP API stack servers implement and adapter-api consumes |
| Change feed | The wire encoding of the change events above — GET /changes over SSE |
A Stack is opened with the async factory Stack.open(adapter, opts), which reads identity (and, optionally, timezone) from the adapter.
// First run — generate an identity keypair and create a new database.
// `did` is a "did:key:z6Mk..." string — that's the owner entityId.
// Persist `privateKey` yourself (OS keychain, encrypted file, ...); the
// stack never stores it. See Identity.
const { did, privateKey } = await generateDidKeypair();
const adapter = await LocalAdapter.open({
path: './my-stack.db',
create: 'exclusive', // create it; fail if it already exists
ownerEntityId: did, // required whenever open() may create — owner entity ID (a DID)
timezone: 'America/New_York', // optional — IANA timezone string, passthrough metadata
});
// Subsequent runs — open an existing database (`create` defaults to 'never')
const adapter = await LocalAdapter.open({ path: './my-stack.db' });
// Always the same — reads identity and timezone from the adapter.
// ownerProfile creates the owner's own _entity profile record on first
// run; a no-op on every later call once that record exists.
const stack = await Stack.open(adapter, { ownerProfile: { name: 'Jane Smith' } });
stack.ownerEntityId; // from adapter.ownerEntityId
stack.timezone; // from adapter.timezone — string | undefinedStack.open() throws InvalidAdapterError when the adapter has no ownerEntityId. Like UseAfterCloseError, it sits outside the StackError taxonomy (see Wire format § The taxonomy root): it reports a local setup mistake, not something a request can run into.
create names what open() does when the store is missing or present: 'never' (the default) opens an existing store and fails if it is missing; 'ifMissing' opens it if present and creates it if not; 'exclusive' creates it and fails if it is present. Making the choice explicit prevents silent config divergence. 'ifMissing' covers the common case where an app doesn't know which run it is on, and there ownerEntityId also accepts a function — called only when the database has to be created, so a returning run neither mints a throwaway keypair nor needs one to hand:
const adapter = await LocalAdapter.open({
path: './my-stack.db',
create: 'ifMissing',
timezone: 'America/New_York',
ownerEntityId: async () => {
const { did, privateKey } = await generateDidKeypair();
await persistSomewhereSafe(privateKey); // see Identity § Key custody
return did;
},
});A plain-string ownerEntityId is also checked against the owner of an existing database: a mismatch releases the file and throws OwnerMismatchError (see Wire format § Identity is trusted on transport). A function is never invoked just to compare. Every adapter that holds a stack's identity is opened the same way; see Adapters § Construction.
StackClient is the passable interface. Plugin and extension code that doesn't need to know the underlying backend should accept StackClient rather than the concrete Stack or ScopedStack. It covers the full record API (create, get, query, getEntityByDid, getOwnerEntity, mutate, patchContent, delete, deleteAndReturn, undelete, associate, dissociate, amendAssociations, grantAccess, revokeAccess, amendAccess, commitMigration, getVersions, getVersion, restoreVersion, getJournal, getAttachment, putAttachment, deleteAttachment, collectAttachmentGarbage), subscribe() for change events, and a capabilities getter. Both Stack and ScopedStack implement it. Every mutating method on it answers with the Record it produced, delete() excepted (it answers with a DeleteResult) — see Versioning § Version history.
Stack identity (ownerEntityId, timezone) is stored as a singleton _config@1 record in the records table. Adapters expose these values as typed readonly properties (adapter.ownerEntityId, adapter.timezone) rather than as a generic key/value store. For the API adapter, they are sourced from the discovery endpoint (GET /.well-known/stack) when the adapter is opened and cached for the session.
timezone is optional, passthrough app metadata — nothing in core reads it for behavior. It's a presentation concern that lives at the data layer because _config is the natural place to store one fact per stack. There is no default: an absent timezone stays undefined end to end (ConfigContent.timezone, adapter.timezone, discovery's timezone field) rather than being defaulted to 'UTC' — a default would assert knowledge the stack was never actually given. Apps that want a display default apply it themselves, explicitly, at the point they format something.
_config is protected, by Stack itself rather than any one adapter — the same layering as schema validation, so every adapter and ScopedStack (which delegates to Stack) inherit it automatically:
- Addressable only by ID.
get('_config')works; a genericquery()never returns it, regardless of filter — matching every other reserved-ID system record, but load-bearing here since_configis read at open and consulted by every permission check. entityIdis immutable.patchContent('_config', { entityId: '...' })throwsStackConflictError— changing it would silently re-anchor stack ownership out from under a running system, and identity is a DID: the field isn't a label, it's the key the whole stack answers to. Other fields (timezonetoday) update normally.restoreVersion('_config', ...)inherits the same rule: a snapshot whoseentityIddisagrees with the live record's cannot be restored. Ownership transfer, if ever added, is a deliberate future API with key-custody semantics — not a field write.- Never deletable, soft or purged.
delete('_config')always throwsStackConflictError: a soft-deleted config is unreadable through normal paths, and a purged one leaves nothing to reopen the stack against.
- Multi-stack patterns — apps managing multiple stacks (personal + group stacks) will likely repeat common fan-out and merge patterns; a
StackWorkspaceabstraction is a likely future addition once real usage patterns emerge.