Skip to content

Commit 975a22a

Browse files
docs(spec): CK#1 @ai-sdk completeness audit — provider-extras bag + 3 field gaps
Two independent audits (MC + SUBC) against @ai-sdk v2 provider-spec canonical (LanguageModelV2Prompt, ~/Work/OSS/ai packages/provider) converged on the same root finding. Closes the lossless-superset gaps before the canonical blessing. G-A (architectural): @ai-sdk routes ALL provider-specific per-part data through ONE opaque providerOptions/providerMetadata bag (Record<provider,Record<key,JSONValue>>) on every message AND part — reasoning signature, redacted-data, cacheControl, even a compaction marker all ride it; the canonical types NONE of them. Typed-field enumeration kept discovering holes (Gemini thoughtSignature was the latest). Adds a NATIVE-PRESERVED provider_extras bag at message + part level (§5.10, ordered BTreeMap so key order can't bust cache; transform never parses it) — makes CK PROVABLY lossless vs any unenumerated provider extra. New typed-vs-bag rule: a datum is a typed neutral field IFF the transform branches on it (today: Reasoning.signature, RedactedReasoning, provider_executed); else it rides the bag. Refactors ContentBlock to {kind, provider_extras}; demotes the speculative Text/ToolCall signature fields into the bag. G-B: ToolResultOutput typed variants (Text|Json|ErrorText|ErrorJson|Content) — JSON tool results no longer flatten to text; error-ness in the variant (drops is_error). G-C: provider_executed on ToolCall/ToolResult + Assistant-role result placement (server-side tools: web_search/built-ins/grounding). G-D: Media.filename. Plus ToolResult.tool_name preserved (no fragile cross-message re-derive). Verified NON-gaps (both audits): source/citation parts (response-side, CK is request-only), system-as-string (CK multi-System richer), cache_control (render-side + provider_extras safety net). Conformance §10 gains lossless-round-trip property test (3c) + typed-output/filename checks (3d). Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 1e63b85 commit 975a22a

1 file changed

Lines changed: 147 additions & 33 deletions

File tree

docs/specs/ck-message.md

Lines changed: 147 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -92,42 +92,75 @@ pub struct CkMessage {
9292
pub role: Role,
9393
/// ORDER is byte-affecting (bucket a). MUST be preserved verbatim.
9494
pub content: Vec<ContentBlock>,
95+
/// MESSAGE-level provider extras (§5.10). NATIVE-PRESERVED, in the projection,
96+
/// deterministic key order. Carries provider-specific message data the
97+
/// transform never interprets (e.g. message-level cache hints, provider tags).
98+
pub provider_extras: ProviderExtras,
9599
/// Bucket `flag`. project() strips this entirely. The keystone invariant
96100
/// (§2) forbids any field here from changing rendered bytes.
97101
pub meta: HarnessMeta,
98102
}
99103

100104
pub enum Role { System, User, Assistant, Tool }
101105

102-
pub enum ContentBlock {
106+
/// A content part = its typed kind + a part-level provider-extras bag (§5.10).
107+
pub struct ContentBlock {
108+
pub kind: ContentKind,
109+
/// PART-level provider extras. NATIVE-PRESERVED, in the projection,
110+
/// deterministic key order. The general-form catch-all for any provider
111+
/// per-part datum the transform does NOT branch on (Gemini text/tool
112+
/// thought-signatures, provider part tags, cache hints the renderer can't
113+
/// re-derive, future provider extras). See §5.10 + the typed-vs-bag rule.
114+
pub provider_extras: ProviderExtras,
115+
}
116+
117+
pub enum ContentKind {
103118
/// `§N§` tag prefixes live as LITERAL TEXT inside `text`, never as a field.
104119
Text { text: String },
105-
/// `signature` is provider-issued; MUST round-trip byte-verbatim when present.
120+
/// `signature` is TYPED because the transform branches on it (nulls it when
121+
/// clearing reasoning, §6.1). The codec maps it to/from the provider's native
122+
/// location, keeping the transform provider-neutral. Byte-verbatim when present.
106123
Reasoning { text: String, signature: Option<String> },
107-
/// Opaque provider blob; MUST round-trip byte-verbatim and MUST NOT be emptied.
124+
/// Separate variant (not a flag on Reasoning) because the transform branches on
125+
/// it (skip-clear, §5.5). `data` is opaque; byte-verbatim; MUST NOT be emptied.
108126
RedactedReasoning { data: String },
109127
/// `id` is the CK-CANONICAL tool-call id; pairing is ALWAYS on it (§5.6.1).
110-
ToolCall { id: String, name: String, input: serde_json::Value },
111-
/// `id` MUST equal the paired ToolCall.id (the CK-canonical id, §5.6.1).
112-
ToolResult { id: String, content: ToolResultContent, is_error: bool },
128+
/// `provider_executed` is TYPED because tool-pairing branches on it (§7).
129+
ToolCall { id: String, name: String, input: serde_json::Value, provider_executed: bool },
130+
/// `id` MUST equal the paired ToolCall.id (§5.6.1). `tool_name` preserved
131+
/// (avoids cross-message name-recovery fragility, §5.6). `output` carries the
132+
/// typed shape incl. structured JSON + typed errors (§5.6.2).
133+
ToolResult { id: String, tool_name: String, output: ToolResultOutput, provider_executed: bool },
113134
/// Multimodal input (§9). Bucket (a) CONTENT in CK day one.
114135
Media(MediaBlock),
115136
}
116137

117-
pub enum ToolResultContent {
138+
/// Mirrors @ai-sdk LanguageModelV2ToolResultOutput so structured/typed-error
139+
/// results round-trip losslessly (§5.6.2). Error-ness is encoded in the variant.
140+
pub enum ToolResultOutput {
118141
Text(String),
119-
Blocks(Vec<ResultBlock>),
142+
Json(serde_json::Value),
143+
ErrorText(String),
144+
ErrorJson(serde_json::Value),
145+
Content(Vec<ResultBlock>), // text + media
120146
}
121147
pub enum ResultBlock { Text(String), Media(MediaBlock) }
122148

123149
pub struct MediaBlock {
124-
pub kind: MediaKind, // Image | File | Document
125-
pub mime: String,
126-
pub source: MediaSource, // Base64 | Url | FileId
150+
pub kind: MediaKind, // Image | File | Document
151+
pub mime: String, // IANA media type (mediaType)
152+
pub filename: Option<String>, // preserved where the provider carries it (§9)
153+
pub source: MediaSource, // Base64 | Url | FileId
127154
}
128155
pub enum MediaKind { Image, File, Document }
129156
pub enum MediaSource { Base64(String), Url(String), FileId(String) }
130157

158+
/// Provider-keyed, deterministic-order extras bag (§5.10). Mirrors @ai-sdk
159+
/// SharedV2ProviderOptions: Record<provider, Record<key, JSONValue>>. BTreeMap
160+
/// (NOT HashMap) because key order is byte-affecting on render -> a nondeterministic
161+
/// order would bust the cache. Empty map renders to nothing.
162+
pub type ProviderExtras = BTreeMap<String, BTreeMap<String, serde_json::Value>>;
163+
131164
/// Harness-only. project() strips ALL of these; none may affect rendered bytes.
132165
pub struct HarnessMeta {
133166
pub harness_id: Option<String>, // provider wire never sees this
@@ -138,7 +171,9 @@ pub struct HarnessMeta {
138171

139172
The downstream-emitter typed policies (bucket b) and render params are NOT fields
140173
of `CkMessage` - they are defined in §6 and §8. The MC Transform reads CK content
141-
+ `meta`; it MUST NOT carry a provider field on the message.
174+
+ `provider_extras` (opaque, never interpreted) + `meta`; it MUST NOT carry a
175+
provider-INTERPRETED field on the message (the typed `signature` /
176+
`provider_executed` fields are provider-NEUTRAL - the codec owns the mapping).
142177

143178
## 4. The three-bucket field model
144179

@@ -161,16 +196,20 @@ Every byte-relevant CK field is EXACTLY ONE of:
161196
| `role` | a | content, all paths |
162197
| `content` part ORDER | a | byte-affecting; Anthropic rejects thinking-after-text |
163198
| `Text.text` (incl. `§N§` tag prefix) | a | a changed tag number IS a content change (bust) |
164-
| `Reasoning.text` + `signature` | a | signature byte-verbatim when present |
199+
| `Reasoning.text` | a | content; `signature` is TYPED-neutral (§5.10 rule) |
200+
| `Reasoning.signature` | a (typed-neutral) | transform branches on it (clear nulls it, §6.1); byte-verbatim |
165201
| `RedactedReasoning.data` | a | byte-verbatim; MUST NOT be emptied |
166202
| `ToolCall.id` / `name` / `input` | a | content, all paths |
167-
| `ToolResult.id` / `content` / `is_error` | a | content, all paths |
168-
| `Media` (image/file/document) | a | CK content day one (§9) |
203+
| `ToolCall.provider_executed` | a (typed-neutral) | pairing branches on it (§7) |
204+
| `ToolResult.id` / `tool_name` / `output` | a | content; `output` typed shape (§5.6.2) |
205+
| `ToolResult.provider_executed` | a (typed-neutral) | pairing / placement branches on it (§7) |
206+
| `Media` (kind/mime/filename/source) | a | CK content day one (§9) |
207+
| message + part `provider_extras` | a (NATIVE-PRESERVED) | the catch-all bag; byte-verbatim; deterministic order (§5.10) |
169208
| reasoning keep/clear shape | b | `decide_reasoning` (§6.1) |
170209
| drop / empty-content shape | b | `decide_drop_shape` / EmptyContent (§6.2) |
171210
| reasoning_content-required | b | `ReasoningContentRequired`, default-off (§6.3) |
172211
| tool-call/result pairing | c | `synthesize_hard_stop` (§7) |
173-
| `cache_control` placement | param | `CachePolicy`; renderer places it (§8) |
212+
| `cache_control` placement | param | `CachePolicy`; renderer places it (§8); unrecoverable hints survive in `provider_extras` |
174213
| `step_context` | param | rides run-config; never CK content (§8) |
175214
| `synthetic` | flag | harness titling/TUI; no byte effect |
176215
| `harness_id` / `ordinal` | flag | MC anchoring; not on the wire |
@@ -191,18 +230,29 @@ Transform and all codecs MUST preserve it verbatim.
191230
text part, NOT a structural field. A change to a tag number is a content change
192231
and therefore a cache bust. `project()` MUST NOT strip tags (they are content).
193232

194-
**5.4 `Reasoning`** - `text` plus optional provider `signature`. When a signature
195-
is present it MUST round-trip byte-verbatim through encode/decode; a signature
196-
over modified text is invalid. Clearing reasoning is a bucket-(b) decision (§6.1),
197-
not a content edit.
198-
199-
**5.5 `RedactedReasoning`** - opaque provider blob. MUST round-trip byte-verbatim
200-
and MUST NOT be emptied or signature-stripped (Pi's serializers bypass the
201-
empty-drop for redacted blocks; emptying one yields a malformed wire block).
202-
203-
**5.6 `ToolCall` / `ToolResult`** - name, input JSON, output content, error flag
204-
are content. `ToolResultContent` is text or a block list (text + media); encoders
205-
MUST preserve which form the harness used. The id is governed by §5.6.1.
233+
**5.4 `Reasoning`** - `text` plus optional provider `signature` (typed-neutral per
234+
§5.10: the transform branches on it, so it is a named field, but the codec owns
235+
the native mapping). When a signature is present it MUST round-trip byte-verbatim;
236+
a signature over modified text is invalid. Clearing reasoning is a bucket-(b)
237+
decision (§6.1) that nulls the signature, not a content edit. NOTE: @ai-sdk's
238+
canonical does not type the signature - it rides `providerMetadata.anthropic.signature`.
239+
CK types it (the transform reads it on clear) while the codec maps it to/from the
240+
provider's native bag location; everything the transform does NOT read stays in
241+
`provider_extras` (§5.10).
242+
243+
**5.5 `RedactedReasoning`** - opaque provider blob, a distinct variant (not a flag
244+
on `Reasoning`) because the transform branches on it (skip-clear). MUST round-trip
245+
byte-verbatim and MUST NOT be emptied or signature-stripped (Pi's serializers
246+
bypass the empty-drop for redacted blocks; emptying one yields a malformed wire
247+
block). @ai-sdk represents this as a `reasoning` part with empty `text` +
248+
`providerMetadata.anthropic.redactedData`; the codec maps that shape to/from this
249+
CK variant.
250+
251+
**5.6 `ToolCall` / `ToolResult`** - name, input JSON, and output are content.
252+
`ToolResult` carries its own `tool_name` (preserved, not re-derived) to avoid
253+
cross-message name-recovery fragility when the result is in a separate Tool-role
254+
message from the call. The id is governed by §5.6.1; the output shape by §5.6.2;
255+
`provider_executed` by §5.6.3.
206256

207257
**5.6.1 `tool_call_id` is CANONICAL with per-edge mapping (NORMATIVE).** The CK
208258
`ToolCall.id` / `ToolResult.id` is a **CK-canonical** id. Each codec maps
@@ -235,12 +285,63 @@ round-trip fidelity is required only where a decoded id is later RE-RENDERED to
235285
wire (plugin round-trip, owned path) - and rule 1 (identity-preserving) makes
236286
that automatic for same-family round-trips.
237287

288+
**5.6.2 `ToolResultOutput` (typed shape, NORMATIVE).** Mirrors @ai-sdk
289+
`LanguageModelV2ToolResultOutput` so structured results round-trip losslessly:
290+
`Text(String) | Json(Value) | ErrorText(String) | ErrorJson(Value) |
291+
Content(Vec<ResultBlock>)`. A structured-JSON tool result MUST be carried as `Json`
292+
(NOT flattened to text - flattening changes wire bytes and loses structure).
293+
Error-ness is encoded in the variant (`ErrorText` / `ErrorJson`), replacing a bare
294+
`is_error: bool` so a structured error's JSON survives. Encoders MUST preserve
295+
which variant the source used.
296+
297+
**5.6.3 `provider_executed` (NORMATIVE).** `ToolCall.provider_executed` /
298+
`ToolResult.provider_executed` mark a provider-side / server-executed tool
299+
(Anthropic web_search, OpenAI built-ins, Gemini grounding). It is typed-neutral
300+
(§5.10): tool-pairing (§7) branches on it. A provider-executed `ToolResult` MAY
301+
appear inside an **Assistant-role** message (where @ai-sdk and the providers place
302+
server-executed results), not only a Tool-role message; the pairing invariant (§7)
303+
holds on the canonical id regardless of which role carries the result. Encoders
304+
MUST preserve both the flag and the role placement; a default of `false` =
305+
client-executed.
306+
238307
**5.7 `Media`** - §9.
239308

240309
**5.8 `HarnessMeta`** - `harness_id`/`ordinal` drive MC anchoring, tagging, and
241310
`computeRawRangeFingerprint`; `synthetic` drives the harness titling gate and TUI
242311
visibility. `project()` MUST drop all three; §2 forbids any byte effect.
243312

313+
**5.10 `provider_extras` - the provider-extras bag (NORMATIVE, the lossless
314+
catch-all).** CK carries a provider-keyed extras bag at BOTH message level
315+
(`CkMessage.provider_extras`) and part level (`ContentBlock.provider_extras`),
316+
mirroring @ai-sdk's `SharedV2ProviderOptions`
317+
(`Record<provider, Record<key, JSONValue>>`). This is what makes CK **provably
318+
lossless** against any provider per-part datum we did not enumerate, rather than
319+
"lossless against the providers we listed."
320+
321+
- **Bucket: NATIVE-PRESERVED** (in the projection; origin-provider bytes;
322+
byte-verbatim; valid only for the origin provider - a provider switch is a HARD
323+
bust so cross-provider reuse never arises mid-epoch, the same rule as signatures).
324+
- **Deterministic key order is MANDATORY.** Both levels use an ordered map
325+
(`BTreeMap`, NOT a hash map): provider-extras render into wire bytes, so a
326+
nondeterministic key order would bust the cache. Encoders MUST emit a stable
327+
order; decoders MUST preserve it.
328+
- **The transform MUST NOT interpret it.** `provider_extras` is opaque to the MC
329+
Transform and the cache core (a frozen unit's bytes include it but are never
330+
parsed). Only codecs read/write it (mapping native<->bag at their edge).
331+
332+
**Typed-vs-bag rule (NORMATIVE, resolves "field or bag?").** A provider-specific
333+
datum is given a TYPED neutral CK field IF AND ONLY IF the MC Transform branches on
334+
it; everything else rides `provider_extras`. By this rule exactly four data are
335+
typed today - `Reasoning.signature` (nulled on clear, §6.1), `RedactedReasoning`
336+
as a variant (skip-clear, §5.5), `ToolCall/ToolResult.provider_executed` (pairing,
337+
§7) - and they are PROVIDER-NEUTRAL fields whose native mapping the codec owns, so
338+
the transform stays provider-neutral while still reasoning about them. Everything
339+
else provider-specific (Gemini text/tool thought-signatures, message/part cache
340+
hints the renderer can't re-derive, provider message tags, any future extra) rides
341+
the bag untyped. A datum is promoted from bag to typed field ONLY when a transform
342+
rule needs to branch on it; until then the bag keeps it lossless with zero
343+
transform coupling.
344+
244345
**5.9 Edge-normalization ownership (which codec owns native<->canonical).** Every
245346
field that has a native representation differing across edges MUST declare ONE
246347
ownership mode, so no two edges assume the other's format. Three modes:
@@ -256,13 +357,15 @@ ownership mode, so no two edges assume the other's format. Three modes:
256357
| field | mode | rule / who owns it |
257358
|---|---|---|
258359
| `tool_call_id` (call/result) | CANONICAL + per-edge mapping | §5.6.1; pairing on canonical; identity where native id exists; Gemini name-binding resolved per-render |
259-
| tool `name` | CONTENT, canonical string | byte-content; load-bearing for Gemini name-binding pairing (§5.6.1 rule 2) |
260-
| `Reasoning.signature` | NATIVE-PRESERVED verbatim | origin-provider-issued; round-trips byte-verbatim; never canonicalized; valid only for its origin family |
261-
| `RedactedReasoning.data` | NATIVE-PRESERVED verbatim | opaque origin blob; byte-verbatim; never emptied/canonicalized |
360+
| tool `name` / `ToolResult.tool_name` | CONTENT, canonical string | byte-content; result carries its own name (no cross-message re-derive); load-bearing for Gemini name-binding (§5.6.1 rule 2) |
361+
| `Reasoning.signature` | TYPED-NEUTRAL + per-edge mapping | typed because transform branches (clear, §6.1); codec maps native bag location (e.g. `providerMetadata.anthropic.signature`) <-> the field |
362+
| `RedactedReasoning.data` | NATIVE-PRESERVED verbatim | opaque origin blob; byte-verbatim; never emptied/canonicalized; codec maps the @ai-sdk empty-reasoning+redactedData shape |
363+
| `provider_executed` (call/result) | TYPED-NEUTRAL + per-edge mapping | typed because pairing branches (§7); codec maps `providerExecuted` <-> the field |
364+
| `provider_extras` (msg + part) | NATIVE-PRESERVED verbatim, ordered | the catch-all bag (§5.10); deterministic key order; codec owns native<->bag; transform never reads |
262365
| `role` | CANONICAL + per-edge mapping | 4-value enum; each codec maps native role names; assembly (system-pull, tool-role reshape) is RENDER-SIDE, not encode-side |
263366
| system block / identity lead | CONTENT, opaque + pinned | §5.11; no distinct identity field; pinned (never summarized); identity PLACEMENT is render-side SystemPromptPolicy |
264-
| `Media.source` (base64/url/file-id) | CANONICAL + per-edge mapping | each codec maps its native media representation to `MediaSource`; owned-render deferred (§9) |
265-
| `cache_control` placement | RENDER-SIDE | not a CK field; renderer places per `CachePolicy` (§8) |
367+
| `Media.source` (base64/url/file-id) + `filename` | CANONICAL + per-edge mapping | each codec maps its native media representation to `MediaSource`; `filename` preserved; owned-render deferred (§9) |
368+
| `cache_control` placement | RENDER-SIDE | not a typed CK field; renderer places per `CachePolicy` (§8); a hint the renderer can't re-derive survives in `provider_extras` |
266369
| `step_context` | RENDER-SIDE param | rides run-config; never CK content (§8) |
267370

268371
Scope note: CK Message represents conversation messages sent TO a provider (the
@@ -429,6 +532,17 @@ per-provider wire policy, and applies on every path.
429532
(§5.6.1): pair on the canonical id; use identity where a native id exists (no
430533
durable mapping state); resolve name-binding families per-render. Every field
431534
in the §5.9 ownership table normalized by the declared edge/mode.
535+
3c. Carry `provider_extras` (§5.10) at message + part level as an ORDERED map
536+
(`BTreeMap`); round-trip byte-verbatim; the transform/cache-core never parse it;
537+
apply the typed-vs-bag rule (a datum is typed ONLY if the transform branches on
538+
it - today: `Reasoning.signature`, `RedactedReasoning`, `provider_executed`).
539+
Pass the LOSSLESS-ROUND-TRIP property test: `encode(decode(wire)) == wire` over a
540+
same-family corpus that includes provider-extras-bearing parts (Gemini
541+
thought-signatures, anthropic cacheControl, redacted reasoning).
542+
3d. Carry `ToolResultOutput` typed variants (§5.6.2) incl. `Json` and typed errors
543+
(never flatten JSON to text); preserve `ToolResult.tool_name`; preserve
544+
`provider_executed` + Assistant-role result placement (§5.6.3); preserve
545+
`Media.filename` (§9).
432546
4. Implement `decide_reasoning` (§6.1) with the FROZEN-BIT RULE: positional bits
433547
captured at freeze, never re-derived on defer. Covered by a defer-replay test
434548
(tail growth MUST NOT flip a frozen turn's reasoning bytes).

0 commit comments

Comments
 (0)