feat(spec): open the frame vocabularies, add FrameEvidence, pin the submit path #25

Merged
peterswimm merged 1 commit from feat/spec-frame-evidence-and-submit-path into main 2026-10-25 04:31:30 +00:00
Owner

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

FrameStatus was a closed five-value enum documented as "Matches
core.frames.frame_status". It did not match — core.frame_status_kinds has eleven, so a
wish held at a steward gate (pending_parlay) deserialized as "in progress". That is the
gate lying. FrameCreateRequest.frame_type had the same shape, and rejected four values
the shared client router already emits.

Both are now anyOf of an enum branch and ^[a-z][a-z0-9_]*$, matching the
forward-compatibility the Rust binding was deliberately given ("any status string
round-trips through Other"). tools/sync_frame_vocabulary.py regenerates exactly four
regions 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.FrameEvidence on FrameTurn and FrameStatusEvent: required frame_id,
decision, scope_enforced; optional principal, agent, grant, scope and window.

Two deliberate omissions, both tested:

  • No zone, tier or trust level anywhere on it. A zone is standing trust; evidence is
    per-action. Putting a level here would reinstate the ladder under a new name.
  • Request-side nothing. What a turn ran under is the server's finding to report, not
    the client's to assert — a request field would let a client claim a grant.

consent_scope carries "A SERVER MUST OMIT THIS FIELD WHEN THE STORED VALUE IS THE
COLUMN DEFAULT", because a default is not a declaration and absent already means unknown.
This matters concretely: measured across all 23,814 live core.frames rows, consent_scope
holds exactly one value and it is the column default.

ConsentDecision is open, with six values named from policies.rs — the same six
directions the consent check currently allows in.

The submit path, reusing CommandForm rather than inventing an envelope

DiffApply said "the client sends action_id with the accepted file paths or hunk_ids"
and defined no field for them. InputBlock defined no submit field. SuggestedAction has
no value field.

Rather than invent an envelope, this reuses the keying CommandForm already pins, in
prose on FrameRequest.frame_data, FrameRequest.entry_point, SuggestedAction and
FinishReason — plus $defs.DiffApplySubmission as the one typed payload, with nothing
required (granularity all omits it entirely).

ExtensionBlock, and the catch-all that was refused

RitualBlock is a typed superset whose eleven extra variants were all rejected by
forge_block.json — including its own lossless Raw fallback. 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/dispatch is finally in openapi.yaml
with both its media types.

Verification

pytest is 46 passed, 1 xfailed, including 28 new tests in
docs/test_frame_vocabulary.py asserting: both vocabularies open, the two spec files
agree, the submit-path tokens present in both, DiffApplySubmission shape and reachability,
evidence required/optional fields, no trust level on evidence, the bare-$ref pinning,
the MUST-OMIT rule, and the streaming endpoint's two media types.

The bare-$ref test exists because of a real bug caught here: a $ref with a sibling
description is a distinct schema, which forked FrameEvidence into a duplicate
FrameEvidence1 in generated TypeScript. Per-site prose moved onto the parents instead.

docs/test_api.py stays out of testpaths; it needs a live server, and the exclusion is
documented 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-chat PR, which vendors these bytes and regenerates
TypeScript from them.

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 `FrameStatus` was a closed five-value enum documented as "Matches `core.frames.frame_status`". It did not match — `core.frame_status_kinds` has eleven, so a wish held at a steward gate (`pending_parlay`) deserialized as "in progress". That is the gate lying. `FrameCreateRequest.frame_type` had the same shape, and rejected four values the shared client router already emits. Both are now `anyOf` of an enum branch and `^[a-z][a-z0-9_]*$`, matching the forward-compatibility the Rust binding was deliberately given ("any status string round-trips through `Other`"). `tools/sync_frame_vocabulary.py` regenerates exactly four regions 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.FrameEvidence` on `FrameTurn` and `FrameStatusEvent`: required `frame_id`, `decision`, `scope_enforced`; optional principal, agent, grant, scope and window. Two deliberate omissions, both tested: - **No zone, tier or trust level anywhere on it.** A zone is standing trust; evidence is per-action. Putting a level here would reinstate the ladder under a new name. - **Request-side nothing.** What a turn ran under is the server's finding to report, not the client's to assert — a request field would let a client *claim* a grant. `consent_scope` carries "A SERVER MUST OMIT THIS FIELD WHEN THE STORED VALUE IS THE COLUMN DEFAULT", because a default is not a declaration and absent already means unknown. This matters concretely: measured across all 23,814 live `core.frames` rows, `consent_scope` holds exactly one value and it is the column default. `ConsentDecision` is open, with six values named from `policies.rs` — the same six directions the consent check currently allows in. ## The submit path, reusing CommandForm rather than inventing an envelope `DiffApply` said "the client sends `action_id` with the accepted file paths or hunk_ids" and defined no field for them. `InputBlock` defined no submit field. `SuggestedAction` has no value field. Rather than invent an envelope, this reuses the keying `CommandForm` already pins, in prose on `FrameRequest.frame_data`, `FrameRequest.entry_point`, `SuggestedAction` and `FinishReason` — plus `$defs.DiffApplySubmission` as the one typed payload, with nothing required (granularity `all` omits it entirely). ## ExtensionBlock, and the catch-all that was refused `RitualBlock` is a typed superset whose eleven extra variants were all rejected by `forge_block.json` — including its own lossless `Raw` fallback. 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/dispatch` is finally in `openapi.yaml` with both its media types. ## Verification `pytest` is **46 passed, 1 xfailed**, including 28 new tests in `docs/test_frame_vocabulary.py` asserting: both vocabularies open, the two spec files agree, the submit-path tokens present in both, `DiffApplySubmission` shape and reachability, evidence required/optional fields, **no trust level on evidence**, the bare-`$ref` pinning, the MUST-OMIT rule, and the streaming endpoint's two media types. The bare-`$ref` test exists because of a real bug caught here: a `$ref` with a sibling `description` is a distinct schema, which forked `FrameEvidence` into a duplicate `FrameEvidence1` in generated TypeScript. Per-site prose moved onto the parents instead. `docs/test_api.py` stays out of `testpaths`; it needs a live server, and the exclusion is documented 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-chat` PR**, which vendors these bytes and regenerates TypeScript from them.
feat(spec): generate the frame vocabulary, pin the submit path, add FrameEvidence
Some checks failed
PR policy check / policy-gate (pull_request) Failing after 0s
CI / schema-registry (pull_request) Successful in 9s
1cbe5a58cc
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.
peterswimm deleted branch feat/spec-frame-evidence-and-submit-path 2026-10-25 04:31:30 +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!25
No description provided.