feat(visual): align the schema, validator, and resolution engine with the 0.8 visual contract

Stage 0 of the five-stage plan in reviews/02-application-code-triage.md: the
shared pre-renderer alignment that has to land before slice 4d writes renderer
code, so the runtime foundation and Format Specification revision 0.8 cannot
drift while three slices are built on top of them. Nothing here draws.

The JSON Schema's `visuals` definition was a Phase 0 stub of `camera`,
`systems`, and `passes`. It is replaced by the real container - scene, layers,
systems, fields, camera, effects, and the exhibit-scope `automation` array that
17.3 previously rejected while 19.1 authorized. Fourteen new definitions cover
visual objects, paints, styles, transforms, behaviors, distributions, trails,
links, automation tracks, the spawn container, and visual components, and
`components.visual` is no longer an unconstrained object. Structural exclusions
carry the contract's own prohibitions: no `id` on a visual object, no `links` on
an emitter, no `trail` on a render object, and none of the four spawn-only
lifecycle names at a system's top level.

src/runtime/visual-contract.js is new and plays the role audio-contract.js
plays for sections 14-16: the primitive, system, behavior, field, distribution,
blend, filter, and post-effect vocabularies; the post-effect parameter tables
with their ranges and pass costs; the centralized ceiling table of 19.5 split
into authoring bounds and runtime ceilings; and matchVisualTarget, which
resolves a path against the four section 8.1 visual target families and
distinguishes a dangling reference from a target that exposes no capability.

src/runtime/visual-validation.js is new and validates the shared surface: the
visuals container, the scene model including the conditional viewport `fit`
rule, layers including the rule that a present layer map makes a system's
`layer` required, the common system fields and the `spawn` container,
procedural fields including the conditional noise `direction`, the post-effect
chain, and visual automation with its two declaration scopes, its automatable
registry, its loop modes, and its authoring bound. Type-specific system fields
are carried through unvalidated and are tightened by 4d, 4e, and 4f.

The resolution engine now exposes the four visual families. `target()` returns a
capability record with a per-family stage table, replacing the hardcoded
`namespace === 'buses'` test for the automation and modulation stages, so a
boolean system `visible` correctly has neither. Visual bases sample once from
the `visual` stream domain at the exhibit-scope instantiation boundary, fall
back to their documented defaults - including the scene center for an
unauthored camera position - and clamp through the shared 8.1 safety stage, so
a `focalLength` that resolves below 1 becomes 1 rather than dividing by zero.
`requireAudioStage` becomes `requireStage(path, stage)` with the old name kept
as its automation wrapper.

The validator's binding-target check is no longer a regex over two families. It
reports ERR_INVALID_REFERENCE for an undeclared layer, system, or effect index
and ERR_UNSUPPORTED_TARGET for everything outside the four rows - per-object
properties, field strengths, emitter rates - which is the distinction trace 14
of 17.16 and trace 4 of 19.7 both check.

exhibits/minimal-visual.xzbt is a new fixture exercising the aligned surface:
two layers, a graphic system with system-scope automation, a repeater over a
component, a particle system reading a declared field, a spawned emitter with
its spawn container, two post-effects, and three bindings reaching three of the
four target families.

