# Sessions

Deterministic, event-sourced engine for form-completion sessions

Canonical URL: https://docs.paradoc.dev/sdk/sessions/

A form-completion session is a back-and-forth: someone answers fields, defers others, revises earlier answers, and you track what's still open. `@paradoc/sessions` models this as an event-sourced engine. Every change is a command; the session's state is derived by replaying its event log. The engine is deterministic and storage-agnostic, so the same events always produce the same view, and you persist them wherever you like.

## Installation

```bash
npm install @paradoc/sessions
```

Or use via the SDK:

```ts
import { execute, deriveView, createParadocRuntime, sessionPayload } from '@paradoc/sdk'
```

## Core flow

There are three moving parts: a **runtime** that knows the artifact, a **session** that holds the event log, and the **command/view** pair that changes and reads it.

### 1. Build a runtime

`createParadocRuntime` wraps an artifact definition in an `ArtifactRuntime`: the read-only interface the engine uses to look up fields, validate values, and compute fill-state. It's built from `@paradoc/core` and recomputed cheaply on every command.

```ts
import { createParadocRuntime } from '@paradoc/sessions'

const runtime = createParadocRuntime(definition) // the artifact as a plain object
```

### 2. Start a session

A `FormSession` is a plain object. Its `events` array is the only state it holds.

```ts
import type { FormSession } from '@paradoc/sessions'

let session: FormSession = {
  formSessionId: 'session-1',
  chatId: 'chat-1',
  artifactRef: { name: 'intake' },
  events: [],
  createdAt: new Date().toISOString(),
}
```

### 3. Apply commands

A `Command` is the only way to change a session. `execute(session, runtime, command, actor, options?)` decides which events to append. It never mutates in place; it returns a new `FormSession` plus the events it emitted. The `actor` records who issued the command: `{ kind: 'user' }`, `{ kind: 'agent', model }`, or `{ kind: 'system', reason }`.

```ts
import { execute } from '@paradoc/sessions'

const result = execute(
  session,
  runtime,
  { kind: 'answer', fieldPath: 'age', value: 25, source: 'user' },
  { kind: 'user' },
)

if (result.ok) {
  session = result.session       // new session with the appended event
  result.emitted                 // the events that were added
} else {
  result.code                    // e.g. 'field-not-found', 'field-not-visible'
  result.reason                  // human-readable explanation
}
```

A rejection carries no session. Keep the one you had: nothing about the fill changed.

Commands cover the full lifecycle: `start`, `prefill`, `answer`, `revise`, `clear`, `defer` / `undefer`, `skip` / `unskip`, `answerParty`, `answerAnnex` / `clearAnnex`, `present`, `validate`, `render`, and `abandon`. A failed command returns a typed `CommandErrorCode` instead of throwing, so callers branch on the outcome.

`start` opens the log with a `SessionStarted` event; it rejects with `session-already-started` on a session that has events. `prefill` takes `values` (field path to value) and optional `lockedPaths` (field paths). Each value must be a field the artifact defines and pass its schema, and is stored with source `prefill`; an unknown path, an invalid value, or a path already answered or locked rejects the whole command. To prefill a party, send `answerParty` with source `prefill`. `answer` and `revise` both take a `source`, so a revised value records where it came from.

`answer`, `revise`, and `prefill` store the value the field's schema returns. Before validation, the runtime coerces an input only when it has one reading: a decimal string for a number (`"1,250.5"`, not `"1e5"` or `"1,5"`), yes/no for a boolean, a JSON string for a list or composite value, a string for a phone number, person, or organization, and an amount such as `"25"` or `"25 EUR"` for money. Money takes the currency given or the field's declared `currency`, and never invents one. Dates are not parsed: a date field takes `YYYY-MM-DD`, so `"March 4, 2025"` rejects with `invalid-value`. A phone number without a leading `+` gets a calling code only from `createParadocRuntime(definition, { defaultCallingCode: '+1' })`.

