Explorar el Código

feat(session): metadata seam + JSON-serializability invariant

Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:

- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
  dsh-session; Session gains a readonly `header`; SessionStore.create takes
  `(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
  lineage). The injection TurnTrigger variant is added for the idle-inject
  one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
  rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
  arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
  constructor validates every seed event (isJsonValue + contiguous seq from
  0), so a replay/fork seed can never build a live log no backend can
  persist — the source-level guarantee a durable backend relies on.

Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
Tianyi Cui hace 4 meses
padre
commit
0731ed374b

+ 1 - 1
packages/agent-loop/tests/loop.spec.ts

@@ -425,7 +425,7 @@ describe('agent loop', () => {
     send(agent, 'run')
     await waitForIdle(ctx, agent)
 
-    const replayed = ctx.sessions.create('replayed', [...agent.session.events])
+    const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] })
     expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
     // event-by-event identity of types
     expect(replayed.events.map(e => e.type)).toEqual(

+ 1 - 1
packages/agent-loop/tests/review-fixes.spec.ts

@@ -429,7 +429,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
     await ctx2.plugin(AgentLoop, { agents: [] })
     ctx2.llm.registerAdapter(['mock'], second)
 
-    const seeded = ctx2.sessions.create('forked', [...agent.session.events])
+    const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] })
     const forked = new LoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
     ctx2.effect(() => forked.start())
 

+ 26 - 7
packages/invariants/tests/invariants.spec.ts

@@ -36,6 +36,16 @@ describe('session-log invariants', () => {
     }).not.toThrow()
   })
 
+  it('rejects a non-monotonic seq (replay spine)', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    // Session.append enforces seq-contiguity at the source, so drive the
+    // invariants seq check directly via session/event with a regressing seq.
+    ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
+    expect(() => { ctx.emit('session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) })
+      .toThrow(/seq must strictly increase/)
+  })
+
   it('rejects a turn/start while another turn is open', async () => {
     const { ctx } = await setup({ freeze: false })
     const session = ctx.sessions.create()
@@ -98,13 +108,15 @@ describe('session-log invariants', () => {
 
   it('holds seeded sessions to the contract on session/created', async () => {
     const { ctx } = await setup({ freeze: false })
-    // A seed whose seq is non-monotonic must be rejected when the session is
-    // created (the constructor copies the seed without emitting session/event).
+    // A seq-contiguous, serializable seed (so it passes Session's constructor
+    // validation) that nonetheless violates turn nesting — a second turn/start
+    // while the first turn is still open — must be rejected by the invariants
+    // plugin when it replays the seed on session/created.
     const badSeed = [
       { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
-      { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+      { type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
     ]
-    expect(() => ctx.sessions.create(undefined, badSeed)).toThrow(InvariantError)
+    expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
   })
 
   it('tracks turns per session independently', async () => {
@@ -218,7 +230,7 @@ describe('dev-freeze', () => {
     const seed = [
       { type: 'user/message' as const, seq: 0, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } },
     ]
-    const session = ctx.sessions.create(undefined, seed)
+    const session = ctx.sessions.create(undefined, { seed })
     expect(Object.isFrozen(session.events[0])).toBe(true)
   })
 
@@ -240,10 +252,17 @@ describe('dev-freeze', () => {
   it('terminates on a cyclic event datum (WeakSet guard)', async () => {
     const { ctx } = await setup()
     const session = ctx.sessions.create()
-    // A self-referential structure must not loop forever.
+    // The deep-freeze WeakSet guard must terminate on a self-referential
+    // structure rather than recursing forever. Session.append now rejects
+    // non-serializable (incl. cyclic) data at the source, so drive the freeze
+    // handler directly via hand-built session/events — exactly the shape the
+    // invariants listener receives. Open a turn first (seq 0) so the cyclic
+    // user/message (seq 1) satisfies seq-contiguity.
+    ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
     const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
     cyclic['self'] = cyclic
-    expect(() => session.append('user/message', { content: [cyclic as never], source: { kind: 'user' } })).not.toThrow()
+    const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
+    expect(() => { ctx.emit('session/event', session, event as never) }).not.toThrow()
     expect(Object.isFrozen(cyclic)).toBe(true)
   })
 })

+ 11 - 6
packages/session/README.md

@@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
 
 ### Public API
 
-- `ctx.sessions.create(id?: string, seed?: SessionEvent[]): Session` Create a session. `seed` replays/forks an existing event log. Disposed with the calling fiber.
+- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader` (the store fills `version`/`id`/`createdAt`). Disposed with the calling fiber.
 - `ctx.sessions.get(id: string): Session | undefined`
 - `ctx.sessions.list(): Session[]`
 
