Skip to content

feat: OTS emitter — real turns, decisions, and content (ARN-109) - #458

Merged
rita-aga merged 21 commits into
mainfrom
claude/jcs-ots-emitter
Aug 12, 2026
Merged

feat: OTS emitter — real turns, decisions, and content (ARN-109)#458
rita-aga merged 21 commits into
mainfrom
claude/jcs-ots-emitter

Conversation

@rita-aga

@rita-aga rita-aga commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Goal

ARN-109 (epic ARN-291): make the emit_ots_trajectory WASM module produce real, trainable OTS trajectories instead of the single synthetic turn with an empty decisions[] that production rows carry today.

Root cause of the empty decisions

Not a missing mapping. os-apps/paw-agent/specs/session.ioa.toml set persist_tool_spans_file = "false" on the run_tools trigger, and monty_repl defaulted the same config key to false when 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

  • Real turn boundaries reconstructed from the SessionEntry tree + recorded leaf
  • Decisions populated with cause_id (tool_call_id), arguments, result summary, error class, duration
  • Message content per turn; TemperFS-backed bodies referenced by file id, inline text bounded
  • metadata.harness = temperpaw, metadata.spec_version = governing spec identity
  • Per-turn token counts; token-id / mask / logprob fields when the serving stack streams them
  • Tool-span persistence re-enabled, compacted, and size-capped
  • trajectory_id = trj-<session_id> idempotency and the emission-status retry fields intact — proven live
  • ADR-0035 extended (sections 9-13, three newly rejected alternatives)

How it works now

  • Turns. Walk the transcript from the recorded 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.
  • Decisions. tool_use blocks give the tool and its arguments; the tool_result blocks 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_id makes decision → observation causality explicit.
  • Payload discipline. Externalized bodies are referenced, never fetched. Inline text is capped at 4k chars per message and 64k per trajectory, tool arguments draw from the same budget, and messages no longer duplicate the arguments their decision already carries. Dropped character counts are recorded so a consumer can tell truncation from absence.
  • Identity. The guest context exposes no spec hash, so spec_version is declared in the spec's own trigger config as <app>@<version> and pinned to app.toml by a contract test.
  • Timestamps. Every SessionEntry is stamped with ts_ms at 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:

  • The shared TemperFS reader maps a missing file to an empty body, so an absent transcript arrived looking like a first-turn session. read_session_transcript now returns a TranscriptPresence (present / pending_first_turn / no_entries / missing_file / empty_file / undeclared).
  • Skip-and-continue parsing hid corrupted transcript and span lines, and a recorded leaf that does not resolve silently drops the newest turns. Both are reported, along with a transcript that parses but yields no turn.
  • A degraded document carries the reason as a degraded:<reason> entry in metadata.tags, as _transcript / _tool_spans_* for a raw-row reader, and on the Session as trajectory_emission_status = "emitted_degraded" with the missing evidence in trajectory_emission_error. The status is derived from the document that was stored, so the row and the entity cannot disagree.

metadata.tags is 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_count is 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.toml pins temper-wasm-sdk and temper-ots to 804633e2, a temper main revision that predates the JCS contract fields. OTSMetadata.harness / .spec_version, OTSTurn.prompt_token_ids / .completion_token_ids / .response_mask / .logprobs and OTSDecision.cause_id exist only on the temper branch claude/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:

Field Interim carrier Proof
decisions[].cause_id mirrors decision_id cause_id_mirrors_the_kernel_modeled_decision_id
metadata.harness, metadata.spec_version metadata.tags build_trajectory_repeats_run_provenance_in_kernel_modeled_tags
per-turn token signals inventory in context.entities[] (turn_token_signals) + token_signals:present tag token_signal_inventory_survives_the_kernel_round_trip
degradation markers degraded:* in metadata.tags degradation_markers_survive_the_kernel_round_trip

The 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-temper branch, then delete the turn_token_signals carrier, the token_signals:present tag, and the harness / spec_version tag mirrors, shrink KERNEL_UNMODELED_FIELDS, and amend ADR-0035 section 17. pinned_kernel_still_lacks_the_jcs_contract_fields fails 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 temperpaw never 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.

  • Reconstructs trajectories from SessionEntry trees and persisted tool spans.
  • Adds bounded payload handling, completeness markers, and retry-aware emission status.
  • Extends provider/session artifacts and CI contract coverage for the new trajectory fields.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
