2026-07-30 21:40:58 +08:00
# Session Projections
English | [中文 ](session-projection.zh.md )
2026-08-25 23:47:20 +08:00
The session-projection seam — a [capability seam ](../capability-seams.md ) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection ](../../packages/session/session-projection ), `ctx.sessionProjections` ), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller ](../../packages/api/session-controller )'s history tail page and `session/projection` push frame). It is one optional capability, not part of the agent-loop spine. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC ](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md ); drive/cache/feed contracts: the [package README ](../../packages/session/session-projection/README.md ).
2026-07-30 21:40:58 +08:00
Source: [`packages/session/session-projection/src/index.ts` ](../../packages/session/session-projection/src/index.ts )
## The unit
2026-08-19 13:09:02 +08:00
`SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer:
2026-07-30 21:40:58 +08:00
```ts type-equiv
/**
2026-08-19 13:09:02 +08:00
* One domain's state-driven computation unit: a pure synchronous fold plus
* declarations and an optional client view — never an opaque getter. The framework drives
2026-07-30 21:40:58 +08:00
* `apply` on every committed session event; the domain holds no
2026-08-19 13:09:02 +08:00
* subscriptions and owns only the computation. All functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut), and
2026-07-30 21:40:58 +08:00
* `state` MUST be plain JSON (the persisted-cache precondition).
*/
2026-08-19 13:09:02 +08:00
interface ProjectionDefinition<
K extends keyof SessionProjectionStateMap,
S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K],
> {
/** The projection key this unit owns (its `SessionProjectionStateMap` entry). */
2026-07-30 21:40:58 +08:00
key: K
2026-08-19 13:09:02 +08:00
/** Validates persisted state before it seeds a fold. */
stateSchema: ZodType< S >
2026-07-30 21:40:58 +08:00
/**
2026-08-25 06:10:25 +08:00
* State for the empty log and its immutable Session metadata.
* @param header - immutable metadata for the Session being projected.
2026-07-30 21:40:58 +08:00
* @returns the initial state.
*/
2026-08-25 06:10:25 +08:00
init(header: SessionHeader): NoInfer< S >
2026-07-30 21:40:58 +08:00
/**
* Pure transition: previous state + one committed event → next state. A
* unit uninterested in an event MUST return the same state reference — an
* unchanged reference (`Object.is` ) produces zero downstream work.
* @param state - the state covering all prior events.
* @param event - the next committed session event.
* @returns the next state (same reference when the event is not the unit's).
*/
2026-08-19 13:09:02 +08:00
apply(state: NoInfer< S > , event: SessionEvent): NoInfer< S >
/** Client view. Omit for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType< SessionProjectionMap [ K ] >
/**
2026-08-31 19:59:54 +08:00
* State → wire payload (the read-side projection). The live drive keeps
* the two latest raw results and compares them with `Object.is` ; an
* object-valued view must reuse its reference to suppress publication
* across internal-only state changes.
2026-08-19 13:09:02 +08:00
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer< S > ): SessionProjectionMap[K]
} : never
2026-07-30 21:40:58 +08:00
/**
2026-08-09 15:27:21 +08:00
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
2026-07-30 21:40:58 +08:00
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
* rows from an older unit are discarded instead of being forward-applied
* into garbage. Non-negative integer.
*/
stateVersion: number
}
```
The whole-value event rule is load-bearing: a state-carrying log event carries the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
## The snapshot and the change feed
```ts type-equiv
/**
2026-08-19 14:32:48 +08:00
* One consistent read cut over every registered client-visible unit for one session.
2026-07-30 21:40:58 +08:00
* `asOfSeq` is the shared watermark — the seq of the last event every value
2026-08-22 21:13:53 +08:00
* reflects (`-1` for an empty log).
2026-07-30 21:40:58 +08:00
*/
interface ProjectionSnapshot {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
2026-08-19 14:32:48 +08:00
/** Whole current client value per registered key. */
values: Partial< SessionProjectionMap >
2026-07-30 21:40:58 +08:00
}
```
```ts type-equiv
/**
2026-08-31 19:59:54 +08:00
* Change-feed listener: one unit's raw `view` result changed by `Object.is`
* for one session. `value` is the schema-validated output; `seq` is the
* unit's watermark at emission (the seq of the event that caused the change).
2026-07-30 21:40:58 +08:00
*/
type ProjectionChangeListener = (
session: Session,
key: Extract< keyof SessionProjectionMap , string > ,
value: unknown,
seq: number,
) => void
```
2026-08-31 19:59:54 +08:00
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is` ; an object-valued view must preserve its reference to suppress publication across internal-only state changes.
2026-07-30 21:40:58 +08:00
## The registry: `ctx.sessionProjections`
2026-08-25 23:47:20 +08:00
`SessionProjectionRegistry` ([signatures ](#ctxsessionprojections--sessionprojectionregistry )) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
2026-07-30 21:40:58 +08:00
<!-- BEGIN GENERATED cordis - surface (gen - cordis - catalog.ts) — do not edit between markers -->
< a id = "cordis-surface" > < / a >
2026-07-24 19:54:25 +08:00
## Cordis API
2026-07-30 21:40:58 +08:00
2026-08-18 21:02:50 +08:00
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` ) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer ](../cordis-primer.md#dispatch-modes ), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md ](../cordis-api/inherited.md ).
2026-07-30 21:40:58 +08:00
< a id = "ctxsessionprojectioncache--sessionprojectioncache" > < / a >
### `ctx.sessionProjectionCache` — `SessionProjectionCache`
2026-08-20 16:37:41 +08:00
The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end` , and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
2026-07-30 21:40:58 +08:00
```ts cordis-catalog
/**
2026-08-20 14:38:39 +08:00
* The zero-I/O listing read: whole values viewed straight from the stored
* rows (version-matching keys only), each cut carried with its watermark so
* a client value store can seed under its higher-seq-wins rule — as stale
* as the last durable checkpoint but never wrong, and never from an
* unrelated log (the caller's header is the identity witness). Fresher
* paths (the history tail baseline) supersede these values whenever a
* session is actually opened.
2026-07-30 21:40:58 +08:00
* @param meta - the listed session's header (identity witness; no log read).
2026-08-25 06:10:25 +08:00
* @param keys - optional projection keys required by the caller's audience.
2026-07-30 21:40:58 +08:00
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
* `undefined` when no usable row exists for this lifecycle.
*/
2026-08-25 06:10:25 +08:00
cachedSnapshot( meta: SessionHeader, keys?: readonly Extract< keyof SessionProjectionMap , string > [], ): ProjectionSnapshot | undefined
/**
* Hydrate projection cells for an already-prepared Session without another
* persistence read. The cache seeds matching rows; the supplied exact log
* advances every unit to the observation cut. No checkpoint is written
* because the logical observation may contain recovery events not yet durable.
* @param session - exact unpublished Session retained by persistence.
* @param meta - observed lifecycle header.
* @param events - exact logical event prefix represented by the observation.
* @returns all projection values at the event cut.
*/
hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot
2026-07-30 21:40:58 +08:00
2026-08-20 17:26:32 +08:00
/**
2026-08-25 23:47:20 +08:00
* Durably checkpoint one live session NOW (all mandatory points call
2026-08-20 17:26:32 +08:00
* this; tests and carriers may too). The registry cut is snapshotted at
* this boundary (states are live references), then the session's record is
* replaced on the domain's write chain. NOT fail-soft — callers on the
* fail-soft paths contain it.
* @param session - the live session to checkpoint.
* @returns resolution after durability and event emission.
*/
async write(session: Session): Promise< void >
2026-08-20 16:37:41 +08:00
/**
* Cold-read one session's projections from its complete log. Each unit is
* seeded from the identity-checked cached rows — the registry skips `apply`
* for the already-folded prefix (events at or below the row's `seq` ) — and
* the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
* the first cold read creates the cache row and later ones seed from it.
* The caller supplies the complete log in seq order: this service never
* consults the persistence layer.
* @param meta - the stored session header (identity witness).
* @param events - the session's complete log, in seq order.
* @returns the projection cut at the log end.
*/
coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
2026-07-30 21:40:58 +08:00
```
2026-08-20 16:37:41 +08:00
Types: [Session ](session.md ) · [SessionEvent ](session.md ) · [SessionHeader ](persistence.md )
2026-07-30 21:40:58 +08:00
2026-08-04 11:38:42 +08:00
Source: [`packages/session/session-projection-cache/src/index.ts` ](../../packages/session/session-projection-cache/src/index.ts )
2026-07-30 21:40:58 +08:00
< a id = "ctxsessionprojections--sessionprojectionregistry" > < / a >
### `ctx.sessionProjections` — `SessionProjectionRegistry`
2026-08-31 19:59:54 +08:00
`ctx.sessionProjections` : the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is` . Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)` . Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
2026-07-30 21:40:58 +08:00
```ts cordis-catalog
/**
* Register one domain's unit. The registration is an effect on the calling
* context's fiber: disposing the fiber (or calling the returned disposer)
* removes the key — and the unit's cached cells — from subsequent drives
* and snapshots.
2026-08-09 15:27:21 +08:00
* @param definition - key, state schema, pure unit functions, and stateVersion.
2026-07-30 21:40:58 +08:00
* @returns the exact disposer that unregisters this unit.
*/
2026-08-19 16:55:10 +08:00
register< K extends keyof SessionProjectionMap , S extends SessionProjectionStateMap [ K ] , > ( definition: Omit< ProjectionDefinition < K , S > , 'wire'> & { wire: NonNullable< ProjectionDefinition < K , S > ['wire']> }, ): () => void
2026-08-19 13:09:02 +08:00
/**
* Register one host-only unit. Its state is omitted from client snapshots
2026-08-19 16:55:10 +08:00
* and always checkpointed like every other unit.
2026-08-19 13:09:02 +08:00
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register< K extends Exclude < keyof SessionProjectionStateMap , keyof SessionProjectionMap > , S extends SessionProjectionStateMap[K], >( definition: Omit< ProjectionDefinition < K , S > , 'wire'>, ): () => void
2026-07-30 21:40:58 +08:00
/**
* Subscribe to the change feed. The registration is an effect on the
* calling context's fiber.
2026-08-31 19:59:54 +08:00
* @param listener - called once per client-visible unit whose raw view changed by `Object.is` , per committed event.
2026-07-30 21:40:58 +08:00
* @returns the exact disposer that unsubscribes.
*/
onChanged(listener: ProjectionChangeListener): () => void
2026-08-19 13:09:02 +08:00
/**
2026-08-25 06:10:25 +08:00
* Read one unit's current host state after materializing every registered
* unit at the Session cursor. Unrelated wire views are not produced.
2026-08-19 13:09:02 +08:00
* The returned value is live; callers must not mutate it.
* @param session - the session whose state is read.
* @param key - the registered unit key.
* @returns current state, or `undefined` when the key is not registered.
*/
stateOf< K extends keyof SessionProjectionStateMap > ( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined
2026-07-30 21:40:58 +08:00
/**
2026-08-19 14:32:48 +08:00
* One consistent cut over every registered client-visible unit for one session, read from
2026-07-30 21:40:58 +08:00
* the watermark cache (missing cells fold lazily over the in-memory log).
* Fully synchronous — every value and `asOfSeq` reflect the same log
2026-08-19 14:32:48 +08:00
* position. Each value passes its unit's `viewSchema` before leaving.
2026-07-30 21:40:58 +08:00
* @param session - the session whose projection values are read.
2026-08-25 06:10:25 +08:00
* @param keys - optional client-visible outputs; state materialization remains complete.
* @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
2026-07-30 21:40:58 +08:00
*/
2026-08-25 06:10:25 +08:00
snapshot( session: Session, keys?: readonly Extract< keyof SessionProjectionMap , string > [], ): ProjectionSnapshot
/**
* Read only already-materialized client-visible cells without folding history.
* Values may trail the live Session and are therefore hints, not a complete
* baseline. Missing cells are omitted.
* @param session - attached Session whose cached cells are inspected.
* @param keys - optional wire keys to view.
* @returns the lowest common cached cut, or `undefined` when no wire cell exists.
2026-07-30 21:40:58 +08:00
*/
2026-08-25 06:10:25 +08:00
cachedSnapshot( session: Session, keys?: readonly Extract< keyof SessionProjectionMap , string > [], ): ProjectionSnapshot | undefined
2026-07-30 21:40:58 +08:00
/**
2026-08-19 13:09:02 +08:00
* State-level checkpoint of every persisted unit for one session, read
2026-07-30 21:40:58 +08:00
* from the watermark cache (missing cells fold lazily over the in-memory
* log). This is the write side of the persisted projection cache: the
* returned rows are the `(key → {ver, seq, val})` part of the durable
* `(sessionId, key, ver, seq, val)`
* rows. Every `val` is a DETACHED structured clone — never the live
* cell reference: the watermark cache is this registry's authoritative
* mutable state, and a caller reaching the live reference could corrupt
* every subsequent snapshot and frame through it (plain JSON by the unit
* contract, so the clone is total).
* @param session - the session whose unit states are checkpointed.
2026-08-19 16:55:10 +08:00
* @returns one row per registered key.
2026-07-30 21:40:58 +08:00
*/
checkpoint(session: Session): ProjectionCheckpoint
/**
* The stored seq a {@link restore} tail read over `checkpoint` must start
* at: one event BELOW the lowest usable watermark (a row is usable when
* its `ver` matches the live unit's `stateVersion` ; an absent or mismatched row
* pulls the floor to `0` — that key must refold the full log). The
* one-below anchor is load-bearing: the tail then proves how far the
* stored log still extends, so {@link restore} can detect a log that
* shrank below a row's watermark (crash-repair truncation) instead of
* serving the stale row as current — an empty tail read from the anchor
* yields an end below every watermark and the restore rejects for a full
* re-read.
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @returns the seq to hand the persistence `readFrom` , or `undefined`
2026-08-19 16:55:10 +08:00
* when no unit is registered (no read needed — {@link restore} would
2026-07-30 21:40:58 +08:00
* serve empty values regardless).
*/
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
/**
* View a checkpoint's rows without any log read: for every registered
2026-08-19 14:32:48 +08:00
* client-visible unit whose row's `ver` matches, serve the schema-validated
2026-08-19 13:09:02 +08:00
* `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key
2026-07-30 21:40:58 +08:00
* absent (a cold or listing consumer treats it as not-yet-available and a
* fuller read path refolds it). The zero-I/O rung of the read ladder —
* values are as stale as their rows, never wrong.
* @param checkpoint - persisted rows for one session (possibly stale or empty).
2026-08-25 06:10:25 +08:00
* @param keys - optional wire keys to view.
2026-07-30 21:40:58 +08:00
* @returns whole values per key with a usable row; empty when none.
*/
2026-08-25 06:10:25 +08:00
viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract< keyof SessionProjectionMap , string > [], ): Partial< SessionProjectionMap >
2026-07-30 21:40:58 +08:00
/**
2026-08-19 13:09:02 +08:00
* Cold read: fold every persisted unit over a stored log suffix, seeding
2026-07-30 21:40:58 +08:00
* each from its checkpoint row when usable — the one read recipe (cached
* state + forward tail replay + `view` ) applied without a live `Session` .
* Call with the events returned by a persistence
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
* `baseSeq` ; the floor's one-below anchor makes the supplied end honest,
* so a shrunk log is detected here. A row is usable iff its
* `ver` matches the live unit's `stateVersion` , it does not predate `baseSeq`
* (`seq >= baseSeq - 1` ), and it does not claim events past the
* supplied end (`seq <= endSeq` ); an unusable row is discarded
* and its key refolds from `init` — which is only sound over the full
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
* from seq 0, e.g. after a crash-repair truncation shrank the log below
* a row's watermark).
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @param events - the stored events with `seq >= baseSeq` , in seq order.
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
2026-08-25 06:10:25 +08:00
* @param header - immutable metadata for the Session being restored.
2026-07-30 21:40:58 +08:00
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
* refreshed checkpoint rows at that cut, ready for a durable write-back.
*/
2026-08-25 06:10:25 +08:00
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
/**
* Restore an exact cut and install its states on the supplied prepared Session.
* A later publication reuses these cells; ordinary live reads and event drive
* advance any constructor-owned suffix exactly once.
* @param session - exact prepared Session that owns the restored log prefix.
* @param checkpoint - persisted rows for this Session lifecycle.
* @param events - exact events at the observation cut.
* @param baseSeq - first supplied event sequence.
* @returns all projection values at the supplied cut.
*/
hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot
2026-07-30 21:40:58 +08:00
```
2026-08-25 06:10:25 +08:00
Types: [Session ](session.md ) · [SessionEvent ](session.md ) · [SessionHeader ](persistence.md )
2026-07-30 21:40:58 +08:00
2026-08-04 11:38:42 +08:00
Source: [`packages/session/session-projection/src/index.ts` ](../../packages/session/session-projection/src/index.ts )
2026-07-30 21:40:58 +08:00
<!-- END GENERATED cordis - surface -->