feat: OTS emitter — real turns, decisions, and content (ARN-109) - #458
Conversation
Live local end-to-end run — passedNot a test-suite claim. A locally started The stored row: The document as stored (abridged): {
"metadata": {
"harness": "temperpaw",
"spec_version": "paw-agent@0.1.0",
"outcome": "success",
"timestamp_start": "2026-08-11T22:41:49.602Z"
},
"context": { "resources": [
{ "type": "session_tree", "uri": "temper://SessionEntries?SessionId=ss-019ff2fd-…" }
]},
"turns": [
{
"turn_id": 1,
"span_id": "ss-019ff2fd-…:a-2",
"timestamp": "2026-08-11T22:41:49.602Z",
"_prompt_tokens": 261, "_completion_tokens": 176,
"messages": [
{ "message_id": "u-…-0", "role": "user", "content": {"type":"text","text":"…"} },
{ "message_id": "a-2", "role": "assistant",
"content": {"type":"tool_call","text":"Let me check the workspace first.",
"data":{"tool_calls":[{"id":"tc-local-1","name":"temper.python"}]}} }
],
"decisions": [
{ "decision_id": "tc-local-1", "cause_id": "tc-local-1",
"decision_type": "tool_selection",
"choice": {"action":"temper.python","arguments":{"code":"print('hello from the local proof')"}},
"consequence": {"success": true, "result_summary": "hello from the local proof\n"} }
]
},
{
"turn_id": 2,
"timestamp": "2026-08-11T22:41:50.898Z",
"messages": [
{ "message_id": "t-3", "role": "tool",
"content": {"type":"tool_response","data":{"tool_results":[
{"tool_call_id":"tc-local-1","is_error":false,"content":"hello from the local proof\n"}]}} },
{ "message_id": "a-4", "role": "assistant", "content": {"type":"text","text":"local proof complete"} }
]
}
]
}Retry idempotency, proven against the running systemForced the failure path and retried on a session that had already emitted: The stored row was replaced in place — 3 rows before, 3 rows after, What this run could not coverThe local environment provisions no workspace, so Suites run
Guest builds: every touched module rebuilt for |
|
@greptile review |
Review findings addressed (commit
|
| Sev | Finding | Fix |
|---|---|---|
| P1 | monty_repl seal check sliced the span document at a byte offset — multibyte tool output trapped the guest after tools had run |
tool_spans_document_sealed reads the last record's reserved tool_name; no byte slicing anywhere on that path |
| P1 | Transcript read failed open: a 503 or policy denial stored a spans-only row that was marked emitted and never repaired | the read error records TrajectoryEmissionFailed and stops before the POST; an empty transcript still emits |
| P1 | Observations/spans indexed globally by tool_call_id, so provider fallbacks (tool_1, or_tool_1) let turn N+1 overwrite turn N |
per-turn attribution plus positional span claiming; synthetic ids also scoped by provider response id and by message position |
| P1 | Kernel round trip proved nothing about the four additions — serde ignores unknown fields | provenance repeated in kernel-modeled metadata.tags; join key stays decision_id; dropped set pinned by a test that fails when the kernel models one; old-row fixture added |
| P2 | Malformed logprob entries filtered individually, misaligning arrays against token ids | logprobs.content[] flattens all-or-nothing; emitter drops misaligned completion-side sets and records _token_signals_misaligned |
| P2 | Truncation detected by unrestricted substring search | decided from the reserved tool_name of a parsed record |
Verification
emit_ots_trajectory43 passed (was 34) ·monty_repl89 ·openai-chat-wire15 ·provider_caller31crates/temperpawfull test suite green (16 binaries, 0 failures),ots_trajectory_contract10 passed (was 7)- Guest builds:
emit_ots_trajectory,openai-chat-wire,provider_callerforwasm32-unknown-unknown;monty_replforwasm32-wasip1. Zero new warnings, zero new clippy findings. - The UTF-8 panic was reproduced standalone before the fix (byte index 514 inside
'の') and the new fixture hits that offset.
Residual, recorded as a known gap in ADR-0035 §17: the per-turn token-level RL signals have no kernel-modeled home, so they survive in the stored row (the server persists the POST body verbatim) but not through a consumer that deserializes into OTSTrajectory and re-serializes. Giving them optional fields on OTSTurn is a temper-repo change outside this lane.
🤖 Generated with Claude Code
|
@greptile review |
Round-2 review fixes — findings → fixesCommits on All three residual findings are fixed. Five independent adversarial reviews — two Codex Sol sessions and three fresh Fable sessions, each with no prior context — turned up seventeen more findings in the same class, and every one of those is fixed too. The last two are worth reading on their own:
The three residual findings
Pin-bump disposition (finding 1)Cannot be done here, and is not deferred silently. The fields exist only on the temper branch Interim carriers, each with a test that proves it lossless through a deserialize/re-serialize round trip:
The signal arrays stay on the turn under the names the JCS branch gives
What the re-reviews found, and what changedFail-open paths, all now tagged
Correctness and honesty fixes:
Tests
Residual risks
|
Live local end-to-end runClosing the gap I flagged in the previous comment. This was run against a local server built from this branch ( Two real Sessions were driven to a terminal state through the governed OData API. They differ in exactly one thing — whether a transcript existed — and that is what the change is about. Run A — transcript absent →
|
Independent review round — six findings, six fixesAn independent Fable review of the branch returned
Tests
Adding the state variable changed three action parameter lists that The reviewer re-verifies from here — nothing above is marked fixed on my own say-so. |
Residual: the double-merge class was only one-third fixedRe-verification confirmed the six findings dead but caught that my P3-3 "class fix" covered one of three accumulators. It was a class claim backed by a single call site. Fixed in The identical within-event pattern was live in
All three now route an event through one shared Each accumulator has its own repeated-payload test, and each fails against the pre-fix code with the doubled array. Verified by reverting both call sites and running them: A fourth wire shape cannot reintroduce this: a contract test walks both source files and fails on any Corrected claims, since the previous ones were scoped to one accumulator while reading as general: the per-site comments now point at Tests
|
|
@greptile review |
The emitter collapsed every session into one synthetic turn with an empty decisions array. It now walks the SessionEntry tree from the recorded leaf, opens a turn at every assistant message, and attaches that cycle's prompt messages, tool decisions, and observations. - decisions come from the assistant's tool_use blocks, are answered by the tool_result blocks that land on the next turn, and carry cause_id = tool_call_id so decision -> observation causality is explicit - tool spans enrich decisions with wall-clock duration and stand in as the only evidence when a message body was externalized - message bodies already stored in TemperFS are emitted as file references; inline text is bounded per message (4k chars) and per trajectory (64k) - metadata carries harness and spec_version; session artifacts are listed as OTS context resources instead of being inlined - per-turn prompt/completion token counts, plus prompt_token_ids, completion_token_ids, response_mask and logprobs when the pipeline recorded them (validated, never fabricated) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Production trajectories carried an empty decisions array because `persist_tool_spans_file = "false"` on the run_tools trigger, and the guest defaulted the same key to false when absent. monty_repl therefore never wrote /tool_spans.jsonl and the emitter had nothing to convert. - flip the spec to persist spans, and default the guest to ON so a missing config key can no longer empty every stored trajectory - bound the span document: results capped at 600 chars, arguments at 2000, the whole file at 256KB with a truncation marker, so the per-batch rewrite cannot turn into unbounded traffic - stamp every SessionEntry with ts_ms, so turns can be dated even after the entity event hot tail has rolled over - record provider, model, stop reason and usage on the assistant entry, plus token ids / masks / logprobs when the serving stack streamed them - carry those signals from the OpenAI-compatible and Responses stream parsers through the provider response artifact; the Anthropic stream has none - declare spec_version on the emitter trigger and pin it to app.toml with a contract test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
…ucts ADR sections 9-13 close the turn-boundary deferral, replace the span-only decision source with transcript reconstruction plus span enrichment, set the payload rules for message content, explain how spec identity and harness are resolved without a new round trip, and state when token ids and logprobs are carried. Records the three newly rejected alternatives. Backs the field-name claims with a test: emit_ots_trajectory takes temper-ots as a host-only dev-dependency and deserializes its own output into OTSTrajectory, so a drift on either side fails the build instead of storing a row no consumer can read. The dev-dependency never enters a guest build. Refs ARN-109. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Logprob and token-id arrays scale with completion length and were written straight into the entry's ExtraJson, which has its own overflow ceiling. A signal over 32KB is now dropped with its size recorded, so a long completion cannot be what pushes a turn over the limit, and the drop stays visible. Also takes the clock as a parameter instead of calling the host from the mapping function, which makes the per-turn extras unit-testable off-host. Refs ARN-109. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
…sions A span document that hit the size ceiling had its truncation marker rewritten on every later tool batch, and the emitter turned that marker into a decision with an empty id. The document now seals once, and the emitter reports the truncation as _tool_spans_truncated on the trajectory instead of inventing a tool call the agent never made. Refs ARN-109. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
A chat-completions server that repeats prompt_token_ids on every streamed chunk would have had the prompt counted once per chunk. Prompt-side signals are now set once; only the completion-side signals append. Also drops the redundant top-level merge so a payload carrying logprobs at both the event and choice level cannot double-count them. Refs ARN-109. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
…S file
Production sessions are entity-backed, so session_file_id is a
session-entries:<id> reference. Advertising it as Files('session-entries:…')
sent consumers to a path that does not exist.
Refs ARN-109.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Tool-call arguments were capped per call but not globally, and the assistant message duplicated them alongside the decision. A session with many large write-style calls could therefore push the stored document well past the inline ceiling. Arguments now draw from the same budget as message text and degrade to a preview once it is spent, and messages carry tool-call identity only. Refs ARN-109. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Recovering from a broken session_leaf_id walked the parent chain of every entry, which is quadratic on a long session. It now tries the hundred newest entries; a tree whose last hundred leaves are all unwalkable is damaged past the point where a wider search would help, and file order still covers it. Refs ARN-109. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Six findings from two independent adversarial reviews of the OTS emitter track. - P1 monty_repl: the tool-span seal check sliced the document at a byte offset, so CJK or emoji tool output trapped the guest after the tools had already run and the result callback never fired. It now reads the last record's reserved `tool_name` instead of slicing or substring-matching. - P1 emit_ots_trajectory: a transcript read error no longer degrades to a spans-only trajectory. That row was permanently incomplete and, being marked emitted, was never repaired; the emission is recorded as failed so retry and the Evolution Engine sweep can produce a complete one. An empty transcript still emits — a first-turn session has no materialized entries. - P1 emit_ots_trajectory: observations and spans are attributed per turn instead of through a document-wide id index. Providers that omit tool-call ids used to make turn N+1's call overwrite turn N's, giving both decisions the last call's consequence and duration. The synthetic id is also scoped by the provider response id, and the history-to-chat conversion scopes its fallback by message position, so one request cannot carry a duplicate call id. - P1 emit_ots_trajectory: the kernel round trip proved nothing about the fields `temper-ots` does not model, because serde ignores unknown fields. Run provenance is now repeated in the kernel-modeled `metadata.tags`, the decision join key stays `decision_id` with `cause_id` mirroring it, the exact dropped set is pinned by a test that fails when the kernel models one of them, and an old-row fixture proves the additions stayed additive. The residual — the token-level RL signals — is recorded as a known gap in ADR-0035. - P2 openai-chat-wire: a `logprobs.content[]` payload is flattened only when every entry carries a numeric logprob, instead of skipping the bad entry and shipping a short array beside full-length token ids. The emitter also refuses to write completion-side signals whose lengths disagree, recording `_token_signals_misaligned` so the drop is visible. - P2 emit_ots_trajectory: span-document truncation is decided from the reserved `tool_name` of a parsed record, not a substring search, so a tool that reads or greps this source cannot make a complete run look partial. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
A trajectory is written once and the session is then marked emitted, so anything the emitter could not read is lost for good — and until now the document said nothing about it. Three ways of losing evidence read as a complete record: the shared TemperFS reader maps a missing file to an empty body, so an absent transcript arrived looking like a first-turn session; skip-and-continue parsing hid corrupted transcript and span lines; and a recorded leaf that does not resolve silently drops the newest turns. Absence is now distinguished from emptiness (`TranscriptPresence`), and every shortfall — absent, unparseable, leaf unresolved, no turns, missing or unparseable spans — is named in `metadata.tags`, in the document, and on the Session as `emitted_degraded` with what was missing. The tag lives in `metadata.tags` because that field is kernel-modeled: a completeness marker that a re-serializing consumer drops turns a partial record into an apparently whole one. The same reasoning covers the fields the pinned `temper-ots` does not model. The JCS contract fields exist only on an unmerged temper branch, so each travels through a kernel-modeled carrier until the pin can move: `cause_id` mirrors `decision_id`, harness and spec_version repeat in tags, and the token-level signals get an inventory in `context.entities` recording what the row holds. Copying the arrays there too would reproduce the payload failure ADR-0035 section 11 exists to prevent. `pinned_kernel_still_lacks_the_jcs_contract_fields` fails the moment a bump brings the real fields in and names the removal work, and CI now runs the os-app WASM manifests so that gate actually executes. Token signals are bounded where they are written and where they are read: against the entry's declared 128KiB `extra_json` ceiling (counted as the kernel counts it, after JSON-string escaping) and at 1MiB across a trajectory. The entry ceiling is also enforced at the single write boundary, so writers with no signal policy — the JSONL sync path — cannot cross it and take the per-turn facts with them. Non-numeric token arrays are rejected at capture rather than sized as if numeric. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
…(ARN-109) Second review round on the same class. Five more ways a trajectory could be short and not say so: An emission failure the guest could see propagated as a top-level error, which leaves `trajectory_emission_status` at "pending" — a state the sweep for failed emissions does not look at, so the trajectory was never retried. The trigger declares no `on_failure`, and a kernel callback could not have fixed it: its params are error / error_message / integration / duration_ms, none of which the Session models, and no effect sets a string field to a literal. The guest now records the failure itself. A trap or timeout stays outside its reach and surfaces as the platform's dropped-integration metric; the comments and ADR that claimed a hook existed are corrected. The entry-extras bound could knowingly return an oversized value once only per-turn facts were left — an oversized `stop_reason` is a provider's prerogative — and returning one costs the entire field, the outcome the bound exists to prevent. It now shortens what it cannot drop, and keeps a single count when the drop markers themselves are what hold the value over. Members are measured once and dropped largest-first: re-measuring per drop was quadratic on an object a corrupted line can make wide. A cyclic ancestry counted as a resolved leaf, so a fragment stored as if it were the session's whole history. Everything above the loop is unreachable, so a cycle is now unresolved and tagged. Token signals refused at capture left `<signal>_dropped_bytes` on the entry that the emitter never read, so a turn whose signals were all refused looked like a provider that sent none. Those refusals now reach the same drop record and the kernel-modeled inventory, and any signal drop — at capture or against the trajectory budget — marks the row degraded rather than only annotating the document. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Mechanical rustfmt of emit_ots_trajectory/src/lib.rs only; the guest's failure handling moved into a closure and left the body at its old indentation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
The contract test sliced the arm at a literal `\n };`, so re-indenting the code around it silently emptied the region it was asserting on rather than failing loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
…alue Confirm review found two holes in the previous round, both of which recreate the loss the round exists to close. Error reporting sliced response bodies at a byte offset. A multibyte character straddling the cut traps the guest — on the very paths that report a failure, so the module dies before recording it and the Session keeps whatever status it had, which is exactly the "pending" row the failed-emission sweep cannot see. Eleven sites across the emitter and wasm-helpers now cut by characters. monty_repl paid for this same class once already (ADR-0035 section 15). The entry-extras bound skipped the per-turn facts when dropping members and only shortened strings, so an oversized non-string under an essential key — which the JSONL sync path will copy verbatim from a corrupted transcript line — sailed through and left the value over the ceiling. The kernel then replaces the whole field, taking the facts and the drop markers with it. Essentials are now bounded too, non-scalars are dropped for their size, and a final check makes the invariant unconditional. Misaligned completion signals are discarded whole, which is the same loss a budget drop is, so they now reach the Session status rather than only annotating the document. Three claims are corrected against the pinned kernel: a WASM on_failure callback receives error / error_message / integration, and error_message IS a Session state variable — so a hook would clobber the session's own failure reason rather than being inert; the entry ceiling equals the kernel's default field ceiling as well as the spec's declaration, so it holds whichever binds; and the contract test's brace scan now ignores string literals and fails on imbalance instead of silently widening to the whole file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Two things stopped `prove_track3_ots.py` from proving anything. It dispatched `TemperPaw.Start`, an action the Session automaton does not have and has not had since the entry point became `Configure` — the run died at 409 before reaching a single assertion. It now uses the same entry production uses: create a blank Session, then `TemperPaw.Configure`, which schedules ProvisionWorkspace itself. It also read the entity the instant the status turned terminal, which races the emission the transition triggers. Losing that race reads as "no trajectory was emitted" rather than "not yet", so the proof reported a failure the system had not made. It now waits for the emitter to record an outcome. Assertions are unchanged: a degraded trajectory still fails the gate, because a proof run is supposed to produce a complete one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
An independent review found six, spanning both writers and the reader. A span document already at its ceiling without a seal returned unchanged, so every later batch vanished into it — and the document parsed clean, so the trajectory built from it claimed every tool call the session made. That is the state a file written before the ceiling existed is in, and the state a batch landing byte-exact on it produces. It now seals when it starts dropping, and only then: sealing a document that dropped nothing would mark a complete run partial. The test that enshrined the silent behaviour now asserts the seal. A failed span append left `tool_spans_file_id` empty, which reads exactly like a session that called no tools. The failure is now recorded on the Session and the emitter degrades on it. The truncation marker was two independent literals — one writer, one reader, nothing tying them. A rename on either side would have left the other reading a real tool call named `_tool_spans_truncated`, a decision the agent never made, on a record no longer aware it was partial. A contract test now pins them to each other. monty_repl's 90 tests also ran nowhere in CI, its manifest being its own workspace; CI runs it now. The entry-extras hard floor deleted the token-signal drop markers, which are the emitter's only evidence that signals existed and were refused. There are at most four, so they cannot be what holds the value over the ceiling, and they now survive it; the emitter also degrades when extras were cut to fit at all. One streamed event could contribute the same token signals twice, once from `usage` and once from `choices[0]`. With a single signal present nothing downstream could catch the doubling — no second array to disagree on length. Each event now contributes each signal once, the per-choice level winning where both carry it. Raw file order is not a walk, but with no recorded leaf it was reported as a resolved chain. A session with dangling parents and no leaf is still missing its shape, and now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
The previous round fixed the within-event double-merge in the chat accumulator and claimed the class. It did not: the identical pattern was live in two more wire shapes. OpenRouter merged from `usage` and then `choices[0]` of the same event; the Responses accumulator merged from `response` and then `response.usage` of one `response.completed`. A server carrying completion_token_ids at both levels stored them twice, and with a single signal present nothing downstream could tell — there is no second array to disagree on length. All three now route an event through one shared `event_token_signals` before merging. The rule it encodes is stated once and generally: the content level wins over the accounting level, because `usage` repeating a signal is a server quirk rather than a second measurement. Each accumulator has a repeated-payload test, and each fails against the old code with the doubled array ([7,8,7,8] and [4,5,6,4,5,6]). A contract test now refuses any accumulator that merges a raw event level straight into itself, so a fourth wire shape cannot reintroduce this, and ADR-0035 states the rule as covering all three rather than one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
temper#416 merged, so the pinned `temper-ots` now models every JCS contract field: OTSMetadata.harness / .spec_version, OTSTurn's four token-level signals, and OTSDecision.cause_id. Verified against the rev before bumping rather than taken from the sha. The fields ride natively now. The carriers that stood in for them are deleted — the `turn_token_signals` inventory in `context.entities[]`, the `token_signals:present` tag, and the `harness:` / `spec_version:` tag mirrors — because a mirror that outlives its reason is a second source of truth with nothing keeping the copies equal. A failing test is what removed them, which is what it was built for: `pinned_kernel_still_lacks_the_jcs_contract_fields` asserted each field was still dropped, so the bump made it fail and its message named the removal list. It is replaced by `kernel_round_trip_keeps_the_jcs_contract_fields`, which asserts each field is emitted, survives the round trip with its value intact through typed struct access, and is mirrored nowhere — so it fails if a carrier returns or the pin rolls back. `KERNEL_UNMODELED_FIELDS` shrinks to `metadata.trajectory_id`, unmodeled by design because the POST handler reads it before any struct is involved. Degradation markers stay in `metadata.tags`: the kernel models no field for what a record was built without, and losing that marker turns a partial row into an apparently whole one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
The rebase base restored `convert_messages_to_openrouter` into provider_caller, and the restored copy predates the id fix: it mints `tool_1`, `tool_2` per message. Position within a message is not unique across a conversation, so two id-less assistant turns send the provider the same call id and the emitter collapses two decisions into one — the defect ADR-0035 section 14 records. Scoped by message position, as the shared chat conversion already is. Fixed in place rather than by re-deleting the function: which copy owns this conversion is the other branch's call, not this one's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
d9ece3c to
583592d
Compare
Rebased onto
|
| Where | Conflict | Resolution |
|---|---|---|
provider_caller/src/lib.rs |
Their branch had moved convert_messages_to_openrouter and the tool-definition builder into the shared openai-chat-wire crate; my commit still carried the local copies |
Took their deletion, after checking the moved copy kept the message-position tool-call-id scoping — it did |
session.ioa.toml ×3 |
They added sandbox_url / sandbox_id / sandbox_provider to three action param lists; I added tool_spans_write_failed |
Union: their params plus mine, in each list |
artifact_batch_apply/Cargo.toml |
They added a wasm-helpers dependency; I bumped the rev |
Union: their dependency plus the bumped rev |
The branch was force-pushed mid-rebase. I rebased onto 2042ded5; that commit no longer exists on the branch, replaced by 9e767256. I caught it because tests failed on content (compaction_round, tool_choice, sandbox sizing) present in no ancestor — it had come from the withdrawn tip. Redone with git rebase --onto 9e767256 2042ded5; the branch is now genuinely on their current tip, verified with merge-base --is-ancestor.
One fix was needed after it: the new tip restored convert_messages_to_openrouter into provider_caller, and the restored copy predates the tool-call-id fix — it mints tool_1, tool_2 per message. Position within a message is not unique across a conversation, so two id-less assistant turns send the provider the same id and the emitter collapses two decisions into one (ADR-0035 §14). Scoped it by message position in place, matching the shared conversion. I did not re-delete the function: which copy owns that conversion is PR #459's call.
Pin bump
804633e2 → a747f7d40cb556371168f8460bc72806c3574d2b (the merge of nerdsane/temper#416), across 108 tracked files plus the gitignored os-app Cargo.locks — every temper-wasm-sdk / temper-ots pin, the EXPECTED_TEMPER_REV pin-contract constant in paw_fs_hot_path.rs, and the three literals in datadog_observability_contract.rs. Zero tracked files remain on the old rev.
I verified the rev carries the fields before bumping rather than trusting the sha — read temper-ots/src/models/{turn,trajectory,decision}.rs at a747f7d4 and confirmed all seven: OTSTurn.prompt_token_ids / .completion_token_ids / .response_mask / .logprobs, OTSMetadata.spec_version / .harness, OTSDecision.cause_id.
Carriers deleted
The bump made pinned_kernel_still_lacks_the_jcs_contract_fields fail, which is what it was built to do, and its message named the removal list. Executed exactly that:
turn_token_signalsinventory incontext.entities[]— gone, along withTOKEN_SIGNAL_CARRIER_TYPEtoken_signals:presenttag — goneharness:/spec_version:tag mirrors — gone, along with both prefix constantsKERNEL_UNMODELED_FIELDSshrunk from eight entries to one:metadata.trajectory_id, unmodeled by design because the POST handler reads it before any struct is involved
The fields now ride natively on the pinned structs. Degradation markers stay in metadata.tags — the kernel models no field for what a record was built without, and losing that marker turns a partial row into an apparently whole one (§16).
Test replacement
pinned_kernel_still_lacks_the_jcs_contract_fields → kernel_round_trip_keeps_the_jcs_contract_fields. It asserts, for all seven fields, that each is emitted, that it survives deserialize→re-serialize with its value intact, and that it is mirrored nowhere: no harness: / spec_version: / token_signals: tag, no context.entities inventory. Typed struct access, not JSON shape alone. So it fails if a carrier is reintroduced or the pin rolls back below a747f7d4 — non-vacuous in both directions. The repo contract test additionally asserts the four carrier constants are absent from the emitter source.
ADR-0035 §17 is rewritten: what rides natively now, what the carriers were, and that a failing test is what removed them. §18's reference to the inventory is corrected.
Tests
emit_ots_trajectory 61 · monty_repl 90 · wasm-helpers 50 · provider_caller 34 · openai-chat-wire 19 · provider_response_applier 19 · cargo test -p temperpaw --no-fail-fast 298 passed, 0 failed.
wasm32 release builds clean for all eight touched modules plus artifact_batch_apply. cargo fmt --all -- --check clean.
One thing to check on PR #459
The earlier tip 2042ded5 reintroduced Status ne 'Archived' into artifact_batch_apply, which artifact_batch_apply_uses_bounded_lossless_file_filters forbids (it prevents query pushdown and causes QueryTooLarge) — that branch was red on it. The current tip 9e767256 no longer is, so it looks already handled; flagging in case the force-push was for something else and this was incidental.
The bump made the kernel validate token signals on every deserialize, and the emitter could build a turn it rejects. `attach_token_signals` decided the completion set was "aligned" by counting distinct lengths among the signals that happened to be present — never requiring `completion_token_ids` to be one of them. A turn carrying only `response_mask` passed as aligned and was written with nothing to index. That is not a degraded row, it is no row: the POST answers 400, the emission records failed, and `build_trajectory` is deterministic, so every retry rebuilds the identical rejected document. It is reachable. The SessionEntry writer bounds each signal on its own against 32KiB, and the ids are several times the size of the mask, so around an 8,000-token completion the ids are refused (56,001 bytes) while the mask survives (16,001). A new writer-boundary test pins that the writer really does produce that shape. The kernel's rule is now mirrored rather than approximated: `completion_token_ids` is required as the anchor, and a set without it is dropped whole and recorded. `response_mask` entries must be 0 or 1 — the kernel rejects the turn over a single entry above 1, which the old u8 check let through. A table test feeds seven asymmetric shapes through the real `OTSTrajectory`, so any shape the emitter can build is one the kernel accepts. The test that asserted a lone `logprobs` "still travels" asserted the defect; it now asserts the drop, and a companion covers the anchor travelling alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
P1 + four P2s — findings → fixesAll five fixed in P1 — the emitter could build a turn the newly-pinned kernel refusesConfirmed, and worse than the alignment bug it looked like. The consequence is not a degraded row — it is no row. Fixed by mirroring the kernel's rule rather than restating it. I read
Tests at both ends, because no existing test exercised an asymmetric drop — which is why every suite was green:
The test that asserted a lone P2-1 — dead code, and the PR record was wrongBoth parts confirmed by me, not taken on the review's word: Correcting the record: my earlier comment said the conflict was resolved by "taking their deletion". That is not what the tree shows — I annotated rather than deleted: a block comment above P2-2 — regression test for the surviving copy
P2-3 — duplicated assertionRemoved the second P2-4 — forbidden-older-revs list
Tests
wasm32 release builds clean for all six touched modules. |
|
Too many files changed for review (125 files, 100 file limit). Bypass the limit by tagging |
Goal
ARN-109 (epic ARN-291): make the
emit_ots_trajectoryWASM module produce real, trainable OTS trajectories instead of the single synthetic turn with an emptydecisions[]that production rows carry today.Root cause of the empty decisions
Not a missing mapping.
os-apps/paw-agent/specs/session.ioa.tomlsetpersist_tool_spans_file = "false"on therun_toolstrigger, andmonty_repldefaulted the same config key tofalsewhen it was absent. The span file was never written, the emitter read an empty document, and every stored trajectory ended up with zero decisions.span_to_decision()was correct all along — it was never fed.The fix does not stop at re-enabling the flag. Decisions now have two independent sources, so no single config key can empty them again.
Scope
cause_id(tool_call_id), arguments, result summary, error class, durationmetadata.harness=temperpaw,metadata.spec_version= governing spec identitytrajectory_id = trj-<session_id>idempotency and the emission-status retry fields intact — proven liveHow it works now
session_leaf_id; each assistant entry opens a turn, and the user / tool-result / steering / compaction entries before it are that turn's prompt side. A stale or broken leaf falls back to the newest walkable entry, then to file order.tool_useblocks give the tool and its arguments; thetool_resultblocks that land on the next turn give success and result; the spans add wall-clock duration and stand in as the only evidence when a body was externalized.cause_id = tool_call_idmakes decision → observation causality explicit.spec_versionis declared in the spec's own trigger config as<app>@<version>and pinned toapp.tomlby a contract test.ts_msat creation, because the entity event log is a hot tail that cannot date a long session's turns.Verification
Live local end-to-end run, retry idempotency proof, and the full suite table are in the comment below.
Completeness of what gets stored (round-2 review)
A trajectory is written once and the session is then marked emitted, so evidence the emitter could not read is lost for good. Three ways of losing it used to read as a complete record, and all three are now named in the document:
read_session_transcriptnow returns aTranscriptPresence(present/pending_first_turn/no_entries/missing_file/empty_file/undeclared).degraded:<reason>entry inmetadata.tags, as_transcript/_tool_spans_*for a raw-row reader, and on the Session astrajectory_emission_status = "emitted_degraded"with the missing evidence intrajectory_emission_error. The status is derived from the document that was stored, so the row and the entity cannot disagree.metadata.tagsis the carrier because it is kernel-modeled: a completeness marker that a re-serializing consumer drops turns a partial record into an apparently whole one.turn_countis deliberately not one of the checks — it counts continuations, not assistant messages, so comparing it to the reconstructed turn count would mark nearly every trajectory degraded.Follow-up: bump the temper pin and delete the interim carriers
emit_ots_trajectory/Cargo.tomlpinstemper-wasm-sdkandtemper-otsto804633e2, a temper main revision that predates the JCS contract fields.OTSMetadata.harness/.spec_version,OTSTurn.prompt_token_ids/.completion_token_ids/.response_mask/.logprobsandOTSDecision.cause_idexist only on the temper branchclaude/jcs-trajectory-core— its pull request (nerdsane/temper#415) was closed unmerged on 2026-08-12 — so a bump is not possible from this branch.Until it is, each field travels through a carrier the pinned kernel does model, and each carrier is proven lossless by a test rather than assumed:
decisions[].cause_iddecision_idcause_id_mirrors_the_kernel_modeled_decision_idmetadata.harness,metadata.spec_versionmetadata.tagsbuild_trajectory_repeats_run_provenance_in_kernel_modeled_tagscontext.entities[](turn_token_signals) +token_signals:presenttagtoken_signal_inventory_survives_the_kernel_round_tripdegraded:*inmetadata.tagsdegradation_markers_survive_the_kernel_round_tripThe signal arrays stay on the turn, under the names the JCS branch gives
OTSTurn, so the bump is a deletion rather than a migration. Copying megabyte-scale arrays into the carrier as well would reproduce the payload failure ADR-0035 section 11 exists to prevent; what the carrier buys is that a consumer holding a re-serialized copy can tell its copy is incomplete.When a temper main revision carries the JCS schema work (under whatever pull request supersedes #415): bump both revs on a
bump-temperbranch, then delete theturn_token_signalscarrier, thetoken_signals:presenttag, and the harness / spec_version tag mirrors, shrinkKERNEL_UNMODELED_FIELDS, and amend ADR-0035 section 17.pinned_kernel_still_lacks_the_jcs_contract_fieldsfails the moment the bump lands and its message names that list, so the interim state cannot outlive it quietly. CI now runs the os-app WASM manifests directly, because they are separate workspaces that-p temperpawnever reached — a gate nothing executes is not a gate.Linear
ARN-109 (epic ARN-291)
🤖 Generated with Claude Code
https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C
Greptile Summary
The PR replaces synthetic OTS output with reconstructed session turns, decisions, bounded message content, token signals, provenance, and explicit degradation metadata.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR A[SessionEntry transcript] --> C[OTS trajectory emitter] B[Persisted tool spans] --> C D[Provider token signals] --> A C --> E[Reconstructed turns and decisions] C --> F[Bounded content and artifact references] C --> G[Provenance and degradation metadata] E --> H[Stored OTS trajectory] F --> H G --> HReviews (3): Last reviewed commit: "fix: count each token signal once in eve..." | Re-trigger Greptile