Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
98338cf
Batch 13 docs: placeholder specs from the owner's answers (TBD29 sour…
tamashi095 Oct 15, 2026
b74fe19
Keep a one-shot spectrum window out of the continuous stream (#1616)
tamashi095 Oct 15, 2026
0d8e0e5
Refuse a source with more than two channels on both hosts (TBD29)
tamashi095 Oct 15, 2026
0642b25
Copy a successor's armed taps from the mask the core prepared (#1679)
tamashi095 Oct 15, 2026
4b11f0b
Merge remote-tracking branch 'origin/main' into codex/controls-batch-…
tamashi095 Oct 15, 2026
64b8cca
Merge remote-tracking branch 'origin/main' into codex/controls-batch-…
tamashi095 Oct 15, 2026
a66f6fa
Boot an empty session on the core and the C ABI (TBD30, core part)
tamashi095 Oct 15, 2026
25830e4
Merge branch 'codex/controls-batch-13ak' into codex/controls-batch-13aw
tamashi095 Oct 15, 2026
f619180
Run the automation row rule when a row's activity changes (#1631)
tamashi095 Oct 15, 2026
0a4c356
Boot an empty session on the browser (TBD30, browser part)
tamashi095 Oct 15, 2026
6fda14f
Batch 13 docs: successor TBD31, designate the browser master strip af…
tamashi095 Oct 15, 2026
167fc10
Make a send's follows_mute and a strip's matrix_mode non-automatable …
tamashi095 Oct 15, 2026
e4e78bb
Merge branch 'codex/controls-batch-13ak' into codex/controls-batch-13a
tamashi095 Oct 15, 2026
4434ea8
Merge branch 'codex/controls-batch-13aw' into codex/controls-batch-13a
tamashi095 Oct 15, 2026
8fa4543
Batch 13a integration: the simd128 boot memory is one page smaller af…
tamashi095 Oct 15, 2026
de4618e
Number the batch 13a placeholders: TBD29 to TBD31 are #1681 to #1683
tamashi095 Oct 15, 2026
9d12d49
Batch 13a review fold: no prose pin on an exclusion, a true epoch com…
tamashi095 Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude/skills/author-session/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ target it.
## Core shapes and vocabularies

- A source has exactly `id`, `content`, `channels`, `bit_depth`, and `frames`. `content` is
`blake3:` plus 64 lowercase hex digits; `frames` is a nonzero decimal string; `bit_depth` is
`16`, `24`, or `"32f"`.
`blake3:` plus 64 lowercase hex digits; `channels` is `1` or `2` (a source is mono or stereo; any
other count is refused with `source.channels.unsupported`); `frames` is a nonzero decimal string;
`bit_depth` is `16`, `24`, or `"32f"`.
- An output is `{"id":"main-out"}`.
- A track is `id`, `source_id`, `left_source_channel`, `right_source_channel`, `builtins`,
`console`, `inserts`, `fader`, then either `pan` with `left`, `right`, `smoothing_samples`, or
Expand Down Expand Up @@ -118,7 +119,7 @@ left/right/both, while matrix IDs 7-10 accept `both` only, and only on a strip i
`console` and `inserts`, `control` is the effect's parameter ID (`0` is its bypass). The session
validator checks only that the owner, slot or insert exists; whether the row is automatable (not
rebuild-only like `delay_samples` 11 or an EQ band's `enabled`, not derived, not a monitor or
setting row), takes the lane, unit and values is `control.address.unknown`,
setting row, and not a send's `follows_mute` (route row 7) or a strip's `matrix_mode` (own row 1): live controls that a ruling keeps out of automation, #1632), takes the lane, unit and values is `control.address.unknown`,
`control.automation.target`, `control.inactive`, `control.lane` or `control.domain` at prepare-effects. A
parameter does not have to be declared on the strip. Stored automation is inert today: it authors
and round-trips, and renders nothing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,47 @@ continuous window.
## Size

Half a day, one sonnet-high implementer.

## Decision record: one-shot window out of the continuous stream (batch 8)

**Decision: option A.** `try_read_continuous_record` (`crates/host-core/src/spectrum.rs`) loads
the stream epoch once before its bounded pop loop and discards every popped record whose
`stream_epoch` is not that epoch. Render is unchanged on the publish side. The pop loop stays
inside the existing `REALTIME_POLICY_BEGIN/END` region and keeps its bound (`available`, frozen at
entry and capped at `SPECTRUM_RESULT_SLOTS`); a discard spends one pop of it, so a read that
meets only stale records answers `Warming` or `Pending`, never a refill chase.

**What the check covers.** A one-shot record (epoch 0, below every continuous epoch) that render
pushes after the switch's drain and before its mode store; a record of an epoch a restart
(`restart_continuous`, control) ended while render was publishing; and a record of an epoch a
render failure ended. Only control changes the epoch outside a failure, and a failure moves it
forward before render publishes anything of the new epoch, so one load covers the loop.

**Departure from the text (code is newer than the spec).** #1609 left an invalidation rule in the
same loop: `invalidated`/`invalidated_epoch` discarded one record with an epoch at or below the
failed one. Every failure bumps the epoch, so the new epoch check subsumes that rule and discards
all such records, not one. Keeping both left a dead flag, so the two fields, their stores in
`continuous_fail` and `commit_continuous`, and the guard arm are removed. Render does two fewer
atomic stores on a failure; nothing else on the render side moved.

**Gates.**
1. `a_one_shot_record_that_lands_after_the_mode_switch_is_never_a_continuous_window` arms a
capture, completes a one-shot window, switches through the collection's replacement path
(`begin_continuous(.., true)`), pushes the epoch-0 record as render's block end would after the
drain, and shows the read answers `Warming` and the next window is epoch 1, sequence 0.
`a_record_of_an_older_epoch_is_discarded_by_the_continuous_read` does the same for an epoch-1
record after a restart to epoch 2. Both are red with the epoch arm disabled (as are five
failure tests that formerly relied on the invalidation rule).
2. The loom model gains `spectrum_mode_switch_loom` (same module, so the CI step filter
`spectrum_handoff_loom` runs it): render completes and publishes an armed one-shot window while
control replaces the capture with a continuous stream; every interleaving of the push against
the drain and the mode store must return only windows of epoch 1 (a continuous window of the
same samples is a correct result when render already saw the new mode). `mode` is a plain
`std` atomic, but render loads it before its first modelled operation and control stores it
after its last, so loom still places the push before, between and after the drain; the model is
red with the epoch arm disabled.
3. `cargo test --locked -p host-core --all-features`, the loom step, `host-web` with
`test-support`, `realtime-policy`, `check-bench-policy.sh`, clippy `-D warnings` (no features,
all features) and rustdoc `-D warnings` are green.

No measured numbers: no benchmark was run (feature sprint mode).
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,32 @@ transaction also retargets or removes the entry. Found by reading, not reproduce
## Size

Half a day.

## Decision record: #1631 (batch 8)

Shipped. Anchors moved: step 2 sits at `crates/host-core/src/live_delta.rs` ~344 (the spec said
371-376); the rule is otherwise as filed.

- `controls::activity_changed(current, next)` (`crates/controls/src/diff.rs`, re-exported from
`lib.rs`): true when the two models do not list the same strips in the same order, or when a strip's
`matrix_or_pan` variant differs. A later value-dependent row joins it there.
- Classifier step 2 runs `automation_diagnostics(next, ..)` when the automation table changes or
`activity_changed` holds. A refusal is `LiveRebuild::AutomationTarget`; the rebuild's preparation
refuses it `control.inactive` at `$.automation[id=<id>].target` before any commit.
- Departure (test shape): the browser parity gate is a separate test, not a script row. A rebuild
route refusal is a non-ok entry result with the diagnostic in the last-error buffer on both hosts
(browser 10, C ABI 5: each host numbers its results), not a typed verdict frame, and the script
harness asserts an ok result. `CAbi::send_status` and `CAbi::last_error` were added to
`control_parity.rs` for it (the diagnostic is read before the reliable-lane drain, which clears it).

Gates and tests:
- Gate 1 and 3 (classifier): `host-core/tests/live_delta.rs::a_mode_switch_that_strands_an_automation_needs_a_rebuild`
(pan entry then matrix switch, and the reverse, give `AutomationTarget`; another strip's entry and a
strip with only a fader entry stay live).
- Gate 1 and 2 (C ABI): `capi/src/runtime/live_tests.rs::set_control::a_mode_switch_under_a_stored_pan_entry_is_refused_before_any_commit`
(refused, diagnostic names `control.inactive` and the path, state unchanged; switch plus
`SetAutomationTarget` commits; an entry-free strip switches live).
- Gate 1 and 2 (parity): `hosts/host-web/tests/control_parity.rs::a_mode_switch_under_a_stored_pan_entry_is_refused_alike_on_both_hosts`
at all four rates (same diagnostic bytes; the same expected revision then commits the switch with
the retarget, `model_only` because the switch keeps the effective 2x2).
- Red on revert: all three fail with step 2 restored to the table-only condition.
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,29 @@ and a mode, not performance values.
## Size

Half a day.

## Decision record: #1632 (batch 13a)

Shipped as written (D1 to D4). `ControlDescriptor` gains `automation: AutomationRule` (`BASE` and every
other table: `Computed`); `automatable()` is the computed rule and `automation == Computed`.
`matrix_mode` ("a mode, not a performance value") and `follows_mute` ("a routing setting, not a
performance value") declare `AutomationRule::Excluded(&AutomationExclusion { reason, reopen: "an
owner request" })`. The row rule (`controls::automation_diagnostics`), preparation, the classifier
and `session-validator` need no new code: all four already refuse `!automatable()` as
`control.automation.target`. The wire check (an implication) is unchanged.

Departure from the text: the exclusion is held by reference (`Excluded(&'static AutomationExclusion)`),
not inline. A listing copies one descriptor per row, and an inline pair of strings measurably grew
the live-only `control_command` heap (#1615 gate: first peak 81,451 against a ceiling of 78,000);
the reference costs one word per row (first peak 77,347, steady 72,588; ceilings 78,000 and 73,000
hold).

Gates and tests: `crates/controls/tests/automation.rs`
`the_row_decides_what_can_be_automated` (both rows refused at `.target`),
`crates/controls/tests/list.rs` `automatable_is_exactly_the_live_model_value_rows` (previous list
less the two names) and `follows_mute_and_matrix_mode_are_live_and_not_automatable`. Re-pins: the
host-web `tests/fixtures/control-list.json` (`automatable` of the five listed rows; reason
"#1632 makes follows_mute and matrix_mode non-automatable"), `sdk/assets/miso-engine-v1-parameter-metadata.json`
and `sdk/src/generated/controls.ts` (regenerated). No conformance corpus digest moved. No checked-in
automation fixture used either row. #1631 interplay: it still covers a pan entry on a strip
switched to matrix; a `matrix_mode` entry is now refused at the target before activity is judged.
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ match, so one function decides which tap a binding arms.
## Size

A few hours.

## Decision record: expose the armed tap mask (batch 13a)

- `EffectObservationHandle` is defined in `crates/effect-compiler/src/prepare.rs` (host-core re-exports it), so the field `armed_taps: u32` (bit `i` = `descriptor.observations[i]`) was added there. `attach_effect_observation` sets a bit exactly when `ObservationLane::arm_prepared` armed that tap, so the mask is the lane's own arming, not a second match.
- host-web's `build_plan_shape` copies `handle.armed_taps` into `observation_armed`; the duplicate binding match and its `bindings` parameter (and the caller's lookup) are deleted. An arm for a tap past bit 31 has no bit, as before.
- Gate: `tests::structural_apply::the_browsers_armed_bits_are_the_mask_host_core_prepared` (a console slot `comp` and insert `comp` on two tracks, one insert and one console tap armed, restart rebuild; every slot's word equals the handle's mask; 4 observed, 2 armed). It goes red when the copy is replaced by 0. The #1584 D6b browser tests pass unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Refuse a source with more than two channels

Owner answer (2026-10-15): sources are mono or stereo only. Decision 17 programme. Code anchors verified on `4be10cd67` (PR #1680). Issue number: assigned at filing (written here as #1681). Feature sprint mode (owner, 2026-10-13): one implementer, normal tests, a batch review.

## Product outcome

A session whose source declares more than 2 channels is refused at session load with a typed
error, the same on both hosts, and the browser's source staging is sized for 2 channels (about
1 KB instead of the 130 KB the grammar's 255-channel domain needs).

## Context

- `session::SOURCE_CHANNELS_MAXIMUM` is `u8::MAX` (`crates/session/src/model.rs:217`), the
domain of `Source::channels`; nothing narrower is validated.
- #1562b's review fold (B1) sized the browser's source PCM and plane staging by that maximum
(`hosts/host-web/src/lib.rs`, `project_buffers`), so a rebuild can add any valid source.
- Product rule (dual mono by source file): only mono source files get dual-mono processing; stereo
files stay stereo. No source has more than 2 channels.

## Decisions frozen for this slice

- **D1.** One rule in the session validator: `channels` is 1 or 2; any other value is refused with a
typed code (for example `session.source.channels`), at load and in a structural edit's
validation, so both hosts refuse it the same way.
- **D2.** A launch constant (for example `SOURCE_CHANNELS_LAUNCH_MAXIMUM = 2`) in the session crate
is the one source of the limit; the browser staging, the C ABI's source checks and the SDK
builder read it (the SDK through its generated constants).
- **D3.** The browser's staging is sized by D2.

## Objective gates

1. A session with a 3-channel source is refused at load on both hosts with the typed code; a
structural edit that adds one is refused before commit; a parity row.
2. Mono and stereo sources load, feed and render as today (existing tests green).
3. Browser `sourcePcmStagingBytes` falls back to the 2-channel size; `expected.json` and
`retained_ceilings.rs` re-pinned from measurements; the boot budget passes.
4. `cargo test --locked -p session -p session-validator -p capi -p host-web --features
host-web/test-support`; SDK checks; the author-session skill and `SESSION_SCHEMA_V1.md` state
the limit.

## Authorized paths

- `crates/session/src/{model.rs,validate.rs,diagnostic.rs}`, `crates/session/tests/`,
`hosts/host-web/src/lib.rs` (the staging size), host-web tests and `expected.json` rows,
`crates/capi` tests, `sdk/` generated constants and evals, `docs/SESSION_SCHEMA_V1.md`,
`.claude/skills/author-session/SKILL.md`, this spec's record.

## Dependencies

- #1562 (B1 staging).

## Size

Half a day.

## Decision record: refuse a source with more than two channels (batch 8)

**Shipped.**

- D1. One rule in `session::validate`: `Source::channels` is 1 or 2. Zero and more than two are one
rule with one new typed code, `DiagnosticCode::SourceChannelsUnsupported`
(`source.channels.unsupported`, path `$.sources[i].channels`). It replaces the old
`capacity.zero` for `channels == 0` (frames keep `capacity.zero`). Every load and every
structural edit already validates the candidate model through this function, so both hosts
refuse alike.
- D2. `session::SOURCE_CHANNELS_LAUNCH_MAXIMUM = 2` replaces `SOURCE_CHANNELS_MAXIMUM` (255,
deleted: nothing else read it). Readers: the validator, the browser's staging
(`hosts/host-web/src/lib.rs` `project_buffers`), the C ABI's source submission
(`crates/capi/src/ffi.rs`: the plane-count bound and the stack plane array, 255 to 2, through
`host_core::SOURCE_CHANNELS_LAUNCH_MAXIMUM`, a re-export beside `LAUNCH_SAMPLE_RATES`) and the
SDK (new ABI-layout constant `maximumSourceChannels`, written by `parameter-metadata`, read by
the session builder).
- D3. The browser's source PCM and plane staging are sized by the constant: `sourcePcmStagingBytes`
130,560 to 1,024. The ID staging keeps its 127 bytes (#1562b B1).

**Departure.** `HostPrepareCaps::maximum_source_channels` stays `None` everywhere: the validator is
the one rule, a second per-host cap would be a second place to drift.

**C ABI.** A chunk with three or more planes is now `RESULT_INVALID_ARGUMENT` at the boundary
(it was `source.channels.mismatch` from the facade); the row "three planes for a stereo source" in
`source_rejections_reach_the_c_host_as_their_own_diagnostic` became "one plane for a stereo source"
and a separate assertion covers three planes.

**Fixtures and corpora.** No fixture under `fixtures/` declares a source of more than two channels.
Tests that did: `host-web` `three_source_session` (alpha, 4 channels, now 1) and
`plan_exchange_threads` (a six-channel source, now a stereo one; renamed
`a_rebuild_adds_the_longest_id_and_feeds_the_new_source`; #1562's record still names the old test
and is history). `hosts/host-web/tests/stem-pump-v1.mjs` pumps a 3-channel PCM file through the
stem pump: it is the pump's own channel-generic test and builds no session, so it stays.

**Gates.**

1. `control_parity::both_hosts_refuse_a_source_of_more_than_two_channels_alike`: a three-channel
source is refused at load by the C ABI compile and the browser boot with identical diagnostics
containing `source.channels.unsupported`; mono and stereo load; a structural `UpsertSource` of
three channels is refused on both with identical reply bytes, the code
`source.channels.unsupported` and the revision unmoved (four rates). Session crate:
`diagnostic_parity` (`source-channels-zero`, `source-channels-three`) and `invalid_matrix`
(`source_identity_and_shape_category_has_22_distinct_cases`).
2. Mono and stereo unchanged: the existing host-web, capi, control-plane and conformance suites.
3. Browser `expected.json`: `sourcePcmStagingBytes` 1,024, `bridgeRetainedBytes` ceiling 1,812,352
to 1,667,648 (measured 1,611,330 on the simd128 module; 562,754 beyond the capture, plus 10 %,
rounded up to 64), the three `memoryBytes` 3,014,656 to 2,883,584. `retained_ceilings.rs`
`bridge_retained_bytes` 787,968 to 641,024 (measured 1,631,294 x86-64, 582,718 beyond the
capture). Boot high-water growth (`check-web-boot-budget.mjs`): 20,447,232 bytes (before:
20,447,232).
4. SDK: `builder-evals` "a source of other than one or two channels is refused at its own path";
`sdk/src/generated/abi.ts` and `sdk/assets/miso-engine-v1-abi-layout.json` regenerated;
`scripts/check-abi-layout-v1.py` and its self-test fixture know `maximumSourceChannels`.
`docs/SESSION_SCHEMA_V1.md` and the author-session skill state the limit.

**Found in review of callers.** `crates/host-core/src/limiter_linked_session.rs` (a `#[cfg(test)]`
unit test, the 64-track linked-limiter corpus) widened its one source to 128 channels. It now
declares 64 stereo sources (`stream-NN`), track `k` reads source `k`'s two channels and the same
noise streams `2k` and `2k + 1` feed them; `maximum_sources` 16 to 64. The `PIN` digest is
unchanged and the three width tests pass, so no rendered bit moved.
Loading
Loading