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
agentspan in the same trace. A handoff is ahandoffspan event on the delegating span and ahandoff_fromlink on the receiving agent span. - Session is an indexed
session_idstring on spans; there is no session table in v1. organization,projectandproject_idare 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.capturedeclares 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[].capabilitiesdeclares 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)
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:
- Absent never means zero. Aggregates report a value and how many spans contributed.
- Costs are never computed from unknown inputs. If tokens or the model price are missing, the
platform estimate is
nullwith a reason (missing_usage,unpriced_model). (Phase 6) - Server-derived values are stored next to, never instead of, client values.
- 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.capturedandredactedrequireformat(text|messages|json) andvalue(any JSON).not_capturedandunavailablemust not carryformat,value,truncatedorredaction_count.truncatedis a flag that can accompanycapturedorredacted;redaction_countis only valid withredacted.messagesvalues 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
valueand recordsnot_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):
- No roots:
in_progressif the newest timestamp is withinstale_after, elseincomplete. - Some root has no end:
in_progressif every unended root is in progress, elseincomplete. - Any root ended
error→error; else any root endedcancelled→cancelled. - Any span anywhere ended
error→ok_with_errors; elseok.
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_versionis"MAJOR.MINOR". This server supports major1and knows minor0.- 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.extrais 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, linkrelation, capability names,sdk.language) and event names. Closed enums (status, contentstatus/format, usagesource, costsource, capturemode) 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.