feat(schema): ratify the FrameTurn/StreamEvent wire contract #6

Merged
peterswimm merged 3 commits from mark/frameturn-streamevent-ratify into main 2026-09-20 22:44:18 +00:00
Member

Forge wish: 1a98b931 (slice S1 of anchor wish 0bcc6c47 — interactive streaming prompts).

Ratifies the conversational wire contract that already existed, written and
unimplemented, in rituals-chat's TypeScript (packages/channel-adapter/src/protocol.ts),
and makes that TypeScript a generated consumer of this repo instead of a second
source of truth.

Scope

One closely-related family: the frame wire contract and the tests that hold it to
its own claims. No endpoint is added — see "Impact Statement" for why.

  • schemas/frame_wire.json (new, canonical, JSON Schema 2020-12, styled to match
    forge_block.json): FrameRequest, FrameTurn, and a 17-variant StreamEvent
    discriminated union, plus the supporting enums and objects. Refs
    ./forge_block.json so both the Python validator and the TS generator resolve it
    offline.
  • The conversational turn convention, encoded in FrameTurn.blocks' description
    in both documents and asserted by test: a prose turn is exactly ONE block — a
    section block whose text is a TextElement, or a rich_text block — and block_delta
    appends into it. This is what lets the existing native chat bubbles keep working
    unchanged while leaving structured multi-block turns possible without another
    protocol change.
  • block_delta has a real patch semantic rather than an untyped partial: three
    typed ops, fixed per-block-type target resolution, RFC 6901 pointers, an optional
    seq loss detector, and a classified error-code set. A reference implementation of
    the fold lives in the test file and is named normative for the Rust, Dart and
    Swift clients that have to match it.
  • Every error code is classified discard or abort, and the fold obeys the
    classification. Discard-class errors are surfaced through an on_error callback
    and folding continues; only block_delta_unknown_index and block_start_index_gap
    abort.
  • openapi.yaml mirrors all of it under components/schemas, closed to the same
    degree as the canonical file.
  • docs/frame_wire.md, a CHANGELOG 1.2.0 entry, and registration in schemas/index.json.

Testing

pytest test_frame_wire.py: 125 passing (from 85 at first draft).

Groups: schema validity and offline cross-file ref resolution; golden payloads
including one per StreamEvent variant with a guard that the fixture set covers
every discriminant; negative cases; block_delta semantics against the normative
reference; and per-definition parity between schemas/frame_wire.json and
openapi.yaml.

This contract went through three review rounds. The first two each shipped a
mismatch between the normative prose and the normative reference implementation —
prose saying "discard the delta and continue", code aborting the whole turn. The
third round fixed the instances and then added the guard that makes the class
non-recurring: test_the_docs_class_tables_say_the_same_thing_as_the_fold, which
fails if any code is filed under a different class than the fold implements. It
would have failed both earlier rounds outright.

Other guards added for the same reason: test_every_well_known_code_is_classified,
test_discard_class_codes_never_abort_the_fold,
test_abort_class_codes_abort_the_fold, and
test_no_unclassified_exception_escapes_the_fold, which walks 8660
pointer/block/op combinations and requires every failure to be a classified
ProtocolError rather than a language-level exception. That last one found three
real escapes — a non-numeric array token, an out-of-range index, and a pointer
running off the end of a scalar — each of which killed the turn while no prose
said it should.

Key fixes were mutation-tested rather than assumed: deleting an
additionalProperties from openapi.yaml, reverting forge_block.json's $id, and
disabling the re-validation path each now fail the suite.

The lookaround removal was demonstrated, not asserted: a throwaway cargo crate
compiled both patterns against the regex crate. The old one fails with
"look-around, including look-ahead and look-behind, is not supported"; the new one
compiles. That matters because Rust clients are the reason this contract exists.

