SDK

Sessions

Last updated on

Deterministic, event-sourced engine for form-completion 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

npm install @paradoc/sessions

Or use via the SDK:

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.

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.

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

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.

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.

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

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

  • Logic - The visibility and required cascade the fill-state honors
  • Artifacts - Loading the artifact a runtime wraps
  • Expr - The expression engine behind field gates

On this page