{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://openwop.dev/spec/v1/run-snapshot.schema.json",
  "title": "RunSnapshot",
  "description": "Projected run state returned by `GET /v1/runs/{runId}`. Source is the run's append-only event log (run-event.schema.json) folded through the `RunProjection`. Forward-compat tolerant: readers MUST ignore unknown fields rather than reject.",
  "type": "object",
  "required": ["runId", "workflowId", "status"],
  "properties": {
    "runId": {
      "type": "string",
      "description": "Globally unique run identifier.",
      "minLength": 1,
      "maxLength": 128
    },
    "workflowId": {
      "type": "string",
      "description": "ID of the workflow this run executes.",
      "minLength": 1
    },
    "status": {
      "type": "string",
      "enum": [
        "pending",
        "running",
        "paused",
        "waiting-approval",
        "waiting-input",
        "waiting-external",
        "completed",
        "failed",
        "cancelling",
        "cancelled"
      ],
      "description": "Current run state. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event` per `interrupt-profiles.md §openwop-interrupt-external-event` — distinguishes external-event waits from HITL waits at the wire level. `cancelling` (RFC 0094 §B) is the transitional state between a cancel request being accepted and the terminal `cancelled` — `rest-endpoints.md` and the OpenAPI cancel responses already document the transition; a snapshot read during the cancel cascade carries it. Forward-compat: future statuses MAY be added; readers SHOULD treat unknown values as terminal-unknown rather than throw."
    },
    "owner": {
      "type": "object",
      "description": "RFC 0048. The identity triple that owns this run. Redaction-safe — `principal` is an opaque identifier, never PII or credential material. Optional: single-tenant hosts omit it. A principal scoped to one `workspace` MUST NOT read a run owned by another (`run_forbidden`).",
      "required": ["tenant"],
      "properties": {
        "tenant": { "type": "string", "minLength": 1, "description": "Top-level isolation boundary." },
        "workspace": { "type": "string", "minLength": 1, "description": "Optional sub-tenant within the tenant (RFC 0048 workspace)." },
        "principal": { "type": "string", "minLength": 1, "description": "Acting identity (user or agent) — opaque id, never PII." }
      },
      "additionalProperties": false
    },
    "currentNodeId": {
      "type": "string",
      "description": "Set when the run is suspended at a specific node (`waiting-approval` / `waiting-input` / `waiting-external`) — identifies which node holds the interrupt."
    },
    "startedAt": { "type": "string", "format": "date-time" },
    "completedAt": { "type": "string", "format": "date-time" },
    "agent": {
      "$ref": "agent-ref.schema.json",
      "description": "Optional run-level agent identity (Multi-Agent Shift Phase 1). When the run is driven by a single agent, this field carries that agent's `AgentRef`. In supervisor-orchestrated runs (Phase 5), this field rotates as workers hand off — it always carries the active worker for the current node. See `runOrchestrator` for the run-lifetime supervisor identity. Absent for runs with no agent provenance (legacy single-actor host)."
    },
    "runOrchestrator": {
      "$ref": "agent-ref.schema.json",
      "description": "Optional orchestrator-supervisor identity (Multi-Agent Shift Phase 5). When set, this agent owns dispatch decisions across the run's lifetime; `runOrchestrator.decided` events emitted during the run carry this agent's `agentId`. Distinct from `agent` — `agent` rotates with each worker; `runOrchestrator` is set at run start (or first `core.orchestrator.supervisor` node) and MUST NOT change for the run's lifetime. In single-agent runs (no supervisor), this field is absent; in supervisor runs both fields MAY co-exist."
    },
    "nodeStates": {
      "type": "object",
      "description": "Per-node state map. Keys are nodeIds; values are implementation-shaped state objects. Spec doesn't constrain the inner shape — see version-negotiation.md §node-states."
    },
    "variables": {
      "type": "object",
      "description": "Workflow-level variables (caller-supplied inputs + per-node outputs that survive the projection)."
    },
    "channels": {
      "type": "object",
      "description": "Optional typed state channels (see channels-and-reducers.md). Present when the workflow declares channel-aware mode."
    },
    "error": {
      "type": "object",
      "description": "Set on terminal `failed`. Structured so consumers can route on code without parsing message text.",
      "required": ["code", "message"],
      "properties": {
        "code": { "type": "string", "minLength": 1 },
        "message": { "type": "string", "minLength": 1 },
        "details": { "type": "object" }
      },
      "additionalProperties": false
    },
    "engineVersion": {
      "type": "string",
      "description": "Engine version the run was started under. Used by the projection for forward-compat folds."
    },
    "eventLogSchemaVersion": {
      "type": "integer",
      "minimum": 0,
      "description": "Per-run event-log subcollection schema version. See version-negotiation.md."
    },
    "tags": {
      "type": "array",
      "items": { "type": "string", "maxLength": 256 },
      "maxItems": 100,
      "description": "Caller-supplied tags from `RunOptions.tags`."
    },
    "metadata": {
      "type": "object",
      "description": "Caller-supplied metadata from `RunOptions.metadata`."
    },
    "configurable": {
      "type": "object",
      "description": "Per-run overlay from `RunOptions.configurable` (see run-options.md). Reserved keys: `recursionLimit`, `model`, `temperature`, `maxTokens`, `promptOverrides`."
    },
    "metrics": {
      "type": "object",
      "description": "Aggregate run-level metrics. Forward-compat: readers MUST tolerate missing/unknown fields. Fields are populated lazily as the engine emits them — absence does NOT mean zero. Implementation-specific fields (e.g., legacy `cost: number` estimates) MAY appear alongside the spec-canonical fields below.",
      "properties": {
        "openwopCost": {
          "type": "object",
          "description": "Spec-canonical cost rollup aggregated from per-node `recordCost()` calls. Keys mirror the `openwop.cost.*` OTel attribute allowlist (see `observability.md` §Cost attribution). Named `openwopCost` rather than `cost` because some implementations carry a legacy `metrics.cost: number` estimate that predates the typed rollup; using a distinct name avoids collision. Multi-provider runs report `provider`/`model` of the LAST contributing call; SDKs that need per-call detail SHOULD subscribe to OTel spans instead of reading this rollup.",
          "properties": {
            "usd": { "type": "number", "minimum": 0, "description": "Total USD cost across all node-level recordCost emissions." },
            "tokens": {
              "type": "object",
              "properties": {
                "input": { "type": "integer", "minimum": 0 },
                "output": { "type": "integer", "minimum": 0 }
              },
              "additionalProperties": false
            },
            "model": { "type": "string", "description": "Model identifier of the most recent recordCost emission (e.g., `claude-opus-4-7`). For multi-model runs, surfaces the last value." },
            "provider": { "type": "string", "description": "Normalized provider name (e.g., `anthropic`, `openai`, `gemini`)." },
            "duration_ms": { "type": "integer", "minimum": 0, "description": "Sum of per-call durations across all recordCost emissions in this run." }
          },
          "additionalProperties": false
        }
      }
    }
  },
  "additionalProperties": true
}
