Skip to content

Canonical Event Model — v1.0

Status: Normative (Phase 1, 2026-10-12). The wire format is defined by schema/v1/ingest.schema.json; this document defines the semantics JSON Schema cannot express (merge rules, derived states, version leniency). The reference implementation is server/src/aobs_server/model/; the conformance suite is schema/fixtures/. Changes since the Phase 0 draft are explained in reviews/2026-10-12-pre-phase-1-review.md.

1. Concepts and hierarchy

organization
└─ project                         (tenant boundary; owns API keys, policies, retention)
   └─ environment                  (dev, staging, production, …)
      └─ session  (optional)       (a conversation / ticket / job grouping several runs)
         └─ run  ≡  trace          (one end-to-end agent execution)
            └─ span (tree)         (agent, llm, tool, retrieval, embedding, step, custom …)
               └─ span event       (point-in-time: retry, handoff, stream milestone, custom)
  • A run is a trace. The run's root span normally has kind: "agent". Work that belongs to a different execution (a background job kicked off by a run) gets its own trace and a link.
  • Multi-agent: a sub-agent invoked in-process is a child agent span in the same trace. A handoff is a handoff span event on the delegating span and a handoff_from link on the receiving agent span.
  • Session is an indexed session_id string on spans; there is no session table in v1.
  • organization, project and project_id are server-side only. No client field can name a project; the project is derived from the API key.

2. Identifiers

ID Format Generated by Notes
trace_id 32 lowercase hex chars, not all zero SDK W3C Trace Context / OTel compatible
span_id 16 lowercase hex chars, not all zero SDK Unique within a trace
event_id lowercase UUID (UUIDv7 recommended) SDK Span events only; idempotency key
batch_id lowercase UUID SDK Optional, diagnostics only

Natural keys: a span is (project_id, trace_id, span_id); its start and end are distinguished by event type. A span event is (project_id, event_id).

3. Wire envelope

POST /v1/ingest, Content-Type: application/json. Required: schema_version, sent_at, resource, events (1–1,000).

{
  "schema_version": "1.0",
  "batch_id": "0192f4a1-7c3e-7b10-9a2d-5f6e1c0b8d41",
  "sent_at": "2026-10-12T12:15:03.120000Z",
  "resource": {
    "service_name": "support-agent",
    "service_version": "git:3f9c2e1",
    "environment": "production",
    "sdk": { "name": "aobs-python", "version": "0.1.0", "language": "python", "runtime": "cpython-3.12.4" },
    "integrations": [
      { "name": "openai_compat", "version": "0.1.0", "capabilities": ["llm.usage", "llm.streaming"] }
    ],
    "capture": { "mode": "metadata_only", "error_messages": true, "stack_traces": false }
  },
  "events": [ "span_start | span_end | span_event objects, in any order" ]
}
  • sent_at (SDK clock at send time) lets the server estimate client clock offset.
  • resource.capture declares what the SDK was configured to capture, so the UI can say "not captured (policy)" rather than "missing". It is informational: the server enforces the project's own policy as a ceiling.
  • resource.integrations[].capabilities declares what each integration can report, so missing data can be explained as "this integration cannot report it".

4. Event types

type Required Meaning
span_start trace_id, span_id, parent_span_id (null = root), kind, name, start_time A span began. Optional but recommended: makes crashed or still-running work visible. May carry links, session_id, agent, llm, tool, retry, input_fingerprint, attributes. Must not carry end-only fields.
span_end everything required on span_start plus end_time, duration_ms, status; and a tool block when kind is tool Final, self-contained record. Can arrive without a start. Additionally may carry error, usage, cost, content.
span_event event_id, trace_id, span_id, name, time Point-in-time annotation; optional attributes.

parent_span_id is always present so that "forgot to send the parent" can never be mistaken for "this is a root".

5. Span fields

