feat(schema): ratify the FrameTurn/StreamEvent wire contract #6
Loading…
Reference in a new issue
No description provided.
Delete branch "mark/frameturn-streamevent-ratify"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
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.
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.
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.
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.
degree as the canonical file.
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
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.
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.
contract; see the companion PR there.
Toilville_Rituals PR #431.
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.
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.