Command contract: the fields toilville-design#181/#182 require and no surface declares #22

Open
opened 2026-10-21 22:46:50 +00:00 by peterswimm · 0 comments
Owner

The wish/parlay command decisions in toilville-design#181 and #182 are accepted and closed. Both rest on one rule:

The command list, ranks, severities and input schemas come from the command contract. The renderer never derives them from a command name, its inputs or the wish state.

The command contract declares none of them. That is what this issue is for.

This is not spec drift

I measured all three surfaces. They agree with each other:

core.wish_available_commands → ritual-core AvailableCommand openapi.yaml AvailableCommand
fields command, label, to_status, required_inputs, input_schema, form same six (forge_handlers/wish_command.rs:138) same six

AvailableParlayCommand adds decision and is otherwise identical. So the spec is an accurate description of what the server serves — nothing here is a spec bug. The contract simply has no vocabulary for what the renderers were told to read, and CLAUDE.md's rule ("REST endpoints must match ritual-server") means the spec PR is last, not first.

Sequence for every item below: spel migration (the catalog columns and core.wish_available_commands' projection) → ritual-engine (AvailableCommand, CommandResult, passthrough) → this repo. Same order the 0.62.0–0.67.0 entries in CHANGELOG.md already record.

What to add

1. severity — per command

#182 is the authoritative list, and it is five values, not four: none, Simple, Review, Ceremony, Destructive. none means no confirmation.

  • A missing or invalid value is a contract error, not a synonym for none. Validation rejects it; the renderer does not default it.
  • Simple with inputs: submitting the completed form is the confirmation. Simple with no inputs: one click, no second dialog, and the CLI matches (this was the #181 blocking point 2 answer, now folded into #182).

2. rank — per command

The vocabulary already exists and needs no new members: SemanticIntent.ACTION_PRIMARY / ACTION_SECONDARY. Overflow ("the rest go to More") is renderer-derived from available space and is not declared.

Three conditions are contract errors, rejected at validation, never silently repaired:

  • a missing or invalid severity;
  • more than one command declared primary;
  • a Destructive command declared primary.

The third is the one case where a renderer would otherwise have to override a declared rank, against the opening rule that it never derives placement — hence an error rather than a demotion.

3. importance — per command

critical | high | normal | low. The vocabulary exists as ImportanceLevel, but only on BlockSemantics, i.e. per block. A command is a ButtonElement, and there is no way today to say "this command is secondary, critical" while its neighbour is primary. critical alone does not forbid being primary.

Client-side counterpart: toilville_rituals#577.

4. locked, with a reason and a next step — per command

If a command is not valid for the wish's current state, the contract does not list it, and nothing is shown. If a command is relevant but unavailable to this person (permission, tier, consent or another declared restriction), the contract may list it as locked, with a reason and a next step where one exists.

So locked is a third state beside present-and-usable and absent, and the reason is text, not tooltip-only. A locked command keeps its place in the order, cannot be primary or default, and cannot be activated.

This is the mechanism that replaces client-side tier derivation — see item 5.

5. min_tier on ButtonElement: both clients decode a field this contract forbids

schemas/forge_block.json's ButtonElement is additionalProperties: false and declares exactly type, text, action_id, style, url, disabled. It declares neither min_tier nor intent. Both clients decode both:

  • Flutter: packages/udg/lib/src/blocks/forge_block.dart:71 — minTier: json['min_tier'] as int?, and intent
  • macOS: Design/ForgeBlock/ForgeBlock.swift:344,349, and it enforces min_tier (ActionsBlockView.swift:156)

A server sending either field would emit a block that fails validation against its own schema, while the clients are built to read them. One of the two has to move. Since #181 retires client-side tier derivation in favour of item 4, the options are:

  • (a) declare min_tier and intent on ButtonElement as a transitional fallback for specs that predate the command contract, with a deprecation note pointing at locked; or
  • (b) confirm the server never sends them, and the clients stop decoding them.

My answer is on toilville_rituals#578, which also covers the client-side parity defect (Flutter decodes and never gates; macOS gates and lets the gated button be activated, which #181 forbids).

6. A timeout, so Pending can reach Failed

Pending → Failed belongs to the command lifecycle contract. The API defines the timeout. The renderer does not invent one.

Today nothing exits Pending. The sent command is disabled so it cannot be sent twice, there is no optimistic update, and no timeout is defined — so if the result never arrives, the command stays disabled indefinitely and Failed, the only state carrying a recovery action, is unreachable. POST …/commands is synchronous, so this is about transport and session failure, not a long-running job.

Decide whether this is one documented contract-wide constant or a per-command value, and say so in openapi.yaml rather than leaving it to each client.

7. A distinct conflict outcome

Outcome is applied | noop | rejected | duplicate in all three surfaces (wish_command.rs:106, WishCommandResult.outcome, ParlayCommandResult.outcome).

A generic 409 is a rejection. The renderer does not guess that it is a concurrent-edit conflict. If the API gives a distinct conflict result, #181 may show a dedicated conflict state.

#187's "conflicts with a newer version" state therefore depends on this and on nothing else — ville recorded it there as an engineering dependency rather than a design deliverable. Note DiffStatus already has a conflict member (schemas/forge_block.json:800) for the diff block; this is the command-result analogue and should read consistently with it.

8. errors on a command result, field-linked where possible

The server's reason and errors stay inline near the command area until resolved, dismissed or replaced. Errors tied to a field, where the contract links them, show at that field.

IntentRunResult already carries errors: [string] for a rejection. WishCommandResult and ParlayCommandResult carry only reason. Adding errors brings them level; the "tied to a field" part needs the link to be expressible at all, so decide whether an error names the inputs key it belongs to.

9. The lineage proof line on an applied result

A successful wish command that changes the wish writes a lineage entry, and Applied shows the resulting lineage proof line.

CommandResult carries no lineage reference, so a renderer cannot show the proof line without a second lookup it has no key for. The entry is already written — GET /api/v1/events streams the fact — so this is about returning its identifier (seq_num, or the entry id/hash) on the result.

Already satisfied — recording so nobody re-opens it

The client mints command_id. WishCommandRequest.command_id is documented as "Idempotency key … Generated when absent", and ritual-core exposes with_command_id. #181's "a retry after a transport or session failure reuses the same command ID" therefore works, provided the client mints the id rather than letting the server generate one — if the server generates it, a transport failure means the key was never learned and the retry applies twice. That is a client rule, not a contract change, and it belongs in whichever client issue implements retry.

Acceptance

  • Each of items 1–4 and 6–9 is declared in openapi.yaml and, where it belongs to the block vocabulary, in schemas/forge_block.json, with schemas/index.json and CHANGELOG.md updated the way the 0.62.0–0.67.0 entries do it.
  • Item 5 is resolved one way or the other, and ButtonElement and the two clients agree.
  • The three contract errors in item 2 are stated in the spec as errors, with what validation does about them.
  • Each lands only after the server serves it, per CLAUDE.md.

Blocks

Design side is closed: toilville-design #178, #181, #182 and #187 are decided and accepted. Nothing here needs a design answer.

The wish/parlay command decisions in [toilville-design#181](https://git.toilville.dev/Toilville/toilville-design/issues/181) and [#182](https://git.toilville.dev/Toilville/toilville-design/issues/182) are accepted and closed. Both rest on one rule: > The command list, ranks, severities and input schemas come from the command contract. The renderer never derives them from a command name, its inputs or the wish state. **The command contract declares none of them.** That is what this issue is for. ## This is not spec drift I measured all three surfaces. They agree with each other: | | `core.wish_available_commands` → | `ritual-core` `AvailableCommand` | `openapi.yaml` `AvailableCommand` | |---|---|---|---| | fields | `command`, `label`, `to_status`, `required_inputs`, `input_schema`, `form` | same six (`forge_handlers/wish_command.rs:138`) | same six | `AvailableParlayCommand` adds `decision` and is otherwise identical. So the spec is an accurate description of what the server serves — nothing here is a spec bug. The contract simply has no vocabulary for what the renderers were told to read, and `CLAUDE.md`'s rule ("REST endpoints must match `ritual-server`") means **the spec PR is last, not first**. **Sequence for every item below:** spel migration (the catalog columns and `core.wish_available_commands`' projection) → ritual-engine (`AvailableCommand`, `CommandResult`, passthrough) → this repo. Same order the `0.62.0`–`0.67.0` entries in `CHANGELOG.md` already record. ## What to add ### 1. `severity` — per command [#182](https://git.toilville.dev/Toilville/toilville-design/issues/182) is the authoritative list, and it is five values, not four: `none`, Simple, Review, Ceremony, Destructive. `none` means no confirmation. - A **missing or invalid** value is a contract error, **not** a synonym for `none`. Validation rejects it; the renderer does not default it. - Simple with inputs: submitting the completed form is the confirmation. Simple with **no** inputs: one click, no second dialog, and the CLI matches (this was the #181 blocking point 2 answer, now folded into #182). ### 2. `rank` — per command The vocabulary already exists and needs no new members: `SemanticIntent.ACTION_PRIMARY` / `ACTION_SECONDARY`. Overflow ("the rest go to More") is renderer-derived from available space and is not declared. Three conditions are **contract errors**, rejected at validation, never silently repaired: - a missing or invalid `severity`; - more than one command declared primary; - a `Destructive` command declared primary. The third is the one case where a renderer would otherwise have to override a declared rank, against the opening rule that it never derives placement — hence an error rather than a demotion. ### 3. `importance` — per command `critical | high | normal | low`. The vocabulary exists as `ImportanceLevel`, but only on `BlockSemantics`, i.e. per **block**. A command is a `ButtonElement`, and there is no way today to say "this command is secondary, critical" while its neighbour is primary. `critical` alone does not forbid being primary. Client-side counterpart: [toilville_rituals#577](https://git.toilville.dev/Toilville/toilville_rituals/issues/577). ### 4. `locked`, with a reason and a next step — per command > If a command is not valid for the wish's current state, the contract does not list it, and nothing is shown. If a command is relevant but unavailable to this person (permission, tier, consent or another declared restriction), the contract may list it as locked, with a reason and a next step where one exists. So locked is a third state beside present-and-usable and absent, and the reason is **text**, not tooltip-only. A locked command keeps its place in the order, cannot be primary or default, and cannot be activated. This is the mechanism that **replaces** client-side tier derivation — see item 5. ### 5. `min_tier` on `ButtonElement`: both clients decode a field this contract forbids `schemas/forge_block.json`'s `ButtonElement` is `additionalProperties: false` and declares exactly `type`, `text`, `action_id`, `style`, `url`, `disabled`. It declares neither `min_tier` nor `intent`. Both clients decode both: - Flutter: `packages/udg/lib/src/blocks/forge_block.dart:71` — `minTier: json['min_tier'] as int?`, and `intent` - macOS: `Design/ForgeBlock/ForgeBlock.swift:344,349`, and it *enforces* `min_tier` (`ActionsBlockView.swift:156`) A server sending either field would emit a block that fails validation against its own schema, while the clients are built to read them. One of the two has to move. Since #181 retires client-side tier derivation in favour of item 4, the options are: - **(a)** declare `min_tier` and `intent` on `ButtonElement` as a transitional fallback for specs that predate the command contract, with a deprecation note pointing at `locked`; or - **(b)** confirm the server never sends them, and the clients stop decoding them. My answer is on [toilville_rituals#578](https://git.toilville.dev/Toilville/toilville_rituals/issues/578), which also covers the client-side parity defect (Flutter decodes and never gates; macOS gates *and* lets the gated button be activated, which #181 forbids). ### 6. A `timeout`, so Pending can reach Failed > Pending → Failed belongs to the command lifecycle contract. The API defines the timeout. The renderer does not invent one. Today nothing exits Pending. The sent command is disabled so it cannot be sent twice, there is no optimistic update, and no timeout is defined — so if the result never arrives, the command stays disabled indefinitely and `Failed`, the only state carrying a recovery action, is unreachable. `POST …/commands` is synchronous, so this is about transport and session failure, not a long-running job. Decide whether this is one documented contract-wide constant or a per-command value, and say so in `openapi.yaml` rather than leaving it to each client. ### 7. A distinct `conflict` outcome `Outcome` is `applied | noop | rejected | duplicate` in all three surfaces (`wish_command.rs:106`, `WishCommandResult.outcome`, `ParlayCommandResult.outcome`). > A generic 409 is a rejection. The renderer does not guess that it is a concurrent-edit conflict. If the API gives a distinct conflict result, #181 may show a dedicated conflict state. [#187](https://git.toilville.dev/Toilville/toilville-design/issues/187)'s "conflicts with a newer version" state therefore depends on this and on nothing else — ville recorded it there as an engineering dependency rather than a design deliverable. Note `DiffStatus` already has a `conflict` member (`schemas/forge_block.json:800`) for the diff block; this is the command-result analogue and should read consistently with it. ### 8. `errors` on a command result, field-linked where possible > The server's reason and errors stay inline near the command area until resolved, dismissed or replaced. Errors tied to a field, where the contract links them, show at that field. `IntentRunResult` already carries `errors: [string]` for a rejection. `WishCommandResult` and `ParlayCommandResult` carry only `reason`. Adding `errors` brings them level; the "tied to a field" part needs the link to be expressible at all, so decide whether an error names the `inputs` key it belongs to. ### 9. The lineage proof line on an applied result > A successful wish command that changes the wish writes a lineage entry, and Applied shows the resulting lineage proof line. `CommandResult` carries no lineage reference, so a renderer cannot show the proof line without a second lookup it has no key for. The entry is already written — `GET /api/v1/events` streams the fact — so this is about returning its identifier (`seq_num`, or the entry id/hash) on the result. ## Already satisfied — recording so nobody re-opens it **The client mints `command_id`.** `WishCommandRequest.command_id` is documented as "Idempotency key … Generated when absent", and `ritual-core` exposes `with_command_id`. #181's "a retry after a transport or session failure reuses the same command ID" therefore works, *provided the client mints the id rather than letting the server generate one* — if the server generates it, a transport failure means the key was never learned and the retry applies twice. That is a client rule, not a contract change, and it belongs in whichever client issue implements retry. ## Acceptance - [ ] Each of items 1–4 and 6–9 is declared in `openapi.yaml` and, where it belongs to the block vocabulary, in `schemas/forge_block.json`, with `schemas/index.json` and `CHANGELOG.md` updated the way the `0.62.0`–`0.67.0` entries do it. - [ ] Item 5 is resolved one way or the other, and `ButtonElement` and the two clients agree. - [ ] The three contract errors in item 2 are stated in the spec as errors, with what validation does about them. - [ ] Each lands only after the server serves it, per `CLAUDE.md`. ## Blocks - [toilville_rituals#577](https://git.toilville.dev/Toilville/toilville_rituals/issues/577) — per-command rank and importance (items 2, 3) - [toilville_rituals#578](https://git.toilville.dev/Toilville/toilville_rituals/issues/578) — `min_tier` parity (items 4, 5) - [toilville_rituals#581](https://git.toilville.dev/Toilville/toilville_rituals/issues/581) — confirmations as a composition (item 1, and the three validation errors in item 2) - [toilville_rituals#583](https://git.toilville.dev/Toilville/toilville_rituals/issues/583) — migrating the hand-built screens (all of them) Design side is closed: toilville-design #178, #181, #182 and #187 are decided and accepted. Nothing here needs a design answer.
Sign in to join this conversation.
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#22
No description provided.