Skip to content

Latest commit

 

History

History
90 lines (68 loc) · 8.65 KB

spec.md

File metadata and controls

90 lines (68 loc) · 8.65 KB

Stack Spec — Core Design

Living document. Captures design decisions made so far.

Overview

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

Stack initialization

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 | undefined

Stack.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.

The _config record

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 generic query() never returns it, regardless of filter — matching every other reserved-ID system record, but load-bearing here since _config is read at open and consulted by every permission check.
  • entityId is immutable. patchContent('_config', { entityId: '...' }) throws StackConflictError — 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 (timezone today) update normally. restoreVersion('_config', ...) inherits the same rule: a snapshot whose entityId disagrees 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 throws StackConflictError: a soft-deleted config is unreadable through normal paths, and a purged one leaves nothing to reopen the stack against.

Open questions

  • Multi-stack patterns — apps managing multiple stacks (personal + group stacks) will likely repeat common fan-out and merge patterns; a StackWorkspace abstraction is a likely future addition once real usage patterns emerge.