@@ -24,9 +24,16 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
 
 Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
 
-- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O.
+- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points).
 - `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages.
 - `session.events`, `session.seq`, `session.id`
+- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bare `Session` construction.
+
+### Metadata types (`types.ts`)
+
+- `SessionHeader` — immutable, written once: `{ version, id, createdAt, cwd?, parentSession? }`.
+- `SessionSummary` — mutable, updateable without touching the log: `{ updatedAt, title?, firstPrompt? }`.
+- `SessionMeta = SessionHeader & SessionSummary` — owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export these rather than own them (which would force a package cycle).
 
 ### Session event vocabulary (`types.ts`)
 
@@ -38,11 +45,9 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types
 
 ### Extension points
 
-- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. See `examples/echo-agent/src/session-jsonl.ts` for the pattern.
-- Replay/fork: `ctx.sessions.create(id, seed)` seeds a new session with an existing event log.
+- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`/`SessionSummary`/`SessionMeta`, `session.header`) is what such a backend stores beside the log.
+- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log.
 
 ### What is NOT here (TODO)
 
-- **Real persistence backends** (JSONL per session dir, sqlite) — future phase.
-- **Session event vocabulary review** — `TODO(review)` once the loop and a persistence plugin coexist.
 - **Session branching/tree** (pi-style entry tree) — defered unless needed beyond seed-based forking.

+ 66 - 9
packages/session/src/index.ts

@@ -7,11 +7,14 @@
  */
 
 import { Context, Service } from 'cordis'
+import { isAbsolute } from 'node:path'
 import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
 import { SessionId } from './types.ts'
-import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts'
+import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts'
+import { isJsonValue } from './json.ts'
 
 export * from './types.ts'
