From 64ddd41571b713a48523a78e41cfbda290442f7d Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Sun, 16 Aug 2026 19:00:14 -0700 Subject: [PATCH 1/3] docs(specs): add spec 78, the canonical API resource model Spec 78 redesigns the API surface Spec 76's mechanics serve: the scene tree becomes the only mutation vocabulary for what renders, effects become a pure catalog with one documented sugar verb, power and brightness merge into one /output resource, and profiles fold into scenes via a lossless idempotent import. The route set drops from 111 paths to 81 (normative inventory in Appendix A), with an estimated 8 to 12k LOC of net deletion across daemon and clients. Grounded in the 2026-08-16 full-surface review (REST route table plus four audit lanes: WS, MCP, clients, OpenAPI/security/docs). Converged through four codex review rounds, finding trajectory 20 -> 8 -> 1 -> 0, with dispositions recorded in section 10. A post-convergence update reframes wave C1b from struck to consumed, since that wave was found in flight on nova/s76-c1b-naming-flip. Co-Authored-By: Nova (Claude Fable 5) --- docs/specs/78-api-resource-model.md | 369 ++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 docs/specs/78-api-resource-model.md diff --git a/docs/specs/78-api-resource-model.md b/docs/specs/78-api-resource-model.md new file mode 100644 index 000000000..d5b7629a8 --- /dev/null +++ b/docs/specs/78-api-resource-model.md @@ -0,0 +1,369 @@ +# Spec 78: Canonical API Resource Model + +**Status:** REVIEW-CONVERGED rev 5 (2026-08-16), awaiting owner lock — four codex review rounds, finding trajectory 20 → 8 → 1 → 0, plus one post-convergence coordination update (full dispositions in §10). +**Findings base:** `docs/review/api-surface-review-2026-08-16.md` — full-surface review (REST route table read + four audit lanes: WS, MCP, clients, OpenAPI/security/docs) with file:line receipts, verified against the tree on 2026-08-16. Local artifact (`docs/review/` is gitignored); re-check line numbers at execution time. +**Relationship to Spec 76:** Spec 76 unifies the *internals* (domain services, typed contracts, envelope, WS registry, OpenAPI catalog). This spec redesigns the *surface* those mechanics serve: the resource model, the route set, and the naming. It amends Spec 76 where the two touch (§7) and subsumes wave C1b (§7.3). +**Authorship model:** same as Spec 76 — Fable owns the contracts and writes the contract-bearing code first; Opus 5 workers execute mechanical waves; Codex reviews per PR. + +--- + +## 0. Goals, non-goals, doctrine + +**Goals** + +1. One model on the wire: the scene tree (active scene → zones → layer stacks → controls) is the only mutation vocabulary for what renders. The pre-multi-zone singleton vocabulary is deleted, not aliased. +2. A surface a human can hold in their head: 81 paths (from 111), one name per concept, one request shape per operation class, verbs with exactly one meaning each. Appendix A is the normative inventory. +3. Convenience without parallelism: high-traffic gestures (apply an effect, stop the show, set brightness) stay one call, defined as documented sugar over the canonical model and returning canonical resources. +4. Significantly less code: dead routes, dead types, second implementations, and hand-mirrored shapes deleted across daemon and all in-repo clients. + +**Non-goals:** no rendering behavior changes; no change to the domain service layer's design (Spec 76 §2 stands); no WS frame-layout redesign beyond the named wire fixes in §7.1; no new features beyond the affordances in §5 (which exist to delete client-side workarounds, not to grow scope). + +**Doctrine:** lockstep, verbatim from Spec 76 §0. Pre-1.0, single user, in-repo clients ship in lockstep, generated clients regenerate. Every route deletion or rename lands with all in-repo consumers updated in the same PR — *consumers* includes the hand-written docs pages named per wave in §8, not just code. Pinned-shape tests are rewritten in the same PR. Persisted user state migrates forward once (§3.3) and is never destroyed. + +--- + +## 1. The scene tree — `/scene` + +### 1.1 Invariant + +An active scene always exists: the auto-managed default scene cannot be renamed or deleted (`core/src/scene/mod.rs:195,255,273`), and deactivation returns to it. Therefore `GET /scene` always returns 200. There is no idle sentinel, no 404-vs-null convention split, and no all-optional response shape anywhere in this tree. + +### 1.2 Routes + +``` +GET /scene full live document (1.3) +PATCH /scene scene-level fields: name (non-default only), unassigned_behavior +POST /scene/deactivate return to the default scene; responds with the new /scene document +POST /scene/clear {zone?} clear one zone's layer stack, or every zone's; the "stop" gesture + +POST /scene/zones create zone +GET /scene/zones/{zone} zone resource (embedded in /scene; addressable for follow-ups) +PATCH /scene/zones/{zone} name, structural fields +DELETE /scene/zones/{zone} +PUT /scene/zones/{zone}/layout zone-scoped spatial layout override +POST /scene/zones/{zone}/members assign device segments to the zone +DELETE /scene/zones/{zone}/members/{member} unassign one membership + +GET /scene/zones/{zone}/layers +POST /scene/zones/{zone}/layers +PATCH /scene/zones/{zone}/layers/order +PUT /scene/zones/{zone}/layers/{layer} +DELETE /scene/zones/{zone}/layers/{layer} +PATCH /scene/zones/{zone}/layers/{layer}/controls +``` + +All mutations route through `SceneMutation`/`commit_scene` (Spec 76 §2.3) — `/scene` is the REST adapter for the live tree, nothing else. + +**Members, not device-zones.** Zone membership is addressed by the membership's own globally unique id (today's layout `Output.id`), returned in the zone document — never by a device-scoped segment name, which is not unique across devices (`spatial.rs:337-351`). The request body names a device and its segments; the resource identity is the membership id. + +### 1.3 The `/scene` document embeds layer identity — this kills the fake-layer hack + +`GET /scene` returns the scene metadata (`id`, `name`, `kind`, `is_default`, `unassigned_behavior`, `layout_id`, `revision`) and every **authored zone** with its full layer stack: layer ids, effect refs, control values, blend/opacity. Clients never synthesize ids: the "synthetic legacy layer" convention (zone id passed as both `group_id` and `layer_id`, `tui/src/client/rest.rs:361-366`, `ui/src/pages/effects/zone_controls.rs:255`) is deleted along with the routes it forged. A client patches the real layer id it read from `/scene` (or from the apply response, §2.3). No zone-scoped controls route exists — conditional shorthand routes ("valid only when the stack has one layer") are rejected as a class. + +**Display faces stay display-domain.** Runtime default display faces materialize as `default_display_groups` outside the authored scene (`core/src/scene/mod.rs:1893-1934`), and their controls live in display preferences. They are deliberately NOT projected into `/scene`: display composition is owned by `/displays/{id}/face` + `face/controls` + `face/composition`, which this spec retains unchanged. One owner per pixel path: authored zones belong to `/scene`, faces belong to `/displays`. + +### 1.4 Layer identity lifecycle + +- Every layer id is a `SceneLayerId` UUID minted at layer creation. Replacement is creation: **every** successful whole-layer `PUT .../layers/{layer}` and every apply (§2.3) mints a new layer id, same effect or not — an id never survives replacement. In-place mutation is exactly two operations: `PATCH .../controls` and `PATCH .../layers/order`. +- A control patch addressing a vanished layer returns 404 `layer_not_found`; the client recovers by re-reading `/scene` (the UI stays current via WS events). Stale patches can never land on a newer effect. +- Layer and zone ids in **persisted** scenes are stable across activation, deactivation, daemon restart, and snapshot. Ids inside the auto-managed default scene are stable within a daemon run only; clients must not persist them. + +### 1.5 Fine-grained mutation is live-tree-only + +`/scenes/{id}/zones/*` and `/scenes/{id}/groups/*` (14 routes) are deleted. Editing a stored scene is either whole-document (`PUT /scenes/{id}`) or live (activate it, edit `/scene`, changes persist through the existing commit path). This matches how the product is used — Studio edits the live scene against real hardware — and removes the doubly-parameterized tree whose paths carried two different "zone" concepts at once. The WS `zone_layout_preview` command loses its caller-selected `scene_id` and becomes active-scene-only, keyed by `zone_id` (§7.1) — the live-tree invariant holds across transports, not just REST. + +### 1.6 Concurrency — one token + +The scene document carries one wire version: `revision`, the commit generation assigned by the sequencer (Spec 76 §2.3). `GET /scene` serves it as `ETag`; **structural** mutations (zone create/delete/patch, member assign/unassign, layer create/delete/reorder/replace, clear, `PUT /scenes/{id}`, and both apply sugars — `/effects/{id}/apply`, `/effects/{id}/presets/{preset}/apply`) honor optional `If-Match` against it and return the canonical 412 envelope (`details: { current }`) on mismatch. The three-token zoo (`groups_revision`, `layers_version`, `controls_version`) disappears from the wire; internals keep whatever bookkeeping Spec 76 §6.5 needs. + +**Control-value writes take no token.** `PATCH .../layers/{layer}/controls` is unguarded: the server applies value writes in commit order, and the layer-id lifecycle (§1.4) already fences staleness — a replaced layer 404s rather than absorbing a stale patch. (The guarded form was unusable in practice: a guarded slider drag self-invalidates every tick, which is why the TUI ships `If-Match`-free today, `tui/src/client/rest.rs:348-378`.) A PATCH naming a control key with an active input binding is rejected 409 `control_bound`, listing the bound keys — a manual write that would be silently overridden by the next sensor resolution is an error, not a race. The rejection is recoverable in the same shape: `PatchControlsRequest.clear_bindings` (§5.7) removes named bindings and applies the accompanying values in one atomic commit. Persisted bindings therefore stay fully manageable — surfaced read-only in the layer document, removable via `clear_bindings` — while the standalone binding-creation route dies until bindings return as a first-class feature (§2.2). + +--- + +## 2. Effects become a catalog; one sugar verb survives + +### 2.1 Catalog routes + +``` +GET /effects ?category&audio_reactive&screen_reactive&input_reactive&q&include=controls,presets +GET /effects/{id} +GET /effects/{id}/cover +GET /effects/{id}/presets +POST /effects/{id}/presets/{preset}/apply sugar; delegates to 2.3 +GET /effects/{id}/layout layout link +PUT /effects/{id}/layout +DELETE /effects/{id}/layout +POST /effects/{id}/apply sugar; see 2.3 +POST /effects/install +POST /effects/rescan +``` + +The filters are implemented server-side, once, in the effect service. Today the handler takes no `Query` extractor at all (`api/effects.rs:352`), which makes the CLI's filter flags silently dead, forces the UI to filter the catalog in WASM, and left MCP to grow a third private filter implementation. `include=controls,presets` expands summaries in the list response, deleting the TUI's N+1 catalog hydration (`tui/src/client/rest.rs:93-127`). + +### 2.2 Deleted from the effects domain + +`/effects/active`, `/effects/active/cover`, `/effects/current/controls`, `/effects/current/controls/{name}/binding`, `/effects/current/reset`, `/effects/pause`, `/effects/resume`, `/effects/stop`, `/effects/{id}/controls`, and the `/effects/screenshots` static mount. Live state lives at `/scene`; pause lives at `/output` (§4); stop is `/scene/clear`; control patches address real layers (§1.3). Binding *creation* returns with a first-class binding design; until then bindings are surfaced in the layer document and removable via `PatchControlsRequest.clear_bindings` (§1.6) — never stranded. `PauseEffectResponse`/`ResumeEffectResponse`/`ActiveEffectResponse` and the idle-sentinel shape die with the routes. + +### 2.3 The sugar contract + +`POST /effects/{id}/apply` body: `{ zone?, controls?, preset_id?, transition? }` (`render_group` renamed `zone`; `controls` typed `BTreeMap` per Spec 76 §4.5, never `serde_json::Value`). + +Semantics, stated in the OpenAPI description verbatim: *replaces the target zone's layer stack with a single new layer (fresh `SceneLayerId`, §1.4) running this effect* (zone omitted = primary zone, created if the scene has none). It is a projection of the same `SceneMutation` a layer-stack replacement performs — not a second code path. + +**Side effects are part of the contract, in this order:** (1) validate effect, zone, controls — any failure returns before any state changes (today power wakes before validation, `domain/effect.rs:190-191`; this spec fixes that ordering as part of the wave); (2) commit the scene mutation; (3) wake paused output; (4) resolve the effect's linked layout, if any. Response: the updated **zone resource** (canonical §1.3 shape, carrying the new layer's id), the applied transition, and the layout-link outcome (`{ layout_id, applied }`) — a linked-layout failure after a successful commit stays visible, not swallowed. `ApplyEffectResponse`'s bespoke shape is deleted. + +**Transitions are honest:** rev 1 accepts exactly `{ type: "cut" }` — the only transition the engine performs today (`domain/effect.rs:24-85` rejects everything else). The type is a closed enum that grows when the engine does; the request field does not accept aspirational values. + +**Preset apply is effect-scoped only.** `POST /effects/{id}/presets/{preset}/apply` delegates to this contract (it already does, `api/effects.rs:531-539`). `POST /library/presets/{id}/apply` — a parallel implementation with divergent semantics (hot-swaps controls without apply's power/layout behavior, `api/library/presets.rs:223-340`) — is **deleted**. Library keeps preset CRUD; applying happens through the one door. + +**Playlist advancement is stack replacement only.** Playlists drive the same zone-stack replacement with **no** power wake and **no** layout-link resolution — an unattended sequencer must not resize the rig or un-pause output. Stated in the playlist service contract; the divergent helper dies with `/library/presets/{id}/apply`. + +--- + +## 3. Scenes collection, snapshots, and the end of profiles + +### 3.1 Collection routes + +``` +GET /scenes list +POST /scenes create (seeds a Primary zone, as today — api/scenes.rs:137-149) +POST /scenes/snapshot create a scene from current runtime state (replaces profile-create) +GET /scenes/{id} +PUT /scenes/{id} whole-document replace +DELETE /scenes/{id} +POST /scenes/{id}/activate +``` + +`/scenes/active` is deleted (`GET /scene` replaces it); `/scenes/deactivate` moves to `/scene/deactivate`. + +### 3.2 Scenes gain what profiles actually carried + +A profile is a pre-multi-zone snapshot: primary effect + controls + preset, display face assignments, a **named layout reference**, and a **brightness** (`profile_store.rs:25-54`). Folding profiles into scenes without losing capability means scenes own two new optional fields: + +- **`layout_id: Option`** — a scene may reference a named spatial layout; activation applies it (the `apply_layout_update` path profile-apply uses today, `api/profiles.rs:307-335`). A dangling reference is kept, skipped at activation with a warning event — never silently dropped. `POST /scenes/snapshot` captures the currently applied layout's id. +- **`activation_brightness: Option`** — applied to `/output` on activation when present. Snapshot does **not** capture it (brightness is global output state, §4); the field exists so migrated profiles keep their restore-brightness behavior and so a user can opt a scene into it explicitly via `PUT /scenes/{id}`. Migration converts the profile's `u8` percentage as `activation_brightness = f32::from(brightness) / 100.0`. + +Display face assignments are representable today (Display-role zones; `scene.rs:81-119`), `active_preset_id` maps to the layer's preset ref (`layer.rs:168-176`), and `Profile.description` maps verbatim onto the scene description. With these two fields, the profile → scene mapping is lossless. + +**Activation ordering is part of the contract:** (1) validate the scene exists and commit the switch; (2) apply `layout_id`, if present; (3) apply `activation_brightness`, if present. Steps 2 and 3 run after the switch and never roll it back; their outcomes are reported in the activation response (`{ layout: { layout_id, applied }, brightness: { applied } }`) exactly as apply reports its layout link (§2.3) — a failure after the switch is visible, not swallowed and not a lie of atomicity. + +All six `/profiles/*` routes, the profile store, `ProfileId`, MCP `set_profile`, and the `hypercolor://profiles` resource are deleted. The `daemon.start_profile` config key becomes `start_scene` (schema bump + rename in normalize; release note). MCP gains `hypercolor://scenes` (§6.7). + +### 3.3 Migration — an import, not a path move + +The Spec 76 §3.4 harness moves one store between paths; it cannot merge two stores (`path_migration.rs:380-417`), so profiles do not go through it. Instead, a one-time **import** on first startup of the shipping version: + +1. Read `profiles.json`. Convert each profile to a named scene: primary → Primary-zone layer (effect + controls + preset ref), displays → Display-role zones, `description` verbatim, `layout_id` and `brightness` → the §3.2 fields. +2. Each imported scene's id derives deterministically from its profile id (UUIDv5 over a fixed import namespace), which makes the import an **idempotent upsert**: a crash between the durable scene write and source retirement re-runs the import as a no-op overwrite, never a duplicate. Name collisions take the first free numbered suffix (`name (imported)`, `name (imported 2)`, …) computed across existing and staged scenes, **excluding the import's own destination id** — an upsert overwriting the scene it wrote on a previous run keeps that scene's persisted name rather than re-suffixing it. Superseded writes follow the store's retry semantics. +3. Retire `profiles.json` (harness-style backup rename) only after the canonical scene write is durable. A conversion that cannot represent a field **fails the import and leaves the source untouched** — with §3.2 the representable set is total, so this is a backstop, not an expected path. + +Nothing dual-reads profiles at runtime after the importing run. + +--- + +## 4. One output resource + +``` +GET /output { power: "running" | "paused", brightness: f32 } +PATCH /output partial: either or both fields +``` + +Receipts for the merge: `POST /effects/pause` is literally `set_output_power(OutputPowerMode::Paused)` (`api/effects.rs:944-945`); no first-party client calls pause/resume (the UI already PUTs `/output/power` directly); and brightness's separate home is the last surface trace of the three-home problem Spec 76 §6.6 collapses. Deleted: `/output/power` (renamed onto `/output`), `/settings/brightness`, and with it the entire `settings` domain — `GET /audio/devices` moves to `/system/audio-devices` where it always belonged. MCP `set_brightness` and `set_output_power` become projections of the one output service; `set_brightness`'s phantom `device_id`/`transition_ms` params are deleted (§6.1). + +--- + +## 5. Naming and shape conventions (normative for every route) + +1. **Segments.** Device LED regions are `segments` in every path, parameter, and `types::api` field; `zones` is reserved for scene render zones (Spec 76 §4.4). Renames: `/devices/{id}/zones/{zone_id}/identify` → `/devices/{id}/segments/{segment}/identify` (`{segment}` is device-scoped here — the device id is in the path). Zone membership is addressed by membership id, not segment name (§1.2). Internal core types rename in a scheduled mechanical wave (§8.5). +2. **Frames are `/frame`; previews are tryouts; dry-runs are `validate_only`.** `GET /displays/{id}/preview.jpg` → `GET /displays/{id}/frame` (same JPEG body; simulators already use `/frame`). `POST /devices/{id}/attachments/preview` is deleted; `PUT /devices/{id}/attachments` accepts `validate_only: true` and returns the computed result without committing. `PUT /layouts/active/preview` keeps its name — a genuine live tryout. +3. **`deactivate` clears an exclusive current; `clear` empties a stack.** `POST /library/playlists/stop` → `/library/playlists/deactivate`. `stop` disappears from the API vocabulary. +4. **`PUT /capture/source`** replaces `POST /capture/source/pick` — it sets state, so it says so. +5. **Sum types are tagged enums.** A response meaning one-of-N shapes is an internally-tagged serde enum in `types::api`. Nullable-field flattening with a string sentinel (the old `ActiveEffectResponse`) is review-rejected as a class. +6. **Closed vocabularies are enums.** `EffectCategory`, effect `source`, transition types, power state serialize as typed enums with `ToSchema`, not `String`. (Receipt for the cost of strings: MCP's hand-typed category list fabricated three categories and omitted four real ones, `mcp/tools/effects.rs:67` vs `types/effect.rs:57-84`.) +7. **One control-patch shape.** `PatchControlsRequest { values: BTreeMap, clear_bindings: Vec }` in `types::api`, used verbatim at every scope: layer controls, display face controls, control-surface values. `clear_bindings` is meaningful only where bindings exist (layers); other scopes reject a non-empty list with a validation error. Today those are four hand-rolled shapes. +8. **Concurrency per §1.6:** one scene `revision` token, structural writes optionally guarded, value writes unguarded with `control_bound` rejection. No other wire version tokens exist. +9. **Attachments read in one call.** `GET /devices?include=attachments` embeds each device's attachment profile in the list response, deleting the UI's one-GET-per-device serial loop (`ui/src/components/layout_builder/editor_session.rs:76-83`). Per-device routes remain for writes. + +--- + +## 6. MCP realignment + +MCP stays a curated concierge, but every tool becomes an honest, thin projection of a domain service: + +1. **Phantom parameters are deleted** (a parameter exists only when its behavior does): `set_effect.devices`, `set_color.devices` (hardcode `target_zone: None` while advertising targeting, `tools/effects.rs:233,477`), `set_brightness.device_id` (reports `"scope": "device"` while setting global) and `.transition_ms`, `diagnose.device_id`/`.checks` (handler binds `_params`), `stop_effect.transition_ms`. The `set_effect.transition_ms` `maximum: 0` pattern is the house-correct "not yet." +2. **`create_scene` routes through the domain scene service** — same Primary-zone seeding and `publish_scene_library_changed` as REST. Its fictional `trigger`/`profile_id` params (written to metadata nothing reads) are deleted, and the `setup_automation` prompt stops promising automation the daemon does not have. +3. **The second diagnostics engine is deleted** (`tools/system.rs:380-555`): the tool calls the same domain diagnostics REST uses, with one check vocabulary. +4. **One resolver policy.** Fuzzy resolution returns a structured ambiguity error listing candidates (the pattern `set_display_face` already implements) instead of first-substring-hit over a `HashMap`. +5. **Tools and resources share payload builders.** `get_status` / `hypercolor://state` and `get_devices` / `hypercolor://devices` render from one builder each; the drifted duplicates die. The dead stub readers and their validators (`resources.rs:109-182`) are deleted with their scaffolding tests. +6. **New tool: `adjust_controls`** — patch named control values on a zone's layer (resolves zone → layer via the scene service). The one missing agent affordance: today an agent must re-apply an effect to nudge a slider. +7. **Renames tracking the model:** `set_profile` deleted (`activate_scene` covers it; a `snapshot_scene` tool ships if agent demand appears); `stop_effect` becomes `clear_zone` over `/scene/clear`; `hypercolor://profiles` becomes `hypercolor://scenes`; the `setup_automation`/`mood_lighting` prompts and the MCP docs pages update in the same wave (`mcp/prompts.rs:228-264`, `docs/content/agents/*`, `docs/content/api/mcp.md`). +8. **Honest annotations:** per-tool `destructive`/`read_only` hints (today `destructive(false)` is hardcoded for all tools including one whose description begins "Destructively stop"). + +--- + +## 7. Amendments to Spec 76 waves + +### 7.1 WS registry (amends wave 3.2 acceptance criteria) + +- Every `define_ws_topics!` entry declares a **backpressure class** — `Lossless` (awaited send), `LatestWins` (watch semantics), or `DropWithNotice` (try-send + `backpressure` message) — replacing today's three ad-hoc behaviors; the manifest states each topic's class. +- The **event vocabulary is generated**: the manifest's event list is emitted from `HypercolorEvent` plus the synthetic names (`resync_required`; `input_source_status_changed` is renamed to not collide with `input_source_changed`). +- **The event and hello vocabulary sheds the singleton model** (not left to 3.2), split cleanly across waves: **78.3** drops the hello payload's singleton `effect`/`active_preset_id` fields (`ws/protocol.rs:1153-1163` — both UIs consume `/scene` + events instead), renames `group_id`/`group_name`/`groups_revision` and `RenderGroupChanged` to zone vocabulary, and gives `EffectControlChanged` zone + layer identity; **78.4** — and only 78.4, so no intermediate revision has profile REST without profile WS — drops the hello `profile` field and deletes the profile events (`event.rs:849-864`). Golden fixtures update in the same PRs. +- **`zone_layout_preview` becomes active-scene-only** and zone-keyed; the caller-selected `scene_id` field is deleted (`ws/protocol.rs:615-622`, `ws/session.rs:1405-1429`, UI payload `ui/src/ws/preview.rs:300-325`). +- **Continuity is a documented contract:** the events channel does not replay across a socket gap; clients fold `connection_generation` into fetch epochs (the pattern the UI already implements). The manifest and SDK say so explicitly. +- **One error vocabulary:** WS-native errors serve `ApiErrorDetail` codes; the parallel four-file code set is deleted. +- **Named wire fixes in the migration:** `0x01` gains a `u16` zone count (u8 truncates at 255 zones silently); `screen_zones` gets its own config block (today it is paced by an *unsubscribed* channel's config); the default preview transport becomes the advertised v2; the `frames` JSON toggle is deleted (zero consumers); RPC tags `0x80`/`0x81` are deleted with their codec (implemented, golden-tested, and unreachable — the daemon discards all inbound binary, `ws/session.rs:544`). +- **The manifest is generated output** from the topic registry and codec metadata; hand-editing `protocol/websocket-v1.json` becomes impossible by construction (six confirmed drift points today). + +### 7.2 OpenAPI (amends wave 3.3) + +Wave 3.3's registration-helper catalog is implemented with `utoipa-axum`'s `OpenApiRouter` (utoipa 5.4 is already a workspace dep): route registration *is* spec registration, so drift is impossible rather than detectable. Acceptance criteria beyond Spec 76 §4.6: response schemas on every operation (today: zero catalog operations have any), true status codes (401/403/429 from security, 201/202 from the 17 handler sites, 101 for `/ws`), typed path parameters, and an extension hook so `ApiExtension` implementors contribute paths. The docs site's endpoint reference (`docs/content/api/rest.md`, 147 hand-written blocks) is generated from the emitted document; conceptual pages stay hand-written. + +### 7.3 Wave C1b is consumed — it lands first, and 78 deletes what it renamed + +C1b was in flight when this spec converged (`nova/s76-c1b-naming-flip`: `current`→`active` route renames, `groups`→`zones` in layer paths and zone payload fields), so it **lands as planned** rather than being struck — aborting a nearly-finished wave costs more than the churn it saves. Division of spoils: C1b's *vocabulary* renames (zone payload fields, path grammar) are load-bearing groundwork this spec builds on; its renamed *routes* are transitional — wave 78.3 deletes `/effects/active-formerly-current/*` and the `/scenes/{id}` fine-grained tree wholesale (§2.2, §1.5). The affected client call sites flip twice (rename, then move to `/scene`); accepted and named here. Any C1b remainder that has not landed when 78.3 starts transfers to 78.3. Config resource routes stay wave 4.3's job. + +**Wave 3.1 re-targeting, made precise:** the `devices+scenes` batch starts only after 78.3/78.4/78.5 merge; the `effects+library` batch after 78.3/78.5. Both batches cover surviving routes only and **extend** the 78.1 contracts — they never redefine a type 78.1 shipped. The `layouts+displays+assets` and `drivers+system` batches are unaffected except for the §5 renames. + +--- + +## 8. Implementation plan + +Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR (code and the docs pages each wave names), pins rewritten in-PR, `just verify` green, Codex inline review, Fable signoff. + +**Error rendering rule for every wave:** every route this spec adds or rewrites renders the canonical `DomainError` envelope from its first commit. Spec 76 waves 2.1 and C1a are both landed (C1a merged 2026-08-16, PR #192), so the canonical envelope is the only error surface this spec ever touches; no new route may reintroduce a bespoke shape. + +**Route-inventory strategy:** Spec 76 wave 0.7's REST matrix remains the sole executable current-state pin, updated in every route-changing PR. Wave 78.1 additionally commits Appendix A as a **target manifest** (data, not a live assertion). A convergence test asserting live router ≡ Appendix A (Appendix A's scope: `/api/v1` JSON routes + `/health`; document routes excluded) activates at the end of wave 78.5, with the config-route rows gated on Spec 76 wave 4.3. + +**78.0 — bug strikes** (independent, immediate; each lands regardless of the redesign) +- 0a. Server-side effect filters + `Query` extractor (fixes the CLI's dead flags; UI drops WASM filtering; MCP drops its private filter). +- 0b. MCP phantom-parameter deletion + honest annotations + category enum from strum + docs tool-count fix. +- 0c. TUI status shadow fix (render_loop fields; FPS shows again). +- 0d. Static assets, Swagger UI, and `/preview` exempted from bearer auth; constant-time key comparison. (The rest of the security findings route to Spec 77; these two are unambiguous now.) + +**78.1 — contracts (Fable).** `/scene` document + zone/layer resource types (incl. layer-identity rules §1.4 and the single `revision` token §1.6), `/output` type, `PatchControlsRequest`, closed enums, tagged-union convention, segment naming and `zone` body fields in `types::api`, scene `layout_id`/`activation_brightness` fields, the §2.3 sugar contract, and Appendix A committed as the target manifest. + +**78.2 — output merge (worker, after 78.1).** `/output` GET/PATCH; delete pause/resume routes + types, `/settings/*`, `/output/power`; move audio devices to `/system/audio-devices`; MCP brightness/power onto the output service. Clients in-PR. + +**78.3 — the scene tree (worker(s), after 78.1 and Spec 76 wave 2.3b).** Mount `/scene` over the domain services (validation-before-side-effects ordering per §2.3); delete `/effects/active|current/*`, `/effects/stop`, `/effects/{id}/controls`, `/scenes/active`, `/scenes/deactivate`, `/scenes/{id}/zones/*`, `/scenes/{id}/groups/*`, broadcast-media, `/library/presets/{id}/apply`; rewrite apply as sugar returning the zone resource; playlist advancement semantics; `/scene/clear`; the §7.1 WS vocabulary migration (hello fields, event renames, `zone_layout_preview`). Both UIs and CLI migrate in the same PR set (split by client if needed, route flip last). Docs checklist: `guide/first-session.md`, `guide/quick-start.md`, `api/cli.md`, `contributing/debugging.md`, `studio/zone-api-and-concurrency.md`, `troubleshooting/studio.md`, `effects/controls.md`. + +**78.4 — profiles fold (worker, after 78.3).** `/scenes/snapshot`; the §3.3 import; scene `layout_id`/`activation_brightness` activation behavior; delete `/profiles/*`, store, types, MCP `set_profile` + `hypercolor://profiles` (add `hypercolor://scenes`); `start_profile` → `start_scene` config rename; CLI `profile` commands become `scene snapshot`/`scene activate`. Docs checklist: `guide/profiles-and-scenes.md`, `docs/content/agents/*` (prompt-templates, resources-reference, tools-reference, workflows), `api/mcp.md`. + +**78.5 — naming + dead-route slash (worker).** Segments rename (API boundary), displays `/frame`, `validate_only`, `capture/source`, playlists `deactivate`; delete debug routes, bindings/rebind, `/diagnose/memory` (fold into diagnose checks), sensors item route, attachment vendors/categories/item-ops, `/devices/metrics`, `/effects/screenshots` mount, logical-devices (pending the downstream check below). Activate the Appendix A convergence test. Docs checklist: `api/openapi.md`, remaining pages the route grep names. Internal segment-type rename as its own mechanical PR. + +**78.6 — MCP realignment (worker, after 78.3/78.4).** §6 items 2 to 8; `adjust_controls`; shared payload builders; dead stubs deleted. + +**Downstream check (blocks only the logical-devices deletion in 78.5):** confirm the internal repo (consumes OSS as the `oss/` submodule) and hypercolor-hass/Python SDK have no logical-devices dependency and nothing beyond §3.3's migration for profiles. Python client regenerates post-78; breaking changes named in release notes per doctrine. + +**Interaction with Spec 76 sequencing:** 78.1 needs 2.0 + 2.1 (landed). 78.3 needs 2.3b and C1b (both in flight at spec time, §7.3). 3.1 batches gate per §7.3. 3.2/3.3 adopt the §7.1/§7.2 amendments. Phases 5/6/7 of Spec 76 are untouched. + +**Estimated effect:** 111 → 81 paths (Appendix A: 118 operations); net deletion 8 to 12k LOC across daemon + clients (dead handlers and types, the 14-route scene nesting, profiles domain, MCP second implementations, hand mirrors 3.1 no longer grows contracts for, fake-layer client code, WASM filtering, N+1 hydration). + +--- + +## 9. Decisions resolved at draft + +1. **`/scene` singleton over `/scenes/active`.** The singular resource reads as what it is (the live tree), never 404s (§1.1), and gives the zone sub-tree a stable home. The one-letter distance from `/scenes` is accepted: the two are different types (document vs collection) and confusion produces a 404 or 405, not silent misbehavior. +2. **No zone-scoped controls route.** The fake-layer hack is evidence of missing layer *identity*, not a missing route. `/scene` embeds real layer ids with a defined lifecycle (§1.4); conditional-validity shorthand routes are rejected as a class. +3. **Fine-grained mutation is live-tree-only** (§1.5), across REST and WS. Offline editing is whole-document PUT. If a real offline-structural-editing need appears, the answer is an editing-session design, not resurrecting the doubly-parameterized tree. +4. **Brightness is not captured by snapshot, but scenes may carry it** (§3.2). `activation_brightness` preserves migrated profile behavior and is explicitly opt-in for new scenes; default semantics stay clean (output owns brightness). +5. **Profiles die rather than become a scene kind.** With `layout_id` + `activation_brightness` + Display-role zones, the mapping is lossless; a `SceneKind::Snapshot` would be the same fossil with a newer name. +6. **One wire concurrency token** (§1.6). Single-writer reality plus commit-sequencer generations make per-subresource tokens theater; a false 412 costs one `/scene` re-read. +7. **`hypercolor-openapi` consumers regenerate; no REST compatibility window.** Doctrine §0. The Python SDK and HASS integration track the release that ships each wave. + +## 10. Review history + +- **Rev 5 (2026-08-16, coordination update):** wave C1b was discovered in flight (`nova/s76-c1b-naming-flip`, three commits plus active edits) after rev 4 converged. §7.3 reframed from *strike* to *consume*: C1b lands, its vocabulary renames become 78's floor, 78.3 deletes the transitional routes. Sequencing-only change; no reviewed contract altered. +- **Rev 4 (2026-08-16):** round-3 convergence check verified 7/8 rev-3 fixes RESOLVED with one residual (the suffix allocator could re-suffix the import's own destination on crash-replay); fixed by excluding the destination id from collision checks. Round-4 micro-check: **VERDICT: PASS. Converged.** +- **Rev 3 (2026-08-16):** round-2 codex verification pass: 15/20 round-1 findings ADDRESSED, 5 PARTIAL, 8 findings total (6 MAJOR, 2 MINOR), all adopted: every whole-layer PUT mints a fresh id (same effect included); apply sugars named in the guarded structural list; `clear_bindings` on `PatchControlsRequest` so persisted bindings are removable (nothing API-locked); idempotent import via UUIDv5-derived scene ids + numbered suffix allocation; `description` mapped and the brightness u8→f32 formula fixed; activation ordering with reported layout/brightness outcomes; profile WS removal pinned to 78.4 alone; Appendix A scoped to `/api/v1` JSON routes + `/health` with document routes excluded. Verdict trajectory: 20 → 8. +- **Rev 2 (2026-08-16):** four lens-locked codex passes over rev 1 (`codex exec`, high effort, read-only; lenses: resource-model keystones, contract semantics, Spec 76 sequencing, consumers + migration). 20 findings — 2 BLOCKER, 14 MAJOR, 4 MINOR — all verified against the tree and all adopted: display-face exception + layer-identity lifecycle (§1.3/§1.4); members addressing (§1.2); apply side-effect ordering + layout-link outcome retained + cut-only transitions + library-preset-apply deletion + playlist semantics (§2.3); scene `layout_id`/`activation_brightness` for lossless profile fold (§3.2); import-not-path-migration (§3.3); single revision token + bound-key 409 replacing the "commutative" claim (§1.6); C1b remainder reassigned + 3.1 gating + canonical-error rule + two-stage inventory strategy (§7.3/§8); WS hello/event vocabulary migration + `zone_layout_preview` scene_id removal scheduled (§7.1); MCP resource/prompt/config/docs consumers added to 78.4; hand-written docs checklists added to 78.3/78.5; Appendix A added with exact counts. Verdict trajectory: 4× NEEDS_CHANGES → rev 2. +- **Rev 1 (2026-08-16):** initial draft from the API surface review. + +--- + +## Appendix A — Normative route inventory (81 paths, 118 operations) + +Scope: the JSON API under `/api/v1` plus `/health`. Document routes are deliberately outside the inventory and the convergence test: `/` (SPA), `/preview`, `/api/v1/docs`, `/api/v1/openapi.json`, and the `/mcp` mount are served pages and protocol endpoints, not API resources. Config rows land via Spec 76 wave 4.3; logical-devices rows are intentionally absent pending the §8 downstream check (re-add via spec amendment if the check fails). `⚡` marks routes whose handler is new or substantially rewritten by this spec. + +| Path | Methods | Notes | +|---|---|---| +| `/health` | GET | bare probe | +| `/api/v1/system` | GET | ⚡ merges `/server` + `/status` | +| `/api/v1/system/sensors` | GET | | +| `/api/v1/system/audio-devices` | GET | ⚡ from `/audio/devices` | +| `/api/v1/output` | GET, PATCH | ⚡ power + brightness | +| `/api/v1/config` | GET | 4.3 | +| `/api/v1/config/schema` | GET | 4.3 | +| `/api/v1/config/keys/{key}` | GET, PUT, DELETE | 4.3 | +| `/api/v1/config/reset` | POST | 4.3 | +| `/api/v1/diagnose` | POST | absorbs memory + queue checks | +| `/api/v1/drivers` | GET | | +| `/api/v1/drivers/{id}/config` | GET | | +| `/api/v1/drivers/{id}/controls` | GET | | +| `/api/v1/devices` | GET | `include=attachments` | +| `/api/v1/devices/discover` | POST | | +| `/api/v1/devices/{id}` | GET, PUT, DELETE | | +| `/api/v1/devices/{id}/controls` | GET | | +| `/api/v1/devices/{id}/identify` | POST | | +| `/api/v1/devices/{id}/segments/{segment}/identify` | POST | ⚡ renamed | +| `/api/v1/devices/{id}/pair` | POST, DELETE | | +| `/api/v1/devices/{id}/attachments` | GET, PUT, DELETE | PUT takes `validate_only` | +| `/api/v1/devices/{id}/attachments/{slot}/identify` | POST | | +| `/api/v1/attachments/templates` | GET, POST | item ops deleted | +| `/api/v1/control-surfaces` | GET | | +| `/api/v1/control-surfaces/{id}` | GET | | +| `/api/v1/control-surfaces/{id}/values` | PATCH | `PatchControlsRequest` | +| `/api/v1/control-surfaces/{id}/actions/{action}` | POST | | +| `/api/v1/displays` | GET | | +| `/api/v1/displays/{id}/frame` | GET | ⚡ from `preview.jpg` | +| `/api/v1/displays/{id}/face` | GET, PUT, DELETE | | +| `/api/v1/displays/{id}/face/controls` | PATCH | `PatchControlsRequest` | +| `/api/v1/displays/{id}/face/composition` | PATCH | | +| `/api/v1/simulators/displays` | GET, POST | | +| `/api/v1/simulators/displays/{id}` | GET, PATCH, DELETE | | +| `/api/v1/simulators/displays/{id}/frame` | GET | | +| `/api/v1/effects` | GET | ⚡ filters + include | +| `/api/v1/effects/{id}` | GET | | +| `/api/v1/effects/{id}/cover` | GET | | +| `/api/v1/effects/{id}/presets` | GET | | +| `/api/v1/effects/{id}/presets/{preset}/apply` | POST | ⚡ sugar | +| `/api/v1/effects/{id}/layout` | GET, PUT, DELETE | | +| `/api/v1/effects/{id}/apply` | POST | ⚡ sugar, §2.3 | +| `/api/v1/effects/install` | POST | | +| `/api/v1/effects/rescan` | POST | | +| `/api/v1/scene` | GET, PATCH | ⚡ | +| `/api/v1/scene/deactivate` | POST | ⚡ | +| `/api/v1/scene/clear` | POST | ⚡ | +| `/api/v1/scene/zones` | POST | ⚡ | +| `/api/v1/scene/zones/{zone}` | GET, PATCH, DELETE | ⚡ | +| `/api/v1/scene/zones/{zone}/layout` | PUT | ⚡ | +| `/api/v1/scene/zones/{zone}/members` | POST | ⚡ | +| `/api/v1/scene/zones/{zone}/members/{member}` | DELETE | ⚡ | +| `/api/v1/scene/zones/{zone}/layers` | GET, POST | ⚡ | +| `/api/v1/scene/zones/{zone}/layers/order` | PATCH | ⚡ | +| `/api/v1/scene/zones/{zone}/layers/{layer}` | PUT, DELETE | ⚡ | +| `/api/v1/scene/zones/{zone}/layers/{layer}/controls` | PATCH | ⚡ `PatchControlsRequest` | +| `/api/v1/scenes` | GET, POST | | +| `/api/v1/scenes/snapshot` | POST | ⚡ | +| `/api/v1/scenes/{id}` | GET, PUT, DELETE | | +| `/api/v1/scenes/{id}/activate` | POST | | +| `/api/v1/layouts` | GET, POST | | +| `/api/v1/layouts/active` | GET | | +| `/api/v1/layouts/active/preview` | PUT | | +| `/api/v1/layouts/{id}` | GET, PUT, DELETE | | +| `/api/v1/layouts/{id}/apply` | POST | | +| `/api/v1/library/presets` | GET, POST | apply route deleted, §2.3 | +| `/api/v1/library/presets/{id}` | GET, PUT, DELETE | | +| `/api/v1/library/favorites` | GET, POST | | +| `/api/v1/library/favorites/{effect}` | DELETE | | +| `/api/v1/library/playlists` | GET, POST | | +| `/api/v1/library/playlists/active` | GET | | +| `/api/v1/library/playlists/deactivate` | POST | ⚡ from `/stop` | +| `/api/v1/library/playlists/{id}` | GET, PUT, DELETE | | +| `/api/v1/library/playlists/{id}/activate` | POST | | +| `/api/v1/assets` | GET, POST | | +| `/api/v1/assets/{id}` | GET, PUT, DELETE | | +| `/api/v1/assets/{id}/blob` | GET | | +| `/api/v1/assets/{id}/thumbnail` | GET | | +| `/api/v1/capture/monitors` | GET | | +| `/api/v1/capture/source` | PUT | ⚡ from `/pick` | +| `/api/v1/ws` | GET | | From c5afb7d749a264c8ba5c958302bd98c2532070ce Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 12:15:13 -0700 Subject: [PATCH 2/3] docs(specs): reconcile spec 78 against the merge sweep and lock it Rev 5 converged one day before ten PRs landed, so every cross-spec claim needed re-verification before the lock. Section 7.1 is rewritten from "amends wave 3.2 acceptance criteria" (that arc has shipped) into a landed/owned split with fresh receipts: the zone vocabulary renames and RPC tag deletion are struck as shipped; the hello singletons, zone_layout_preview keying, backpressure classes, generated vocabulary and manifest, error vocabulary, frames toggle, and remaining wire fixes transfer to waves 78.3/78.4. Section 7.3 records C1b as landed and the 3.1 gating as overtaken by execution, with the accepted churn named. Wave 3.3 is explicitly sequenced after 78.5 so the OpenAPI catalog is born against Appendix A. Spec 76's wave table picks up the same unification: 3.1 batch statuses, the 3.3 resequencing, the 0x80/0x81 fixture annotation, and the persisted-groups rename reassigned to Phase 5.1, where the scene-schema bump already lives. Two codex review rounds over the reconciliation; all eight findings closed, incl. two false claims of my own the first round caught (the frames toggle is still live; effects+library is in flight, not shipped). Co-Authored-By: Nova (Claude Fable 5) --- docs/specs/76-internal-api-unification.md | 8 +-- docs/specs/78-api-resource-model.md | 60 +++++++++++++++-------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/docs/specs/76-internal-api-unification.md b/docs/specs/76-internal-api-unification.md index 894c9ad42..f4844e233 100644 --- a/docs/specs/76-internal-api-unification.md +++ b/docs/specs/76-internal-api-unification.md @@ -506,7 +506,7 @@ pub trait DeviceBackendFactory: Send + Sync { 0.5 Delete `DevicePlugin` and `health_check`; delete `types/palette.rs` (zero consumers). 0.6 Fix literal-`~` config fallback (CLI/TUI adopt `core::config::paths`). 0.7 **REST wire matrix** artifact + tests pinning current shapes (intentionality fence — rewritten in-PR when shapes deliberately change). -0.8 **WS golden fixtures** pinning every binary tag (0x01, 0x02, 0x03, 0x05–0x11, and the leptos-ext RPC tags 0x80/0x81; 0x04 is deliberately unassigned) and byte layout. +0.8 **WS golden fixtures** pinning every binary tag (0x01, 0x02, 0x03, 0x05–0x11; 0x04 is deliberately unassigned) and byte layout. The RPC tags 0x80/0x81 were pinned here originally and deleted with their codec in wave C1e; 0x12 joined in wave 3.2c. 0.9 **Path-migration harness** (old→new path, precedence, backup, idempotence) modeled on `driver_inventory.rs:98`. **Phase 1 — color** (after 0.3; independent otherwise) @@ -526,14 +526,14 @@ pub trait DeviceBackendFactory: Send + Sync { **Wave C1 — the compat-ectomy** (after 2.3a/4.1 merge; before or interleaved with Phase 3; each an atomic PR with all in-repo clients updated in-PR) C1a **Error-surface flip**: every route renders the canonical `DomainError` envelope; delete `domain::legacy`, `into_v1_response`, and `ApiError` entirely; rewrite the matrix tests to pin canonical shapes; unblocks wave 2.2's nine deferred helpers (bespoke 404 prose normalizes, `ResourceKind::Driver` exists). -C1b **Naming flip**: `current`→`active`, `groups`→`zones`, `/config/get|set`→resource routes — old routes deleted, not aliased. The REST half shipped in C1b; the WS half (the `render_group_changed` event name and the `group_id`/`group_name`/`groups_revision` event fields) shipped in wave 3.2c, which owned that surface. Persisted scene files keep `groups` until C1c migrates them. +C1b **Naming flip**: `current`→`active`, `groups`→`zones`, `/config/get|set`→resource routes — old routes deleted, not aliased. The REST half shipped in C1b; the WS half (the `render_group_changed` event name and the `group_id`/`group_name`/`groups_revision` event fields) shipped in wave 3.2c, which owned that surface. Persisted scene files keep `groups`: C1c landed without that migration, so the rename rides Phase 5.1's scene-schema bump alongside the legacy Zone codec deletion. C1c **Persisted-legacy deletion**: delete core's `migrate_config` (schema ≤3 path) and driver-inventory's legacy runtime-state import entry (already completed on the only install); update `types::control`'s keep-raw doc paragraph; schema bumps + release notes where shapes require it. (The legacy Zone codec deletes in 5.1, where its consumers restructure.) C1d **Deprecated-surface deletion**: migrate remaining callers off `hypercolor-color`'s `compat.rs` and delete it; delete the TS `audio/helpers` color re-export. **Phase 3 — contract rollout** (after 2) -3.1 Worker(s): grow `types::api` to full coverage **in per-domain batches** (devices+scenes; effects+library; layouts+displays+assets; drivers+system), daemon + UI + TUI mirrors deleted in the same PR per batch. +3.1 Worker(s): grow `types::api` to full coverage **in per-domain batches**, daemon + UI + TUI mirrors deleted in the same PR per batch. **Shipped:** layouts+displays+assets (#202) and devices+scenes (#204, ahead of Spec 78 §7.3's gate — accepted churn named there). **In flight:** effects+library, rescoped mid-flight to Spec 78 Appendix A survivors. **Remaining:** drivers+system, either side of the 78 waves. Batches extend Spec 78's 78.1 contracts and never redefine a type it ships. 3.2 Fable: `define_ws_topics!` registry + codec/tag contracts. Worker: promote WS types to leptos-ext, migrate subscribes, keyed subscriptions replacing the old message forms outright (clients in the same PRs) — split extraction / topics / clients into separate PRs. **Shipped:** 3.2a extraction, 3.2b daemon adoption, 3.2c the keyed wire with every client, the WS half of C1b's naming flip, and the deletion of the daemon's embedded `/preview` page (a fourth WS client that also shadowed the UI's own route). `interval_ms`→`Cadence` is the one §5 item 3.2c left open, for the reason recorded there. -3.3 Worker: honest pagination everywhere (absorbed by C1a where it lands first) + registration-helper OpenAPI catalog (142-entry table dies). +3.3 Worker: honest pagination everywhere + registration-helper OpenAPI catalog (142-entry table dies). **Resequenced: executes after wave 78.5**, implemented per Spec 78 §7.2 (`utoipa-axum` OpenApiRouter) — the catalog is born against Appendix A's 81 paths, never the pre-78 surface. **Phase 4 — config** (after 0.9) 4.1 Fable: `ConfigSources`/`LoadedConfig`, Boot/Live types, descriptor macro + completeness design. diff --git a/docs/specs/78-api-resource-model.md b/docs/specs/78-api-resource-model.md index d5b7629a8..890ee1183 100644 --- a/docs/specs/78-api-resource-model.md +++ b/docs/specs/78-api-resource-model.md @@ -1,6 +1,6 @@ # Spec 78: Canonical API Resource Model -**Status:** REVIEW-CONVERGED rev 5 (2026-08-16), awaiting owner lock — four codex review rounds, finding trajectory 20 → 8 → 1 → 0, plus one post-convergence coordination update (full dispositions in §10). +**Status:** LOCKED rev 6 (2026-08-17) — four codex review rounds (finding trajectory 20 → 8 → 1 → 0), one post-convergence coordination update, and one owner reconciliation pass against the 2026-08-17 merge sweep (ten PRs landed between rev 5 and lock (#195-#204: six Spec 76 waves, four CI/build fixes); every §7 amendment re-verified against main, dispositions in §10). **Findings base:** `docs/review/api-surface-review-2026-08-16.md` — full-surface review (REST route table read + four audit lanes: WS, MCP, clients, OpenAPI/security/docs) with file:line receipts, verified against the tree on 2026-08-16. Local artifact (`docs/review/` is gitignored); re-check line numbers at execution time. **Relationship to Spec 76:** Spec 76 unifies the *internals* (domain services, typed contracts, envelope, WS registry, OpenAPI catalog). This spec redesigns the *surface* those mechanics serve: the resource model, the route set, and the naming. It amends Spec 76 where the two touch (§7) and subsumes wave C1b (§7.3). **Authorship model:** same as Spec 76 — Fable owns the contracts and writes the contract-bearing code first; Opus 5 workers execute mechanical waves; Codex reviews per PR. @@ -58,7 +58,7 @@ All mutations route through `SceneMutation`/`commit_scene` (Spec 76 §2.3) — ` ### 1.3 The `/scene` document embeds layer identity — this kills the fake-layer hack -`GET /scene` returns the scene metadata (`id`, `name`, `kind`, `is_default`, `unassigned_behavior`, `layout_id`, `revision`) and every **authored zone** with its full layer stack: layer ids, effect refs, control values, blend/opacity. Clients never synthesize ids: the "synthetic legacy layer" convention (zone id passed as both `group_id` and `layer_id`, `tui/src/client/rest.rs:361-366`, `ui/src/pages/effects/zone_controls.rs:255`) is deleted along with the routes it forged. A client patches the real layer id it read from `/scene` (or from the apply response, §2.3). No zone-scoped controls route exists — conditional shorthand routes ("valid only when the stack has one layer") are rejected as a class. +`GET /scene` returns the scene metadata (`id`, `name`, `kind`, `is_default`, `unassigned_behavior`, `layout_id`, `revision`) and every **authored zone** with its full layer stack: layer ids, effect refs, control values, blend/opacity. Clients never synthesize ids: the "synthetic legacy layer" convention (zone id passed as both zone and layer id — `zone_id` post-C1b; receipts `tui/src/client/rest.rs:361-366`, `ui/src/pages/effects/zone_controls.rs:255` from the pre-C1b tree) is deleted along with the routes it forged. A client patches the real layer id it read from `/scene` (or from the apply response, §2.3). No zone-scoped controls route exists — conditional shorthand routes ("valid only when the stack has one layer") are rejected as a class. **Display faces stay display-domain.** Runtime default display faces materialize as `default_display_groups` outside the authored scene (`core/src/scene/mod.rs:1893-1934`), and their controls live in display preferences. They are deliberately NOT projected into `/scene`: display composition is owned by `/displays/{id}/face` + `face/controls` + `face/composition`, which this spec retains unchanged. One owner per pixel path: authored zones belong to `/scene`, faces belong to `/displays`. @@ -70,7 +70,7 @@ All mutations route through `SceneMutation`/`commit_scene` (Spec 76 §2.3) — ` ### 1.5 Fine-grained mutation is live-tree-only -`/scenes/{id}/zones/*` and `/scenes/{id}/groups/*` (14 routes) are deleted. Editing a stored scene is either whole-document (`PUT /scenes/{id}`) or live (activate it, edit `/scene`, changes persist through the existing commit path). This matches how the product is used — Studio edits the live scene against real hardware — and removes the doubly-parameterized tree whose paths carried two different "zone" concepts at once. The WS `zone_layout_preview` command loses its caller-selected `scene_id` and becomes active-scene-only, keyed by `zone_id` (§7.1) — the live-tree invariant holds across transports, not just REST. +`/scenes/{id}/zones/*` (14 routes; the `groups` spelling died in C1b) are deleted. Editing a stored scene is either whole-document (`PUT /scenes/{id}`) or live (activate it, edit `/scene`, changes persist through the existing commit path). This matches how the product is used — Studio edits the live scene against real hardware — and removes the doubly-parameterized tree whose paths carried two different "zone" concepts at once. The WS `zone_layout_preview` command loses its caller-selected `scene_id` and becomes active-scene-only, keyed by `zone_id` (§7.1) — the live-tree invariant holds across transports, not just REST. ### 1.6 Concurrency — one token @@ -102,7 +102,7 @@ The filters are implemented server-side, once, in the effect service. Today the ### 2.2 Deleted from the effects domain -`/effects/active`, `/effects/active/cover`, `/effects/current/controls`, `/effects/current/controls/{name}/binding`, `/effects/current/reset`, `/effects/pause`, `/effects/resume`, `/effects/stop`, `/effects/{id}/controls`, and the `/effects/screenshots` static mount. Live state lives at `/scene`; pause lives at `/output` (§4); stop is `/scene/clear`; control patches address real layers (§1.3). Binding *creation* returns with a first-class binding design; until then bindings are surfaced in the layer document and removable via `PatchControlsRequest.clear_bindings` (§1.6) — never stranded. `PauseEffectResponse`/`ResumeEffectResponse`/`ActiveEffectResponse` and the idle-sentinel shape die with the routes. +`/effects/active`, `/effects/active/cover`, `/effects/active/controls` (C1b's renamed spelling of `current`), `/effects/active/controls/{name}/binding`, `/effects/active/reset`, `/effects/pause`, `/effects/resume`, `/effects/stop`, `/effects/{id}/controls`, and the `/effects/screenshots` static mount. Live state lives at `/scene`; pause lives at `/output` (§4); stop is `/scene/clear`; control patches address real layers (§1.3). Binding *creation* returns with a first-class binding design; until then bindings are surfaced in the layer document and removable via `PatchControlsRequest.clear_bindings` (§1.6) — never stranded. `PauseEffectResponse`/`ResumeEffectResponse`/`ActiveEffectResponse` and the idle-sentinel shape die with the routes. ### 2.3 The sugar contract @@ -203,26 +203,43 @@ MCP stays a curated concierge, but every tool becomes an honest, thin projection ## 7. Amendments to Spec 76 waves -### 7.1 WS registry (amends wave 3.2 acceptance criteria) +### 7.1 WS registry — the 3.2 arc is COMPLETE; the residue transfers here -- Every `define_ws_topics!` entry declares a **backpressure class** — `Lossless` (awaited send), `LatestWins` (watch semantics), or `DropWithNotice` (try-send + `backpressure` message) — replacing today's three ad-hoc behaviors; the manifest states each topic's class. -- The **event vocabulary is generated**: the manifest's event list is emitted from `HypercolorEvent` plus the synthetic names (`resync_required`; `input_source_status_changed` is renamed to not collide with `input_source_changed`). -- **The event and hello vocabulary sheds the singleton model** (not left to 3.2), split cleanly across waves: **78.3** drops the hello payload's singleton `effect`/`active_preset_id` fields (`ws/protocol.rs:1153-1163` — both UIs consume `/scene` + events instead), renames `group_id`/`group_name`/`groups_revision` and `RenderGroupChanged` to zone vocabulary, and gives `EffectControlChanged` zone + layer identity; **78.4** — and only 78.4, so no intermediate revision has profile REST without profile WS — drops the hello `profile` field and deletes the profile events (`event.rs:849-864`). Golden fixtures update in the same PRs. -- **`zone_layout_preview` becomes active-scene-only** and zone-keyed; the caller-selected `scene_id` field is deleted (`ws/protocol.rs:615-622`, `ws/session.rs:1405-1429`, UI payload `ui/src/ws/preview.rs:300-325`). -- **Continuity is a documented contract:** the events channel does not replay across a socket gap; clients fold `connection_generation` into fetch epochs (the pattern the UI already implements). The manifest and SDK say so explicitly. -- **One error vocabulary:** WS-native errors serve `ApiErrorDetail` codes; the parallel four-file code set is deleted. -- **Named wire fixes in the migration:** `0x01` gains a `u16` zone count (u8 truncates at 255 zones silently); `screen_zones` gets its own config block (today it is paced by an *unsubscribed* channel's config); the default preview transport becomes the advertised v2; the `frames` JSON toggle is deleted (zero consumers); RPC tags `0x80`/`0x81` are deleted with their codec (implemented, golden-tested, and unreachable — the daemon discards all inbound binary, `ws/session.rs:544`). -- **The manifest is generated output** from the topic registry and codec metadata; hand-editing `protocol/websocket-v1.json` becomes impossible by construction (six confirmed drift points today). +Spec 76's 3.2 arc landed in full before this lock (registry contract PR #193, wire-stable daemon adoption #196, keyed wire migration #203). Rev 5 wrote this section as amendments to 3.2's acceptance criteria; that ship has sailed, so each item below is now either **struck as landed** (with the PR that shipped it) or **owned by a Spec 78 wave**. Re-verified against main at lock time: + +**Landed — struck from the workload:** +- The zone-vocabulary renames (`group_id`/`group_name`, `RenderGroupChanged`, WS message fields) shipped across C1b (#195) and 3.2c (#203); the 3.2c verifier's sweep confirms zero group-vocabulary survivors on any WS path. `groups_revision` inside persisted scenes remains the deliberate carve-out. +- RPC tags `0x80`/`0x81` and their codec were deleted by wave C1e (#191). + +**78.3 owns (verified still open at lock):** +- Drop the hello payload's singleton `effect`/`active_preset_id` fields (`active_preset_id` confirmed live at `ws/protocol.rs:912`) — both UIs consume `/scene` + events instead. Give `EffectControlChanged` zone + layer identity. +- `zone_layout_preview` becomes active-scene-only and zone-keyed; the caller-selected `scene_id` field (confirmed live at `ws/protocol.rs:523,528`) is deleted. +- Every `define_ws_topics!` entry declares a **backpressure class** — `Lossless` (awaited send), `LatestWins` (watch semantics), or `DropWithNotice` (try-send + `backpressure` message) — replacing today's ad-hoc behaviors; the manifest states each topic's class. +- The **event vocabulary is generated**: the manifest's event list is emitted from `HypercolorEvent` plus the synthetic names (`resync_required`; `input_source_status_changed` renamed to not collide with `input_source_changed`). +- **One error vocabulary:** WS-native errors serve `ApiErrorDetail` codes; the parallel code set is deleted. +- **Named wire fixes:** `0x01` gains a `u16` zone count (u8 truncates at 255 zones silently); `screen_zones` gets its own config block (confirmed still configless in the registry at lock — it is paced by an *unsubscribed* channel's config); the default preview transport becomes the advertised v2 (verify at wave start; 3.2c staged negotiation but the default was out of its scope); the `frames` JSON toggle is deleted (still live: `FrameFormat::Json` at `leptos-ext/src/ws/registry.rs:125`, JSON frame building at `ws/cache.rs:339`; zero consumers). +- **The manifest becomes generated output** from the topic registry and codec metadata (the generator at `python/scripts/generate_ws_protocol.py` still consumes the hand-maintained `protocol/websocket-v1.json`); hand-editing becomes impossible by construction. The golden suite's source-scanning tag census (shipped in 3.2) is the pattern to extend. + +**78.4 owns:** drop the hello `profile` field and delete the profile events (`event.rs:849-864`) — only 78.4, so no intermediate revision has profile REST without profile WS. Golden fixtures update in the same PRs. + +**Documented contract:** the events channel does not replay across a socket gap; clients fold `connection_generation` into fetch epochs (the UI already does). Writing this into the manifest and SDK is 78.3's job — neither states it today. ### 7.2 OpenAPI (amends wave 3.3) Wave 3.3's registration-helper catalog is implemented with `utoipa-axum`'s `OpenApiRouter` (utoipa 5.4 is already a workspace dep): route registration *is* spec registration, so drift is impossible rather than detectable. Acceptance criteria beyond Spec 76 §4.6: response schemas on every operation (today: zero catalog operations have any), true status codes (401/403/429 from security, 201/202 from the 17 handler sites, 101 for `/ws`), typed path parameters, and an extension hook so `ApiExtension` implementors contribute paths. The docs site's endpoint reference (`docs/content/api/rest.md`, 147 hand-written blocks) is generated from the emitted document; conceptual pages stay hand-written. -### 7.3 Wave C1b is consumed — it lands first, and 78 deletes what it renamed +### 7.3 Wave C1b is consumed — it landed, and 78 deletes what it renamed + +C1b landed as PR #195 (2026-08-17): `current`→`active` route renames, `groups`→`zones` in layer paths and zone payload fields, the display-face field rename with its shared-fixture fence. Its *vocabulary* renames are load-bearing groundwork this spec builds on; its renamed *routes* are transitional — wave 78.3 deletes `/effects/active-formerly-current/*` and the `/scenes/{id}` fine-grained tree wholesale (§2.2, §1.5). The affected client call sites flip twice (rename, then move to `/scene`); accepted and named here. Config resource routes were wave 4.3's job and landed (#190/#184). + +**Wave 3.1 status at lock — the rev-5 gating was overtaken by execution.** Rev 5 gated the `devices+scenes` batch behind 78.3/78.4/78.5 and `effects+library` behind 78.3/78.5; both dispatched before this spec was read into the program: -C1b was in flight when this spec converged (`nova/s76-c1b-naming-flip`: `current`→`active` route renames, `groups`→`zones` in layer paths and zone payload fields), so it **lands as planned** rather than being struck — aborting a nearly-finished wave costs more than the churn it saves. Division of spoils: C1b's *vocabulary* renames (zone payload fields, path grammar) are load-bearing groundwork this spec builds on; its renamed *routes* are transitional — wave 78.3 deletes `/effects/active-formerly-current/*` and the `/scenes/{id}` fine-grained tree wholesale (§2.2, §1.5). The affected client call sites flip twice (rename, then move to `/scene`); accepted and named here. Any C1b remainder that has not landed when 78.3 starts transfers to 78.3. Config resource routes stay wave 4.3's job. +- `layouts+displays+assets` landed (#202) — unaffected, as rev 5 predicted. +- `devices+scenes` landed (#204) **ahead of its gate**. Accepted churn, named here: 78.3/78.4 will delete or reshape some of its contracts (the scene activate/deactivate acknowledgements gain the §3.2 outcome fields; the fine-grained zone-route responses die with their routes). The promotion still pays for itself — the reshape now propagates by compiler across every client instead of by hand-hunting mirrors, which is the exact failure C1b demonstrated. +- `effects+library` was rescoped mid-flight to **Appendix A survivors only** (no promotions for `/effects/active|current/*`, pause/resume/stop acknowledgements, or `/library/presets/{id}/apply`); the template-catalog drift fix stays in. +- `drivers+system` remains unaffected except for the §5 renames and may run before or after 78's waves. -**Wave 3.1 re-targeting, made precise:** the `devices+scenes` batch starts only after 78.3/78.4/78.5 merge; the `effects+library` batch after 78.3/78.5. Both batches cover surviving routes only and **extend** the 78.1 contracts — they never redefine a type 78.1 shipped. The `layouts+displays+assets` and `drivers+system` batches are unaffected except for the §5 renames. +The standing rule survives the overtaking: 3.1 batches **extend** the 78.1 contracts and never redefine a type 78.1 ships. --- @@ -232,19 +249,19 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( **Error rendering rule for every wave:** every route this spec adds or rewrites renders the canonical `DomainError` envelope from its first commit. Spec 76 waves 2.1 and C1a are both landed (C1a merged 2026-08-16, PR #192), so the canonical envelope is the only error surface this spec ever touches; no new route may reintroduce a bespoke shape. -**Route-inventory strategy:** Spec 76 wave 0.7's REST matrix remains the sole executable current-state pin, updated in every route-changing PR. Wave 78.1 additionally commits Appendix A as a **target manifest** (data, not a live assertion). A convergence test asserting live router ≡ Appendix A (Appendix A's scope: `/api/v1` JSON routes + `/health`; document routes excluded) activates at the end of wave 78.5, with the config-route rows gated on Spec 76 wave 4.3. +**Route-inventory strategy:** Spec 76 wave 0.7's REST matrix remains the sole executable current-state pin, updated in every route-changing PR. Wave 78.1 additionally commits Appendix A as a **target manifest** (data, not a live assertion). A convergence test asserting live router ≡ Appendix A (Appendix A's scope: `/api/v1` JSON routes + `/health`; document routes excluded) activates at the end of wave 78.5 (the config-route rows landed with Spec 76 wave 4.3). **78.0 — bug strikes** (independent, immediate; each lands regardless of the redesign) - 0a. Server-side effect filters + `Query` extractor (fixes the CLI's dead flags; UI drops WASM filtering; MCP drops its private filter). - 0b. MCP phantom-parameter deletion + honest annotations + category enum from strum + docs tool-count fix. - 0c. TUI status shadow fix (render_loop fields; FPS shows again). -- 0d. Static assets, Swagger UI, and `/preview` exempted from bearer auth; constant-time key comparison. (The rest of the security findings route to Spec 77; these two are unambiguous now.) +- 0d. Static assets and Swagger UI exempted from bearer auth; constant-time key comparison. (`/preview` was in this list at rev 5; wave 3.2c deleted the page outright, #203. The rest of the security findings route to Spec 77; these two are unambiguous now.) **78.1 — contracts (Fable).** `/scene` document + zone/layer resource types (incl. layer-identity rules §1.4 and the single `revision` token §1.6), `/output` type, `PatchControlsRequest`, closed enums, tagged-union convention, segment naming and `zone` body fields in `types::api`, scene `layout_id`/`activation_brightness` fields, the §2.3 sugar contract, and Appendix A committed as the target manifest. **78.2 — output merge (worker, after 78.1).** `/output` GET/PATCH; delete pause/resume routes + types, `/settings/*`, `/output/power`; move audio devices to `/system/audio-devices`; MCP brightness/power onto the output service. Clients in-PR. -**78.3 — the scene tree (worker(s), after 78.1 and Spec 76 wave 2.3b).** Mount `/scene` over the domain services (validation-before-side-effects ordering per §2.3); delete `/effects/active|current/*`, `/effects/stop`, `/effects/{id}/controls`, `/scenes/active`, `/scenes/deactivate`, `/scenes/{id}/zones/*`, `/scenes/{id}/groups/*`, broadcast-media, `/library/presets/{id}/apply`; rewrite apply as sugar returning the zone resource; playlist advancement semantics; `/scene/clear`; the §7.1 WS vocabulary migration (hello fields, event renames, `zone_layout_preview`). Both UIs and CLI migrate in the same PR set (split by client if needed, route flip last). Docs checklist: `guide/first-session.md`, `guide/quick-start.md`, `api/cli.md`, `contributing/debugging.md`, `studio/zone-api-and-concurrency.md`, `troubleshooting/studio.md`, `effects/controls.md`. +**78.3 — the scene tree (worker(s), after 78.1; Spec 76 wave 2.3b landed).** Mount `/scene` over the domain services (validation-before-side-effects ordering per §2.3); delete `/effects/active/*` (C1b's renamed spelling), `/effects/stop`, `/effects/{id}/controls`, `/scenes/active`, `/scenes/deactivate`, `/scenes/{id}/zones/*`, broadcast-media, `/library/presets/{id}/apply`; rewrite apply as sugar returning the zone resource; playlist advancement semantics; `/scene/clear`; the §7.1 items 78.3 owns (hello singletons, `EffectControlChanged` zone+layer identity, `zone_layout_preview` keying, backpressure classes, generated event vocabulary and manifest, error vocabulary, named wire fixes, and stating the no-replay continuity contract in the manifest and SDK). Both UIs and CLI migrate in the same PR set (split by client if needed, route flip last). Docs checklist: `guide/first-session.md`, `guide/quick-start.md`, `api/cli.md`, `contributing/debugging.md`, `studio/zone-api-and-concurrency.md`, `troubleshooting/studio.md`, `effects/controls.md`. **78.4 — profiles fold (worker, after 78.3).** `/scenes/snapshot`; the §3.3 import; scene `layout_id`/`activation_brightness` activation behavior; delete `/profiles/*`, store, types, MCP `set_profile` + `hypercolor://profiles` (add `hypercolor://scenes`); `start_profile` → `start_scene` config rename; CLI `profile` commands become `scene snapshot`/`scene activate`. Docs checklist: `guide/profiles-and-scenes.md`, `docs/content/agents/*` (prompt-templates, resources-reference, tools-reference, workflows), `api/mcp.md`. @@ -254,7 +271,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( **Downstream check (blocks only the logical-devices deletion in 78.5):** confirm the internal repo (consumes OSS as the `oss/` submodule) and hypercolor-hass/Python SDK have no logical-devices dependency and nothing beyond §3.3's migration for profiles. Python client regenerates post-78; breaking changes named in release notes per doctrine. -**Interaction with Spec 76 sequencing:** 78.1 needs 2.0 + 2.1 (landed). 78.3 needs 2.3b and C1b (both in flight at spec time, §7.3). 3.1 batches gate per §7.3. 3.2/3.3 adopt the §7.1/§7.2 amendments. Phases 5/6/7 of Spec 76 are untouched. +**Interaction with Spec 76 sequencing (as of lock, 2026-08-17):** every 78 prerequisite has landed — 2.0, 2.1, 2.3b, C1b (#195), the full 3.2 arc (#193/#196/#203), config authority (#184/#190), and 2.4 (#200). 78.0 and 78.1 are dispatchable immediately. Wave 3.1 status per §7.3; the remaining `drivers+system` batch may run on either side of 78's waves. **Wave 3.3 executes after 78.5** — the OpenAPI catalog is born against Appendix A's 81 paths, never the pre-78 surface, using the §7.2 implementation. Spec 76's remaining phases interleave by surface: Phase 5 (types restructure) follows the 78 route waves, since both churn `types::api` and the restructure should move the post-78 shapes once; wave 4.4 (store batches) coordinates with 78.4 by store — the profile import owns `profiles.json`'s retirement, 4.4 owns the rest; Phases 6/7 (engine, benchmark-gated) are surface-disjoint and may run in parallel with any 78 wave. **Estimated effect:** 111 → 81 paths (Appendix A: 118 operations); net deletion 8 to 12k LOC across daemon + clients (dead handlers and types, the 14-route scene nesting, profiles domain, MCP second implementations, hand mirrors 3.1 no longer grows contracts for, fake-layer client code, WASM filtering, N+1 hydration). @@ -272,6 +289,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( ## 10. Review history +- **Rev 6 (2026-08-17, owner reconciliation + lock):** rev 5 converged one day before ten PRs landed (#195-#204: C1b, waves 3.2b/3.2c completing the 3.2 arc, 3.1a/3.1b, 2.4, and four CI/build fixes), so every §7 amendment was re-verified against main before lock. §7.1 rewritten from "amends 3.2 acceptance criteria" to a landed/owned split — the zone-vocabulary renames and RPC tag deletion are struck as shipped; hello singletons, `zone_layout_preview` keying, backpressure classes, generated vocabulary/manifest, error vocabulary, and the remaining wire fixes transfer to 78.3/78.4 with fresh file:line receipts. §7.3 updated: C1b landed; the 3.1 `devices+scenes` batch landed ahead of its rev-5 gate (churn accepted and named); `effects+library` rescoped mid-flight to Appendix A survivors. 78.0d drops the deleted `/preview` page. Wave 3.3 explicitly sequenced after 78.5. No reviewed contract changed; this is sequencing and status reconciliation only. - **Rev 5 (2026-08-16, coordination update):** wave C1b was discovered in flight (`nova/s76-c1b-naming-flip`, three commits plus active edits) after rev 4 converged. §7.3 reframed from *strike* to *consume*: C1b lands, its vocabulary renames become 78's floor, 78.3 deletes the transitional routes. Sequencing-only change; no reviewed contract altered. - **Rev 4 (2026-08-16):** round-3 convergence check verified 7/8 rev-3 fixes RESOLVED with one residual (the suffix allocator could re-suffix the import's own destination on crash-replay); fixed by excluding the destination id from collision checks. Round-4 micro-check: **VERDICT: PASS. Converged.** - **Rev 3 (2026-08-16):** round-2 codex verification pass: 15/20 round-1 findings ADDRESSED, 5 PARTIAL, 8 findings total (6 MAJOR, 2 MINOR), all adopted: every whole-layer PUT mints a fresh id (same effect included); apply sugars named in the guarded structural list; `clear_bindings` on `PatchControlsRequest` so persisted bindings are removable (nothing API-locked); idempotent import via UUIDv5-derived scene ids + numbered suffix allocation; `description` mapped and the brightness u8→f32 formula fixed; activation ordering with reported layout/brightness outcomes; profile WS removal pinned to 78.4 alone; Appendix A scoped to `/api/v1` JSON routes + `/health` with document routes excluded. Verdict trajectory: 20 → 8. @@ -282,7 +300,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( ## Appendix A — Normative route inventory (81 paths, 118 operations) -Scope: the JSON API under `/api/v1` plus `/health`. Document routes are deliberately outside the inventory and the convergence test: `/` (SPA), `/preview`, `/api/v1/docs`, `/api/v1/openapi.json`, and the `/mcp` mount are served pages and protocol endpoints, not API resources. Config rows land via Spec 76 wave 4.3; logical-devices rows are intentionally absent pending the §8 downstream check (re-add via spec amendment if the check fails). `⚡` marks routes whose handler is new or substantially rewritten by this spec. +Scope: the JSON API under `/api/v1` plus `/health`. Document routes are deliberately outside the inventory and the convergence test: `/` (SPA), `/api/v1/docs`, `/api/v1/openapi.json`, and the `/mcp` mount are served pages and protocol endpoints, not API resources (`/preview` was on this list until wave 3.2c deleted the page). Config rows landed via Spec 76 wave 4.3; logical-devices rows are intentionally absent pending the §8 downstream check (re-add via spec amendment if the check fails). `⚡` marks routes whose handler is new or substantially rewritten by this spec. | Path | Methods | Notes | |---|---|---| From f8184f62f60cfb32bb653fc861308e8fbb88b4b8 Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Mon, 17 Aug 2026 13:19:11 -0700 Subject: [PATCH 3/3] docs(specs): close the CodeRabbit lock-review findings on spec 78 Three were real contract gaps: the If-Match guarded set omitted two structural live-tree mutations (scene-level PATCH and the zone layout PUT), nothing stated what an apply or activation returns when a post-commit side effect fails (now: 200 with outcome fields, repair through the side effect's own route, never a blind re-apply since apply mints a fresh layer id), and PUT /scenes/{id} had no request contract (now: whole-document replace, path id authoritative, omitted optionals clear, today's partial UpdateSceneRequest dies). One was a genuine contradiction: set_effect.transition_ms with maximum 0 survived the phantom-parameter rule it sat beside; it is now deleted in favor of the same closed transition enum the REST sugar takes. Appendix A's scope sentence now covers the /ws row it already listed, and two prose lints are fixed. Co-Authored-By: Nova (Claude Fable 5) --- docs/specs/78-api-resource-model.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/specs/78-api-resource-model.md b/docs/specs/78-api-resource-model.md index 890ee1183..6aeae4173 100644 --- a/docs/specs/78-api-resource-model.md +++ b/docs/specs/78-api-resource-model.md @@ -18,7 +18,7 @@ **Non-goals:** no rendering behavior changes; no change to the domain service layer's design (Spec 76 §2 stands); no WS frame-layout redesign beyond the named wire fixes in §7.1; no new features beyond the affordances in §5 (which exist to delete client-side workarounds, not to grow scope). -**Doctrine:** lockstep, verbatim from Spec 76 §0. Pre-1.0, single user, in-repo clients ship in lockstep, generated clients regenerate. Every route deletion or rename lands with all in-repo consumers updated in the same PR — *consumers* includes the hand-written docs pages named per wave in §8, not just code. Pinned-shape tests are rewritten in the same PR. Persisted user state migrates forward once (§3.3) and is never destroyed. +**Doctrine:** lockstep, verbatim from Spec 76 §0. Pre-1.0, single-user, in-repo clients ship in lockstep, generated clients regenerate. Every route deletion or rename lands with all in-repo consumers updated in the same PR — *consumers* includes the hand-written docs pages named per wave in §8, not just code. Pinned-shape tests are rewritten in the same PR. Persisted user state migrates forward once (§3.3) and is never destroyed. --- @@ -30,7 +30,7 @@ An active scene always exists: the auto-managed default scene cannot be renamed ### 1.2 Routes -``` +```text GET /scene full live document (1.3) PATCH /scene scene-level fields: name (non-default only), unassigned_behavior POST /scene/deactivate return to the default scene; responds with the new /scene document @@ -74,7 +74,7 @@ All mutations route through `SceneMutation`/`commit_scene` (Spec 76 §2.3) — ` ### 1.6 Concurrency — one token -The scene document carries one wire version: `revision`, the commit generation assigned by the sequencer (Spec 76 §2.3). `GET /scene` serves it as `ETag`; **structural** mutations (zone create/delete/patch, member assign/unassign, layer create/delete/reorder/replace, clear, `PUT /scenes/{id}`, and both apply sugars — `/effects/{id}/apply`, `/effects/{id}/presets/{preset}/apply`) honor optional `If-Match` against it and return the canonical 412 envelope (`details: { current }`) on mismatch. The three-token zoo (`groups_revision`, `layers_version`, `controls_version`) disappears from the wire; internals keep whatever bookkeeping Spec 76 §6.5 needs. +The scene document carries one wire version: `revision`, the commit generation assigned by the sequencer (Spec 76 §2.3). `GET /scene` serves it as `ETag`; **structural** mutations (scene-level `PATCH /scene`, zone create/delete/patch, zone `PUT .../layout`, member assign/unassign, layer create/delete/reorder/replace, clear, `PUT /scenes/{id}`, and both apply sugars — `/effects/{id}/apply`, `/effects/{id}/presets/{preset}/apply`) honor optional `If-Match` against it and return the canonical 412 envelope (`details: { current }`) on mismatch. The three-token zoo (`groups_revision`, `layers_version`, `controls_version`) disappears from the wire; internals keep whatever bookkeeping Spec 76 §6.5 needs. **Control-value writes take no token.** `PATCH .../layers/{layer}/controls` is unguarded: the server applies value writes in commit order, and the layer-id lifecycle (§1.4) already fences staleness — a replaced layer 404s rather than absorbing a stale patch. (The guarded form was unusable in practice: a guarded slider drag self-invalidates every tick, which is why the TUI ships `If-Match`-free today, `tui/src/client/rest.rs:348-378`.) A PATCH naming a control key with an active input binding is rejected 409 `control_bound`, listing the bound keys — a manual write that would be silently overridden by the next sensor resolution is an error, not a race. The rejection is recoverable in the same shape: `PatchControlsRequest.clear_bindings` (§5.7) removes named bindings and applies the accompanying values in one atomic commit. Persisted bindings therefore stay fully manageable — surfaced read-only in the layer document, removable via `clear_bindings` — while the standalone binding-creation route dies until bindings return as a first-class feature (§2.2). @@ -84,7 +84,7 @@ The scene document carries one wire version: `revision`, the commit generation a ### 2.1 Catalog routes -``` +```text GET /effects ?category&audio_reactive&screen_reactive&input_reactive&q&include=controls,presets GET /effects/{id} GET /effects/{id}/cover @@ -112,6 +112,8 @@ Semantics, stated in the OpenAPI description verbatim: *replaces the target zone **Side effects are part of the contract, in this order:** (1) validate effect, zone, controls — any failure returns before any state changes (today power wakes before validation, `domain/effect.rs:190-191`; this spec fixes that ordering as part of the wave); (2) commit the scene mutation; (3) wake paused output; (4) resolve the effect's linked layout, if any. Response: the updated **zone resource** (canonical §1.3 shape, carrying the new layer's id), the applied transition, and the layout-link outcome (`{ layout_id, applied }`) — a linked-layout failure after a successful commit stays visible, not swallowed. `ApplyEffectResponse`'s bespoke shape is deleted. +**Post-commit failure is a 200, and repair is targeted.** Once step 2 commits, the response is `200` with the outcome fields reporting any step-3/4 failure (`applied: false` plus a message) — never an error envelope, because the resource state the response describes is real. Error envelopes are reserved for step-1 refusals, where nothing changed. Clients repair a failed side effect through that side effect's own route (`PATCH /output`, `POST /layouts/{id}/apply`) rather than re-applying: a retried apply is not idempotent (it mints a fresh layer id, §1.4). The same rule governs activation's layout/brightness outcomes (§3.2). + **Transitions are honest:** rev 1 accepts exactly `{ type: "cut" }` — the only transition the engine performs today (`domain/effect.rs:24-85` rejects everything else). The type is a closed enum that grows when the engine does; the request field does not accept aspirational values. **Preset apply is effect-scoped only.** `POST /effects/{id}/presets/{preset}/apply` delegates to this contract (it already does, `api/effects.rs:531-539`). `POST /library/presets/{id}/apply` — a parallel implementation with divergent semantics (hot-swaps controls without apply's power/layout behavior, `api/library/presets.rs:223-340`) — is **deleted**. Library keeps preset CRUD; applying happens through the one door. @@ -124,7 +126,7 @@ Semantics, stated in the OpenAPI description verbatim: *replaces the target zone ### 3.1 Collection routes -``` +```text GET /scenes list POST /scenes create (seeds a Primary zone, as today — api/scenes.rs:137-149) POST /scenes/snapshot create a scene from current runtime state (replaces profile-create) @@ -136,6 +138,8 @@ POST /scenes/{id}/activate `/scenes/active` is deleted (`GET /scene` replaces it); `/scenes/deactivate` moves to `/scene/deactivate`. +**`PUT /scenes/{id}` is whole-document replace, and the request says so.** The body is the full scene document — the same shape `GET /scenes/{id}` returns minus server-assigned fields (`revision`; ids for zones/layers the caller is creating may be omitted and are minted server-side, while supplied ids must belong to this scene). The path id is authoritative; a body `id` must match or the request is a 422. Omitted **optional** fields clear — replace means replace; a client that wants read-modify-write reads first. `If-Match` against `revision` per §1.6. Today's partial-update `UpdateSceneRequest` (`api/scenes.rs:97-263`) dies with this wave; partial semantics live on `PATCH /scene` (live tree) only. + ### 3.2 Scenes gain what profiles actually carried A profile is a pre-multi-zone snapshot: primary effect + controls + preset, display face assignments, a **named layout reference**, and a **brightness** (`profile_store.rs:25-54`). Folding profiles into scenes without losing capability means scenes own two new optional fields: @@ -163,7 +167,7 @@ Nothing dual-reads profiles at runtime after the importing run. ## 4. One output resource -``` +```text GET /output { power: "running" | "paused", brightness: f32 } PATCH /output partial: either or both fields ``` @@ -190,7 +194,7 @@ Receipts for the merge: `POST /effects/pause` is literally `set_output_power(Out MCP stays a curated concierge, but every tool becomes an honest, thin projection of a domain service: -1. **Phantom parameters are deleted** (a parameter exists only when its behavior does): `set_effect.devices`, `set_color.devices` (hardcode `target_zone: None` while advertising targeting, `tools/effects.rs:233,477`), `set_brightness.device_id` (reports `"scope": "device"` while setting global) and `.transition_ms`, `diagnose.device_id`/`.checks` (handler binds `_params`), `stop_effect.transition_ms`. The `set_effect.transition_ms` `maximum: 0` pattern is the house-correct "not yet." +1. **Phantom parameters are deleted** (a parameter exists only when its behavior does): `set_effect.devices`, `set_color.devices` (hardcode `target_zone: None` while advertising targeting, `tools/effects.rs:233,477`), `set_brightness.device_id` (reports `"scope": "device"` while setting global) and `.transition_ms`, `diagnose.device_id`/`.checks` (handler binds `_params`), `stop_effect.transition_ms`, and `set_effect.transition_ms` — its `maximum: 0` form honestly rejected nonzero values, but a parameter accepting only its no-op value fails the same rule. `set_effect` gains the §2.3 `transition` closed enum instead (rev 1: `cut` only), so MCP and the REST sugar share one transition vocabulary that grows when the engine does. 2. **`create_scene` routes through the domain scene service** — same Primary-zone seeding and `publish_scene_library_changed` as REST. Its fictional `trigger`/`profile_id` params (written to metadata nothing reads) are deleted, and the `setup_automation` prompt stops promising automation the daemon does not have. 3. **The second diagnostics engine is deleted** (`tools/system.rs:380-555`): the tool calls the same domain diagnostics REST uses, with one check vocabulary. 4. **One resolver policy.** Fuzzy resolution returns a structured ambiguity error listing candidates (the pattern `set_display_face` already implements) instead of first-substring-hit over a `HashMap`. @@ -261,7 +265,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( **78.2 — output merge (worker, after 78.1).** `/output` GET/PATCH; delete pause/resume routes + types, `/settings/*`, `/output/power`; move audio devices to `/system/audio-devices`; MCP brightness/power onto the output service. Clients in-PR. -**78.3 — the scene tree (worker(s), after 78.1; Spec 76 wave 2.3b landed).** Mount `/scene` over the domain services (validation-before-side-effects ordering per §2.3); delete `/effects/active/*` (C1b's renamed spelling), `/effects/stop`, `/effects/{id}/controls`, `/scenes/active`, `/scenes/deactivate`, `/scenes/{id}/zones/*`, broadcast-media, `/library/presets/{id}/apply`; rewrite apply as sugar returning the zone resource; playlist advancement semantics; `/scene/clear`; the §7.1 items 78.3 owns (hello singletons, `EffectControlChanged` zone+layer identity, `zone_layout_preview` keying, backpressure classes, generated event vocabulary and manifest, error vocabulary, named wire fixes, and stating the no-replay continuity contract in the manifest and SDK). Both UIs and CLI migrate in the same PR set (split by client if needed, route flip last). Docs checklist: `guide/first-session.md`, `guide/quick-start.md`, `api/cli.md`, `contributing/debugging.md`, `studio/zone-api-and-concurrency.md`, `troubleshooting/studio.md`, `effects/controls.md`. +**78.3 — the scene tree (worker(s), after 78.1; Spec 76 wave 2.3b landed).** Mount `/scene` over the domain services (validation-before-side-effects ordering per §2.3); delete `/effects/active/*` (C1b's renamed spelling), `/effects/stop`, `/effects/{id}/controls`, `/scenes/active`, `/scenes/deactivate`, `/scenes/{id}/zones/*`, broadcast-media, `/library/presets/{id}/apply`; rewrite apply as sugar returning the zone resource; playlist advancement semantics; `/scene/clear`; the §7.1 items 78.3 owns (hello singletons, `EffectControlChanged` zone+layer identity, `zone_layout_preview` keying, backpressure classes, generated event vocabulary and manifest, error vocabulary, named wire fixes, and stating the no-replay continuity contract in the manifest and SDK). Both UIs and the CLI migrate in the same PR set (split by client if needed, route flip last). Docs checklist: `guide/first-session.md`, `guide/quick-start.md`, `api/cli.md`, `contributing/debugging.md`, `studio/zone-api-and-concurrency.md`, `troubleshooting/studio.md`, `effects/controls.md`. **78.4 — profiles fold (worker, after 78.3).** `/scenes/snapshot`; the §3.3 import; scene `layout_id`/`activation_brightness` activation behavior; delete `/profiles/*`, store, types, MCP `set_profile` + `hypercolor://profiles` (add `hypercolor://scenes`); `start_profile` → `start_scene` config rename; CLI `profile` commands become `scene snapshot`/`scene activate`. Docs checklist: `guide/profiles-and-scenes.md`, `docs/content/agents/*` (prompt-templates, resources-reference, tools-reference, workflows), `api/mcp.md`. @@ -289,7 +293,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( ## 10. Review history -- **Rev 6 (2026-08-17, owner reconciliation + lock):** rev 5 converged one day before ten PRs landed (#195-#204: C1b, waves 3.2b/3.2c completing the 3.2 arc, 3.1a/3.1b, 2.4, and four CI/build fixes), so every §7 amendment was re-verified against main before lock. §7.1 rewritten from "amends 3.2 acceptance criteria" to a landed/owned split — the zone-vocabulary renames and RPC tag deletion are struck as shipped; hello singletons, `zone_layout_preview` keying, backpressure classes, generated vocabulary/manifest, error vocabulary, and the remaining wire fixes transfer to 78.3/78.4 with fresh file:line receipts. §7.3 updated: C1b landed; the 3.1 `devices+scenes` batch landed ahead of its rev-5 gate (churn accepted and named); `effects+library` rescoped mid-flight to Appendix A survivors. 78.0d drops the deleted `/preview` page. Wave 3.3 explicitly sequenced after 78.5. No reviewed contract changed; this is sequencing and status reconciliation only. +- **Rev 6 (2026-08-17, owner reconciliation + lock):** rev 5 converged one day before ten PRs landed (#195-#204: C1b, waves 3.2b/3.2c completing the 3.2 arc, 3.1a/3.1b, 2.4, and four CI/build fixes), so every §7 amendment was re-verified against main before lock. §7.1 rewritten from "amends 3.2 acceptance criteria" to a landed/owned split — the zone-vocabulary renames and RPC tag deletion are struck as shipped; hello singletons, `zone_layout_preview` keying, backpressure classes, generated vocabulary/manifest, error vocabulary, and the remaining wire fixes transfer to 78.3/78.4 with fresh file:line receipts. §7.3 updated: C1b landed; the 3.1 `devices+scenes` batch landed ahead of its rev-5 gate (churn accepted and named); `effects+library` rescoped mid-flight to Appendix A survivors. 78.0d drops the deleted `/preview` page. Wave 3.3 explicitly sequenced after 78.5. No reviewed contract changed; this is sequencing and status reconciliation only. A CodeRabbit round on the PR then closed seven findings at lock: the §1.6 guarded set gains `PATCH /scene` and zone `PUT .../layout`; §2.3 defines the post-commit 200-with-outcomes contract and targeted repair; §3.1 defines the `PUT /scenes/{id}` whole-document request; §6.1's `transition_ms` contradiction resolves into the shared `transition` enum; Appendix A's scope names the `/ws` row's path-only matching; plus two prose/lint fixes. - **Rev 5 (2026-08-16, coordination update):** wave C1b was discovered in flight (`nova/s76-c1b-naming-flip`, three commits plus active edits) after rev 4 converged. §7.3 reframed from *strike* to *consume*: C1b lands, its vocabulary renames become 78's floor, 78.3 deletes the transitional routes. Sequencing-only change; no reviewed contract altered. - **Rev 4 (2026-08-16):** round-3 convergence check verified 7/8 rev-3 fixes RESOLVED with one residual (the suffix allocator could re-suffix the import's own destination on crash-replay); fixed by excluding the destination id from collision checks. Round-4 micro-check: **VERDICT: PASS. Converged.** - **Rev 3 (2026-08-16):** round-2 codex verification pass: 15/20 round-1 findings ADDRESSED, 5 PARTIAL, 8 findings total (6 MAJOR, 2 MINOR), all adopted: every whole-layer PUT mints a fresh id (same effect included); apply sugars named in the guarded structural list; `clear_bindings` on `PatchControlsRequest` so persisted bindings are removable (nothing API-locked); idempotent import via UUIDv5-derived scene ids + numbered suffix allocation; `description` mapped and the brightness u8→f32 formula fixed; activation ordering with reported layout/brightness outcomes; profile WS removal pinned to 78.4 alone; Appendix A scoped to `/api/v1` JSON routes + `/health` with document routes excluded. Verdict trajectory: 20 → 8. @@ -300,7 +304,7 @@ Waves are atomic PRs from lane worktrees, every in-repo consumer updated in-PR ( ## Appendix A — Normative route inventory (81 paths, 118 operations) -Scope: the JSON API under `/api/v1` plus `/health`. Document routes are deliberately outside the inventory and the convergence test: `/` (SPA), `/api/v1/docs`, `/api/v1/openapi.json`, and the `/mcp` mount are served pages and protocol endpoints, not API resources (`/preview` was on this list until wave 3.2c deleted the page). Config rows landed via Spec 76 wave 4.3; logical-devices rows are intentionally absent pending the §8 downstream check (re-add via spec amendment if the check fails). `⚡` marks routes whose handler is new or substantially rewritten by this spec. +Scope: the `/api/v1` surface (JSON routes plus the one `/ws` upgrade endpoint, which the convergence test matches by path without asserting a JSON shape) and `/health`. Document routes are deliberately outside the inventory and the convergence test: `/` (SPA), `/api/v1/docs`, `/api/v1/openapi.json`, and the `/mcp` mount are served pages and protocol endpoints, not API resources (`/preview` was on this list until wave 3.2c deleted the page). Config rows landed via Spec 76 wave 4.3; logical-devices rows are intentionally absent pending the §8 downstream check (re-add via spec amendment if the check fails). `⚡` marks routes whose handler is new or substantially rewritten by this spec. | Path | Methods | Notes | |---|---|---|