Skip to content

Storage

The storage subsystem persists everything that is not a session event log (session logs have their own seam — persistence.md). It is one optional capability, not part of the agent-loop spine, split as a capability seam: the hub and Service Definition (dsh-storage, ctx.storage), the Service Providers (dsh-storage-json, registered as json, and dsh-storage-sqlite, registered as sqlite), and the Consumer data form (dsh-storage-domain, ctx.storageDomain, also reachable as ctx.storage.domain) — the backend contract's only Consumer and the typed API everything else uses. The hub performs no IO itself: backends own media, data forms own semantics, and product packages never touch backends directly. Design record: domain KV storage Agent Note.

Source: packages/storage/storage/src/backend.ts · packages/storage/storage-domain/src/spec.ts · packages/storage/storage-domain/src/events.ts

The hub: ctx.storage

Storage (signatures) is a meeting point, not a store. ctx.storage.backend is a name → backend table: multiple backends stay mounted side by side, and which backend serves which consumer is that consumer's configuration (the domain layer's route table), never a hub-global choice. register(name, backend) returns the disposer; duplicate names and unknown lookups throw StorageError. Disposal only unregisters the name — the owning plugin closes the backend after unregistering. Each backend plugin also publishes a lifecycle-only service key (storageBackendServiceKey(name)), which form providers inject so their activation cannot race backend registration.

Data forms mount on the hub under a merge-extensible key map:

ts
/**
 * Data forms mountable on the hub, keyed by form name. Form owners extend
 * this map via declaration merging (the domain layer merges
 * `domain: DomainFacility`) and mount the facility in their `apply`.
 */
interface StorageForms {}

mount(form, facility) is an effect whose disposer unmounts; a second mount of the same key throws duplicate-mount. form(form) resolves a mounted facility and throws form-not-mounted until the owning plugin loads — assemblies order plugins accordingly rather than silently deferring. The domain layer merges domain: DomainFacility, so ctx.storage.domain and ctx.storageDomain are the same object.

The backend contract

ts
/**
 * One registered backend. A backend owns exactly one medium and shares its
 * lifecycle across all facets; facets are optional members — a backend that
 * cannot serve a data kind simply omits it, and resolution fails loud instead.
 */
interface StorageBackend {
  /** Key-value operations; absent when this backend cannot serve them. */
  readonly kv?: KvFacet

  /**
   * Drain in-flight writes across all open units and release the medium.
   * Idempotent; concurrent and repeated calls resolve once teardown finishes.
   * @returns resolution after the medium is released.
   */
  close(): Promise<void>
}

