docs/orchestration-v2/core-graph-and-data-model.md

Core Graph And Data Model

Overview

V2 models orchestration as a graph with a small number of durable entity types:

text
1Project
2 AppThread
3 Run
4 ExecutionNode tree
5 ProviderThread handles
6 Context transfers and handoffs
7 Checkpoints

The central separation is:

  • AppThread: the user-visible conversation in T3 Code.
  • Run: a counted user-visible turn on an app thread.
  • ExecutionNode: a unit of provider/runtime work inside a run.
  • ProviderThread: a provider-native conversation handle.
  • ProviderSession: a live or resumable provider process/runtime.
  • ContextTransfer: a provider-neutral relationship/source-point record used by forks, provider switches, merge-back, and subagents.
  • ContextHandoff: a materialized portable context artifact used when native transfer is unavailable or insufficient.

Provider-specific lifecycle is preserved in provider refs and diagnostic raw-provider logs. App behavior is driven by app-owned ids, V2-native orchestration events, and graph relationships.

V2 events are durable because they are the app state transition log. Raw provider frames are not durable app state. They may be attached by optional correlation ids such as rawEventId, but the canonical durable row is the normalized V2 event.

Every committed V2 event has a store-assigned monotonically increasing sequence. Projection snapshots and websocket streams use that sequence as the cursor boundary:

text
1read snapshot at sequence N
2 -> stream committed V2 events where sequence > N

The sequence belongs to the stored event envelope, not the provider event and not the domain payload. Provider-native ordering is preserved separately through provider refs and diagnostic logs. Clients should treat the snapshot's snapshotSequence as the authoritative cursor and apply only subsequent events with a greater sequence.

Entity Summary

text
1AppThread
2 id: ThreadId
3 projectId: ProjectId
4 title
5 providerBinding
6 activeProviderThreadId?
7 lineage?
8 status projection
9
10Run
11 id: RunId
12 threadId: ThreadId
13 ordinal: number
14 status
15 rootNodeId
16 attempts[]
17 providerThreadId
18 contextHandoffId?
19 userMessageId
20 checkpoint?
21
22ExecutionNode
23 id: NodeId
24 threadId: ThreadId
25 runId: RunId | null
26 parentNodeId: NodeId | null
27 rootNodeId: NodeId
28 kind
29 status
30 providerThreadId?
31 providerTurnId?
32 itemId?
33 countsForRun
34 checkpointScopeId?
35
36CheckpointScope
37 id: CheckpointScopeId
38 threadId
39 runId?
40 nodeId
41 parentScopeId?
42 kind
43 advancesAppRunCount
44
45ProviderSession
46 id: ProviderSessionId
47 provider
48 status
49 cwd
50 capabilities
51
52ProviderThread
53 id: ProviderThreadId
54 providerSessionId?
55 appThreadId?
56 nativeThreadRef?
57 nativeConversationHeadRef?
58 coveredRunRange?
59 contextHandoffIds[]
60 forkSource?
61
62ContextTransfer
63 id: ContextTransferId
64 type
65 sourceThreadId
66 targetThreadId
67 sourcePoint
68 basePoint?
69 sourceProvider?
70 targetProvider?
71 status
72 resolution?
73
74ContextHandoff
75 id: ContextHandoffId
76 transferId
77 kind
78 payload
79 status
80
81ProviderTurn
82 id: ProviderTurnId
83 providerThreadId
84 nativeTurnRef?
85 nodeId
86 runAttemptId?
87 status
88
89RuntimeRequest
90 id: RuntimeRequestId
91 nodeId
92 providerRequestRef?
93 kind
94 status

AppThread

An AppThread is the stable user-facing conversation. It owns messages, runs, checkpoints, and UI state. It does not have to map one-to-one with a provider-native thread forever.

ts
1type AppThread = {
2 id: ThreadId;
3 projectId: ProjectId;
4 title: string;
5 defaultProvider: ProviderKind;
6 modelSelection: ModelSelection;
7 runtimeMode: RuntimeMode;
8 interactionMode: InteractionMode;
9 branch: string | null;
10 worktreePath: string | null;
11 activeProviderThreadId: ProviderThreadId | null;
12 lineage: AppThreadLineage | null;
13 createdAt: string;
14 updatedAt: string;
15 archivedAt: string | null;
16 deletedAt: string | null;
17};

activeProviderThreadId points to the provider-native conversation currently backing the app thread. Forking, provider migration, or recovery may create new provider threads while preserving the same app thread, depending on the operation.

defaultProvider is only the currently selected default for future runs. Historical runs retain their own provider and provider thread bindings.

