feat(frame-wire): amend the ratified wire contract to 1.3.0 #7

Merged
peterswimm merged 2 commits from feat/frame-wire-1-3-0 into main 2026-09-22 14:20:49 +00:00
Member

Forge wish: 82197cdc (slice T1a of anchor wish 4160ffa9). Blocks T1b and S9.
Companion: Toilville/ritual-engine PR on the same branch name — these land together.

Amends the ratified wire contract 1.2.0 → 1.3.0. Five amendments, all found by
diffing the two implementations against each other and by designing a real
TypeScript consumer.

Scope

1 · value is typed explicitly. FieldSetDelta.value,
ElementAppendDelta.value and ToolCall.result had no type keyword at all
— only a description. Under json-schema-to-typescript with
additionalProperties: false that renders as an object map, so
field_set("/progress_pct", 42) and any null failed typecheck, though the
contract's own motivating examples for the op are scalars. Now a $defs/JsonValue
oneOf over the six JSON types, referenced from all three sites.

Permissiveness is unchanged — verified, not asserted: a Draft 2020-12
validator was run over null, true, false, 0, 42, -1, 3.5, "",
"str", [], [1,{"a":1}], {} and {"a":{"b":[null]}}; every one valid. The
branches are mutually disjoint, so oneOf's exactly-one rule admits everything a
missing type admitted.

2 · A failed turn can say why. FrameTurn is additionalProperties: false
and had no member for an error, so both folds dropped ErrorEvent's message
and code and a client could not carry them even if it wanted to. Adds
optional error_message and error_code.

3 · The fold returns the partial turn on abort. It previously returned an
error alone, losing the turn the user was mid-render on. The discard/abort
classification is unchanged — this is about what the caller receives, not
which codes abort.

4 · The unspecified is specified. A second block_start for an open index is
idempotent last-write-wins (both implementations already did this; neither
document said so). RFC 6901 leading zeros in array indices are rejected — the
grammar is 0 / (%x31-39 *DIGIT) and both implementations previously accepted
them, agreeing with each other and disagreeing with the RFC. BlockPointer gains
a receiver rule against __proto__, constructor and prototype: "every
container on the path MUST already exist" is satisfied by an inherited member in
JavaScript, and a TypeScript client is queued behind this contract. The
block_id-mismatch silent discard is recorded as deliberate so it stops being
rediscovered as a bug.

5 · The EventSource justification is corrected. It claimed data-only frames
keep browser clients on EventSource's default onmessage — but this endpoint is
a POST with an auth header and EventSource is GET-only and cannot set headers,
so no browser client can use it regardless. The format choice stands; only the
reason was wrong.

openapi.yaml mirrors every schema change; schemas/index.json registers
frame_wire.

Testing

This repo runs no tests today (only pr-policy-check.yml), so verification was
done by hand and in the companion PR: frame_wire.json and openapi.yaml both
parse (JSON and YAML, OpenAPI 3.1.0, 113 schemas) and were diffed property by
property across all nine amended definitions. The only differences are
pre-existing translation conventions — schema const → openapi enum, and
external ./forge_block.json $ref → #/components/schemas/ForgeBlock —
confirmed pre-existing by re-running the same comparison against HEAD~1.

Wish ebda71cf gives this repo a Rust contract-test crate that actually runs;
until it lands, the behavioural proof lives in the companion PR's 767 passing
ritual-core tests.

A correction in the second commit, worth reading

The first commit stated the caller receives "the turn as folded up to the
offending event." That is true for block_delta_unknown_index, raised by an
event — and false for block_start_index_gap, which is a property of the
completed turn and is therefore reached only once the whole stream has folded.
That partial turn carries every event in it, done included. The branch's own
test asserts finish_reason == "done" on a gap abort, so the shipped tests
contradicted the shipped prose.

A second thing a renderer has to expect went unsaid: blocks holds opened blocks
in index order and a gap has no representation in a JSON array, so the positions
close up — a block the sender opened at index 2 arrives at blocks[0], and
the real indices survive only in the ErrorEvent's message.

This matters exactly as much as amendment 3 does: its whole purpose is that
clients render the partial turn rather than discard it, so an implementer reading
the ratified spec would have mis-rendered the gap case. Corrected in every
location that states it.

Known limits

test_frame_wire.py is untouched — wish ebda71cf replaces it wholesale, so
repairing it would be wasted effort. Consequence worth stating: the Python
reference fold is now behind this contract on two amendments — it accepts
leading zeros and does not implement the __proto__ rule. The Rust fold
documents both as deliberate 1.3.0 divergences rather than defects. This is
another reason that wish exists.