Impact Statement

  • Existing features: none consume this contract yet.
  • Public APIs / contracts: this IS the contract addition. No endpoint changes
    behaviour. No path was added deliberately — ritual-server implements no streaming
    turn endpoint, and ritual-engine ships a drift tool comparing spec routes against
    server routes, so inventing a path here would register drift. Rationale is in
    docs/frame_wire.md and the CHANGELOG.
  • Schemas: two changes to existing definitions, both named in the CHANGELOG —
    openapi.yaml's ForgeBlock union is widened with rich_text (required to express
    the ratified convention), and forge_block.json's $id gains a .json suffix so
    cross-file refs resolve under $id-based loading as well as filesystem-relative.
  • Data / artifacts: vendored copies in rituals-chat are regenerated from this
    contract; see the companion PR there.
  • Integrations: gates the ritual-server implementation and every client slice.
  • Mappings: FrameTurn.lineage_ref maps to the client lineage entry added in
    Toilville_Rituals PR #431.
  • Backward compatibility: additive apart from the two schema changes above.

Known residuals

Six minor items are recorded on wish 1a98b931 rather than fixed here. The one
worth a follow-up: the parity reducer compares constraining keywords but not
description, and this contract's normative prose lives in descriptions. Three
mirrored descriptions already differ cosmetically, which shows the two documents
are hand-maintained in parallel.

Forge wish: 1a98b931 (slice S1 of anchor wish 0bcc6c47 — interactive streaming prompts). Ratifies the conversational wire contract that already existed, written and unimplemented, in rituals-chat's TypeScript (packages/channel-adapter/src/protocol.ts), and makes that TypeScript a generated consumer of this repo instead of a second source of truth. ## Scope One closely-related family: the frame wire contract and the tests that hold it to its own claims. No endpoint is added — see "Impact Statement" for why. - **schemas/frame_wire.json** (new, canonical, JSON Schema 2020-12, styled to match forge_block.json): FrameRequest, FrameTurn, and a 17-variant StreamEvent discriminated union, plus the supporting enums and objects. Refs ./forge_block.json so both the Python validator and the TS generator resolve it offline. - **The conversational turn convention**, encoded in FrameTurn.blocks' description in both documents and asserted by test: a prose turn is exactly ONE block — a section block whose text is a TextElement, or a rich_text block — and block_delta appends into it. This is what lets the existing native chat bubbles keep working unchanged while leaving structured multi-block turns possible without another protocol change. - **block_delta has a real patch semantic** rather than an untyped partial: three typed ops, fixed per-block-type target resolution, RFC 6901 pointers, an optional seq loss detector, and a classified error-code set. A reference implementation of the fold lives in the test file and is named normative for the Rust, Dart and Swift clients that have to match it. - **Every error code is classified discard or abort**, and the fold obeys the classification. Discard-class errors are surfaced through an on_error callback and folding continues; only block_delta_unknown_index and block_start_index_gap abort. - **openapi.yaml mirrors all of it** under components/schemas, closed to the same degree as the canonical file. - docs/frame_wire.md, a CHANGELOG 1.2.0 entry, and registration in schemas/index.json. ## Testing pytest test_frame_wire.py: 125 passing (from 85 at first draft). Groups: schema validity and offline cross-file ref resolution; golden payloads including one per StreamEvent variant with a guard that the fixture set covers every discriminant; negative cases; block_delta semantics against the normative reference; and per-definition parity between schemas/frame_wire.json and openapi.yaml. This contract went through three review rounds. The first two each shipped a mismatch between the normative prose and the normative reference implementation — prose saying "discard the delta and continue", code aborting the whole turn. The third round fixed the instances and then added the guard that makes the class non-recurring: test_the_docs_class_tables_say_the_same_thing_as_the_fold, which fails if any code is filed under a different class than the fold implements. It would have failed both earlier rounds outright. Other guards added for the same reason: test_every_well_known_code_is_classified, test_discard_class_codes_never_abort_the_fold, test_abort_class_codes_abort_the_fold, and test_no_unclassified_exception_escapes_the_fold, which walks 8660 pointer/block/op combinations and requires every failure to be a classified ProtocolError rather than a language-level exception. That last one found three real escapes — a non-numeric array token, an out-of-range index, and a pointer running off the end of a scalar — each of which killed the turn while no prose said it should. Key fixes were mutation-tested rather than assumed: deleting an additionalProperties from openapi.yaml, reverting forge_block.json's $id, and disabling the re-validation path each now fail the suite. The lookaround removal was demonstrated, not asserted: a throwaway cargo crate compiled both patterns against the regex crate. The old one fails with "look-around, including look-ahead and look-behind, is not supported"; the new one compiles. That matters because Rust clients are the reason this contract exists. ## Impact Statement - **Existing features:** none consume this contract yet. - **Public APIs / contracts:** this IS the contract addition. No endpoint changes behaviour. No path was added deliberately — ritual-server implements no streaming turn endpoint, and ritual-engine ships a drift tool comparing spec routes against server routes, so inventing a path here would register drift. Rationale is in docs/frame_wire.md and the CHANGELOG. - **Schemas:** two changes to existing definitions, both named in the CHANGELOG — openapi.yaml's ForgeBlock union is widened with rich_text (required to express the ratified convention), and forge_block.json's $id gains a .json suffix so cross-file refs resolve under $id-based loading as well as filesystem-relative. - **Data / artifacts:** vendored copies in rituals-chat are regenerated from this contract; see the companion PR there. - **Integrations:** gates the ritual-server implementation and every client slice. - **Mappings:** FrameTurn.lineage_ref maps to the client lineage entry added in Toilville_Rituals PR #431. - **Backward compatibility:** additive apart from the two schema changes above. ## Known residuals Six minor items are recorded on wish 1a98b931 rather than fixed here. The one worth a follow-up: the parity reducer compares constraining keywords but not description, and this contract's normative prose lives in descriptions. Three mirrored descriptions already differ cosmetically, which shows the two documents are hand-maintained in parallel.
Promotes the conversational wire from rituals-chat, where it lived as
TypeScript only (packages/channel-adapter/src/protocol.ts) with no schema
and no server implementation, into the canonical spec.