+export { isJsonValue } from './json.ts'
 
 declare module 'cordis' {
   interface Context {
@@ -61,8 +64,35 @@ export class Session {
   /** Set by the store so appends are observable; undefined when detached. */
   onAppend: ((event: SessionEvent) => void) | undefined
 
-  constructor(public readonly id: SessionId, seed?: SessionEvent[]) {
-    if (seed) this.log = [...seed]
+  /**
+   * Immutable creation metadata (format version, cwd, lineage). Supplied by
+   * the store via `ctx.sessions.create()`. When a `Session` is constructed
+   * bare (tests, ad-hoc replay), a minimal v1 header is synthesized so
+   * `session.header` is always present. Kept out of the event log — it is a
+   * storage concern, not replayable conversation state.
+   */
+  readonly header: SessionHeader
+
+  constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
+    if (seed) {
+      // Validate the seed to the SAME invariants `append` enforces, so a
+      // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
+      // live log that no persistence backend could store: each event's `data`
+      // must be JSON-serializable, and `seq` must be contiguous from 0 (the
+      // `seq = log.length` contract the whole system relies on). Without this,
+      // a bad seed would surface only later as a backend rejection or a silent
+      // divergence between the live log and disk.
+      seed.forEach((event, index) => {
+        if (event.seq !== index) {
+          throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
+        }
+        if (!isJsonValue(event.data)) {
+          throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
+        }
+      })
+      this.log = [...seed]
+    }
+    this.header = header ?? { version: 1, id, createdAt: Date.now() }
   }
 
   get events(): readonly SessionEvent[] {
@@ -77,8 +107,19 @@ export class Session {
    * Append one typed event to the log and synchronously notify observers via
    * `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
    * asynchronously.
+   *
+   * @throws if `data` is not losslessly JSON-serializable (BigInt, function,
+   *   symbol, undefined, non-finite number, circular ref, or an exotic object
+   *   like Map/Set/Date). The event log is the durable source of truth, so this
+   *   invariant is enforced at the source — a bad event never enters the log,
+   *   keeping `session.events` always equal to what a backend can persist. The
+   *   throw surfaces at the buggy caller's append site, not asynchronously in a
+   *   backend flush.
    */
   append<T extends SessionEventType>(type: T, data: SessionEventMap[T]): SessionEvent<T> {
+    if (!isJsonValue(data)) {
+      throw new Error(`session event "${type}" carries non-JSON-serializable data`)
+    }
     const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent<T>
     this.log.push(event)
     this.onAppend?.(event)
@@ -158,15 +199,31 @@ export class SessionStore extends Service {
   }
 
   /**
-   * Create a session. If `seed` is provided, the session is populated with
-   * a copy of those events (replay/fork). The session is a Cordis effect:
-   * disposing the calling fiber stops event notification and removes the
-   * session from the store.
+   * Create a session. `options.seed` populates the session with a copy of
+   * those events (replay/fork); `options.meta` attaches creation metadata
+   * (validated absolute `cwd`, `parentSession` lineage) as the immutable
+   * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). The
+   * session is a Cordis effect: disposing the calling fiber stops event
+   * notification and removes the session from the store.
+   *
+   * @throws if a session with `id` already exists, or if `meta.cwd` is a
+   *   non-absolute path (storage backends key directories off it).
    */
-  create(id?: string, seed?: SessionEvent[]): Session {
+  create(id?: string, options?: CreateSessionOptions): Session {
     const sessionId = SessionId(id ?? `session-${++this.counter}`)
     if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
-    const session = new Session(sessionId, seed)
+    const cwd = options?.meta?.cwd
+    if (cwd !== undefined && !isAbsolute(cwd)) {
+      throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
+    }
+    const header: SessionHeader = {
+      version: 1,
+      id: sessionId,
+      createdAt: options?.meta?.createdAt ?? Date.now(),
+      ...cwd !== undefined ? { cwd } : {},
+      ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
+    }
+    const session = new Session(sessionId, options?.seed, header)
     this.ctx.effect(function* (this: SessionStore) {
       session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
       this.store.set(sessionId, session)

+ 63 - 0
packages/session/src/json.ts

@@ -0,0 +1,63 @@
+/**
+ * JSON-serializability validation for session event data.
+ *
+ * The session event log is the durable source of truth (ADR 0003/0016): every
+ * `event.data` must round-trip losslessly through JSON so any persistence
+ * backend can store and reload it byte-identically. This invariant belongs to
+ * the log itself — `Session.append` enforces it at the source, so a
+ * non-serializable event never enters `session.events` and the live log can
+ * never diverge from what a backend can persist. Backends re-use the same
+ * predicate to validate their own `append(events)` entry point (replay/fork
+ * paths that do not go through a live `Session`).
+ *
+ * @module @deepseek-ai/dsh-session/json
+ */
+
+/**
+ * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
+ * booleans, strings, plain arrays, and plain objects of such values. Rejects
+ * `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
+ * which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
+ * `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
+ * convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
+ * so `[1, , 3]` would not round-trip. Detects circular references (which would
+ * throw) and reports them as non-serializable rather than propagating the throw.
+ */
+export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
+  if (value === null) return true
+  switch (typeof value) {
+    case 'boolean':
+    case 'string':
+      return true
+    case 'number':
+      return Number.isFinite(value)
+    case 'bigint':
+    case 'function':
+    case 'symbol':
+    case 'undefined':
+      return false
+    case 'object':
+      break // handled below
+  }
+  // object
+  if (seen.has(value)) return false // circular
+  seen.add(value)
+  try {
+    if (Array.isArray(value)) {
+      // Reject sparse arrays: a hole is skipped by `every`/`forEach` but
+      // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
+      // lossily. Require every index 0..length-1 to be an OWN property.
+      for (let i = 0; i < value.length; i++) {
+        if (!Object.prototype.hasOwnProperty.call(value, i)) return false
+        if (!isJsonValue(value[i], seen)) return false
+      }
+      return true
+    }
+    // Plain object only (reject Map/Set/Date/class instances).
+    const proto = Object.getPrototypeOf(value) as unknown
+    if (proto !== Object.prototype && proto !== null) return false
+    return Object.values(value).every(v => isJsonValue(v, seen))
+  } finally {
+    seen.delete(value)
+  }
+}

+ 80 - 2
packages/session/src/types.ts

@@ -8,6 +8,68 @@ export function SessionId(id: string): SessionId {
   return id as SessionId
 }
 
+/**
+ * Immutable session metadata — written once at creation and never rewritten.
+ *
+ * Kept SEPARATE from the event log deliberately: format-version, cwd, and
+ * lineage are storage concerns, not conversation events, so they stay out of
+ * {@link SessionEventMap} and never reach `deriveMessages()`. Every reference
+ * system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail
+ * metadata) writes such a header.
+ */
+export interface SessionHeader {
+  /** On-disk format version; a persistence backend rejects unknown versions. */
+  version: number
+  /** The session's id (mirrors the {@link Session}'s id). */
+  id: SessionId
+  /** Unix epoch milliseconds when the session was created. */
+  createdAt: number
+  /** Absolute working directory the session was created in (if any). */
+  cwd?: string
+  /** The session this one was forked from (seed lineage), if any. */
+  parentSession?: SessionId
+}
+
+/**
+ * Mutable session metadata — updateable without touching the append-only log.
+ * A persistence backend stores this beside the log (a sidecar file, a header
+ * row) and rewrites only it on update.
+ */
+export interface SessionSummary {
+  /** Unix epoch milliseconds of the last mutation (event append or update). */
+  updatedAt: number
+  /** Human-facing title (derived/edited), if any. */
+  title?: string
+  /** The first user prompt, cached for listing previews. */
+  firstPrompt?: string
+}
+
+/**
+ * Full session metadata: the immutable {@link SessionHeader} merged with the
+ * mutable {@link SessionSummary}. Owned here in `dsh-session` (beside
+ * {@link SessionId}) because `Session.header` is typed by it; the persistence
+ * package imports/re-exports these rather than owning them, which would force
+ * a package cycle.
+ */
+export type SessionMeta = SessionHeader & SessionSummary
+
+/**
+ * Options for creating a {@link Session} via the store. `seed` replays/forks
+ * an existing event log; `meta` carries the caller-supplied storage fields the
+ * store folds into a {@link SessionHeader}.
+ */
+export interface CreateSessionOptions {
+  /** Events to seed the new session with (replay/fork). */
+  seed?: SessionEvent[]
+  /**
+   * Creation metadata. The store fills in `version`/`id` and defaults
+   * `createdAt` to now; the caller supplies the storage-level fields (validated
+   * absolute `cwd`, `parentSession` lineage, and — when reconstructing a
+   * persisted session — the original `createdAt` to preserve it).
+   */
+  meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number }
+}
+
 /**
  * What started a turn.
  * Merge-extensible sum type (same pattern as MessageSourceMap).
@@ -15,6 +77,15 @@ export function SessionId(id: string): SessionId {
 export interface TurnTriggerMap {
   message: { kind: 'message'; source: MessageSource }
   continuation: { kind: 'continuation' }
+  /**
+   * An out-of-band context injection (`agent.inject()`) made while the agent
+   * was idle. The loop wraps the injected `context/message` in a one-shot turn
+   * (`turn/start` → `context/message` → `turn/end`) so every event in the log
+   * stays turn-enclosed — the durability/replay boundary is the turn, and a
+   * bare event between turns would otherwise be indistinguishable from a crash
+   * tail on reload.
+   */
+  injection: { kind: 'injection'; source: MessageSource }
 }
 
 export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
@@ -41,8 +112,15 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
  * Merge-extensible: plugins declare extra event types via declaration merging
  * (e.g. a compaction plugin adds `'compaction/marker'`).
  *
- * TODO(review): this vocabulary needs careful review once the loop and the
- * first persistence plugin exist side by side.
+ * Durability contract (what a persistence backend relies on): the durable log
+ * persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
+ * contiguous (`seq = log.length`), so chunks cannot be filtered out of the
+ * canonical log. All `event.data` must be JSON-serializable — `Session.append`
+ * (and the seed path in the constructor) enforces this at the source (throwing
+ * on non-serializable data), so a bad event never enters the log and
+ * `session.events` always equals what a backend can persist. Adding a new event
+ * type that carries non-serializable data, or that breaks the turn/step nesting
+ * the invariants plugin checks, is a breaking change to the on-disk format.
  */
 export interface SessionEventMap {
   'turn/start': { turn: number; trigger: TurnTrigger }

+ 101 - 1
packages/session/tests/session.spec.ts

@@ -79,8 +79,69 @@ describe('Session', () => {
     // And a fresh derivation still reflects the original content.
     expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
   })
+
+  it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
+    const session = new Session(SessionId('s5'))
+    const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never)
+    expect(bad(1n)).toThrow(/non-JSON-serializable/)
+    expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
+    expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
+    expect(bad(new Map())).toThrow(/non-JSON-serializable/)
+    expect(bad(undefined)).toThrow(/non-JSON-serializable/)
+    expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
+    // A sparse array: `every` skips the hole but JSON.stringify writes it null.
+    // Build the hole without a sparse literal or `delete` (both linted).
+    const sparse: unknown[] = Array(3)
+    sparse[0] = 1
+    sparse[2] = 3 // index 1 stays a hole
+    expect(bad(sparse)).toThrow(/non-JSON-serializable/)
+    // A DENSE array carrying a non-serializable element is rejected too.
+    expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
+    // A nested non-serializable value (inside a plain object) is rejected.
+    expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
+    // A circular reference is rejected (the seen-set guard, not a stack blow-up).
+    const cyclic: Record<string, unknown> = { a: 1 }
+    cyclic['self'] = cyclic
+    expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
+    // The rejected appends never entered the log.
+    expect(session.events).toHaveLength(0)
+  })
+
+  it('accepts dense arrays and nested plain objects', () => {
+    const session = new Session(SessionId('s6'))
+    expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never)).not.toThrow()
+    expect(session.events).toHaveLength(1)
+  })
+
+  it('validates seed events: rejects a non-JSON-serializable seed', () => {
+    // A replay/fork seed must satisfy the SAME invariant as Session.append, or
+    // it builds a live log no backend can persist.
+    const badSeed = [
+      { type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
+    ] as unknown as SessionEvent[]
+    expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/)
+  })
+
+  it('validates seed events: rejects a non-contiguous seq', () => {
+    const gapSeed = [
+      { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+      { type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
+    ] as SessionEvent[]
+    expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
+  })
+
+  it('accepts a well-formed contiguous serializable seed', () => {
+    const goodSeed = [
+      { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+      { type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
+      { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
+    ] as SessionEvent[]
+    const session = new Session(SessionId('seed-ok'), goodSeed)
+    expect(session.events).toHaveLength(3)
+  })
 })
 
+
 describe('SessionStore', () => {
   it('creates sessions, emits session/created and session/event', async () => {
     const ctx = new Context()
@@ -110,10 +171,49 @@ describe('SessionStore', () => {
     expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
 
     a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
-    const forked = ctx.sessions.create('fork', [...a.events])
+    const forked = ctx.sessions.create('fork', { seed: [...a.events] })
     expect(forked.deriveMessages()).toEqual(a.deriveMessages())
   })
 
+  it('synthesizes a minimal v1 header for a bare-created session', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const session = ctx.sessions.create('plain')
+    expect(session.header).toMatchObject({ version: 1, id: 'plain' })
+    expect(typeof session.header.createdAt).toBe('number')
+    expect(session.header.cwd).toBeUndefined()
+    expect(session.header.parentSession).toBeUndefined()
+  })
+
+  it('attaches cwd and parentSession from meta to the header', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const session = ctx.sessions.create('child', {
+      meta: { cwd: '/work/project', parentSession: SessionId('parent') },
+    })
+    expect(session.header).toMatchObject({
+      version: 1,
+      id: 'child',
+      cwd: '/work/project',
+      parentSession: 'parent',
+    })
+  })
+
+  it('rejects a non-absolute meta.cwd', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
+      .toThrow(/cwd must be an absolute path/)
+    // the rejected session was not registered
+    expect(ctx.sessions.get('rel')).toBeUndefined()
+  })
+
+  it('a bare Session() constructed without the store still exposes a v1 header', () => {
+    const session = new Session(SessionId('bare'))
+    expect(session.header).toMatchObject({ version: 1, id: 'bare' })
+    expect(typeof session.header.createdAt).toBe('number')
+  })
+
   it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)