A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; kv is the only group today. KvFacet.open(descriptor) opens one named unit — KvUnitDescriptor carries the name, format version, table names, and whether a global singleton slot exists — and returns a KvUnit with loadAll, putRecord, deleteRecord, setGlobal, and close. Unit and table names must match UNIT_NAME_RE (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects version-mismatch; one that cannot be parsed as the unit rejects malformed-medium (no migration, pre-release stance). backend.ts is the normative clause-by-clause contract, and the shared conformance suite in tests/contract.ts checks every clause against each backend. The json backend republishes one whole human-readable file per unit atomically; the sqlite backend stores one document per row in one database for frequently updated data.

Declaring a domain

A domain is declared once by its owning package as a spec object — the single source of the domain's identity, layout, and record schemas (zod, so z.infer keeps consumer types un-duplicated):

ts
/** Static declaration of one domain: identity, version, and record layout. */
interface DomainSpec {
  /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
  readonly name: string
  /** Domain format version; a medium stamped with a different version rejects at open. */
  readonly version: number
  /** Optional global singleton slot. */
  readonly global?: DomainGlobalSpec<unknown>
  /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
  readonly tables: Record<string, DomainTableSpec>
}

defineDomain(spec) pins the spec's literal types and fails loud at the owner's module load, before any medium is touched: a domain or table name outside UNIT_NAME_RE, a version that is not a non-negative integer, or a global schema that accepts null all throw (null is the medium's "never written" sentinel, so a stored nullable global could not round-trip). domainTable<K, V>(schema) declares one table with a phantom compile-time key type (typically a branded id); descriptorOf(spec) projects the backend-facing unit descriptor.

The open domain

ts
/** One open domain, typed by its spec. */
interface Domain<S extends DomainSpec> {
  /** Domain name from the spec. */
  readonly name: string
  /** Global singleton handle; a spec without `global` has no usable handle (`never`). */
  readonly global: DomainGlobalHandleOf<S>
  /**
   * Resolve one declared table handle. Handles are stable — repeated calls
   * return the same instance.
   * @param name - Declared table name.
   * @returns the typed table handle.
   */
  table<N extends keyof S['tables'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>

  /**
   * Close this domain: reject new writes immediately, drain already-queued
   * writes (their events still emit), release the backend unit, then free
   * the domain name for a later open. Idempotent — repeated calls share one
   * teardown. The consumer owns this call (typically as its own `ctx.effect`
   * disposer); the facility closes any domain left open when it unmounts.
   * @returns resolution after the unit is released.
   */
  close(): Promise<void>
}

Reads are synchronous from authoritative in-memory state: KvTable exposes get/entries/keys/size (snapshot iterators that stay stable while queued writes land), and the global handle's get() serves the spec's initial until the first set materializes the slot on the medium. Every write — put, delete, update, global.set — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits domain/changed; a rejected backend write leaves memory untouched, so reads never diverge from the medium. update(key, fn) is an atomic read-modify-write at its chain slot (a missing key rejects missing-key); delete of an absent key resolves false with no write and no event. Returned records are the stored objects themselves, not copies — replace via put/update, never mutate in place.

The domain facility: ctx.storageDomain

DomainFacility (signatures) opens declared domains over routed backends. Routing is the domain plugin's configuration, never the hub's: backend names the required default route and routes overrides it per domain name. open(spec) runs a strict sequence, each step failing the whole call: it rejects a name already open or still closing (already-open), resolves the route (backend-not-found), requires the backend's kv facet (facet-unsupported), opens the unit (backend version-mismatch/malformed-medium pass through), and validates every stored record and global against the spec's zod schemas (invalid-record with the offending table and key). The caller owns the returned handle and releases it with Domain.close(); domains still open when the plugin unmounts are closed by the facility, and a closed domain's name frees for reopening only after teardown fully completes. get(name) is an untyped diagnostic lookup onto the package-private DomainImpl runtime behind every typed handle; closeAll() is the unmount path.

The change event: domain/changed

Every durable write emits one event strictly after the backend acknowledged durability, in the domain's write-chain order (event entry):

ts
/** Shared location fields of one durable domain change. */
interface DomainChangedBase {
  /** Owning domain name. */
  readonly domain: string
  /** Table name; `''` for a global-singleton write. */
  readonly table: string
  /** Record key; `''` for a global-singleton write. */
  readonly key: string
}
ts
/** One durable domain change; a closed union — switch on `operation`. */
type DomainChanged = DomainChangedPut | DomainChangedDeleted

put (inserts, overwrites, and global writes) carries the new snapshot in value — never the old value; a diffing consumer keeps its own previous snapshot. deleted is a tombstone with no value. The event is a notification, not a transaction participant: the commit point has passed at emission, so a synchronously throwing listener is contained with a logged warning rather than rejecting the already-durable write, and emitted values equal the in-memory state at emission. The event is in-process only; cross-process change push is a recorded limitation (package README).

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.storageStorage

The storage hub service. Backends register under backend; data forms mount under their StorageForms key and are reached as ctx.storage.<form>.

ts
/**
 * Mount a data-form facility on the hub. Mounting is an effect: the
 * returned disposer unmounts the form.
 * @param form - Form key declared in {@link StorageForms}.
 * @param facility - The facility instance to expose.
 * @returns the disposer that unmounts the form.
 */
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void

/**
 * Resolve a mounted data form.
 * @param form - Form key declared in {@link StorageForms}.
 * @returns the mounted facility.
 */
form<K extends keyof StorageForms>(form: K): StorageForms[K]

Source: packages/storage/storage/src/index.ts:47

ctx.storageDomainDomainFacility

The mounted domain facility. Opens declared domains over routed backends; one facility instance owns the open-domain table and enforces single-open per domain name.

ts
/**
 * Open one declared domain. Steps, each failing the whole call: reject a
 * name that is already open (`already-open`); resolve the backend route
 * (`backend-not-found` passes through from the hub); require its `kv` facet
 * (`facet-unsupported`); open the unit projected from the spec (backend
 * `version-mismatch`/`malformed-medium` pass through); load and validate
 * every stored record against the spec's zod schemas (`invalid-record`
 * with the offending table and key); construct the domain.
 *
 * Lifecycle: the CALLER owns the returned handle and closes it via
 * `Domain.close()` (typically as its own `ctx.effect` disposer) — the
 * facility does not tie the domain to any consumer fiber. Domains still
 * open when the facility unmounts are closed by the plugin disposer.
 * @param spec - The domain declaration, typically from `defineDomain`.
 * @returns the opened domain handle, typed by the spec.
 */
async open<S extends DomainSpec>(spec: S): Promise<Domain<S>>

/**
 * Look up an open domain by name, untyped. Diagnostic surface (the package
 * invariant cross-checks change events against live domain state); typed
 * consumers hold the handle returned by {@link open}.
 * @param name - Domain name.
 * @returns the open domain runtime, or `undefined` when not open.
 */
get(name: string): DomainImpl | undefined

/**
 * Close every domain still open on this facility. The unmount path for
 * consumers that never called `Domain.close()` themselves; closing is
 * idempotent, so double-closing an already-closed domain is harmless.
 * @returns resolution after every unit is released.
 */
async closeAll(): Promise<void>

Source: packages/storage/storage-domain/src/index.ts:69

domain/* events

domain/changed — emit

A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability. Events of one domain arrive in its write-chain order.

ts
/**
 * A domain record or the global singleton changed, emitted once per write
 * strictly after the backend acknowledged durability. Events of one
 * domain arrive in its write-chain order.
 * @param change - domain, table (`''` for global), key (`''` for global),
 * operation discriminant, and on `put` the new snapshot.
 * @mode emit
 */
'domain/changed'(change: DomainChanged): void

Source: packages/storage/storage-domain/src/events.ts:46