PAIPAI

Persisted Data Model

Logical storage records for threads, SDK-native messages, queued items, and runs.

This page describes the logical state a ThreadStore provider must preserve. A provider may use SQL, MongoDB, files, memory, or another backend as long as it preserves the same identities, ordering, visibility, and commit semantics.

Application code does not read these records directly. It consumes ThreadState.messages, normalized runs and queue items, lifecycle views, and trusted administration APIs.

Scoped Identity And Versions

Every record belongs to (scopeKey, threadId). Plain threadId is never a complete storage address.

type ThreadVersion = {
  threadInstanceId: string;
  revision: number;
};

threadInstanceId changes when a deleted key is recreated. revision increases within one incarnation. Providers compare the complete version so a late event or stale write from an earlier incarnation cannot affect the new thread.

scopeKey comes from trusted server authorization context. It is not accepted from an untrusted request body.

Logical Record Families

threads
messages
queued_items
runs
FamilyAuthority
ThreadsIncarnation/version, status, lease, list attributes, metadata, usage, latest safe error
MessagesOrdered transcript and model context, each as a thin envelope around one complete SDK-native message
Queued itemsFuture input messages plus the captured identity/request context needed for later admission
RunsAdmission, lifecycle, accepted input ids, usage, metadata, and failure outcome

Pending actions, render state, conversation turns, and client transcript grouping are derived views, not more persisted record families.

Thread Record

The root ThreadRecord contains the durable thread incarnation and control authority:

type ThreadRecord = {
  scopeKey: ScopeKey;
  threadId: ThreadId;
  agent: string;
  directExecutionAllowed: boolean;
  parentThreadId?: ThreadId;
  category?: string;
  listVisibility: "visible" | "hidden";
  version: ThreadVersion;
  status: ThreadStatus;
  lease?: ThreadLeaseRecord;
  title?: ThreadTitleRecord;
  error?: ThreadErrorRecord;
  usage: UsageSummary;
  privateMetadata: JsonObject;
  metadata: JsonObject;
  createdAt: string;
  updatedAt: string;
};

The lease is either one run execution or one trusted administrative lock. Providers use their own authoritative clock for acquisition, renewal, expiry, and release checks.

Stored Message Envelope

Every message row contains one complete AI SDK UIMessage inside a thin PAI envelope:

type PaiStoredMessageEnvelope = {
  format: { kind: "ai-sdk-ui-message"; sdkMajor: 7 };
  scopeKey: ScopeKey;
  threadId: ThreadId;
  visibility: {
    transcript: boolean;
    context: boolean;
  };
  privateMetadata: JsonObject;
  message: PaiStoredUIMessage;
};

The nested value uses the SDK's exact standard message/part names. Public PAI message facts live in the reserved metadata namespace:

message.metadata.pai = {
  producer,
  relation,
  runId,
  status,
  createdAt,
  updatedAt,
  usage,
  diagnostics,
};

The producer/role/relation arms are correlated:

  • send and trigger are committed user inputs associated with a run;
  • model messages are assistant run output and may be open or committed;
  • lifecycle messages are committed run context or output;
  • admin messages are committed standalone trusted history.

Messages remain normalized ordered rows. One model step owns one assistant message. A run can therefore reference several transcript messages without storing another run-owned part grammar.

Native Parts And PAI Extensions

Standard text, reasoning, source, file, custom/provider content, data, tool, and approval parts retain AI SDK shapes. PAI initially owns four native data-part names:

  • data-pai-tool-data;
  • data-pai-suspension;
  • data-pai-reasoning-summary;
  • data-pai-attachment.

Each reserved part has a stable non-empty id and explicit correlation fields. Applications cannot author the reserved namespace. The client consumes these parts and exposes their enriched PaiMessage view instead.

JSON And Schema Invariants

Stored/wire messages are canonical strict JSON. Values entering durable storage are parsed through their application or tool schemas at the point of entry, and the canonical value written is a fixed point: parsing the stored value again produces a deeply equal value.