os-apps/paw-agent/wasm/monty_repl/src/session.rs Reworks tool-span compaction and sealing using UTF-8-safe character truncation and parsed truncation-marker detection.
os-apps/paw-agent/wasm/emit_ots_trajectory/src/ots_build.rs Builds bounded, multi-turn OTS trajectories from transcript entries, spans, token signals, and completeness metadata.
os-apps/paw-agent/wasm/emit_ots_trajectory/src/lib.rs Coordinates transcript and span reads, trajectory storage, and emitted, degraded, or failed session status.
os-apps/paw-agent/wasm/wasm-helpers/src/lib.rs Adds transcript-presence reporting, entry timestamps, and bounded SessionEntry metadata helpers.
os-apps/paw-agent/wasm/provider_response_applier/src/lib.rs Persists bounded per-turn provider usage and token-signal metadata for trajectory reconstruction.
.github/workflows/ci.yml Runs the independent WASM workspace test suites that enforce the OTS contract.

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 --> H
Loading

Reviews (3): Last reviewed commit: "fix: count each token signal once in eve..." | Re-trigger Greptile

@rita-aga

Copy link
Copy Markdown
Collaborator Author

Live local end-to-end run — passed

Not a test-suite claim. A locally started temperpaw-server (release build, file-backed libSQL store, every core os-app module built) ran a real paw-agent Session through the deterministic mock provider with a two-step plan: turn 1 calls a tool, turn 2 finishes.

[3/5] wait for a terminal state
  ... Created
  ... Completed
[4/5] emission bookkeeping on the entity
  trajectory_id='trj-ss-019ff2fe-820f-...' status='emitted'
[5/5] read the stored trajectory back and inspect it
  PASS: more than one real turn
  PASS: decisions populated
  PASS: every decision has cause_id
  PASS: decision matches the tool call
  PASS: decision names the tool
  PASS: consequence carries a result
  PASS: messages carry role + content
  PASS: assistant text present
  PASS: tool observation present
  PASS: metadata.harness == temperpaw
  PASS: metadata.spec_version set
  PASS: turn token counts present
  PASS: row turn_count matches
  PASS: per-turn timestamps are distinct
  PASS: context resources reference the transcript

turns=2 decisions=1 messages=4
ALL CHECKS PASSED

The stored row: turn_count = 2, persistence_status = persisted, 2808 bytes. Before this change the same run would have stored one synthetic turn with decisions: [] and no messages.

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 system

Forced the failure path and retried on a session that had already emitted:

POST TemperPaw.TrajectoryEmissionFailed  -> 200
POST TemperPaw.RetryTrajectoryEmission   -> 200

The stored row was replaced in place — 3 rows before, 3 rows after, updated_at on that row moved 22:43:13 -> 22:43:36 while created_at stayed put. The Session returned to trajectory_emission_status = "emitted" with trajectory_retry_count = 1.

What this run could not cover

The local environment provisions no workspace, so workspace_id was empty and monty_repl correctly skipped the tool-span file. Every decision above therefore came purely from the transcript — which is the redundancy this change adds: the old emitter, which depended on the span file alone, would have produced an empty decisions array in exactly this situation. The span-enrichment path (wall-clock duration_ms, and decisions for turns whose body was externalized) is covered by unit tests and needs a workspace-backed session to exercise live.

Suites run

suite result
emit_ots_trajectory (incl. round trip through the kernel temper-ots structs) 34 passed
wasm-helpers 45 passed
monty_repl 86 passed
provider_caller 31 passed
provider_response_applier 16 passed
openai-chat-wire 11 passed
session-tree-lib / session-turn-artifacts / context_preparer / agent_reply / context_compactor / steering_checker all green
cargo test -p temperpaw (16 binaries, incl. the new ots_trajectory_contract) all green

Guest builds: every touched module rebuilt for wasm32-unknown-unknown, monty_repl for wasm32-wasip1, zero failures.

@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread os-apps/paw-agent/wasm/monty_repl/src/session.rs Outdated
@rita-aga