Field Type Notes
kind open vocabulary Known: agent, llm, tool, retrieval, embedding, step, custom. Reserved: guardrail, evaluation. Unknown kinds are accepted and rendered generically.
name string 1–256 Human label. Do not put prompt or user content in names.
start_time, end_time RFC 3339 UTC ending in Z Up to 9 fractional digits accepted; stored with µs precision. Wall clock, for placement.
duration_ms number 0 – 30 days Measured with a monotonic clock by the SDK; authoritative over end_time − start_time.
status ok | error | cancelled Closed enum; span_end only.
error {type, message?, code?, origin?, retryable?, stack?} origin is open vocabulary: application, provider, tool, timeout, instrumentation, unknown. message ≤ 8 KiB (secret-scrubbed; can be disabled by capture policy). stack ≤ 32 KiB, only when stack_traces capture is on.
links ≤ 32 × {trace_id, span_id, relation} relation (open): follows_from, triggered_by, handoff_from, retry_of
session_id string 1–256 Optional grouping
agent {name, id?, version?} On agent spans
retry {attempt ≥ 1, max_attempts?} Each attempt is its own span; a retry_of link points to the previous attempt
input_fingerprint hmac-sha256:<key_id>:<64 hex> Keyed hash of the canonical JSON input (tool arguments, LLM request, retrieval query). Lets repeated identical calls be detected without storing inputs. Unkeyed hashes are rejected (ADR-011).
attributes ≤ 128 keys Keys ^[A-Za-z][A-Za-z0-9_.-]{0,127}$, namespaced (app.customer_tier, openai.system_fingerprint). Values: string ≤ 4,096 chars, number, boolean, or array (≤ 128) of those. No nulls, no nested objects. Reserved prefixes: aobs. (e.g. aobs.prompt.name, aobs.prompt.version, aobs.handoff.*), gen_ai., mcp.

llm (all optional; provider-specific extras go in attributes)

"llm": {
  "provider": "openai",                     // open vocabulary, lowercase
  "operation": "chat",                      // open vocabulary: chat, completion, embeddings, responses …
  "request_model": "model-x",
  "response_model": "model-x-2026-08-01",
  "request": { "temperature": 0.2, "top_p": 1, "max_tokens": 512, "stream": true },
  "response": { "id": "resp_…", "finish_reasons": ["tool_calls"] },
  "provider_request_id": "req_…",
  "streaming": { "time_to_first_token_ms": 312.4, "chunk_count": 41, "completed": true }
}

tool: { "name": "get_order_status", "type": "function", "call_id": "call_abc123" } — name required; type open vocabulary (function, mcp, http, browser, db, file, …).

usage (span_end only)

"usage": { "input_tokens": 812, "output_tokens": 95, "cache_read_input_tokens": 512, "source": "provider_reported" }

Fields: input_tokens, output_tokens, total_tokens, cache_read_input_tokens, cache_write_input_tokens, reasoning_tokens (integers ≥ 0); source is provider_reported or sdk_estimated. At least one count is required. Unknown counts are omitted — null is rejected. total_tokens is only what the provider reported; the platform never derives it.

Canonical meaning (clarified in Phase 2; no wire change): input_tokens is all input tokens the model processed, including tokens read from or written to a prompt cache; cache_read_input_tokens and cache_write_input_tokens are subsets of it. Likewise reasoning_tokens is a subset of output_tokens. Integrations must convert providers that report cached tokens separately (adding them in), so totals and Phase 6 cost estimates mean the same thing for every provider. OpenAI Chat Completions already reports prompt_tokens inclusively.

cost (span_end only; only when the provider itself reports cost)

"cost": { "amount": "0.000731", "currency": "USD", "source": "provider_reported" }

amount is a decimal string (no exponent); currency is ISO 4217. Platform estimates are server-derived and cannot be sent by clients.

6. Unknown, estimated, measured

State Representation
Measured / reported Field present; source: provider_reported where provenance matters
Estimated Field present; source: sdk_estimated
Unknown / not reported Field absent (the only spelling of unknown; null is rejected)
Not captured by policy content.*.status = "not_captured"
Redacted content.*.status = "redacted", optional redaction_count
Unavailable from integration content.*.status = "unavailable", or the capability is not declared in resource.integrations

Rules:

  1. Absent never means zero. Aggregates report a value and how many spans contributed.
  2. Costs are never computed from unknown inputs. If tokens or the model price are missing, the platform estimate is null with a reason (missing_usage, unpriced_model). (Phase 6)
  3. Server-derived values are stored next to, never instead of, client values.
  4. Money is a decimal string, never a float.

7. Content (prompts, responses, arguments, results)

