You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Proposal: graduate per-response model + token usage from options.<provider>.* into typed, optional canonical fields on the assistant message, extracted at the adapter parse face (seam-enforced), materialized as narrow analytics columns on messages, and backfilled in place on existing stores. Follow-on to the parts.tool_name/call_id/is_failure materialization (#89) and the additive-schema-migration mechanism that lands with it.
This is a PROPOSAL issue: the design below was researched in depth but deliberately NOT implemented yet. Two semantic questions (OTel normalization, provider column) need a decision before code.
Why
pond's own usage evidence (all 601 historical pond_sql_query calls analyzed): variant_data tool analytics is the top pattern (covered by Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89), and messages.options model/usage drilling is second - anthropic.usage.* ~44 path hits, anthropic.model 17. Token-spend and model-mix analytics are real, recurring, and currently require provider-specific JSON drilling that cannot be uniform across agents.
The goal is agentsview-class analytics (usage_events, cost, model mix) served uniformly from pond for ANY agentic client, including the ~100+ adapters planned. That requires agent-agnostic columns, not per-provider JSON paths.
Ground truth (verified against real sources and the live store, 2026-07-07)
Every current source records model + usage; pond's lossless guarantee held everywhere - nothing needs re-ingest:
Adapter
Where it lives in the store today
Granularity
claude-code (+ subagents)
options.anthropic.model / .usage (same row)
per assistant row; modern CLI repeats one API response's envelope on EVERY row of that response (verified 59/59 rows, 18 responses); older CLI wrote it on one row only, continuation rows are literally {content, role}
rule-3 system rows: 30,022 event_msg records (incl. token_count with last_token_usage{input,cached_input,output,reasoning_output}) + 3,595 turn_context records (model, effort)
per turn / per API call, on SIBLING rows
claude-ai-export
to verify against fixtures; the export format likely carries no per-message usage (genuine absence)
-
Future adapters with fixtures already in-repo: nanoclaw (claude-code-shaped, anthropic envelope), openclaw (model_change events + messages), claude_managed_agents (span.model_request_start/end events). Fixture README "Provider / model recording" row documents the granularity variance: per-message vs per-span vs per-turn.
Key design findings
Extraction must live in the adapter parse face, not core. Only the sequential parse has the context to attribute turn-level (codex) or span-level (managed agents) usage to the right message. Core-side derivation would need adapter-specific record knowledge, which the seam-boundaries rule forbids.
Seam enforcement: model: Option<Extracted<String>> and usage: Option<Usage> as REQUIRED struct fields on the assistant wire variant - every adapter construction site must explicitly decide them (compile error otherwise). Usage counters individually sealed (Option<Extracted<i64>>, new extract_int seam primitive) so unwrap_or(0)-class fabricated counters do not compile. Absence stays honest NULL (model-no-synthesis).
The attribution invariant (proposed new named spec rule): usage is attributed to exactly ONE assistant message per provider-reported accounting event, as close to the generating turn as the source allows. Modern claude-code repeats one response's envelope on every row - naive per-row copying inflates SUM(tokens) ~4x. Rule: first row seen per API message.id carries it; later rows of the same response derive NULL. Codex: each token_count event attributes to the most recent unattributed assistant message. This is what makes SUM() over messages equal provider-reported totals.
Conformance extension (spec 6.8 already mandates per-adapter round-trip tests): every adapter's fixtures must demonstrate extraction of Some(model) + Some(usage) on at least one row, or carry an explicit documented opt-out in code. "Forgot the mapping" becomes red CI, never a silent NULL.
Storage: 5-6 nullable columns on messages (see open questions), derived-storage class like the embedding columns (spec 5.5 precedent). Backfill rides the additive-migration mechanism from Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89/PR1: same-row mapper for anthropic/opencode/pi; codex needs a session-scoped second pass replaying the stored system rows in order (turn_context model carry-forward + token_count attribution), filled via the same partial-update machinery the embed pass uses.
Spec amendments required
Spec 4.6 currently says the OPPOSITE, deliberately: "Turn-level metadata - model, token usage, finish reason, error - is not a canonical field; clients record it on their assistant turns and adapters route it to options.<provider>.*." Graduating model/usage amends this. Justification: they are stable conversational-layer facts (every one of the 9 fixture platforms records them; OTel standardizes them), not volatile harness behavior - and spec 9.6.8 already defers "OTel-compatible projection of the canonical model".
Spec 5.1 "no projections, no promotions" needs the derived-columns exception widened (embedding-columns precedent).
New named rule for the attribution invariant + the conformance extension.
Open questions to decide BEFORE implementation
OTel normalization vs provider-raw. OTel gen_ai.usage.input_tokens = total input INCLUDING cache; cache_read.input_tokens / cache_creation.input_tokens are sub-counters contained in it; reasoning.output_tokens is contained in output_tokens. Providers disagree: Anthropic raw input_tokens EXCLUDES cache tokens; OpenAI/codex raw input_tokens INCLUDES cached. Storing provider-raw makes cross-agent SUM(input_tokens) incomparable; normalizing to OTel semantics mutates Anthropic's reported number (raw stays verbatim in options). Naming would mirror OTel: input_tokens, output_tokens, reasoning_output_tokens, cache_read_input_tokens, cache_creation_input_tokens. Reference: docs/references/otel-genai-semconv.md section 1.4.
model_provider column (OTel gen_ai.provider.name): pi (ollama), opencode (zai-coding-plan), codex (session_meta.model_provider) record it; disambiguates same model name via different providers. Include alongside model or drop?
finish_reason (OTel gen_ai.response.finish_reasons; pi stopReason, anthropic stop_reason): no historical query evidence - defer or bundle?
cost: opencode/pi record provider-computed cost. Derivable from tokens + pricing tables; only 2 of 9 sources report it. Proposed: leave in options.
Old-format claude-code continuation rows ({content, role}, no message.id): model attribution via parentUuid chain is possible but inferential. Proposed: leave NULL (their usage is captured on the envelope row; only per-message model-mix counts undercount slightly for old sessions).
Recoverability guarantee
All data for all current adapters is recoverable from the store alone (verified above) - the backfill requires no re-ingest and no source access, consistent with session-durable-copy ("re-ingest is not a recovery path").
Partial-update machinery (embed-pass pattern) for the codex session-scoped backfill.
Prior partial work
A first draft of the wire types existed briefly on feat/parts-zstd-tool-columns (Usage struct + fields on the assistant variant + extract_int) and was reverted when this was split out; see that branch history around 2026-07-07 for reference.
Summary
Proposal: graduate per-response
model+ tokenusagefromoptions.<provider>.*into typed, optional canonical fields on the assistant message, extracted at the adapter parse face (seam-enforced), materialized as narrow analytics columns onmessages, and backfilled in place on existing stores. Follow-on to theparts.tool_name/call_id/is_failurematerialization (#89) and the additive-schema-migration mechanism that lands with it.This is a PROPOSAL issue: the design below was researched in depth but deliberately NOT implemented yet. Two semantic questions (OTel normalization, provider column) need a decision before code.
Why
pond_sql_querycalls analyzed):variant_datatool analytics is the top pattern (covered by Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89), andmessages.optionsmodel/usage drilling is second -anthropic.usage.*~44 path hits,anthropic.model17. Token-spend and model-mix analytics are real, recurring, and currently require provider-specific JSON drilling that cannot be uniform across agents.Ground truth (verified against real sources and the live store, 2026-07-07)
Every current source records model + usage; pond's lossless guarantee held everywhere - nothing needs re-ingest:
options.anthropic.model/.usage(same row){content, role}options.anthropic.*(same API shape)options.source.raw_record(modelID,providerID,tokens{input,output,reasoning,cache.read,cache.write},cost)options.pinamespace, already extracted (model,provider,api,usage{input,output,cacheRead,cacheWrite},cost,stopReason)event_msgrecords (incl.token_countwithlast_token_usage{input,cached_input,output,reasoning_output}) + 3,595turn_contextrecords (model,effort)Future adapters with fixtures already in-repo: nanoclaw (claude-code-shaped, anthropic envelope), openclaw (
model_changeevents + messages), claude_managed_agents (span.model_request_start/endevents). Fixture README "Provider / model recording" row documents the granularity variance: per-message vs per-span vs per-turn.Key design findings
model: Option<Extracted<String>>andusage: Option<Usage>as REQUIRED struct fields on the assistant wire variant - every adapter construction site must explicitly decide them (compile error otherwise). Usage counters individually sealed (Option<Extracted<i64>>, newextract_intseam primitive) sounwrap_or(0)-class fabricated counters do not compile. Absence stays honest NULL (model-no-synthesis).SUM(tokens)~4x. Rule: first row seen per APImessage.idcarries it; later rows of the same response derive NULL. Codex: eachtoken_countevent attributes to the most recent unattributed assistant message. This is what makesSUM()over messages equal provider-reported totals.Some(model)+Some(usage)on at least one row, or carry an explicit documented opt-out in code. "Forgot the mapping" becomes red CI, never a silent NULL.messages(see open questions), derived-storage class like the embedding columns (spec 5.5 precedent). Backfill rides the additive-migration mechanism from Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89/PR1: same-row mapper for anthropic/opencode/pi; codex needs a session-scoped second pass replaying the stored system rows in order (turn_context model carry-forward + token_count attribution), filled via the same partial-update machinery the embed pass uses.Spec amendments required
options.<provider>.*." Graduating model/usage amends this. Justification: they are stable conversational-layer facts (every one of the 9 fixture platforms records them; OTel standardizes them), not volatile harness behavior - and spec 9.6.8 already defers "OTel-compatible projection of the canonical model".Open questions to decide BEFORE implementation
gen_ai.usage.input_tokens= total input INCLUDING cache;cache_read.input_tokens/cache_creation.input_tokensare sub-counters contained in it;reasoning.output_tokensis contained inoutput_tokens. Providers disagree: Anthropic rawinput_tokensEXCLUDES cache tokens; OpenAI/codex rawinput_tokensINCLUDES cached. Storing provider-raw makes cross-agentSUM(input_tokens)incomparable; normalizing to OTel semantics mutates Anthropic's reported number (raw stays verbatim inoptions). Naming would mirror OTel:input_tokens,output_tokens,reasoning_output_tokens,cache_read_input_tokens,cache_creation_input_tokens. Reference:docs/references/otel-genai-semconv.mdsection 1.4.model_providercolumn (OTelgen_ai.provider.name): pi (ollama), opencode (zai-coding-plan), codex (session_meta.model_provider) record it; disambiguates same model name via different providers. Include alongsidemodelor drop?finish_reason(OTelgen_ai.response.finish_reasons; pistopReason, anthropicstop_reason): no historical query evidence - defer or bundle?cost: opencode/pi record provider-computed cost. Derivable from tokens + pricing tables; only 2 of 9 sources report it. Proposed: leave inoptions.{content, role}, nomessage.id): model attribution via parentUuid chain is possible but inferential. Proposed: leave NULL (their usage is captured on the envelope row; only per-message model-mix counts undercount slightly for old sessions).Recoverability guarantee
All data for all current adapters is recoverable from the store alone (verified above) - the backfill requires no re-ingest and no source access, consistent with
session-durable-copy("re-ingest is not a recovery path").Dependencies
add_columnsbackfill) from the Remote-store SQL analytics over parts.variant_data time out: compress JSON columns + materialize tool columns #89 PR - PR2 reuses it.Prior partial work
A first draft of the wire types existed briefly on
feat/parts-zstd-tool-columns(Usage struct + fields on the assistant variant +extract_int) and was reverted when this was split out; see that branch history around 2026-07-07 for reference.