lineage is intentionally lightweight browsing metadata. Operational source points, lazy native fork resolution, portable context handoffs, merge-back deltas, and subagent result transfers live in ContextTransfer rows because an app thread can participate in more than one transfer over time.

ts
1type AppThreadLineage = {
2 parentThreadId: ThreadId | null;
3 relationshipToParent: "fork" | "subagent" | null;
4 rootThreadId: ThreadId;
5};

Run

A Run is the counted user-visible turn. This replaces using provider turn ids as the app-level lifecycle boundary.

ts
1type Run = {
2 id: RunId;
3 threadId: ThreadId;
4 ordinal: number;
5 provider: ProviderKind;
6 providerThreadId: ProviderThreadId | null;
7 userMessageId: MessageId;
8 rootNodeId: NodeId | null;
9 activeAttemptId: RunAttemptId | null;
10 status:
11 | "queued"
12 | "starting"
13 | "running"
14 | "waiting"
15 | "completed"
16 | "interrupted"
17 | "failed"
18 | "cancelled"
19 | "rolled_back";
20 requestedAt: string;
21 startedAt: string | null;
22 completedAt: string | null;
23 checkpointId: CheckpointId | null;
24 contextHandoffId: ContextHandoffId | null;
25 sourcePlanRef?: {
26 threadId: ThreadId;
27 planId: ProposedPlanId;
28 };
29};

Only a run with countsForConversation = true contributes to the user-visible turn count and checkpoint count.

providerThreadId is the provider-native conversation used for this run. This makes mixed-provider app threads explicit: run 1 may be Codex, run 2 may be Claude, and run 3 may return to the original Codex provider thread.

contextHandoffId points to a materialized handoff artifact consumed by this run. A run may also be associated with a broader ContextTransfer through the transfer's target/source fields. The handoff is the payload; the transfer is the durable relationship and policy record.

RunAttempt

A RunAttempt represents one provider execution attempt for an app run. Most runs have exactly one attempt. Steering, retries, provider recovery, or provider-switch recovery may create more than one attempt.

ts
1type RunAttempt = {
2 id: RunAttemptId;
3 runId: RunId;
4 attemptOrdinal: number;
5 rootNodeId: NodeId;
6 provider: ProviderKind;
7 providerThreadId: ProviderThreadId;
8 providerTurnId: ProviderTurnId | null;
9 reason: "initial" | "steering_restart" | "retry" | "provider_recovery";
10 status:
11 | "pending"
12 | "running"
13 | "completed"
14 | "interrupted"
15 | "failed"
16 | "cancelled"
17 | "superseded";
18 startedAt: string | null;
19 completedAt: string | null;
20};

Run attempts let app-level steering work even for providers that cannot steer an active native turn. The app can interrupt the active provider turn and create a replacement attempt under the same RunId.

Only one attempt is the final selected attempt for run completion and checkpointing. Superseded/interrupted attempts remain in the execution graph for audit/debugging.

ExecutionNode

An ExecutionNode is the generic unit of runtime work. It is the bridge between provider events and app behavior.

ts
1type ExecutionNode = {
2 id: NodeId;
3 threadId: ThreadId;
4 runId: RunId | null;
5 parentNodeId: NodeId | null;
6 rootNodeId: NodeId;
7 kind:
8 | "root_turn"
9 | "assistant_message"
10 | "reasoning"
11 | "plan"
12 | "todo_list"
13 | "tool_call"
14 | "approval_request"
15 | "user_input_request"
16 | "subagent"
17 | "hook"
18 | "system";
19 status:
20 | "pending"
21 | "running"
22 | "waiting"
23 | "completed"
24 | "interrupted"
25 | "failed"
26 | "cancelled"
27 | "rolled_back";
28 countsForRun: boolean;
29 providerThreadId: ProviderThreadId | null;
30 providerTurnId: ProviderTurnId | null;
31 nativeItemRef: ProviderRef | null;
32 runtimeRequestId: RuntimeRequestId | null;
33 checkpointScopeId: CheckpointScopeId | null;
34 startedAt: string | null;
35 completedAt: string | null;
36};

The root node of a run is the only node allowed to complete the run. Subagent nodes, tool nodes, approval nodes, and plan nodes may complete independently.

CheckpointScope

A CheckpointScope describes a unit of filesystem state that can be checkpointed. Root runs and child execution nodes can both have checkpoint scopes.

ts
1type CheckpointScope = {
2 id: CheckpointScopeId;
3 threadId: ThreadId;
4 runId: RunId | null;
5 nodeId: NodeId;
6 parentScopeId: CheckpointScopeId | null;
7 providerThreadId: ProviderThreadId | null;
8 kind: "root_run" | "subagent" | "tool" | "provider_thread" | "manual";
9 ordinalWithinParent: number;
10 advancesAppRunCount: boolean;
11 cwd: string;
12 createdAt: string;
13};