"content": {
  "input":  { "status": "captured", "format": "messages", "value": [ … ], "truncated": false },
  "output": { "status": "redacted", "format": "text", "value": "Mail [REDACTED:email]", "redaction_count": 1, "truncated": true }
}
  • status (closed): captured, redacted, not_captured, unavailable.
  • captured and redacted require format (text | messages | json) and value (any JSON). not_captured and unavailable must not carry format, value, truncated or redaction_count.
  • truncated is a flag that can accompany captured or redacted; redaction_count is only valid with redacted.
  • messages values follow the OpenTelemetry GenAI {role, parts[]} shape.
  • Content is only allowed on span_end, so the SDK can decide at the end whether to attach it.
  • Server-side (Phase 3, not expressible in JSON Schema): each content part is limited to 64 KiB of serialized JSON; the project's policy ceiling strips value and records not_captured.

8. Span events

{ "type": "span_event", "event_id": "0192f4a1-7c3e-7b10-9a2d-5f6e1c0b8d41",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7",
  "name": "handoff", "time": "2026-10-12T12:15:02.410000Z",
  "attributes": { "aobs.handoff.from_agent": "triage", "aobs.handoff.to_agent": "refund_specialist" } }

Well-known names: handoff, retry.scheduled, stream.first_chunk, log, exception (a handled exception that did not fail the span). A span event whose span never arrives is listed as an orphan event.

9. Merge, ordering and derived state

Implemented in aobs_server.model.merge and aobs_server.model.assembly.

Merge (ADR-009)

Rule Detail
Canonical form Event parsed into the model, dumped as JSON with unset fields omitted, keys sorted. Equal instants written differently (…:01Z vs …:01.000000Z) are the same content.
Duplicates Same natural key and identical canonical form: counted, ignored.
Conflicts Same natural key, different content: the copy with the lowest SHA-256 of its canonical JSON wins; the others are counted and the span is flagged conflicting_duplicates.
Start + end Deep merge: objects merge key by key, end wins at leaves, arrays are replaced. If parent_span_id, kind, name or start_time differ, the end's value is used and the span is flagged start_end_mismatch.
Order independence The assembled result is identical for every permutation of the events and with any number of injected duplicates (property-tested).

Per-span state

State Rule
lifecycle complete (has an end) · in_progress (no end, started less than stale_after ago; default 15 min, per project later) · incomplete (no end, older)
orphan_reason missing_parent (parent id not present in the trace) · cycle (parent pointers loop, including a span naming itself). Orphans are listed separately and never attached to a guessed parent; their own children still attach to them.
clock_skew_suspected Child starts more than skew_tolerance (default 50 ms) before its parent, or ends that much after it. Timestamps are never rewritten.
Children order By start_time, then span_id.

Trace status, evaluated over all roots (a trace may have several):

  1. No roots: in_progress if the newest timestamp is within stale_after, else incomplete.
  2. Some root has no end: in_progress if every unended root is in progress, else incomplete.
  3. Any root ended error → error; else any root ended cancelled → cancelled.
  4. Any span anywhere ended error → ok_with_errors; else ok.

open_span_count is reported alongside: an ok trace with open spans has instrumentation gaps or detached work, which the UI shows rather than hiding in the status.

