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| Family | Authority |
|---|---|
| Threads | Incarnation/version, status, lease, list attributes, metadata, usage, latest safe error |
| Messages | Ordered transcript and model context, each as a thin envelope around one complete SDK-native message |
| Queued items | Future input messages plus the captured identity/request context needed for later admission |
| Runs | Admission, 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 nestedmessage.idandmessage.metadata.paifacts.
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.