docs: raise the format specification to revision 0.9 and land the reconciliation

Revision 0.9 adds section 20, the cadence and event subsystems contract, and
carries two corrections the implementation forced. Section 6.1 now states that a
duration is the authored literal or a non-negative finite number already in
milliseconds, since a DurationSpec may be the resolved output of a ValueSpec or
a bounded TimeSpec, with the one documented exception of an automation track's
`at`, which 19.1 keeps literal-only so that point ordering stays decidable at
import. Section 20.11 documents the rejection of an undeclared input name in an
event action's `with` map as ERR_UNKNOWN_FIELD — the section's own convention
for that shape of error, replacing an invented code that appeared nowhere in the
registry.

The review record is committed with the code it describes: the two code triages
that found these defects, the reconciliation plan that sequenced the fixes, and
a follow-up debt record listing what was deliberately left open — the unchecked
JSON Schema artifact, degenerate path arcs, post-effect transient allocation,
the window-traffic fixture's per-copy wrap bounds, and the unstated
`ownership: "persistent"` value on a sound action. None of the five blocks phase
6; all five are written down rather than dropped.

Devlog entries are backfilled for the two milestones that had none: phase 3c
slice 2, the audio lifecycle and voice ceilings, and slice 4d, the renderer
core. The implementation status summary now reflects the reconciled state rather
than the in-flight one.