Copy link
Copy Markdown
Collaborator Author

Review findings addressed (commit 2a9cd2b3)

Two independent adversarial reviews produced six findings. All six are fixed; none were skipped.

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_trajectory 43 passed (was 34) · monty_repl 89 · openai-chat-wire 15 · provider_caller 31
  • crates/temperpaw full test suite green (16 binaries, 0 failures), ots_trajectory_contract 10 passed (was 7)
  • Guest builds: emit_ots_trajectory, openai-chat-wire, provider_caller for wasm32-unknown-unknown; monty_repl for wasm32-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

https://claude.ai/code/session_01SF1Xhjcg7zG38WjmfC239C

@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

@rita-aga

Copy link
Copy Markdown
Collaborator Author

Round-2 review fixes — findings → fixes

Commits on claude/jcs-ots-emitter: 03225892, bde1582c, 378de026 (rustfmt of the re-indented closure), c23a5f67 (a source-grep contract test that sliced on indentation — it emptied its own assertion region instead of failing, which the re-indent exposed), fbd29bf3.

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 failure path could trap before reporting. Error messages sliced response bodies at a byte offset, so a multibyte character straddling the cut killed the guest while it was recording the failure — leaving exactly the "pending" row the sweep cannot see. Eleven sites now cut by characters. monty_repl paid for this same class once already (ADR-0035 §15).
  • The entry-extras bound could still return an oversized value. It 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 copies verbatim from a corrupted transcript line — left the value over the ceiling, and the kernel then replaces the whole field, taking the facts and the drop markers with it. Essentials are bounded now, and a final check makes the invariant unconditional.

The three residual findings

# Finding Fix Where
1 (P1) Pinned temper-ots predates the JCS contract fields, so serde drops them at the boundary while the round-trip test passes Each unmodeled field now travels through a carrier the pinned kernel does model, and each carrier is proven lossless rather than asserted. A gate fails the moment a pin bump brings the real fields in, and names the removal work. Pin-bump disposition below. ots_build.rs, Cargo.toml, ADR §17
2 (P1) Transcript emission failed closed only on Err; the shared reader maps a legacy 404 to Ok(""), so a missing transcript emitted as complete read_session_transcript returns a TranscriptPresence, and absence — along with every other way the record can be short — is named in the document, in metadata.tags, and on the Session as emitted_degraded wasm-helpers/src/lib.rs, emit_ots_trajectory/src/lib.rs, ADR §16
3 (P2) Token signals bounded individually at 32KiB but not against the 128KiB aggregate ExtraJson ceiling Running budget against MAX_ENTRY_EXTRA_BYTES, counted the way the kernel counts (the field is a JSON string, so after escaping), with the ceiling pinned to the spec that declares it — and enforced again at the single write boundary every writer passes through provider_response_applier/src/lib.rs, wasm-helpers/src/lib.rs, ADR §18

Pin-bump disposition (finding 1)

