|
| 1 | +# CK Message field inventory (MC-grounded, draft for reconciliation) |
| 2 | + |
| 3 | +The companion to `cache-stability-golden-vectors.json`. The golden vectors pin |
| 4 | +the cache *mechanics* (anchor/freeze/replay/classify). This pins the *message |
| 5 | +representation* those mechanics operate on: the lossless `encode`/`decode` |
| 6 | +contract for the one harness-agnostic MC Transform. |
| 7 | + |
| 8 | +Status: **draft, for reconciliation** against llm-runner's request canonical. |
| 9 | +The canonical-unification decision (CK == llm-runner canonical, vs CK a superset |
| 10 | +with llm-runner's a projection) is the major-architecture call; this draft is |
| 11 | +written to the **converged direction: CK is the superset, llm-runner's canonical |
| 12 | +is the byte-affecting projection.** |
| 13 | + |
| 14 | +## The flow it serves |
| 15 | + |
| 16 | +There is one transform, one canonical, and provider knowledge lives downstream: |
| 17 | + |
| 18 | +``` |
| 19 | +harness msg --encode--> CK Message --[MC Transform]--> CK Message --decode--> harness msg --> harness serializer --> wire |
| 20 | + \--(own-harness / MITM)--> [downstream quirk pass] --> provider wire |
| 21 | +``` |
| 22 | + |
| 23 | +The MC Transform is **provider-neutral**: it freezes CK-level units (a logical |
| 24 | +"drop of tag N"), never provider-specific bytes. The provider-correct rendering |
| 25 | +(empty part vs `[dropped]` text, Gemini name-binding vs id-binding) is a |
| 26 | +**downstream quirk pass** that consumes llm-runner's typed policies. Placement of |
| 27 | +that pass is flexible (Rust after the MC pass, plugin on subc-return, or |
| 28 | +llm-runner's framework for the own-harness path); it is not part of the cache |
| 29 | +core. |
| 30 | + |
| 31 | +## The keystone conformance invariant |
| 32 | + |
| 33 | +CK is a superset; llm-runner's canonical is the byte-affecting projection. The |
| 34 | +invariant that keeps the superset non-lossy (the CK analog of the render |
| 35 | +goldens, and a checkable conformance test): |
| 36 | + |
| 37 | +``` |
| 38 | +render(project(ck)) == render(project(strip_harness_flags(ck))) |
| 39 | +``` |
| 40 | + |
| 41 | +Stripping every CK field that is NOT in the projection (harness-only flags: |
| 42 | +`synthetic`, harness message id, ordinal) must NEVER change the rendered bytes. |
| 43 | +If it does, that field was mis-bucketed as harness-only when it is actually |
| 44 | +content. |
| 45 | + |
| 46 | +## The buckets |
| 47 | + |
| 48 | +Every byte-relevant field is exactly one of: |
| 49 | + |
| 50 | +- **(a) CONTENT** - round-trips in CK verbatim and is in llm-runner's projection. |
| 51 | + Renders to provider bytes; cannot be stripped without changing bytes. |
| 52 | +- **(b) DOWNSTREAM-EMITTER POLICY** - a render-time typed-policy *unit decision*, |
| 53 | + NOT carried in CK as content. Reused from llm-runner's policy layer at |
| 54 | + per-unit granularity (`decide_reasoning`, `decide_drop_shape`), not whole-body |
| 55 | + `shape()`. |
| 56 | +- **(c) LOOP / DURABILITY INVARIANT** - guaranteed by canonical normalization, |
| 57 | + target-agnostic, NOT a wire policy. |
| 58 | +- **(param) RENDER PARAM** - render input that rides run-config / the frozen |
| 59 | + render config, never a CK message field. |
| 60 | +- **(flag) HARNESS-ONLY** - in the CK superset, stripped in the projection; the |
| 61 | + keystone invariant forbids it from affecting rendered bytes. |
| 62 | + |
| 63 | +## Field table |
| 64 | + |
| 65 | +| CK field | bucket | llm-runner mapping | consuming policy / invariant | render target per path | |
| 66 | +|---|---|---|---|---| |
| 67 | +| message `role` (System/User/Assistant/Tool) | a | `Message.role` | - | content, all paths | |
| 68 | +| text part `text` | a | `ContentBlock::Text{text}` | - | content, all paths | |
| 69 | +| `§N§` tag prefix | a (literal text *inside* a Text block, NOT a field) | part of `Text.text` | a changed tag number IS a bust | content, all paths | |
| 70 | +| reasoning `text` + `signature` | a | `ContentBlock::Reasoning{text, signature}` | signature byte-verbatim | content, all paths | |
| 71 | +| redacted reasoning `data` | a | `ContentBlock::RedactedReasoning{data}` | data byte-verbatim | content, all paths | |
| 72 | +| tool call `id` / `name` / `input` | a | `ContentBlock::ToolCall{...}` | - | content, all paths | |
| 73 | +| tool result `id` / `output` / `is_error` | a | `ContentBlock::ToolResult{...}` | - | content, all paths | |
| 74 | +| **part ORDER within a message** | a (structural) | `Vec<ContentBlock>` order | order is byte-affecting (Anthropic rejects thinking-after-text) | content, all paths | |
| 75 | +| **image / file part (base64)** | a | **NO llm-runner ContentBlock twin (the projection gap, see below)** | `stripProcessedImages` is a frozen unit over these | content, all paths | |
| 76 | +| drop / empty-content shape | **b** | net-new typed policy (`EmptyContent`) | `decide_drop_shape(provider, drop) -> EmptyPart \| DroppedTextPart`; **MC is the reference spec** (#135) | emitter: empty part (Anthropic/Bedrock) vs `[dropped]` text (Copilot) | |
| 77 | +| reasoning last-turn strip / clear | **b** + positional render-param | `ReasoningPolicy` (`decide_reasoning`) | Anthropic merges consecutive assistants and rejects thinking-after-text; OpenAI-compat may require non-empty `reasoning_content`. **Decision depends on `is_last_assistant_turn` (body-derived); frozen at freeze time, never re-derived (see below).** | emitter: literal `[cleared]`/empty per provider | |
| 78 | +| tool-call/result pairing completeness | **c** | `synthesize_hard_stop` core invariant | every `tool_use` has a matching `tool_result` pre-send; MC's skeleton-drop + newest-20 reserve preserve adjacency | canonical normalization, target-agnostic | |
| 79 | +| `cache_control` breakpoint placement | param | `CachePolicy` | normalized OUT of the bust decision (golden vectors V7); the renderer places it | emitter / render-param | |
| 80 | +| `step_context` (run_id, step_index) | param | `StepContext` (serde-skip; only Antigravity reads it) | render input, not content | render-param | |
| 81 | +| `synthetic` | flag | (none - stripped in projection) | harness titling-gate + TUI visibility; keystone invariant forbids byte effect | harness-only | |
| 82 | +| harness message `id` / `ordinal` | flag | (none - stripped in projection) | MC anchoring / tagging / `computeRawRangeFingerprint`; not on the provider wire | harness-only | |
| 83 | + |
| 84 | +## EmptyContent: the one net-new policy (MC is the reference) |
| 85 | + |
| 86 | +llm-runner has no twin for this, so MC's `modelAcceptsEmptyContent` is the |
| 87 | +reference spec. The per-unit decision the downstream pass needs llm-runner to |
| 88 | +expose: |
| 89 | + |
| 90 | +``` |
| 91 | +decide_drop_shape(provider, logical_drop_unit) -> EmptyPart | DroppedTextPart |
| 92 | +``` |
| 93 | + |
| 94 | +- `EmptyPart`: providers whose serializer drops empty content blocks |
| 95 | + (Anthropic, Bedrock). The dropped unit becomes an empty content part the |
| 96 | + provider/serializer removes, preserving tool adjacency. |
| 97 | +- `DroppedTextPart`: providers whose serializer does NOT drop empty parts and |
| 98 | + break tool-use adjacency on them (Copilot / OpenAI-compatible passthroughs, |
| 99 | + issue #135). The dropped unit becomes a literal `[dropped §N§]` text part. |
| 100 | + |
| 101 | +The choice is per (provider, unit), constant within a cache epoch: the provider |
| 102 | +is fixed across defer passes, and a provider/model switch is a HARD bust, so the |
| 103 | +emitter cannot vary mid-epoch. That fixed-determinism is what makes CK-level |
| 104 | +byte-stability imply wire byte-stability uniformly (no per-path vector binding). |
| 105 | + |
| 106 | +## Where assembly happens: plugin gap-fills, owned/MITM assembles |
| 107 | + |
| 108 | +Family *assembly* (Anthropic pulls System-role messages to a top-level `system` |
| 109 | +field; tool-role messages reshape into a user-turn carrying `tool_result` |
| 110 | +blocks; consecutive assistants merge) is real, but **on the plugin path MC does |
| 111 | +not do it** - verified at source: MC strips/clears/drops parts *in place*, role |
| 112 | +always preserved (the `role !== "user"` guards in `strip-content.ts`), never a |
| 113 | +top-level `system` pull, never a tool-role reshape. The harness serializer |
| 114 | +(OpenCode's `@ai-sdk` adapter) does assembly downstream of MC. So the split is: |
| 115 | + |
| 116 | +- **Owned / MITM path:** the downstream pass renders the FULL provider request |
| 117 | + via llm-runner's family renderer - assembly (system-pull, tool-role reshape, |
| 118 | + merge) INCLUDED. This is `shape()` whole-body; llm-runner owns it. |
| 119 | +- **Plugin path:** the harness serializer does assembly. MC's quirk pass is |
| 120 | + **gap-fill ONLY** - it applies the per-unit decisions the harness serializer |
| 121 | + gets wrong or does incompletely (EmptyContent drop-shape #135; reasoning |
| 122 | + handling), and the role-preserved array it hands back is then assembled by the |
| 123 | + harness. The plugin quirk pass must NOT replicate family assembly rules; that |
| 124 | + would double-assemble. |
| 125 | + |
| 126 | +The one nuance: some plugin gap-fills are **assembly-AWARE** without being |
| 127 | +assembly-PERFORMING. `stripReasoningFromMergedAssistants` exists precisely |
| 128 | +because the harness/provider MERGES consecutive assistants and Anthropic rejects |
| 129 | +thinking repositioned after text - so MC strips reasoning from all-but-the-first |
| 130 | +merged assistant *before* the harness merges. MC must KNOW the merge will happen |
| 131 | +(to gap-fill correctly) but does not perform it. So: plugin quirk pass = gap-fill, |
| 132 | +sometimes assembly-aware, never assembly-performing. |
| 133 | + |
| 134 | +Inventory consequence: the "render target per path" column reads, for plugin, |
| 135 | +"gap-fill; harness assembles" (and for the reasoning row, "assembly-aware: |
| 136 | +compensates for harness assistant-merge"). Full family assembly lives only in the |
| 137 | +owned/MITM target. |
| 138 | + |
| 139 | +## Reasoning-strip: the one policy carrying a positional render-param |
| 140 | + |
| 141 | +The Anthropic last-turn thinking strip is bucket (b), but it is the one policy |
| 142 | +whose decision is not unit-pure: the *shape* is unit-pure, but the *trigger* |
| 143 | +(strip thinking from all but the last assistant turn) is **body-derived** - |
| 144 | +`is_last_assistant_turn`, an `rposition` over non-system messages. This is |
| 145 | +Anthropic-specific and a single bit; other families' reasoning is `None` or a |
| 146 | +top-level Responses object with no per-message strip. |
| 147 | + |
| 148 | +Critically, this bit is **frozen at freeze time, never re-derived per pass** - |
| 149 | +it is exactly the byte-identity hazard the golden vectors guard against. If the |
| 150 | +downstream pass re-computed `is_last_assistant_turn` on a later defer pass after |
| 151 | +the tail grew, the strip decision for a previously-frozen assistant turn could |
| 152 | +flip, mutating cached-prefix bytes (a bust). So the reasoning-strip frozen unit |
| 153 | +is frozen as `(reasoning_block + is_last_assistant_turn)`, and that positional |
| 154 | +param rides alongside exactly like `step_context` (render-input, not content, |
| 155 | +not whole-body hidden state). MC's freeze must capture the one bit at freeze |
| 156 | +time. |
| 157 | + |
| 158 | +## The projection gap llm-runner must resolve: multimodal |
| 159 | + |
| 160 | +llm-runner's relayed `ContentBlock` set is |
| 161 | +`Text | Reasoning | RedactedReasoning | ToolCall | ToolResult` - **no image / |
| 162 | +file / document block.** MC carries multimodal image parts (base64) in the |
| 163 | +message array, and `stripProcessedImages` is a frozen unit operating on them |
| 164 | +(the screenshot-in-a-user-message cache scar). Image parts are bucket **(a) |
| 165 | +CONTENT** (they render to provider bytes), so they cannot be a harness-only flag |
| 166 | +stripped in the projection. Therefore the **projection must grow an image/file |
| 167 | +ContentBlock**, or multimodal input is declared out-of-scope for the bytes-owned |
| 168 | +paths initially (in which case plugin paths STILL need it in CK for the harness |
| 169 | +round-trip, so it stays a CK content field either way). llm-runner owns this |
| 170 | +call; flagged here as the one place the byte-affecting projection needs to grow. |
| 171 | + |
| 172 | +## Open decisions (routed to Ufuk, major-arch) |
| 173 | + |
| 174 | +1. **Canonical unification:** CK == llm-runner canonical, or CK superset with |
| 175 | + llm-runner's a projection (this draft assumes superset). |
| 176 | +2. **Multimodal scope:** grow llm-runner's projection with an image/file block |
| 177 | + now, or defer multimodal on bytes-owned paths. |
| 178 | +3. **Quirk-pass placement default:** Rust-after-MC vs plugin-on-return vs |
| 179 | + per-harness (own-harness uses llm-runner's framework regardless). |
| 180 | + |
| 181 | +## Provenance |
| 182 | + |
| 183 | +Field set extracted from MC's actual wire handling: `strip-content.ts` |
| 184 | +(reasoning clear/replay, structural-noise, processed-image strip), `sentinel.ts` |
| 185 | ++ `transform.ts:803` / `transform-postprocess-phase.ts:384` |
| 186 | +(`modelAcceptsEmptyContent` drop-shape, the #135 scar), `apply-operations.ts` |
| 187 | +(`buildReplacementContent`, the `[dropped §N§]` placeholder), `tagger.ts` (the |
| 188 | +`§N§` tag text), `inject-compartments.ts` (the `synthetic` prepend flag), |
| 189 | +`read-session-true-raw-tokens.ts` (`computeRawRangeFingerprint`, the harness-id / |
| 190 | +ordinal anchor). Reconciled against the relayed llm-runner request canonical |
| 191 | +(`Message` / `ContentBlock` / `CallOptions` / `FrozenRenderConfig`). |
0 commit comments