`answerParty` takes an `index` for a role that accepts more than one party (`max` above 1). Answer the indices in order, starting at 0: an index that skips a position rejects with `party-index-out-of-order`, and an index at or past `max` rejects with `invalid-value`. Answering an index again replaces that party. `answerAnnex` takes an `annexId` and an attachment (`{ name, mimeType, checksum? }`) and replaces any attachment already in that slot.

A field locked by prefill rejects `answer`, `revise`, and `clear` with `field-locked`; the reason names the field. To show it as read-only before the user tries, read `locked` on the field's `fieldIndex` entry in the derived view.

`options` takes `expectedEventCount`, which rejects with `stale-state` when another writer appended first, and `now`, a clock for deterministic tests.

### 4. Derive the view

State lives only in the event log. To read it, call `deriveView`, which replays the events into a `SessionView`.

```ts
import { deriveView } from '@paradoc/sessions'

const view = deriveView(session, runtime)

view.phase            // where the session is in its lifecycle
view.next             // the next field to ask about, or null
view.nextParty        // the next party to ask about, or null
view.nextAnnex        // the next attachment to collect, or null
view.progress         // ProgressSummary: answered vs. remaining
```

Only one of `next`, `nextParty`, and `nextAnnex` is non-null at a time. Continue until `view.phase === "ready"`.

A `SessionView` also carries `projected` (the replayed session), `pendingParties`, `pendingAnnexes` (required annexes not yet attached), `fieldIndex` (status and `locked` flag per field, in declaration order, with list item fields as `items[].name`), `partyIndex` (status, `max`, and `filled` count per party role), and `annexIndex` (status per annex slot).

The session is `ready` only when no required field, party, or annex is open. `progress` counts fields as core reports them: `requiredTotal` and `optionalTotal` count each field by its current status, whether answered or not, and a field that is now hidden counts toward neither.

`phase` is one of `collecting-required`, `revisit-deferred`, `collecting-optional`, `unresolved`, `ready`, `rendered`, or `abandoned`. The engine reads visibility and required cascades straight from `@paradoc/core`, so they resolve the same way they do everywhere else.

## Fill, render, and seal from a session

`sessionPayload` turns a projected session into the artifact's own shape: nested `fields`, `parties` by role, and `annexes` by id. It takes the runtime too, because a role with `max` above 1 is always an array, even with one party. Pass the payload to `fill`, then render or seal the draft like any other. It is valid part-way through a fill: a field, party, or annex nobody has answered is absent.

```ts
import { p, deriveView, sessionPayload } from '@paradoc/sdk'

const form = p.form(definition)

const view = deriveView(session, runtime)
const draft = form.fill(sessionPayload(view.projected, runtime))

// Preview while the session is still collecting.
const preview = await draft.render({ progressive: { missing: '—' } })

// Seal once the session is ready and signers are configured.
if (view.phase === 'ready') {
  const signable = await draft.seal()
}
```

A PDF layer seals locally. Other layer types need a seal adapter; see [DraftForm](/sdk/artifacts/form#draftform) for `SealOptions`.

## Storage-agnostic persistence

The engine holds no database. A `FormSession` is a plain object whose `events` array is the single source of truth; `execute` hands you back a new one to persist however you want. To rebuild state from any store, replay the log with `deriveView`. Store the events append-only, in order, and without duplicates: `deriveView` folds the log exactly as given and does not reorder or deduplicate it.

```ts
// Persist the new event log after each accepted command.
if (result.ok) await store.save(session.formSessionId, result.session.events)

// Rehydrate later from stored events.
const session = { ...rest, events: await store.load(id) }
const view = deriveView(session, runtime)
```

Because the log is the source of truth, the same events replayed against the same artifact always produce the same view. That makes sessions reproducible and easy to test: assert on the events, not on hidden internal state.

## Related

* [Logic](/concepts/logic) - The visibility and required cascade the fill-state honors
* [Artifacts](/sdk/artifacts) - Loading the artifact a runtime wraps
* [Expr](/sdk/expr) - The expression engine behind field gates