Cannot be done here, and is not deferred silently. The fields exist only on the temper branch claude/jcs-trajectory-core; its pull request (nerdsane/temper#415) was closed unmerged on 2026-08-12, and the pin (804633e2) is a temper main revision, which has none of them.

Interim carriers, each with a test that proves it lossless through a deserialize/re-serialize round trip:

Unmodeled field Carrier Proof
decisions[].cause_id mirrors decision_id cause_id_mirrors_the_kernel_modeled_decision_id
metadata.harness, metadata.spec_version metadata.tags build_trajectory_repeats_run_provenance_in_kernel_modeled_tags
per-turn token signals inventory in context.entities[] (turn_token_signals) + token_signals:present token_signal_inventory_survives_the_kernel_round_trip
degradation markers degraded:* in metadata.tags degradation_markers_survive_the_kernel_round_trip

The 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 §11 exists to prevent; what the carrier buys is that a consumer holding a re-serialized copy can tell its copy is incomplete instead of training on it as whole.

pinned_kernel_still_lacks_the_jcs_contract_fields asserts each field is emitted and dropped, so it cannot pass vacuously, and its failure message lists exactly what to delete. CI now runs the os-app WASM manifests directly — they are separate workspaces that -p temperpaw never reached, so the gate was not executing at all.

What the re-reviews found, and what changed

Fail-open paths, all now tagged degraded:* and reflected in trajectory_emission_status:

  • A transcript that arrives but does not parse — skipped lines were invisible, so a truncated file stored as complete (transcript_unparseable, with the count).
  • A recorded leaf that does not resolve — the fallback chain is an older leaf, so the newest turns are exactly what is missing (transcript_leaf_unresolved). A cyclic ancestry counts as unresolved: everything above the loop is unreachable, so the fragment is not the leaf's history.
  • A transcript that parses but yields no turn (transcript_no_turns), which produced the same synthetic single-turn document an empty one does.
  • Malformed tool-span lines, each a tool call whose only evidence is gone (tool_spans_unparseable), and a declared span file that 404s (tool_spans_missing_file).
  • Token signals refused at capture, by the trajectory budget, or discarded for misalignment (token_signals_dropped). Capture-stage <signal>_dropped_bytes is now read back by the emitter, so a turn whose signals were all refused is distinguishable from a provider that sent none.
  • token_signals:present is no longer advertised when every signal was dropped.

Correctness and honesty fixes:

  • Emission failures left the status at "pending". The guest propagated them as top-level errors; the trigger declares no on_failure, and a kernel callback could not have set the field anyway (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 rather than left standing.
  • The entry-extras bound could knowingly return an oversized value once only per-turn facts remained (a provider may return a huge stop_reason), and returning one costs the entire field. It now shortens what it cannot drop and keeps a single count when the drop markers themselves hold it over. Members are measured once and dropped largest-first: re-measuring per drop was quadratic on an object a corrupted line can make wide.
  • Non-numeric token arrays are rejected at capture rather than sized as if numeric — the OpenAI-compatible endpoint is configurable per agent.
  • turn_count is deliberately not a completeness check: it counts continuations, not assistant messages, so comparing it would mark nearly every trajectory degraded. It travels as _session_turn_count for a consumer to weigh.
  • Three claims corrected against the pinned kernel rather than left standing: 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 harmlessly inert; the entry ceiling equals the kernel's DEFAULT_FIELD_INLINE_MAX as well as the spec's declaration, so it holds whichever binds; and a contract test's brace scan now ignores string literals and fails on imbalance instead of silently widening to the whole file.

Tests

Suite Result
emit_ots_trajectory 60 passed
provider_response_applier 19 passed
wasm-helpers 49 passed
openai-chat-wire 16 passed
cargo test -p temperpaw (incl. ots_trajectory_contract 13, session_turn_architecture 24) 295 passed

wasm32-unknown-unknown release builds clean for emit_ots_trajectory, provider_response_applier, wasm-helpers, openai-chat-wire, and the dependents provider_caller, context_preparer, plan_review_feedback_handler; monty_repl clean for wasm32-wasip1. cargo fmt --all -- --check clean at the root. The temper-ots dev-dependency is never part of a guest build, and a contract test pins its rev equal to the temper-wasm-sdk rev — a round trip against a kernel the guest is not built for proves nothing.

Residual risks

  • A guest trap or timeout leaves trajectory_emission_status = "pending". Nothing the guest can do covers that; it surfaces as temper_integration_failure_dropped_total and an integration_failure_dropped Observe event. A sweep should treat a terminal session still at "pending" as unemitted.
  • The POST returns 201 when the tenant has no metadata store (kernel behaviour, trajectories.rs): the row is not persisted and the guest cannot tell, so it marks emitted. Outside this PR's reach, but worth knowing before the emitted flag is trusted as proof a row exists.
  • The token-signal carriers are interim by construction and live until the temper pin can move.
  • The live end-to-end run against a local server has not been re-run since these commits; the evidence above is unit, contract and build coverage.

@rita-aga

Copy link
Copy Markdown
Collaborator Author

Live local end-to-end run

Closing the gap I flagged in the previous comment. This was run against a local server built from this branch (b1952901), not a test double: cargo build -p temperpaw plus every os-app WASM bundle rebuilt from the branch, booted on 127.0.0.1:3477 with a fresh libSQL store, paw-agent installed from ./os-apps at startup.

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 → emitted_degraded

ss-019ff4b1-af13-7db0-8a5b-4228d3719610, a first-turn session whose SessionEntries were never materialized.

Guest log, live:

read_session_from_temperfs: virtual first-turn SessionEntries ref for ss-019ff4b1-…; materialization is false
WARN emit_ots_trajectory: session ss-019ff4b1-… produced a degraded trajectory (transcript_pending_first_turn)
ots.trajectory.queued  turn_count=1 outcome=failure
emit_ots_trajectory: emitted trajectory trj-ss-019ff4b1-… (status=Failed)
ots.trajectory.persisted  attempts=1

Session entity afterwards:

Status                       = 'Failed'
trajectory_id                = 'trj-ss-019ff4b1-af13-7db0-8a5b-4228d3719610'
trajectory_emission_status   = 'emitted_degraded'
trajectory_emission_error    = 'transcript_pending_first_turn'

Persisted row (ots_trajectories.data):

"tags": ["gpt-5.5", "openai_codex", "execute", "harness:temperpaw",
         "spec_version:paw-agent@0.1.0", "degraded:transcript_pending_first_turn"],
"_transcript": { "present": false, "reasons": ["pending_first_turn"] }

Before this branch that same row would have said emitted and carried no marker at all — a spans-only record indistinguishable from a complete one.

Run B — transcript present → emitted

ss-019ff4b5-fb67-7432-880d-ea39d819091a, same server, same code, with real SessionEntry rows to read.

trajectory_emission_status = 'emitted'
trajectory_emission_error  = ''

The emitter reconstructed 2 turns from those rows, with a real decision and the token-level signals, and wrote the interim carrier:

{
  "trajectory_id": "trj-ss-019ff4b5-fb67-7432-880d-ea39d819091a",
  "metadata": {
    "outcome": "failure", "harness": "temperpaw", "spec_version": "paw-agent@0.1.0",
    "tags": ["claude-sonnet-4-6", "anthropic", "execute",
             "harness:temperpaw", "spec_version:paw-agent@0.1.0", "token_signals:present"]
  },
  "turns[0]": {
    "turn_id": 1, "span_id": "ss-019ff4b5-…:a-1", "error": false,
    "decisions": [{
      "decision_id": "tc-1", "cause_id": "tc-1", "decision_type": "tool_selection",
      "choice": { "action": "temper.list", "arguments": {"entity_set": "Sessions", "top": 3} },
      "consequence": { "success": true, "result_summary": "3 sessions returned" }
    }],
    "prompt_token_ids": [91,92,93], "completion_token_ids": [11,12,13,14],
    "response_mask": [1,1,1,1], "logprobs": [-0.11,-0.22,-0.33,-0.44]
  },
  "context.entities": [{
    "type": "turn_token_signals",
    "id": "ss-019ff4b5-…:a-1",
    "metadata": { "turn_id": 1, "stored_on": "turns[].<signal>",
                  "lengths": {"prompt_token_ids": 3, "completion_token_ids": 4,
                              "response_mask": 4, "logprobs": 4} }
  }]
}

No degraded: tag, no _transcript marker, cause_id mirroring decision_id, and the kernel-modeled turn_token_signals carrier present in a persisted row — every carrier this PR added, in real stored data.

What ran live, and what could not

Live: the server and all WASM guests built from this branch; Session creation, Configure, provisioning, the terminal transition; emit_ots_trajectory executing in the WASM runtime; the transcript read over the real SessionEntries path; turn and decision reconstruction; the POST to /api/ots/trajectories; queue, persist, and read-back from the store; MarkTrajectoryEmitted applied to the entity.

Not live — the LLM call itself. Neither local provider credential can complete one: the Codex refresh token is missing (OpenAI Codex sign-in is required; start the Codex device login again) and ANTHROPIC_API_KEY is empty in the dev .env. Run A therefore terminates at provider auth, and Run B's transcript was seeded through the governed SessionEntries API in the exact shape wasm-helpers::session_entry_create_body writes, rather than being produced by a model. The emitter read it back with no knowledge of where it came from, so everything downstream of the transcript is genuinely exercised; what is unproven here is only that a live model produces rows of that shape — which the existing round-trip and contract tests cover.

Both runs ended in Failed for the same credential reason, so outcome: "failure" above is honest, not a masked error.

Proof script

scripts/prove_track3_ots.py could not run at all: it dispatched TemperPaw.Start, an action the Session automaton does not have (409 Unknown action: Start), so it died before its first assertion. It also read the entity the instant the status turned terminal, racing the emission that transition triggers — losing that race reported "no trajectory was emitted" when the answer was "not yet". Both are fixed in b1952901; the assertions are unchanged, so a degraded trajectory still fails the gate. Against this local server it correctly reports:

PASS: Session reached Failed
PASS: trajectory_id populated
FAIL: trajectory_emission_status == 'emitted'
degraded emission: missing transcript_pending_first_turn

which is the right answer for a run with no provider: the gate demands a complete trajectory and refuses to call this one complete.

Note on isolation

The run intended a scratch database but the server took its default path (~/.local/share/temperpaw/paw.db) — my TEMPER_DB_PATH override is not a variable the config reads. Nothing else was using that store at the time, and the port was separate from the unrelated server already on 3467, but the isolation was less than intended and the local dev store now holds these two rows.

@rita-aga

Copy link
Copy Markdown
Collaborator Author

Independent review round — six findings, six fixes

An independent Fable review of the branch returned Verdict: FAIL with six findings. All six are fixed in 394bd61d. They split cleanly: three more ways a short record read as whole, two gaps in what was actually verified, and one silent data corruption.

# Finding Fix
P2-1 A span document already at TOOL_SPANS_FILE_MAX_BYTES without a seal returned unchanged, so new spans vanished on that batch and every later one — with no _tool_spans_truncated marker. The emitter then parsed a clean document and stored the row as complete. Reachable by any file written before the ceiling existed, and by a batch landing byte-exact on it. encode_tool_spans_jsonl now seals on that path. Only when spans are actually being dropped — sealing a document that dropped nothing would mark a complete run partial, so an empty batch leaves it alone. The test that enshrined the silent behaviour now asserts the seal, plus idempotency and the empty-batch case.
P2-2a monty_repl is its own workspace; its 90 tests ran nowhere in CI. Its manifest is in the Cargo test step, and the contract test that lists the CI-covered modules now includes it.
P2-2b TOOL_SPANS_TRUNCATED_MARKER existed as two independent literals — writer (monty_repl/src/session.rs) and reader (ots_build.rs) — with nothing tying them. A rename on either side would leave the other reading a real tool call named _tool_spans_truncated: a decision the agent never made, attributed to it, on a record no longer aware it was partial. A contract test extracts both literals and asserts they are equal, and that the line the writer appends carries that value as its tool_name — the field the reader actually matches on.
P3-1 A total span-write failure left tool_spans_file_id empty, which reads exactly like a session that called no tools; the trajectory emitted undegraded. New tool_spans_write_failed Session state variable, carried by every action that already carries tool_spans_file_id (spec + CSDL). monty_repl sets it on failure; the emitter degrades with degraded:tool_spans_write_failed. Never cleared — a later success does not undo the earlier loss.
P3-2 When drop markers alone exceeded the ceiling, the hard floor deleted the <signal>_dropped_bytes markers — the emitter's only evidence that signals existed and were refused — replacing them with _extra_json_dropped_members, which nothing read. Both halves. The four token-signal drop markers now survive the hard floor (bounded to one per signal, so they cannot be what holds the value over), and the emitter reads _extra_json_dropped_members, records it per turn and in the kernel-modeled inventory, and degrades on it.
P3-3 Signals merged from both usage and choices[0] of the same event could double-concatenate ids when a server repeats them. With only one signal present nothing catches it: there is no second array to disagree on length. Each event now contributes each signal once, via a single collapsed source. The per-choice level wins where both carry a field — that is where the chat-completions format defines these — and a field only usage carries is still taken. Cross-event accumulation is unchanged, and tested.
P3-4 Empty session_leaf_id plus dangling-parent entries fell through to raw file order with no degradation. Raw file order is not a walk: no leaf produced a chain, so the parent structure is unusable and the order is a guess. It now reports unresolved whether or not a leaf was recorded. An empty transcript is the one exception — the presence reasons already speak for it.

Tests

Suite Result
emit_ots_trajectory 63 passed
monty_repl 90 passed (now in CI)
wasm-helpers 50 passed
openai-chat-wire 19 passed
provider_response_applier 19 passed
cargo test -p temperpaw 298 passed

wasm32-unknown-unknown release builds clean for emit_ots_trajectory, provider_response_applier, wasm-helpers, openai-chat-wire and the dependents provider_caller, context_preparer, plan_review_feedback_handler; monty_repl clean on wasm32-wasip1. cargo fmt --all -- --check clean.

Adding the state variable changed three action parameter lists that session_turn_architecture pins by exact literal; those literals and the matching CSDL action parameters were updated with it, and the suite is green.

The reviewer re-verifies from here — nothing above is marked fixed on my own say-so.

@rita-aga

Copy link
Copy Markdown
Collaborator Author

Residual: the double-merge class was only one-third fixed

Re-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 d9ece3c5.

The identical within-event pattern was live in provider_caller:

Accumulator Two levels of one event Was
ChatCompletionStreamAccumulator usage, choices[0] fixed last round
OpenRouterStreamAccumulator usage, choices[0] still doubling
OpenAiStreamAccumulator response, response.usage (one response.completed) still doubling

All three now route an event through one shared event_token_signals before merging. The rule it encodes is stated once, in the function, and generally: the content level wins over the accounting level — the choice in a chat chunk, the response in a Responses event — because usage repeating a signal is a server quirk, not a second measurement.

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:

test openrouter_event_contributes_each_token_signal_once ... FAILED
  left: Array [Number(7), Number(8), Number(7), Number(8)]
 right: Array [Number(7), Number(8)]

test openai_response_completed_contributes_each_token_signal_once ... FAILED
  left: Array [Number(4), Number(5), Number(6), Number(4), Number(5), Number(6)]
 right: Array [Number(4), Number(5), Number(6)]

A fourth wire shape cannot reintroduce this: a contract test walks both source files and fails on any merge_token_signals(&mut self.…) that is not fed the collapsed source, naming the file and line. It also requires each of the three repeated-payload tests to exist.

Corrected claims, since the previous ones were scoped to one accumulator while reading as general: the per-site comments now point at event_token_signals for the rule rather than restating a chat-specific version, and ADR-0035 §18 states it as covering all three accumulators, names both level pairs, and records the contract test.

Tests

provider_caller 33 · openai-chat-wire 19 · emit_ots_trajectory 63 · monty_repl 90 · wasm-helpers 50 · provider_response_applier 19 · cargo test -p temperpaw 298. All wasm builds clean (provider_caller, openai-chat-wire, emit_ots_trajectory, provider_response_applier, wasm-helpers on wasm32-unknown-unknown; monty_repl on wasm32-wasip1). cargo fmt --all -- --check clean.

@rita-aga

Copy link
Copy Markdown
Collaborator Author

@greptile review

rita-aga and others added 20 commits August 12, 2026 12:59
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
@rita-aga
rita-aga force-pushed the claude/jcs-ots-emitter branch from d9ece3c to 583592d Compare August 12, 2026 17:05
@rita-aga

Copy link
Copy Markdown
Collaborator Author

Rebased onto claude/mirror-genesis-os-apps, temper pin bumped, interim carriers deleted

Branch is now 583592d4, based on 9e767256 (the current tip of claude/mirror-genesis-os-apps, PR #459).

Rebase

Conflicts, and how each was resolved:

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

804633e2a747f7d40cb556371168f8460bc72806c3574d2b (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_signals inventory in context.entities[] — gone, along with TOKEN_SIGNAL_CARRIER_TYPE
  • token_signals:present tag — gone
  • harness: / spec_version: tag mirrors — gone, along with both prefix constants
  • KERNEL_UNMODELED_FIELDS shrunk 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_fieldskernel_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
@rita-aga

Copy link
Copy Markdown
Collaborator Author

P1 + four P2s — findings → fixes

All five fixed in 16eb43a8.

P1 — the emitter could build a turn the newly-pinned kernel refuses

Confirmed, and worse than the alignment bug it looked like. 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 had one distinct length, passed as aligned, and was written with nothing to index into.

The consequence is not a degraded row — it is no row. OTSTurn::validate_token_signals runs on every deserialize at a747f7d4, the POST answers 400, the emission records failed, and build_trajectory is deterministic, so every retry rebuilds the identical rejected document.

Fixed by mirroring the kernel's rule rather than restating it. I read turn.rs:276-310 and implemented exactly what it enforces:

Kernel rule Emitter now
response_mask / logprobs require completion_token_ids COMPLETION_ANCHOR must be among the present signals, or the whole completion set is dropped and recorded
lengths must match the anchor's unchanged equal-length check, now anchored
every response_mask entry is 0 or 1 is_u8_arrayis_mask_array (0/1 only) — the old check accepted up to 255, so a mask with a 2 was a second rejection path
prompt_token_idsnot validated left alone; it indexes nothing, so nothing to anchor

Tests at both ends, because no existing test exercised an asymmetric drop — which is why every suite was green:

  • Writer boundary (provider_response_applier): assistant_turn_extra_can_keep_a_mask_after_refusing_its_token_ids builds an 8,000-token completion, asserts the fixture straddles the ceiling (ids 56,001 bytes > 32,768 ≥ mask 16,001), and pins that the writer really does emit the anchorless shape. The emitter's guard is not guarding a hypothetical.
  • Kernel round trip: every_asymmetric_signal_shape_still_deserializes_as_a_kernel_turn feeds seven shapes — lone logprobs, lone response_mask, non-binary mask, length mismatch, mask+logprobs without ids, the full aligned set, and prompt-ids-plus-mask — through the real OTSTrajectory. Whatever the emitter can build, the kernel accepts.
  • build_trajectory_drops_completion_signals_with_no_anchor, build_trajectory_keeps_a_lone_completion_token_ids_signal, build_trajectory_refuses_a_response_mask_that_is_not_binary.

The test that asserted a lone logprobs "still travels" was asserting the defect. It now asserts the drop.

P2-1 — dead code, and the PR record was wrong

Both parts confirmed by me, not taken on the review's word: call_provider sends "openrouter" to call_openai_compatible_chat (line 3982), and the compiler names call_openrouter, convert_messages_to_openrouter, convert_tools_to_openrouter, OpenRouterStreamAccumulator, OpenRouterToolCallAccum as never used. So the id collision I described cannot occur on that path.

Correcting the record: my earlier comment said the conflict was resolved by "taking their deletion". That is not what the tree shows — provider_caller at my tip is a superset, and both copies live. What actually happened: I took the deletion when rebasing onto 2042ded5, then their force-push to 9e767256 restored the OpenRouter path, and my later commit patched the restored copy. The description was written for the first rebase and never corrected for the second. My mistake.

I annotated rather than deleted: a block comment above OpenRouterToolCallAccum states the path is unreachable, names the live one, and says the "never used" warnings are left standing deliberately instead of being silenced with #[allow(dead_code)]. Deleting ~600 lines that PR #459 restored one commit ago is that PR's call, not this one's. The trap the review names — dead code that looks live — is closed by making the deadness unmissable at the top of the block.

P2-2 — regression test for the surviving copy

openrouter_conversion_scopes_missing_tool_call_ids_per_message, mirroring openai-chat-wire's. That exact line regressed once through a rebase, which is why the fix commit exists, so the invariant is pinned rather than trusted to the code being unreachable today.

P2-3 — duplicated assertion

Removed the second degradations(&t).contains("token_signals_dropped") in build_trajectory_marks_turn_extras_cut_to_fit_as_degraded.

P2-4 — forbidden-older-revs list

804633e2 added to datadog_observability_contract.rs with a note on why it is now forbidden: rolling back leaves the emitter writing contract fields the structs no longer model.

Tests

emit_ots_trajectory 64 · monty_repl 90 · provider_caller 35 · wasm-helpers 50 · provider_response_applier 20 · openai-chat-wire 19 · cargo test -p temperpaw --no-fail-fast 298 passed, 0 failed.

wasm32 release builds clean for all six touched modules. cargo fmt --all -- --check clean.

@rita-aga
rita-aga marked this pull request as ready for review August 12, 2026 18:05
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown

Too many files changed for review (125 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@rita-aga
rita-aga merged commit 75cc6c5 into main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant