feat(spec): open the frame vocabularies, add FrameEvidence, pin the submit path #25
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/spec-frame-evidence-and-submit-path"
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?
Lands the contract half of "the frame is the evidence": a chat turn is an action running
under a frame, so the wire must be able to say what it ran under — and must stop
closing vocabularies the database keeps open.
The vocabularies are open now, and stay open by generation
FrameStatuswas a closed five-value enum documented as "Matchescore.frames.frame_status". It did not match —core.frame_status_kindshas eleven, so awish held at a steward gate (
pending_parlay) deserialized as "in progress". That is thegate lying.
FrameCreateRequest.frame_typehad the same shape, and rejected four valuesthe shared client router already emits.
Both are now
anyOfof an enum branch and^[a-z][a-z0-9_]*$, matching theforward-compatibility the Rust binding was deliberately given ("any status string
round-trips through
Other").tools/sync_frame_vocabulary.pyregenerates exactly fourregions from the database so this cannot drift back by hand; it was verified by forcing a
rewrite and confirming hand-written neighbours survived.
FrameEvidence — response-side only, and carrying no trust level
New
$defs.FrameEvidenceonFrameTurnandFrameStatusEvent: requiredframe_id,decision,scope_enforced; optional principal, agent, grant, scope and window.Two deliberate omissions, both tested:
per-action. Putting a level here would reinstate the ladder under a new name.
the client's to assert — a request field would let a client claim a grant.
consent_scopecarries "A SERVER MUST OMIT THIS FIELD WHEN THE STORED VALUE IS THECOLUMN DEFAULT", because a default is not a declaration and absent already means unknown.
This matters concretely: measured across all 23,814 live
core.framesrows,consent_scopeholds exactly one value and it is the column default.
ConsentDecisionis open, with six values named frompolicies.rs— the same sixdirections the consent check currently allows in.
The submit path, reusing CommandForm rather than inventing an envelope
DiffApplysaid "the client sendsaction_idwith the accepted file paths or hunk_ids"and defined no field for them.
InputBlockdefined no submit field.SuggestedActionhasno value field.
Rather than invent an envelope, this reuses the keying
CommandFormalready pins, inprose on
FrameRequest.frame_data,FrameRequest.entry_point,SuggestedActionandFinishReason— plus$defs.DiffApplySubmissionas the one typed payload, with nothingrequired (granularity
allomits it entirely).ExtensionBlock, and the catch-all that was refused
RitualBlockis a typed superset whose eleven extra variants were all rejected byforge_block.json— including its own losslessRawfallback. Nothing emits them today,so this was latent rather than live.
Added
ExtensionBlock(union 19 → 20). Refused a catch-all branch: it generates{ type: string; [k: string]: unknown }, which every block is structurally assignable to,so it would hollow out the exhaustiveness guards on all three TypeScript surfaces — trading
the one mechanism that catches this bug class for the convenience of not naming things.
Also: the CEREMONY/bubble conflict is resolved by stating the single-prose-block convention
as a MAY rather than a MUST, and
POST /api/wish/dispatchis finally inopenapi.yamlwith both its media types.
Verification
pytestis 46 passed, 1 xfailed, including 28 new tests indocs/test_frame_vocabulary.pyasserting: both vocabularies open, the two spec filesagree, the submit-path tokens present in both,
DiffApplySubmissionshape and reachability,evidence required/optional fields, no trust level on evidence, the bare-
$refpinning,the MUST-OMIT rule, and the streaming endpoint's two media types.
The bare-
$reftest exists because of a real bug caught here: a$refwith a siblingdescriptionis a distinct schema, which forkedFrameEvidenceinto a duplicateFrameEvidence1in generated TypeScript. Per-site prose moved onto the parents instead.docs/test_api.pystays out oftestpaths; it needs a live server, and the exclusion isdocumented with its measurement.
Versions: frame_wire 1.5 → 1.6, forge_block 1.2 → 1.3, openapi 0.67.0 → 0.69.0.
Land this before the
rituals-chatPR, which vendors these bytes and regeneratesTypeScript from them.
Four contract gaps, each measured against the live toilville database on 2026-10-24 rather than inferred. frame_wire 1.5 — the vocabulary is generated, not hand-copied. FrameStatus listed five values and claimed to match core.frames.frame_status; core.frame_status_kinds holds twelve, and 638 live frames were in a status the wire could not name — 622 archived and 16 pending_parlay, held at a governance gate. FrameType listed nine values of which exactly one, wish, was a row in core.frame_types, which core.frames has a foreign key onto; the other eight cannot be inserted at all. Both vocabularies are now open and generated by tools/sync_frame_vocabulary.py, which owns them in both spec files. Every value the old closed enums accepted still validates. frame_wire 1.5 — the submit path is written down. Four things were convention with nothing in the contract behind them: where an answer to an awaiting turn goes (frame_data, keyed by the input element's action_id, reusing CommandForm's keying rather than inventing an envelope), where a tapped action_id is echoed (entry_point, the only field that can carry it), what frame_status an awaiting turn carries (in_progress — no status means "awaiting", because waiting on the user is not a lifecycle state of the frame), and what shape a diff acceptance has (new: DiffApplySubmission, which DiffApply has promised since 1.2 and never defined). frame_wire 1.6 — a turn says what it ran under. New FrameEvidence on FrameTurn and on FrameStatusEvent, so a surface can show the principal, agent, grant, scope and window without a second round trip. ConsentDecision is required on it, and names the six Ok() paths in ritual-core/src/authorization/policies.rs: exactly one, allowed_by_policy, is a per-action check, and two of the rest mean the policy DENIED the action and it ran anyway. A chip rendered without reading this would claim "checked grant" for all six, which is the same overstatement a zone=Z2 log line makes. scope_enforced is required too and is false on every path today. Measured while building it, and it reorders the remaining work: across 23,814 live frames, frame_zone has four distinct values while consent_scope and data_classification are one defaulted constant each, starts_at/ends_at are populated on zero rows, and delegation_grant_id on zero of the 219 delegated frames. The per-action fields have no writers, not just no readers, so a check written against them could not refuse anything. Hence the rule that a server MUST OMIT consent_scope when the value is the column default: a default is not a declaration, and absent already means unknown. forge_block 1.3 — one escape hatch, and a union that stays closed. ritual-blocks-core's RitualBlock is a typed superset: four governance blocks, five SPELWork entity blocks, an Extension and a lossless Raw fallback. All eleven are rejected by this schema, so the fallback built to never drop data is itself what cannot cross the wire. Added: extension, with schema_id, version, an opaque payload and fallback_text. Refused: a catch-all branch for unknown types, which generates { type: string; [k: string]: unknown } — every other block is structurally assignable to that, so it would silently hollow out the exhaustive `never` guard on all three renderers. The residue is a narrow replay gap, taken deliberately. openapi 0.68.0 — POST /api/wish/dispatch is documented. Served since 1.2 and written down nowhere, while two documents said no streaming endpoint existed. One 200 returns either SSE or a buffered FrameTurn and the caller does not fully choose, so clients must branch on Content-Type. Also corrected: docs/frame_wire.md pointed the normative fold at a path that has never existed, and its ForgeBlock mirror count was two editions stale. A test now pins the gap set so it cannot go stale a third time. Testing: docs/test_frame_vocabulary.py, 28 tests, added to pytest.ini's green set (bare pytest is now 46 passed, 1 xfailed). Each assertion was mutation-tested — every subject broken individually and the matching test confirmed to catch it, including the generator's rewrite path, which was forced to confirm it preserves the hand-written definitions beside the ones it owns. CI cannot reach the database, so the generator's --check is a local gate and this suite is the CI-safe half.