docs/orchestration-v2/entity-ids-and-correlation.md

Entity IDs And Correlation

Principle

Provider ids are evidence. App ids are identity.

Every durable entity uses an app-owned id as its primary key. Provider-native ids are stored as optional refs and used only for correlation, debugging, replay, and command routing.

ID Families

text
1ThreadId app-visible conversation thread
2RunId counted app turn
3NodeId execution graph node
4ProviderSessionId live/resumable provider runtime session
5ProviderThreadId app handle for provider-native conversation
6ProviderTurnId app handle for provider-native turn
7RuntimeRequestId app handle for provider callback/request
8RawEventId optional raw provider diagnostic frame id
9CheckpointId app checkpoint id
10PlanId app plan/todo/question artifact id

Provider ids never replace these ids.

Provider Refs

Provider refs are metadata on runtime entities and events.

ts
1type ProviderRefs = {
2 provider: ProviderKind;
3 nativeSessionRef?: string;
4 nativeThreadRef?: string;
5 nativeTurnRef?: string;
6 nativeItemRef?: string;
7 nativeRequestRef?: string;
8 rawEventId?: RawEventId;
9 method?: string;
10};

Codex can populate most of these fields. Other providers may populate only a subset.

Correlation Scope

Correlation is always scoped. Never match a native id globally without a provider/session/thread scope.

Preferred matching keys:

text
1ProviderThread:
2 provider + providerSessionId + nativeThreadRef
3
4ProviderTurn:
5 providerThreadId + nativeTurnRef
6 or providerThreadId + turnOrdinal
7
8Node / TurnItem:
9 providerTurnId + nativeItemRef
10 or providerTurnId + itemOrdinal
11
12RuntimeRequest:
13 providerSessionId + nativeRequestRef
14 or providerTurnId + requestOrdinal

If a provider recycles ids, the provider adapter must widen the scope until the mapping is unambiguous.

Mapping Store

V2 needs a durable correlation table or equivalent event-sourced binding stream.

ts
1type IdentityBinding = {
2 id: IdentityBindingId;
3 appEntityKind: "provider_thread" | "provider_turn" | "node" | "item" | "request" | "message";
4 appEntityId: string;
5 provider: ProviderKind;
6 providerSessionId: ProviderSessionId;
7 nativeKind: string | null;
8 nativeRef: string | null;
9 scope: {
10 threadId: ThreadId | null;
11 runId: RunId | null;
12 parentNodeId: NodeId | null;
13 providerThreadId: ProviderThreadId | null;
14 providerTurnId: ProviderTurnId | null;
15 };
16 correlation: "native_exact" | "native_scoped" | "ordinal" | "fingerprint" | "synthetic";
17 firstRawEventId?: RawEventId;
18 lastRawEventId?: RawEventId;
19 createdAt: string;
20 updatedAt: string;
21};

This is not meant to be a large identity subsystem. It is the minimal durable place that says, "this native thing corresponds to this app thing." Raw event references are optional diagnostic pointers; the binding itself must not require production SQLite to retain raw provider frames forever.

ID Allocation Rules

  1. If the provider gives a stable native id, reuse the existing binding or allocate a new app id and bind it.
  2. If the provider gives no stable id, allocate by scoped ordinal.
  3. If ordinals are insufficient, add a fingerprint inside the scope.
  4. Once allocated, never change the app id for that entity.
  5. Never infer parent/root completion by string matching ids.

Scoped Ordinals

Weak providers need deterministic ordinals.

text
1runOrdinalWithinThread
2providerTurnOrdinalWithinProviderThread
3nodeOrdinalWithinParent
4itemOrdinalWithinProviderTurn
5requestOrdinalWithinProviderTurn
6messageOrdinalWithinNode

Ordinals are assigned by the normalizer when provider frames are processed. They must be replay-deterministic for a given provider transcript.

Fingerprints

Fingerprints are only fallback correlation tools. They should include scope and stable structure, not large mutable text.

Examples:

text
1ProviderTurn fingerprint:
2 providerThreadId + runId + providerTurnOrdinalWithinProviderThread
3
4Node / TurnItem fingerprint:
5 providerTurnId + itemKind + itemOrdinalWithinProviderTurn + toolName?
6
7RuntimeRequest fingerprint:
8 providerTurnId + requestKind + nativeItemRef? + requestOrdinalWithinProviderTurn

Do not use complete assistant text as a primary fingerprint. Text is mutable, can stream in chunks, and can be duplicated.

Runtime Events

Normalized runtime events should carry both app ids and provider refs.

ts
1type RuntimeEvent = {
2 id: RuntimeEventId;
3 type: RuntimeEventType;
4 threadId: ThreadId;
5 runId: RunId | null;
6 nodeId: NodeId | null;
7 parentNodeId: NodeId | null;
8 providerSessionId: ProviderSessionId | null;
9 providerThreadId: ProviderThreadId | null;
10 providerTurnId: ProviderTurnId | null;
11 nativeItemRef: string | null;
12 requestId: RuntimeRequestId | null;
13 providerRefs: ProviderRefs;
14 payload: unknown;
15 createdAt: string;
16};

Downstream systems should use app ids. Provider refs are preserved for inspection and adapter routing.

Command Routing

UI and orchestration commands target app ids.

text
1approval.respond(RuntimeRequestId)
2interrupt.run(RunId)
3interrupt.node(NodeId)
4fork.fromNode(NodeId)
5rollback.toRun(ThreadId, runOrdinal)

The provider command layer resolves app ids to provider refs at the edge. If the provider ref is missing or no longer live, the command fails with a typed capability/state error.

Examples:

text
1RuntimeRequestId -> nativeRequestRef + providerSessionId
2RunId -> root NodeId -> ProviderTurnId -> nativeTurnRef
3NodeId -> ProviderThreadId -> nativeThreadRef

Pending Requests After Restart

Pending request records may survive restart, but provider callback state usually does not.

V2 should distinguish historical pending-looking requests from respondable requests:

ts
1type ResponseCapability =
2 | { type: "live"; providerSessionId: ProviderSessionId }
3 | { type: "not_resumable"; reason: string };

The UI can show that a request expired and the user must restart or rerun the turn.

Subagent Correlation

Subagents are represented through parent-child execution nodes.

For Codex:

text
1collabAgentToolCall item
2 -> ExecutionNode(kind="subagent" or "tool_call")
3receiverThreadIds[]
4 -> ProviderThread records
5child turn/started and turn/completed
6 -> child ProviderTurn and child ExecutionNode

The child provider turn keeps its own provider refs. It is linked to the parent through parentNodeId, not by replacing its turn id with the parent turn id.

For weak providers, the adapter may create a child node by ordinal under the active parent node.

Replay Determinism

Given the same provider transcript and same initial app state, normalization must produce the same ids.

Requirements:

  • Identity bindings are persisted as events or durable rows.
  • Generated ids can be random if the binding is persisted before downstream projection.
  • Fixture replay may use deterministic id generation to make assertions easier.
  • Reprocessing the same provider frame should find the existing binding, not allocate another entity.