Reading is deliberately more tolerant than writing. A durable read no longer re-proves stored payloads or metadata bags against the current schemas: a value the current schema cannot reproduce — a tool input or output, a ToolData channel value, a suspension payload, an application data part, or a message, thread, or run metadata bag — is reported through the runtime's onWarning handler and returned verbatim instead of failing the read. Schema evolution (renaming a field, adding a defaulted key) therefore describes old records badly rather than making them unreadable. Structural invariants — the native grammar, duplicate identities, cross-part references — remain fatal on read, because tolerating them would corrupt data rather than merely describe it badly.

Two operations act on stored values and follow the same line. A metadata merge tolerates drift on keys the patch left untouched (reported, merged bag carried verbatim) while a failing key the caller's own patch wrote still throws, and a stored tool input that re-enters execution is re-proven against the tool's current input schema — one that no longer parses fails that call with a safe tool_input_invalid error rather than reaching execute().

A successful static tool output of exact undefined is normalized to native JSON null. Unsupported dynamic-tool undefined and provider custom-output combinations are rejected rather than stored in an ambiguous form.

Message-owned timestamps stay ISO JSON strings even when a provider uses native database timestamps for its outer physical fields.

Queued Items

One queued item contains a stable queue/run identity, one or more complete input message envelopes, its admission mode, and private captured request authority:

type StoredQueuedItemRecord = {
  scopeKey: ScopeKey;
  threadId: ThreadId;
  queueItemId: QueueItemId;
  runId: RunId;
  messages: readonly [StoredInputMessageEnvelope, ...StoredInputMessageEnvelope[]];
  mode: "queue" | "steer";
  runContext: PaiQueuedRunContextRecord;
  createdAt: string;
};

Queued input permits native text/file parts and PAI attachment references. It does not retain the former PAI message-part grammar. Admission moves the same logical message envelope into transcript storage and retargets only trusted server metadata required by the admitted run.

runContext captures validated user, scope, client data/tools, operation, and optional trace context. It is private authority and never projected into the public queue DTO.

Runs

RunRecord is normalized from message content. It owns:

  • initiator and operation;
  • accepted ordered input message ids;
  • lifecycle status and timestamps;
  • run metadata and private metadata;
  • usage and client-safe terminal error;
  • execution/admission facts needed for recovery and regeneration.

Messages retain their metadata.pai.runId relation so providers can query by run without embedding the transcript inside the run record.

Selective Reads

ThreadStore.readThread() returns the root and only the requested child pages:

type ThreadReadResult = {
  observedAt: string;
  thread: ThreadRecord | null;
  messages?: ThreadPage<PaiStoredMessageEnvelope>;
  runs?: ThreadPage<RunRecord>;
  queuedItems?: ThreadPage<StoredQueuedItemRecord>;
};

Every returned page belongs to the same committed ThreadVersion. Message pages can select raw, transcript-visible, or model-context-visible rows in stable order. Continuation tokens are opaque and bound to the original query.

Atomic Changes

Providers commit root changes, child inserts/replacements/deletes, truncation, and lease operations against one expected thread version. A successful commit advances the revision once. A conflict applies nothing.

Ordered message and run families preserve insert order. Replacement never changes order or identity. Queue admission atomically removes its queued item, inserts the canonical input messages, and updates the run/thread control facts.

Mechanical provider validation covers envelope shape, strict JSON, identities, ordering, visibility, and message/run/queue topology. Core alone performs agent-aware schema refinement.

Physical Providers

  • Memory and filesystem providers store the same logical envelope directly.
  • SQLite normalizes rows and mirrors indexed identity/control fields while retaining the complete envelope JSON.
  • MongoDB stores provider-owned _id, order, and schema fields beside the thin logical envelope, with indexes on nested message.id and message.metadata.pai facts.

The physical provider schema remains version 1 under the direct-reset policy. The nested message format carries sdkMajor: 7 so a future SDK-major upgrade cannot silently reinterpret stored content.

On this page