Root run scopes have advancesAppRunCount = true. Child scopes, such as subagents, have advancesAppRunCount = false and are nested under the parent run's scope.

ProviderSession

A ProviderSession is a live provider runtime process/session, such as a Codex app-server process or a Claude SDK session.

ts
1type ProviderSession = {
2 id: ProviderSessionId;
3 provider: ProviderKind;
4 status: "starting" | "ready" | "running" | "waiting" | "stopped" | "error";
5 cwd: string;
6 model: string | null;
7 capabilities: ProviderCapabilities;
8 createdAt: string;
9 updatedAt: string;
10 lastError: string | null;
11};

A provider session may host one or more provider threads if the provider supports it. If a provider only supports one active conversation per process, V2 still models that as one provider thread attached to the session.

The session entity is durable metadata for a live-or-recoverable runtime, not the runtime handle itself. The in-memory process/client handle may disappear because the server restarted, an idle reaper released it, or the provider process crashed. In all cases, the app keeps the ProviderSession, ProviderThread, and native resume refs in durable state, then recreates the live runtime through the normal provider session manager path.

Provider runtime ids and ordinals must not depend on adapter process memory. If a value is persisted or must survive recovery, it is allocated by the orchestrator/correlation/id services or derived from durable provider refs.

ProviderThread

A ProviderThread is a provider-native conversation handle. It is addressable even when it is nested under a subagent execution node.

ts
1type ProviderThread = {
2 id: ProviderThreadId;
3 provider: ProviderKind;
4 providerSessionId: ProviderSessionId | null;
5 appThreadId: ThreadId | null;
6 ownerNodeId: NodeId | null;
7 nativeThreadRef: string | null;
8 nativeConversationHeadRef: ProviderRef | null;
9 status: "not_loaded" | "idle" | "active" | "archived" | "closed" | "error";
10 firstRunOrdinal: number | null;
11 lastRunOrdinal: number | null;
12 handoffIds: ContextHandoffId[];
13 forkedFrom: ProviderThreadForkSource | null;
14 createdAt: string;
15 updatedAt: string;
16};

appThreadId is set when the provider thread backs a first-class app thread. ownerNodeId is set when the provider thread is nested under an execution node, such as a subagent. A provider thread can later be forked or promoted into a first-class app thread.

A provider thread may have gaps in its native run coverage. Example: runs 1-5 use Codex provider thread A, runs 6-8 use Claude provider thread B, and run 9 returns to Codex thread A with a handoff summary covering runs 6-8. In that case, Codex thread A remains the same provider thread, but it has an explicit ContextHandoff before run 9.

ContextTransfer

A ContextTransfer records a source/target relationship and how context should move between them. It is the shared primitive for user forks, provider switching, merge-back, and subagents.

ts
1type ContextTransfer = {
2 id: ContextTransferId;
3 type: "fork" | "provider_handoff" | "merge_back" | "subagent_spawn" | "subagent_result";
4 sourceThreadId: ThreadId;
5 targetThreadId: ThreadId;
6 sourcePoint: ContextSourcePoint;
7 basePoint: ContextSourcePoint | null;
8 sourceProvider: ProviderKind | null;
9 targetProvider: ProviderKind | null;
10 status:
11 | "pending"
12 | "resolved_native"
13 | "resolved_portable"
14 | "failed"
15 | "consumed"
16 | "superseded";
17 resolution: ContextTransferResolution | null;
18 createdBy: "user" | "agent" | "system";
19 createdAt: string;
20 consumedAt: string | null;
21};
ts
1type ContextSourcePoint = {
2 threadId: ThreadId;
3 runId: RunId | null;
4 checkpointId: CheckpointId | null;
5 turnItemId: TurnItemId | null;
6 providerThreadRef: NativeThreadRef | null;
7 providerTurnRef: NativeTurnRef | null;
8};
9
10type ContextTransferResolution =
11 | { strategy: "native_fork"; providerThreadRef: NativeThreadRef }
12 | { strategy: "portable_context"; contextHandoffId: ContextHandoffId }
13 | { strategy: "delta_context"; contextHandoffId: ContextHandoffId }
14 | { strategy: "checkpoint_context"; contextHandoffId: ContextHandoffId };

Creating a transfer should be cheap. Expensive context handoffs are materialized lazily when the target run starts and the selected provider is known.

ContextHandoff

A ContextHandoff is a first-class artifact created when portable provider context must be bridged. It is most common when changing providers between runs, but it also applies when provider resume fails and a replacement provider thread must be seeded from app history.

In the broader transfer model, ContextTransfer records source, target, and lifecycle. ContextHandoff is the materialized payload consumed by a run when native transfer cannot satisfy the relationship.

ts
1type ContextHandoff = {
2 id: ContextHandoffId;
3 transferId: ContextTransferId;
4 threadId: ThreadId;
5 targetRunId: RunId;
6 fromProviderThreadIds: ProviderThreadId[];
7 toProviderThreadId: ProviderThreadId;
8 coveredRunOrdinals: {
9 from: number;
10 to: number;
11 };
12 strategy:
13 | "delta_since_target_last_seen"
14 | "full_thread_summary"
15 | "checkpoint_summary"
16 | "manual_context";
17 status: "pending" | "ready" | "failed" | "superseded";
18 summaryMessageId: MessageId | null;
19 summaryText: string;
20 createdByProvider: ProviderKind | null;
21 createdAt: string;
22 updatedAt: string;
23};

The handoff is not just prompt text. It is part of the graph and can be inspected, regenerated, superseded, or audited.

The preferred return-to-provider strategy is delta_since_target_last_seen: resume the previous provider thread and summarize only the runs that happened while that provider was inactive. Use full_thread_summary when resume fails, provider settings are incompatible, or the accumulated handoffs would create poor context quality.

ProviderTurn

A ProviderTurn is the normalized handle for a provider-native turn.

ts
1type ProviderTurn = {
2 id: ProviderTurnId;
3 providerThreadId: ProviderThreadId;
4 nodeId: NodeId;
5 runAttemptId: RunAttemptId | null;
6 nativeTurnRef: string | null;
7 ordinal: number;
8 status: "pending" | "running" | "completed" | "interrupted" | "failed" | "cancelled";
9 startedAt: string | null;
10 completedAt: string | null;
11};

Codex has strong native turn ids. Weaker providers may only have ordinals. Both map into ProviderTurnId.

RuntimeRequest

Requests represent provider-originated callbacks that require app/user response.

ts
1type RuntimeRequest = {
2 id: RuntimeRequestId;
3 nodeId: NodeId;
4 providerTurnId: ProviderTurnId | null;
5 nativeRequestRef: string | null;
6 kind:
7 | "command"
8 | "file-read"
9 | "file-change"
10 | "dynamic_tool_call"
11 | "user_input"
12 | "auth_refresh";
13 status: "pending" | "resolved" | "expired" | "cancelled";
14 responseCapability:
15 | { type: "live"; providerSessionId: ProviderSessionId }
16 | { type: "not_resumable"; reason: string };
17 createdAt: string;
18 resolvedAt: string | null;
19};

The permission kinds are the same canonical domain values used by V1 ProviderRequestKind. Adapters map provider-native callback names such as Codex item/commandExecution/requestApproval and Claude tool permission names into these app-level values.

Requests may remain visible after restart, but they are only respondable if their responseCapability is live.

Messages

Messages are part of the conversation projection, not the raw provider graph. They link back to runs and nodes when possible.

ts
1type ConversationMessage = {
2 id: MessageId;
3 threadId: ThreadId;
4 runId: RunId | null;
5 nodeId: NodeId | null;
6 role: "user" | "assistant" | "system";
7 text: string;
8 attachments: Attachment[];
9 streaming: boolean;
10 createdAt: string;
11 updatedAt: string;
12};

Provider message chunks are collected through items/content events and projected into messages. The projection may hide child/subagent messages by default while preserving them in the graph.

Plans, Questions, And Todo Lists

V2 treats these as structured turn items or execution nodes.

ts
1type PlanArtifact = {
2 id: PlanId;
3 threadId: ThreadId;
4 runId: RunId | null;
5 nodeId: NodeId;
6 kind: "proposed_plan" | "todo_list" | "questions";
7 status: "draft" | "active" | "completed" | "superseded";
8 markdown?: string;
9 steps?: Array<{ id: string; text: string; status: "pending" | "running" | "completed" }>;
10 questions?: UserInputQuestion[];
11};

Codex turn/plan/updated maps to a todo_list artifact. Plan-mode final plan items map to proposed_plan. User-input question requests map to questions plus a RuntimeRequest.

Checkpoint

Checkpoints attach to checkpoint scopes. A root run checkpoint is the user-visible conversation checkpoint. A child checkpoint records nested filesystem state for a subagent, tool, or provider thread without advancing the parent run count.

ts
1type Checkpoint = {
2 id: CheckpointId;
3 threadId: ThreadId;
4 scopeId: CheckpointScopeId;
5 runId: RunId | null;
6 nodeId: NodeId;
7 parentCheckpointId: CheckpointId | null;
8 ordinalWithinScope: number;
9 appRunOrdinal: number | null;
10 ref: string;
11 status: "ready" | "missing" | "error" | "stale";
12 files: CheckpointFileSummary[];
13 capturedAt: string;
14};

A child/subagent provider turn can create a child checkpoint. It does not create an app-run checkpoint unless it is running as a first-class app run in a forked/promoted thread.

Raw Provider Diagnostics

Raw provider diagnostics are append-only evidence for debugging, support, and replay fixture generation. They should be written to bounded rotating log files, not treated as canonical durable SQLite state.

ts
1type RawProviderEvent = {
2 id: RawEventId;
3 provider: ProviderKind;
4 providerSessionId: ProviderSessionId;
5 sequence: number;
6 direction: "incoming" | "outgoing";
7 messageKind: "request" | "response" | "notification" | "error";
8 method: string | null;
9 jsonRpcId: string | number | null;
10 payload: unknown;
11 observedAt: string;
12};

No domain behavior should depend on parsing historic UI events if raw provider diagnostics are available. Production behavior should depend on normalized orchestration events/entities and provider refs. Raw provider frames are evidence and replay input, not the source of truth for normal app state.

Normalized V2 entities should preserve enough correlation metadata to explain and route behavior:

  • provider kind.
  • provider session/thread/turn refs.
  • provider item/request refs when available.
  • native id strength.
  • diagnostic log location or raw frame reference when useful.

The replay framework can use raw provider transcripts as input without requiring production SQLite to store every raw frame.

Projections

V2 should expose separate projections:

  • Thread shell: fast sidebar list.
  • Thread detail: messages, runs, activities, checkpoints, plans.
  • Execution graph: debug/developer view of nodes and provider refs.
  • Provider sessions: live runtime/process state.
  • Pending requests: actionable approvals/user input.

The UI can remain simple while the graph remains precise.

Projection streaming should use the existing app snapshot-plus-cursor contract. A thread-detail subscription should return a projection snapshot at sequence N, then stream only events after N. The frontend should not receive events already reflected in the snapshot. Reconnects may reset from a fresh snapshot instead of replaying local client state; the fresh snapshot and its snapshotSequence become the new cursor boundary.

Turn Item Projection

The frontend should not reconstruct display order by merging messages, plans, checkpoints, approvals, and activities itself. V2 exposes an ordered turnItems projection for thread detail rendering.

turnItems are projection records, not the canonical source of truth. The canonical graph remains normalized in runs, attempts, nodes, requests, messages, plans, and checkpoints. Provider-native item refs live directly on nodes and turn items where correlation is needed.

Forked threads also expose a derived visibleTurnItems projection. turnItems remains the canonical items emitted on that app thread. visibleTurnItems is the user-visible ordered read model: it may reference inherited source items through the fork source point, include synthetic lifecycle markers such as "forked from conversation", and then continue with local target-thread items. This keeps storage normalized while giving thread detail views and tests one backend-owned surface to assert.

Known common tools should have structured item variants so the frontend can render stable custom components without parsing provider text:

ts
1type TurnItem =
2 | UserMessage
3 | AssistantMessage
4 | Reasoning
5 | Plan
6 | FileChange
7 | CommandExecution
8 | FileSearch
9 | WebSearch
10 | ApprovalRequest
11 | Checkpoint
12 | Compaction
13 | Handoff
14 | Fork
15 | DynamicTool;

Examples:

ts
1type FileChange = {
2 type: "file_change";
3 fileName: string;
4 additions?: number;
5 deletions?: number;
6 diffStr?: string;
7 oldStr?: string;
8 newStr?: string;
9};
10
11type CommandExecution = {
12 type: "command_execution";
13 input: string;
14 status: TurnItemStatus;
15 output?: string;
16 exitCode?: number;
17};
18
19type DynamicTool = {
20 type: "dynamic_tool";
21 toolName: string | null;
22 input: unknown;
23 output?: unknown;
24};

Compaction, handoff, and fork are orchestration lifecycle items, not dynamic tools:

  • compaction: one item can transition from running with title like "Compacting context..." to completed with title like "Compacted context".
  • handoff: records the context bridge from one or more source provider threads/providers to a target provider thread/provider.
  • fork: records that the user or system created a new app thread from a run, node, or provider thread.

The UI can render known variants with deterministic components and render dynamic_tool as expandable JSON input/output. Each turn item keeps refs back to runId, nodeId, providerTurnId, and nativeItemRef so debug views can jump from the display stream back into the graph and provider logs.