Impact Statement

  • Existing features: the emitted event shapes are unchanged. What changes is
    what a fold returns on abort and what a turn can carry.
  • Public APIs / contracts: frame_wire.json 1.2.0 → 1.3.0. Amendments 1, 2
    and 4 are additive or clarifying; 3 changes the fold's Rust signature, which is
    internal to ritual-engine and handled in the companion PR.
  • Schemas: FrameTurn gains two optional members; three value sites gain
    an explicit type union via a new JsonValue definition; BlockPointer gains a
    receiver rule.
  • Data / artifacts: the vendored copy in ritual-core/schemas/ is re-synced
    byte-identically in the companion PR.
  • Integrations: S9 (Dart) and S5 (TypeScript) both consume the amended
    contract; both were blocked on it.
  • Mappings: ErrorEvent.message/code now map onto
    FrameTurn.error_message/error_code.
  • Backward compatibility: a conforming sender is unaffected. No
    currently-valid payload becomes invalid — confirmed by the 13-value
    permissiveness check above.
Forge wish: 82197cdc (slice T1a of anchor wish 4160ffa9). Blocks T1b and S9. Companion: Toilville/ritual-engine PR on the same branch name — these land together. Amends the ratified wire contract 1.2.0 → 1.3.0. Five amendments, all found by diffing the two implementations against each other and by designing a real TypeScript consumer. ## Scope **1 · `value` is typed explicitly.** `FieldSetDelta.value`, `ElementAppendDelta.value` and `ToolCall.result` had **no `type` keyword at all** — only a description. Under `json-schema-to-typescript` with `additionalProperties: false` that renders as an object map, so `field_set("/progress_pct", 42)` and any `null` failed typecheck, though the contract's own motivating examples for the op are scalars. Now a `$defs/JsonValue` `oneOf` over the six JSON types, referenced from all three sites. Permissiveness is **unchanged** — verified, not asserted: a Draft 2020-12 validator was run over `null`, `true`, `false`, `0`, `42`, `-1`, `3.5`, `""`, `"str"`, `[]`, `[1,{"a":1}]`, `{}` and `{"a":{"b":[null]}}`; every one valid. The branches are mutually disjoint, so `oneOf`'s exactly-one rule admits everything a missing `type` admitted. **2 · A failed turn can say why.** `FrameTurn` is `additionalProperties: false` and had no member for an error, so both folds dropped `ErrorEvent`'s `message` and `code` and a client **could not carry them even if it wanted to**. Adds optional `error_message` and `error_code`. **3 · The fold returns the partial turn on abort.** It previously returned an error alone, losing the turn the user was mid-render on. The discard/abort classification is **unchanged** — this is about what the caller *receives*, not which codes abort. **4 · The unspecified is specified.** A second `block_start` for an open index is idempotent last-write-wins (both implementations already did this; neither document said so). RFC 6901 leading zeros in array indices are rejected — the grammar is `0 / (%x31-39 *DIGIT)` and both implementations previously accepted them, agreeing with each other and disagreeing with the RFC. `BlockPointer` gains a receiver rule against `__proto__`, `constructor` and `prototype`: "every container on the path MUST already exist" is satisfied by an *inherited* member in JavaScript, and a TypeScript client is queued behind this contract. The `block_id`-mismatch silent discard is recorded as deliberate so it stops being rediscovered as a bug. **5 · The `EventSource` justification is corrected.** It claimed data-only frames keep browser clients on `EventSource`'s default `onmessage` — but this endpoint is a POST with an auth header and `EventSource` is GET-only and cannot set headers, so no browser client can use it regardless. **The format choice stands**; only the reason was wrong. `openapi.yaml` mirrors every schema change; `schemas/index.json` registers `frame_wire`. ## Testing This repo runs no tests today (only `pr-policy-check.yml`), so verification was done by hand and in the companion PR: `frame_wire.json` and `openapi.yaml` both parse (JSON and YAML, OpenAPI 3.1.0, 113 schemas) and were diffed property by property across all nine amended definitions. The only differences are **pre-existing translation conventions** — schema `const` → openapi `enum`, and external `./forge_block.json` `$ref` → `#/components/schemas/ForgeBlock` — confirmed pre-existing by re-running the same comparison against `HEAD~1`. Wish `ebda71cf` gives this repo a Rust contract-test crate that actually runs; until it lands, the behavioural proof lives in the companion PR's 767 passing `ritual-core` tests. ## A correction in the second commit, worth reading The first commit stated the caller receives "the turn as folded up to the offending event." That is true for `block_delta_unknown_index`, raised *by* an event — and **false** for `block_start_index_gap`, which is a property of the *completed* turn and is therefore reached only once the whole stream has folded. That partial turn carries every event in it, `done` included. The branch's own test asserts `finish_reason == "done"` on a gap abort, so the shipped tests contradicted the shipped prose. A second thing a renderer has to expect went unsaid: `blocks` holds opened blocks in index order and a gap has no representation in a JSON array, so the positions **close up** — a block the sender opened at index 2 arrives at `blocks[0]`, and the real indices survive only in the `ErrorEvent`'s `message`. This matters exactly as much as amendment 3 does: its whole purpose is that clients render the partial turn rather than discard it, so an implementer reading the ratified spec would have mis-rendered the gap case. Corrected in every location that states it. ## Known limits **`test_frame_wire.py` is untouched** — wish `ebda71cf` replaces it wholesale, so repairing it would be wasted effort. Consequence worth stating: the Python reference fold is now **behind** this contract on two amendments — it accepts leading zeros and does not implement the `__proto__` rule. The Rust fold documents both as deliberate 1.3.0 divergences rather than defects. This is another reason that wish exists. ## Impact Statement - **Existing features:** the emitted event shapes are unchanged. What changes is what a fold returns on abort and what a turn can carry. - **Public APIs / contracts:** `frame_wire.json` 1.2.0 → 1.3.0. Amendments 1, 2 and 4 are additive or clarifying; 3 changes the fold's Rust signature, which is internal to ritual-engine and handled in the companion PR. - **Schemas:** `FrameTurn` gains two optional members; three `value` sites gain an explicit type union via a new `JsonValue` definition; `BlockPointer` gains a receiver rule. - **Data / artifacts:** the vendored copy in `ritual-core/schemas/` is re-synced byte-identically in the companion PR. - **Integrations:** S9 (Dart) and S5 (TypeScript) both consume the amended contract; both were blocked on it. - **Mappings:** `ErrorEvent.message`/`code` now map onto `FrameTurn.error_message`/`error_code`. - **Backward compatibility:** a conforming sender is unaffected. No currently-valid payload becomes invalid — confirmed by the 13-value permissiveness check above.
Five amendments to an already-ratified, already-implemented contract. No
payload valid against 1.2.0 is invalid against 1.3.0.