schemas/frame_wire.json is the canonical JSON Schema: FrameRequest inbound,
FrameTurn as the terminal outbound payload, and a 17-variant StreamEvent
union for incremental chunks. openapi.yaml mirrors it under
components/schemas; test_frame_wire.py fails on drift between the two.

Two design decisions are encoded rather than left to each client:

- Conversational turn convention: a prose turn is exactly ONE block, either
  a section block whose text is a TextElement or a rich_text block, and
  block_delta appends into that block. Native chat bubbles special-case
  that one shape and fall through to the generic block-list renderer for
  everything else, so structured multi-block turns ship later against the
  same wire with no protocol change. RichTextBlock and its element types
  were added to openapi.yaml's ForgeBlock union so the convention is
  expressible there (schemas/forge_block.json already had them).

- block_delta carries a typed operation, not Partial<ForgeBlock>. A
  structural merge over a 15-variant discriminated union forces every
  client to invent merge rules per block type, and those rules then differ
  between Rust, Dart and Swift. Three ops instead: text_append (fixed
  target resolution per block type), field_set and element_append (RFC 6901
  pointers, /type and /block_id reserved), plus a seq loss detector and
  five well-known error codes.

test_frame_wire.py carries the normative reference implementation of the
stream-to-turn fold, and asserts the invariant the whole contract rests on:
applying every event of a stream in arrival order produces exactly the
FrameTurn the non-streaming path would have returned.

No endpoint references these schemas yet. ritual-server implements no
streaming turn endpoint, and adding a path for a route that does not exist
would register spec drift against it.
Review of the ratified contract found six blocking defects and five smaller
ones. The wire shapes are unchanged — these are fidelity and implementability
fixes.

The fold invariant was unsatisfiable. FrameTurn requires request_id, frame_id,
frame_type, frame_status and blocks, but no StreamEvent carried request_id or
frame_type, so "applying every event produces the FrameTurn the non-streaming
path would have returned" could not be honoured by any client; the test only
passed because it hand-injected the three identity members before validating.
frame_status now carries request_id and frame_type, and a stream MUST open with
one. That makes a stream self-contained: a client reconnecting mid-stream, or
replaying from a log or a Kafka topic, reconstructs the turn without holding
the original request — worth more than the smaller diff of weakening the
invariant. The injection is gone and the fold test validates its result with no
fixups.

BlockPointer's pattern used a negative lookahead. Rust's regex crate and Go's
RE2 compile no lookaround, and this contract exists so Rust and Dart clients can
implement it. The pattern is plain RFC 6901 now, and the reserved-pointer rule
(/type, /block_id) is a receiver MUST with its own code. The negative tests
still exist; they assert the receiver rejects those pointers.

