# control-surface-schema spec delta ## ADDED Requirements ### Requirement: ControlSurface root document `@suaveplan/control-surface-schema` SHALL export a `ControlSurface` type with: `id` (uuid v7), `name`, `version` (semver matching `CURRENT_SCHEMA_VERSION`), `createdAt`, `updatedAt`, `skin`, `root: ControlNode`. Every field is validated by the Zod schema. #### Scenario: Empty surface validates - **WHEN** `validateSurface({ id, name: "x", version: CURRENT_SCHEMA_VERSION, createdAt: 0, updatedAt: 0, skin: { id: "default" }, root: { type: "group", id: "root", layout: { kind: "absolute" }, transform: { position: { x:0, y:0 } }, children: [] } })` is called - **THEN** it returns `{ surface }` with no error #### Scenario: Missing fields rejected - **WHEN** `validateSurface({})` is called - **THEN** it returns `{ error }` with code `CONTROL_SURFACE_SCHEMA.INVALID_DOCUMENT` and Zod issues attached **Verified by:** `src/validate/validate.test.ts` ### Requirement: ControlNode discriminated union `ControlNode = Group | Control`. Validators distinguish by `type` field; cross-validation rejects controls inside controls or groups with mixed-type children that don't match the layout kind. #### Scenario: Nested groups validate - **WHEN** a surface contains a Group containing two Groups each containing three Controls - **THEN** validation succeeds and `walkSurface(surface, visitor)` visits 9 nodes in DFS order **Verified by:** `src/walk/walk.test.ts` ### Requirement: 18 ControlKind variants `ControlKind` SHALL be a closed union of 18 values: `knob`, `slider-v`, `slider-h`, `button-momentary`, `button-latching`, `switch-2`, `switch-3`, `led`, `digital-display`, `meter-vu`, `meter-peak`, `meter-spectral`, `xy-pad`, `jog-wheel`, `piano-key`, `drum-pad`, `text-label`, `image`. The validator rejects unknown kinds. #### Scenario: Unknown kind rejected - **WHEN** a Control has `kind: "not-a-thing"` - **THEN** `validateSurface` rejects with `INVALID_DOCUMENT` **Verified by:** `src/validate/validate.test.ts::"unknown ControlKind"` ### Requirement: Six Binding variants `Binding` SHALL cover `pluginParam`, `transport`, `midiOut`, `midiIn`, `dmx`, `compound`. The `compound` variant wraps an array of other bindings (no nested compounds — flat-only) so one knob can drive a plugin parameter, a MIDI CC, and a DMX channel simultaneously. #### Scenario: pluginParam binding round-trips through validate + JSON - **WHEN** a Control has `binding: { kind: "pluginParam", pluginId: "p1", paramPath: "channels/0/volume", curve: "log" }` - **THEN** `JSON.parse(JSON.stringify(validateSurface(surface).surface))` deep-equals the original #### Scenario: Nested compound rejected - **WHEN** a `compound` binding contains another `compound` in its `bindings` array - **THEN** validation rejects with `INVALID_DOCUMENT` **Verified by:** `src/validate/validate.test.ts::"binding variants"` ### Requirement: GroupLayout — 4 variants `GroupLayout` SHALL cover `grid`, `absolute`, `stack`, `free3d`. `free3d` is the only layout that admits z-axis transforms; the validator rejects nonzero `transform.position.z` under any other layout. #### Scenario: 2D layout rejects z-transform - **WHEN** a Group has `layout: { kind: "grid", columns: 4, rows: 4, gap: 8 }` and a child with `transform: { position: { x:0, y:0, z:1 } }` - **THEN** validation rejects with `INVALID_DOCUMENT` **Verified by:** `src/validate/validate.test.ts::"layout z-axis discipline"` ### Requirement: Schema versioning + forward migration `upgradeSurface(unknown)` SHALL accept any prior version registered in the migration chain and migrate forward to `CURRENT_SCHEMA_VERSION`. Unknown versions reject with `UNSUPPORTED_VERSION`. #### Scenario: Upgrade 0.1.0 → 0.2.0 (illustrative) - **WHEN** a v0.1.0 document is passed to `upgradeSurface` and a registered `0.1.0 → 0.2.0` migration exists - **THEN** the returned document validates against the current schema #### Scenario: Unknown version rejects - **WHEN** `upgradeSurface({ version: "99.0.0", ... })` is called - **THEN** it throws `SchemaError("CONTROL_SURFACE_SCHEMA.UNSUPPORTED_VERSION")` **Verified by:** `src/upgrade/upgrade.test.ts` ### Requirement: Duplicate-id detection `validateSurface` SHALL detect duplicate `id` values across the entire node tree and reject with `DUPLICATE_ID` carrying the offending ids on `context.ids`. #### Scenario: Two controls with the same id rejected - **WHEN** a surface has two `Control` nodes with `id: "x"` - **THEN** validation rejects with `DUPLICATE_ID` listing `["x"]` **Verified by:** `src/identity/identity.test.ts` ### Requirement: Binding-reference cross-check When a binding references a `pluginId` that is not present in the surface's optional `pluginCatalog` (passed alongside), validation SHALL reject with `BINDING_REFERENCE_MISSING` carrying the missing reference on context. #### Scenario: Reference missing plugin - **WHEN** `validateSurface(surface, { pluginCatalog: new Set(["p1"]) })` is called and a control references `pluginId: "p99"` - **THEN** validation rejects with `BINDING_REFERENCE_MISSING` carrying `{ missing: "p99" }` **Verified by:** `src/identity/identity.test.ts::"binding ref"` ### Requirement: walkSurface DFS traversal `walkSurface(surface, visitor)` SHALL visit every node depth-first, passing the node + its parent stack (root-first). Mutation during traversal is undefined behaviour (visitor is read-only by contract). #### Scenario: DFS order - **WHEN** a surface has root → A → [B, C → [D]] → E - **THEN** visitor is called with node ids in order `["root", "A", "B", "C", "D", "E"]` **Verified by:** `src/walk/walk.test.ts`