Token totals (per count field): summed over llm and embedding spans only (usage on other kinds, e.g. an agent's aggregate, would double count). Each total reports value (null if no span reported it), reported_spans, expected_spans (all llm/embedding spans, ended or not), complete and estimated (any contributing span used sdk_estimated).

10. Versioning and evolution (ADR-010)

  • schema_version is "MAJOR.MINOR". This server supports major 1 and knows minor 0.
  • Unknown major: the batch is rejected (unsupported_schema_version).
  • Minor ≤ known: strict — unknown fields are errors (catches typos such as stauts).
  • Minor > known: lenient — unknown fields are moved into extra (keyed by path, ≤ 4 KiB per event; beyond that they are dropped and the event index is reported) and all known fields are still validated strictly. extra is part of an event's canonical identity.
  • Minor versions may only add optional fields, values of open vocabularies (kind, llm.provider, llm.operation, tool.type, error.origin, link relation, capability names, sdk.language) and event names. Closed enums (status, content status/format, usage source, cost source, capture mode) change only in a new major.
  • The server will support the current and previous major for at least 12 months after a new major.
  • Payloads are parsed strictly: NaN/Infinity, numbers that overflow to infinity, duplicate object keys, unpaired surrogates, invalid UTF-8 and nesting deeper than 64 levels are rejected.
  • Every schema change needs a CHANGELOG entry and an updated SCHEMA_LOCK; a test enforces both.
  • Patterns in the schema are ECMA-262. Note for implementers in Python: $ must be treated as an end anchor (\Z), since Python's $ also matches before a trailing newline.

11. Mapping to OpenTelemetry GenAI (Phase 7)

The OTel GenAI conventions live in open-telemetry/semantic-conventions-genai and are marked Development. We map at the boundary rather than adopting their names internally:

Canonical OTel attribute
llm.provider gen_ai.provider.name
llm.operation gen_ai.operation.name (chat, embeddings, execute_tool, invoke_agent, …)
llm.request_model / response_model gen_ai.request.model / gen_ai.response.model
usage.input_tokens / output_tokens gen_ai.usage.input_tokens / gen_ai.usage.output_tokens
usage.cache_read_input_tokens gen_ai.usage.cache_read.input_tokens
tool.name / tool.call_id gen_ai.tool.name / gen_ai.tool.call.id
agent.name / agent.id / agent.version gen_ai.agent.name / .id / .version
session_id gen_ai.conversation.id
content.input / content.output gen_ai.input.messages / gen_ai.output.messages (opt-in in OTel too)
input_fingerprint no OTel equivalent — absent for OTLP-ingested spans

12. Example: one run, with relationships

A support agent answers a ticket, calls a tool, hands off to a refund agent whose tool times out. (Fixtures success_run, failed_tool_run and nested_multi_agent encode variants of this.)

trace 4bf92f3577b34da6a3ce929d0e0e4736   run "answer_ticket"     agent   2.84 s  error
├─ llm   chat  model-x  attempt 1         0.91 s  ok      812 in / 95 out tokens
├─ tool  get_order_status                 0.12 s  ok      input hmac-sha256:p1:9f2c…
├─ llm   chat  model-x                    1.20 s  ok      usage: not reported (stream)
│   · event stream.first_chunk  +312 ms
├─ agent refund_specialist                0.55 s  error   link handoff_from → root
│   └─ tool issue_refund                  0.50 s  error   TimeoutError (origin: timeout)
└─ · event handoff  triage → refund_specialist

What the platform can then say, honestly: 2 LLM calls, 2 tool calls, 1 sub-agent; the failure is at issue_refund (timeout); input tokens are 812, partial (1 of 2 LLM calls reported); prompt and argument content were not captured (metadata-only mode).

[
  { "type": "span_start", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7",
    "parent_span_id": null, "kind": "agent", "name": "answer_ticket",
    "start_time": "2026-10-12T12:15:00.000000Z", "session_id": "ticket-8812",
    "agent": { "name": "triage", "version": "1.4.0" } },

  { "type": "span_end", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "b7ad6b7169203331",
    "parent_span_id": "00f067aa0ba902b7", "kind": "tool", "name": "get_order_status",
    "start_time": "2026-10-12T12:15:00.930000Z", "end_time": "2026-10-12T12:15:01.050000Z",
    "duration_ms": 120.4, "status": "ok",
    "tool": { "name": "get_order_status", "type": "function", "call_id": "call_abc123" },
    "input_fingerprint": "hmac-sha256:p1:9f2c4e0000000000000000000000000000000000000000000000000000000000",
    "content": { "input": { "status": "not_captured" }, "output": { "status": "not_captured" } } },

  { "type": "span_end", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "e457b5a2e4d86bd1",
    "parent_span_id": "5fb397be34d26b51", "kind": "tool", "name": "issue_refund",
    "start_time": "2026-10-12T12:15:02.300000Z", "end_time": "2026-10-12T12:15:02.800000Z",
    "duration_ms": 500.2, "status": "error",
    "tool": { "name": "issue_refund", "type": "http" },
    "error": { "type": "TimeoutError", "message": "refund API did not respond within 500 ms",
               "origin": "timeout", "retryable": true } }
]

If the root's span_end never arrives, the run is shown as in progress and then incomplete — never as successful.