additionalProperties had drifted: 28 of 36 mirrored definitions were closed in
schemas/frame_wire.json and open in openapi.yaml, so the same named schema
accepted different payloads —
{"type":"done","frame_id":"f-1","finish_reason":"done","SMUGGLED":1} passed one
and failed the other. The parity reducer could not see it, because its compared
key tuple omitted additionalProperties along with maxItems, maxLength,
uniqueItems, anyOf, allOf, not and propertyNames. The mirror is closed, the
reducer projects every constraining keyword, and a new test guards the reducer
itself against the next omission.

block_delta_invalid_result was specified as a MUST and implemented nowhere.
apply_stream now re-validates the block against ForgeBlock after every delta,
restores it and raises the code when the delta would invalidate it.

The reference implementation contradicted the normative prose in three places;
prose is the contract, so the code moved:
  - a block_id mismatch discards that delta and keeps folding, rather than
    aborting with block_delta_bad_pointer;
  - a seq gap stops delta application to that block and hands the caller the
    refetch, rather than aborting the whole fold;
  - block_start places its block at index rather than requiring arrival order.
    A hole in the indices has no representation in a JSON array, so that is
    block_start_index_gap — not a block_delta code borrowed for a block_start
    failure.

The cross-file $ref did not resolve under $id-based loading: ./forge_block.json
resolved to .../schemas/forge_block.json while forge_block.json declared $id
.../schemas/forge_block. The suite passed only on a synthetic alias its fixture
registered and nothing else in the repo knew about. forge_block.json's $id now
carries the .json suffix, so filesystem-relative and $id-based loading agree,
and the alias is deleted.

Also: the BlockStartEvent rationale no longer claims a prose turn always opens
with its first chunk — that holds for section, but RichTextRun.text has no
minLength so a rich_text turn can open empty; schemas/index.json's formatting
churn and openapi.yaml's discriminator re-alignment are reverted; the CHANGELOG
no longer says 83 tests or claims nothing changed, and records the two
frame_type vocabularies as a known, unresolved divergence with the reason.

105 tests pass.
fix(schema): give every wire error code a class, and make the fold obey it
All checks were successful
PR policy check / policy-gate (pull_request) Successful in 0s
2d0622f305
The previous pass fixed six review findings and reintroduced the defect class
it was correcting: the two error paths it added (block_delta_reserved_pointer,
block_delta_invalid_result) raised out of apply_stream, while four places in
the normative prose said the receiver discards the delta and keeps folding. A
stream of [frame_status, block_start, invalidating field_set, good text_append,
done] lost the good delta, the done event and the whole turn.

The classes are now declared once, in DISCARD_CLASS_CODES / ABORT_CLASS_CODES,
and tests hold prose and implementation to them:

- Discard-class (seq_gap, unsupported_target, bad_pointer, reserved_pointer,
  invalid_result): drop the event, restore the block, surface the code via the
  new on_error callback, keep folding. Abort-class is only unknown_index and
  index_gap.
- block_start_index_gap moved to completion time. It ran after every fold, so
  folding a PREFIX raised whenever block_start events arrived out of index
  order — legal per the contract, and exactly what the reconnecting client the
  contract advertises does. A partial fold with holes is now a legal state.
- block_delta_seq_gap is surfaced instead of being a code nothing ever emitted.
- Non-numeric, negative and out-of-range RFC 6901 array indices, and pointers
  that run off the end of a scalar, were escaping as ValueError / IndexError /
  TypeError — an unclassified crash, which kills the turn like an abort while
  no prose says it should. They are bad_pointer discards now.
- _apply_delta restores the block on every failure path, not only on the
  re-validation one.
- $id spelling is uniform across schemas/ (frame_wire.json gained the suffix)
  and schemas/index.json advertises each schema at its $id path.

Guards, because this mismatch has now shipped twice: the classes are asserted
against the code lists in BOTH documents and against the class tables in
docs/frame_wire.md; one stream per code proves the fold does or does not
survive it; 8660 pointer/block/op combinations prove nothing escapes
unclassified; the exact-code-per-parameter assertion replaces a set membership
that let the re-validation path be deleted with two of three cases passing;
and the parity-reducer guard walks openapi.yaml as well as the JSON Schema.
peterswimm deleted branch mark/frameturn-streamevent-ratify 2026-09-20 22:44:18 +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!6
No description provided.