231 tests pass. tools/verify-spec-contract.py reports 46 declared diagnostic
codes with every used code resolving and its two long-standing unresolved
cross-references unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
This commit is contained in:
2026-09-06 21:54:09 +00:00
co-authored by Claude Opus 5
parent 3db1df058f
commit c4332363a9
30 changed files with 7892 additions and 137 deletions
@@ -0,0 +1,49 @@
_model: devlog-entry
---
schema_version: 1
---
title: Giving every voice an ending
---
date: 2026-09-05
---
author: Labyricorn
---
summary: The second slice of the audio engine is the one about endings — a seven-state lifecycle, an engine-owned release gain no author can reach, a proof that a one-shot sound actually stops, and a four-step eviction order for when the voice ceiling is reached.
---
tags: Audio, Lifecycle, Limits, Runtime, Phase 3
---
source_commit: 9ae2c760754477df86b01cede0dd2aaa28963921
---
body:
The [second slice of Phase 3c](https://git.labyricorn.com/Labyricorn/XZBT/commit/9ae2c760754477df86b01cede0dd2aaa28963921) is about endings. The first slice could build a sound graph and start it. This one has to answer what happens after that: when a sound is finished, who says so, what it costs while it waits to be cleaned up, and what the runtime does when more sounds are asked for than it can carry.
## A state machine, and the transitions that are refused
A voice moves through seven states — `CREATED`, `SCHEDULED`, `ACTIVE`, `RELEASING`, `FINISHED`, `DISPOSED`, and `FAILED`. Writing them down is the easy half. The half that matters is that every transition *not* in the permitted set raises `ERR_RUNTIME_FAULT` rather than being quietly tolerated, and that calling `stop()` on a voice that has already ended is a verified no-op rather than an error. A runtime that shrugs at an illegal transition will eventually let a disposed voice be resurrected by a stale reference, and the bug that produces is unreproducible.
## The gain the author cannot reach
Release is implemented as a gain node the engine owns, sitting between a sound graph's `output` and its destination bus. It starts at 1.0, and entering `RELEASING` ramps it linearly to zero over the release duration.
The important properties are negative ones: it cannot be bypassed, it cannot be addressed from a document, and it does not count against an author's node limits. Release is not an effect an author composes with — it is the runtime's guarantee that nothing stops with a click. An author who wants a longer fade declares `release` on the recipe, between `0ms` and `10s`, defaulting to `50ms`. Declaring it inside a component definition is `ERR_UNKNOWN_FIELD`, because release belongs to a sound instance and not to any part of one.
## Proving a one-shot ends
A `oneshot` sound has to be *determinably* finite, and the runtime computes that bound rather than guessing at it: a longest-path traversal over the expanded route graph, summing each node's documented contribution — an impulse's duration, a delay's decay bound, a reverb's predelay plus decay, a resonator's longest mode ring-down, an inlined component's own bound — plus the recipe's release.
The consequence is a rejection an author can act on. An oscillator or a noise source on an audible path in a one-shot sound has no bound, so the document fails validation with `ERR_INDETERMINATE_ONESHOT`. The remedy is to declare the sound `continuous` and stop it explicitly, which is what the author actually meant. Catching this at import is the whole point: the alternative is a "one-shot" that runs until the exhibit closes.
## What happens at the ceiling
One-shot and continuous voices have independent ceilings — 64 and 16 — and a voice occupies its slot from `CREATED` all the way to `DISPOSED`, not merely while it is audible. A `FINISHED` voice still costs a slot until it is cleaned up, which is the honest accounting.
When a request arrives at the ceiling, eviction runs in a strict order: dispose the oldest `FINISHED` instance; failing that, advance the oldest `RELEASING` instance to immediate completion and dispose it; failing that, and only for one-shots, evict the oldest `ACTIVE` one-shot by starting its release; failing that, refuse the request. Every eviction and every refusal emits `WARN_VOICE_LIMIT`.
Two rules in that list are deliberate and worth stating plainly. Nothing is ever hard-stopped — step 2 completes a release rather than dropping the node mid-sample, because a drop is a click, and a click is a defect. And the two pools never evict across each other: a burst of one-shots cannot silence the continuous bed an exhibit is built on. A refused one-shot is a sound the visitor does not hear; an evicted ambience is an exhibit that has visibly broken.
## The reconciliation that came first
Five inconsistencies in Section 16 were found and corrected *before* this code was written, in `e5ed468` — among them an eviction step that contradicted the no-hard-stop invariant, a recipe field table that had omitted `release` entirely, and an author remedy the contract elsewhere forbids. Implementing against a section that still disagrees with itself produces code that encodes the disagreement, and the disagreement then becomes very hard to see.
All 67 tests pass, and two clean builds are byte-identical. Traces 7, 9, and 10 of 16.11 are automated. What remains open is the part no test can close: no sound has yet been heard from a production build.
@@ -0,0 +1,47 @@
_model: devlog-entry
---
schema_version: 1
---
title: Frame plans before pixels
---
date: 2026-09-06
---
author: Labyricorn
---
summary: The first visual code that draws anything. The renderer is split in two so that a frame can be checked numerically without a display — an engine that emits a frame plan in device pixels, and a backend that turns a plan into drawing calls. Implementing it corrected two rules in the contract.
---
tags: Visuals, Renderer, Testing, Runtime, Phase 4
---
source_commit: 0af58da89dd6095fa9ca3ed546a2e48d7c7971b7
---
body:
Slice 4d is [the first visual code that draws](https://git.labyricorn.com/Labyricorn/XZBT/commit/0af58da89dd6095fa9ca3ed546a2e48d7c7971b7). Sections 17.4 through 17.14 of the contract are implemented, and the standalone artifact now has a stage canvas that renders a declared scene.
## Why the renderer is two pieces
The required traces of 17.16 ask things like: this scene point lands at this display point; this object's perspective factor is this number; this object's fog fraction is that one; these objects sort in this order. None of those questions can be answered by looking at a canvas. A canvas has pixels. The answers live one step earlier.
So `visual-engine.js` resolves an exhibit's objects once at their instantiation boundary, composes the scene-to-device chain, sorts by depth, applies fog and the compositing order, and emits a **frame plan** in device pixels. `visual-canvas2d.js` turns a plan into drawing calls and does nothing else. The split is not tidiness — it is what makes the traces runnable without a display, which is what those traces require. A frame plan carries the numeric oracles; the backend carries the pixels.
## Exactness as a contract property
Colors are parsed into exact components, and a color outside the four documented forms is `ERR_TYPE_MISMATCH` rather than a best-effort interpretation. That strictness has a specific payoff: per-object fog is arithmetic on those components, so two conforming renderers must produce the same fogged color. A permissive parser would make fog renderer-dependent, which is a thing you cannot un-promise later.
The depth rules are similarly narrow. Sortable units order farthest-first with the documented tie-break; a point's own `z` projects that point without making it a sortable unit; an object at or behind the eye is culled with no diagnostic at all, because that is a scene an author composed, not an error.
## Two rules the implementation corrected
Writing the code found two defects in the prose I had written.
The first is buffer accounting. Section 17.12 originally counted compositing buffers as *concurrently live*, which sounded right and was wrong: group nesting is already capped at 8, so that reading made 19.5's farthest-first shedding rule nearly unreachable. It is now **16 allocations per frame** — the per-frame "pass budget" reading that 17.5 had been using since it first appeared. The shedding rule now actually engages, and the sixteen grants go nearest-first so refusals fall on the far content, each one diagnosed under the 19.5 cadence rather than dropped in silence.
The second is arc sweep. The prose always described `350°` to `10°` clockwise as a 20-degree sweep, but the rule as written normalized the wrong quantity and produced 340 degrees the long way round. It now normalizes the *signed* delta, and the bound is checked before normalization rather than after.
Both corrections came from implementing the section rather than from re-reading it. That is an argument for writing the contract first *and* for not treating it as finished until something has been built against it.
## What this slice does not do
`visual-validation.js` gains the whole object tree — fifteen object types and their declared fields, no `id` and no `layer` on an object, a mask only on a group and only naming its own child, path legality, spline modes and point counts, gradient stop order, and every authoring limit of 19.5 that applies to a drawn object.
Twenty-two new tests cover traces 1 through 13 of 17.16 plus buffer shedding and the backend's call order; the suite goes from 131 to 153. No procedural system runs, no automation track advances, and no post-effect executes — 4e and 4f own those. Traces 15 and 16 stay user-observed, because what they check is whether it looks right, and nothing automated can answer that.