test/phase4-visual-contract.test.mjs adds 29 tests. `npm test` goes from 102 to
131 passing with zero failures; no existing test changed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0162Jb1J36judZNT8fHabGVt
This commit is contained in:
2026-09-06 16:21:17 +00:00
co-authored by Claude Opus 5
parent 1bc49018f4
commit 6587d3e442
9 changed files with 3153 additions and 30 deletions
+3 -3
View File
@@ -1692,7 +1692,7 @@ A **visual system** is the addressable unit of the visual subsystem and the reso
**Lifecycle configuration lives in its own container.** Every spawned-instance lifecycle field — `lifetime`, `release`, `ownership`, `inputs`, and `cancelWithScenario` — is a field of the `spawn` object and is specified in 19.2. None of the five is a top-level system field under any lifecycle, and the container exists precisely so that they cannot collide with the type-specific fields that already carry two of those names: a `particles` system's top-level `lifetime` is the lifetime of one *particle* (18.2) and an `emitter`'s is the lifetime of one *emitted item* (18.4), while `spawn.lifetime` is the lifetime of the *system instance*; likewise an `emitter`'s or `repeater`'s component `inputs` (18.1) configure one created item, while `spawn.inputs` declares the parameters of the template itself. A spawned emitter therefore expresses instance duration and item duration independently, and a spawned `repeater` — which has no per-item lifetime at all (18.5) — still takes `spawn.lifetime` without contradiction.
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of those five names appearing at the top level of a system of either lifecycle. A system declared in `visuals.systems` without a `lifecycle` field is persistent.
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`. So are `release`, `ownership`, `inputs`, and `cancelWithScenario` at the top level of a system of **either** lifecycle: those four names exist only inside `spawn`. A top-level `lifetime` is `ERR_UNKNOWN_FIELD` too, with one exception that is the whole point of the container — the two system types whose own field tables declare one, `particles` (18.2) and `emitter` (18.4), where `lifetime` is the per-item duration and has nothing to do with the instance. A system declared in `visuals.systems` without a `lifecycle` field is persistent.
### 17.8 The `graphic` system
@@ -2936,7 +2936,7 @@ A visual system is either **persistent** or **spawned** (PRD 86). The discrimina
| `spawn.inputs` | object | No | `{}` | Parameters the template exposes to the `spawn` action, with the shape, types, and construct scope 18.1 fixes for visual components. Distinct from the component `inputs` of an object inside `emit` or `repeat` (18.1), which configure a created item. |
| `spawn.cancelWithScenario` | boolean | No | `true` | `false` requires `spawn.ownership: "persistent"`, or `ERR_UNSUPPORTED_TARGET`. |
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of those five names at the top level of a system of either lifecycle, per the strict unknown-field policy. This resolves the deferral 17.7 records, and the container is what keeps instance duration and item duration from sharing one key.
A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so are `release`, `ownership`, `inputs`, and `cancelWithScenario` at the top level of a system of either lifecycle, per the strict unknown-field policy; a top-level `lifetime` is `ERR_UNKNOWN_FIELD` except on `particles` and `emitter`, whose own tables declare it as the per-item duration (17.7). This resolves the deferral 17.7 records, and the container is what keeps instance duration and item duration from sharing one key.
**A template's own `inputs` reference scope.** Inside a spawned template — its type-specific fields, its objects, and its `automation` point values — a ValueSpec may read `{ "ref": "inputs.<parameter-id>" }` for a parameter declared in `spawn.inputs`, exactly as a component's `content` reads its own exposed parameters (18.1). The two scopes never overlap: a component's `content` sees the component's parameters, a template's body sees the template's, and a component instantiated inside a template sees only its own. `inputs.*` outside both is `ERR_INVALID_REFERENCE`, it is not a section 1.3 document namespace, and it grants no section 8.1 capability.
@@ -3249,7 +3249,7 @@ Automated, and executable without a display measurement:
5. Two tracks on one expanded visual target are `ERR_AUTOMATION_CONFLICT`; a behavior writing a channel a track targets on the same object is `ERR_AUTOMATION_CONFLICT`; the same behavior on an object with no track on that channel passes and composes by the 18.6 rule.
6. The authoring bound counts declared records once per declaration: `129` declared tracks or `2049` declared points is `ERR_VISUAL_LIMIT_EXCEEDED` at import, and `128` and `2048` pass however many times a template is later spawned. The live budget is separate: repeated spawns of a legal template up to the boundary succeed, the spawn that would cross it is refused **atomically** — no track allocated, no partial instance, no spawn ordinal consumed, `WARN_VISUAL_CEILING` under the 19.5 cadence, not a scenario failure — and disposing a live instance frees its records so a later spawn succeeds again. No admitted instance ever runs with a subset of its declared tracks.
7. Each of the four new section 8.1 rows takes binding, override, and — where the row permits — automation and modulation, through the shared pipeline, with masking and release behaving as 8.3 and 8.4 require; a `set` or `override` on any per-object, particle, emitter, behavior, or field property is `ERR_UNSUPPORTED_TARGET`, and trace 19 of 18.10 still passes unchanged.
8. A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so is any of `lifetime`, `release`, `ownership`, `inputs`, or `cancelWithScenario` at the top level of a system of either lifecycle; `spawn.cancelWithScenario: false` without `spawn.ownership: "persistent"` is `ERR_UNSUPPORTED_TARGET`; a `spawned` template is not drawn at activation and draws only after a spawn. A spawned `particles` system with both a top-level `lifetime` and a `spawn.lifetime` resolves them independently: its particles expire on the first and its instance releases on the second, and a spawned `repeater` with `spawn.lifetime` and no top-level `lifetime` is legal.
8. A `spawn` object on a `persistent` system is `ERR_UNKNOWN_FIELD`, and so are `release`, `ownership`, `inputs`, and `cancelWithScenario` at the top level of a system of either lifecycle, while a top-level `lifetime` passes on `particles` and `emitter` and is `ERR_UNKNOWN_FIELD` on `graphic` and `repeater`; `spawn.cancelWithScenario: false` without `spawn.ownership: "persistent"` is `ERR_UNSUPPORTED_TARGET`; a `spawned` template is not drawn at activation and draws only after a spawn. A spawned `particles` system with both a top-level `lifetime` and a `spawn.lifetime` resolves them independently: its particles expire on the first and its instance releases on the second, and a spawned `repeater` with `spawn.lifetime` and no top-level `lifetime` is legal.
9. Every permitted state transition of 19.2 is exercised and every forbidden one rejected; a `release` of `0ms` still passes through `RELEASING` for one tick; a second `remove` on a `DISPOSED` instance is a no-op.
10. Two spawns of one template resolve independently from `<system-id>#<spawn-ordinal>`, and one seed reproduces both exactly across two runs and across two frame rates.
11. A scenario-owned spawned instance is released at scenario cleanup; a `persistent`-owned instance with `cancelWithScenario: false` survives it; a cleanup that exceeds the 10.2 deadline force-disposes with `WARN_CLEANUP_FORCED`.