1. TYPE `value` EXPLICITLY. FieldSetDelta.value, ElementAppendDelta.value and
   ToolCall.result carried a description and no `type` keyword at all. Under
   json-schema-to-typescript a member with no `type` inside an
   additionalProperties:false object renders as `{ [k: string]: unknown }` — an
   object map — so field_set("/progress_pct", 42), field_set("/text/markdown",
   true) and any null failed typecheck in a generated client, though the
   contract's own motivating examples for the op are scalars. All three now
   $ref a new $defs/JsonValue, a oneOf over the six JSON types. Permissiveness
   is unchanged; the omission becomes a statement every generator reads alike.

2. A FAILED TURN CAN SAY WHY. FrameTurn is additionalProperties:false and had
   no member for an error, so both folds dropped ErrorEvent's message and code
   and a client could not carry them even if it wanted to. Adds optional
   error_message and error_code.

3. AN ABORT RETURNS THE PARTIAL TURN. Abort means "no further event may be
   applied", not "discard what was already applied": a rendering client must
   stop folding AND keep what it drew. The discard/abort classification is
   untouched — this is about what the caller receives, not which codes abort.

4. SPECIFIES FOUR UNSPECIFIED BEHAVIOURS: a second block_start for an open
   index is idempotent last-write-wins (and does not reset seq); RFC 6901
   leading zeros in array indices are rejected per 0 / (%x31-39 *DIGIT);
   a pointer must not address or traverse __proto__/constructor/prototype,
   because "every container on the path MUST already exist" is satisfied by an
   inherited member in JavaScript; the block_id-mismatch silent discard is
   recorded as deliberate rather than a missing code.

5. Engine-side only: the SSE framing rationale correction lands in
   ritual-engine. Nothing here changes for it.

openapi.yaml mirrors every schema change. schemas/index.json registers
frame_wire at v1.1; the index is bumped to 1.4.
docs(frame-wire): say how much of the stream an abort actually folded
All checks were successful
PR policy check / policy-gate (pull_request) Successful in 0s
5c93c35eba
The 1.3.0 amendment said the caller receives "the turn as folded up to the
offending event". That is true for block_delta_unknown_index, which is raised
BY an event. It is false for block_start_index_gap, which is a property of the
COMPLETED turn and is therefore reached only once the whole stream has folded —
that partial turn carries every event in it, done included.

The branch's own test asserts finish_reason == "done" on a gap abort, so the
shipped tests already contradicted the shipped prose.

A second thing a renderer has to expect went unsaid: blocks holds the opened
blocks in index order and a gap has no representation in a JSON array, so the
positions close up. A block the sender opened at index 2 arrives at blocks[0],
and the real indices survive only in the ErrorEvent's message.

This matters exactly as much as the amendment does. Its whole purpose is that
clients RENDER the partial turn instead of discarding it, and a TypeScript
client is queued behind this contract — an implementer reading the ratified spec
would have mis-rendered the gap case.

Corrected in every location that states it: docs/frame_wire.md, the ErrorEvent
description in schemas/frame_wire.json and its openapi.yaml mirror, and the
CHANGELOG entry.
peterswimm deleted branch feat/frame-wire-1-3-0 2026-09-22 14:20:50 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Toilville/rituals-api-spec!7
No description provided.