From 6159cfcea9aa1713074f3cb4e2c1c50d05961b15 Mon Sep 17 00:00:00 2001 From: Aqua Date: Thu, 12 Nov 2026 09:50:55 +0000 Subject: [PATCH 1/3] docs: Dings patch identity authority map and host pipe protocol Co-Authored-By: Claude Sonnet 5.5 --- docs/dings-patch-identity-authority-map.md | 316 +++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/dings-patch-identity-authority-map.md diff --git a/docs/dings-patch-identity-authority-map.md b/docs/dings-patch-identity-authority-map.md new file mode 100644 index 0000000..64b77d8 --- /dev/null +++ b/docs/dings-patch-identity-authority-map.md @@ -0,0 +1,316 @@ +# Dings patch identity: authority map + +Serves follow-up `aqua-body:follow_up:aquasynth-dings-authority-map`. Claims are from +source at `c0fe4ee` (origin/main). Nothing was built or run; the one place behaviour is +inferred rather than read is marked. Paths are repo-relative; `Host` = +`tools/AquaSynthDingsHost`, `Mcp` = `tools/AquaSynthDingsMcp`. + +Companion to `docs/research/dings-playback-and-ogg-export.md`, which describes the +playback path and an export sketch. This note asks a narrower question: in the Dings +path, what decides which sound "this patch" is, and how many places decide it. + +## What I counted + +The follow-up says four owners. The source supports four places that *decide* a +voice's identity, plus one place that names the same thing a second time without +deciding, and one generated mirror: + +| # | Owner | Decides | Section | +|---|---|---|---| +| 1 | `DingCatalog` | the instrument id and the script that defines its sound | A | +| 2 | Host and daemon derivation | the daemon `patchId`, `faustName`, `revision`, session id and store file names | B | +| 3 | MCP `AgentInstrumentRegistry` | which instrument an agent session gets | C | +| 4 | `tools/dings-voices` | which voice a sender (agent localpart) gets, in a separate synth | D | +| - | Earcon Workshop `IDENTITIES` | reuses catalog instrument ids with its own parameters | E | +| - | `tools/aqua-feel/lowered-dings` | checked-in copy of #1, guarded by a test | E | + +The follow-up does not list its four, so this grouping is my reading. Someone who +counted differently (for example splitting the daemon's session id from the store file +name, or counting Earcon Workshop as a fifth) would get a different number from the same +code. The grouping above follows who writes the key. + +## A. `DingCatalog` instrument id and script + +- Where: `src/AquaSynth.Dings/DingCatalog.cs:22-28` (four instruments), built by the + private `Instrument(...)` at `:48-50`, which writes the Faust-DSL `Script` string and + fixes `RootFrequency = 261.6256f` for all four. +- Keys on: the instrument `Id` string (`warm-bell`, `glass-chime`, `soft-triangle`, + `muted-pluck`), case-insensitive dictionary (`:28`). Events are a separate keyed + dictionary (`:9-20`, nine ids) and are not voice identity. +- Lifetime: compiled in. Changing an entry means a new build of every consumer. +- Writer: whoever edits the file. `ValidateCuration` (`:31-46`) is the only gate. +- Readers: `Host/DingsPlaybackHost.cs:66-67`, `Mcp/DingService.cs:9-10`, + `Mcp/AgentInstrumentRegistry.cs:11`, `Mcp/DingTools.cs:28,31,49`, + `tools/AquaLower/Program.cs:17`, and the tests `DingCatalogTests`. +- Content hash: none. The id is a label; the script is the content. Nothing here says + "this script, at this version". + +## B. Host and daemon derivation of the patch + +The Host does not pass the catalog id through. It builds the daemon's identity fields +itself, and the daemon builds more keys from them. + +- `Host/AquaDaemonClient.cs:126` per note: `patchId = "dings." + instrument.Id`, + `faustName = "dings_" + instrument.Id.Replace('-', '_')`, `revision = 1` (a literal), + `durationSeconds = 1.5f`, `controls["/ding/frequency"]`. +- `src/AquaSynth.Faust/NativeFaustRuntime.cs:596-599`: the compile key is SHA-256 of + `"{revision}\n{faustName}\n{script}"`. `patchId` is not in it. So two ids with the same + name and script share a compile key; one id with an edited script gets a new key even + though `revision` stays 1. +- `src/AquaSynth.Faust/AquaSynthDaemonService.cs:1136`: session id = + `StoreFileName.From(patchId) + "-" + compileKey[..12]`. A recompile with the same + session id disposes the old session (`:365-372`). +- `src/AquaSynth.Core/StoreFileName.cs:26-54`: the mapping from any id to a file name + (lowercase ASCII passes unchanged, anything else gets a 16-hex SHA-256 suffix). + `dings.warm-bell` is a fixed point (pinned by `StoreFileNameTests`). +- What is *not* keyed by patch: render output and receipts. Samples are + `/samples/.wav` (`:660-667`), where + `commandId` is a fresh `ding-` per note (`AquaDaemonClient.cs:125`). Receipts + go through `WriteReceiptAsync` (`:922-937`) under the same command-derived ids. The + store therefore keeps one WAV per note played, none addressable by instrument; the + patch id only appears inside the receipt body. Whether the store ever gets cleaned + I did not find. +- The `dings_` rule is written twice: `AquaDaemonClient.cs:126` and + `tools/AquaLower/Program.cs:19`, plus a third time in + `DingCatalogTests.LoweredDingsFedToTheFeelMeterMatchTheCatalog` (the test builds the + name with the same expression). Nothing ties the three together except the test. +- Writer: the Host (fields) and the daemon (session id, file names). Readers: the + daemon's compile/render path and receipts; no Dings code reads the patch id back. +- `Host/AquaDaemonClient.cs:129-134` returns the WAV path from the receipt. The Host + never asks the daemon which patch rendered it. + +## C. MCP `AgentInstrumentRegistry` + +- Where: `Mcp/AgentInstrumentRegistry.cs:10` (dictionary), `:13-17` `GetOrAssign`, + `:19-29` `Claim`, `:31-36` `AssignDeterministically`. +- Keys on: `agentSessionId`, which is `server.SessionId ?? "stdio:" + ProcessId` + (`Mcp/DingTools.cs:45`). That is the MCP transport session, not the agent. In HTTP + mode (`Mcp/Program.cs:34-54`, `Stateless = false`) it lives as long as that MCP + session; in stdio mode it is the bridge process id. +- Value: an instrument id string. Unclaimed sessions get + `SHA256(sessionId)[0..4] % sortedInstrumentIds.Length`. `Claim` overwrites silently, + and there is no uniqueness check, so two sessions can hold the same instrument. +- Lifetime: in-process, a `ConcurrentDictionary`. Lost on bridge restart. A restarted + or reconnected session gets a different session id, hence generally a different + hashed instrument unless it claims again. (Inferred from the keying; I did not + observe a reconnect.) +- Writer: the agent, through `claim_instrument`, or the hash. Reader: `play_ding` + (`DingTools.cs:16`) and `get_agent_instrument` (`:19`). +- Also decides: `DingService.PlayAsync` (`Mcp/DingService.cs:9-11`) re-validates event, + instrument and gain, and the Host validates the same three again + (`DingsPlaybackHost.cs:66-68`). Two gates on one decision; they agree today because + both read `DingCatalog`. + +## D. `tools/dings-voices` sender voices + +- Where: `tools/dings-voices/src/voices.mjs:8` (`SENDERS`, eight localparts), `:51-73` + (`VOICES`, one `hit` function each), `:13-18` (`RHYTHM`, four motifs). +- Keys on: sender localpart x motif (`mention`, `dm`, `message`, `invite`), emitted as + `senders//.ogg` and `manifest.json` + (`finish.py:62`, shape `{neutral, senders:{localpart:{motif:path}}}`). +- It does not use `DingCatalog`, `AquaSynthDaemon` or Faust. It is a JS synthesizer that + imports only the sample rate from `earcon-workshop/web/earcon.js` (`voices.mjs:5`). + Its vocabulary (4 motifs, 8 senders) shares no ids with Aqua's (9 events, 4 + instruments). +- One link to the catalog family: the neutral fallback is rendered through the Earcon + Workshop `warm-bell` identity (`build.mjs:10-14`), with an event mapping of + `mention=warning, dm=waiting, message=message, invite=done` (`:12`) that the file + itself calls provisional. +- Lifetime: a build artifact. The files are not committed (README, "Not here"); the + consumer, Ratatoskr's `dings.ts`, is outside this repo and I did not read it. +- Writer: a person editing `voices.mjs`; `NIBU_PROPOSALS` (`:81-92`) shows the selection + of the default voice is a one-line assignment (`VOICES.nibu = ...`, `:92`). +- This is the only place a *sender* is identity. Everything else in the Dings path + keys on an MCP session. + +## E. Places that echo the identity without deciding it + +- Earcon Workshop `IDENTITIES`, `tools/earcon-workshop/web/earcon.js:22-28`. Same four + ids; its comment (`:21`) says values "start from the Dings catalog instruments and + were not tuned by ear". The values have drifted from the catalog: + + | id | catalog root / decay (`DingCatalog.cs:23-26`) | workshop `rootHz` / `decay` | + |---|---|---| + | warm-bell | 261.6256 / 1.15 | 261.6256 / 0.55 | + | glass-chime | 261.6256 / 0.85 | 329.6276 / 0.45 | + | muted-pluck | 261.6256 / 0.34 | 293.6648 / 0.25 | + + Decay is read with different units in the two voice models, so only the root column + is a clean disagreement. A "warm-bell" in the Ratatoskr neutral fallback is therefore + not the "warm-bell" an MCP agent hears. +- `tools/aqua-feel/lowered-dings/` (checked in): `.dsp` per instrument and `dings.json`, + produced by `AquaLower --dings`. `DingCatalogTests` fails when it falls out of date + (the test comment at the top of `LoweredDingsFedToTheFeelMeterMatchTheCatalog` names + the regeneration command). A mirror guarded by a test; not an independent owner. + `tools/aqua-feel/shelf.mjs:64,70` and `catalog.js:22-24` read it, and + `shelf.mjs:64` picks `warm-bell` as the shelf default (`DEF_INS`), a fifth voice + default beyond the registry's hash. + +## Where owners can disagree + +1. **Name versus content.** A is a name plus a script. B keys sessions on the content + (compile key) and labels receipts with the name. Editing a script in place keeps + `patchId = dings.warm-bell` and `revision = 1`, so receipts from before and after the + edit carry the same patch id and different session ids. Nothing in a receipt says + which catalog version produced it. +2. **Session versus agent.** C assigns by MCP session; the stated intent in + `Mcp/README.md:3-4` is "which agent spoke". After a reconnect the same agent can + change timbre. D, meanwhile, keys on the agent address localpart. No code maps a + session id to a localpart, so the two cannot be joined today. +3. **Four timbres versus eight senders.** C picks among 4 catalog instruments by hash; + D has 8 hand-made voices. An agent can have one voice in `play_ding` and a different + one in its Ratatoskr notification, and nothing records either choice. +4. **Same id, different sound.** `warm-bell` is a catalog instrument (A), a workshop + identity (E) and the neutral fallback voice (D). Three definitions, parameter sets + that have drifted (table above). +5. **Derivation written three times.** `dings_` in `AquaDaemonClient.cs:126`, + `AquaLower/Program.cs:19`, and the test; and the `dings.` patch id appears in + `StoreFileNameTests` as a literal. A rename rule changed in one place breaks + store-file naming in another without a compile error. +6. **Double validation.** `DingService.cs:9-11` and `DingsPlaybackHost.cs:66-68` each + decide whether an id is valid. Safe while both read one dictionary. + +## The Host pipe protocol, as read from source + +This is the "private pipe": the only channel into the audio host. MCP is a client of it; +it is not itself the pipe. + +**Listener.** `AquaSynthDingsHost.exe`. `Host/Program.cs:3-4` takes the named mutex +`Local\GameCult.AquaSynthDings.Playback.v1` and exits if another instance holds it, then +runs `DingsPlaybackHost.RunAsync` (`:7`). The accept loop is +`DingsPlaybackHost.cs:12-20`: one `NamedPipeServerStream` per accepted client, name +`GameCult.AquaSynthDings.Playback.v1` (`src/AquaSynth.Dings/DingsProtocol.cs:8`), +`PipeDirection.InOut`, byte mode, `MaxAllowedServerInstances`, `Asynchronous | +CurrentUserOnly` (`:16`). Each connection is handled fire-and-forget (`:18`). The host +uses NAudio WASAPI (`Host/DingAudioPlayer.cs`) and is Windows-only in practice. + +**Clients.** `Mcp/DingsHostClient.cs`, used by the MCP bridge (both stdio and +`--http`) and by the bridge's direct flags `--play`, `--get-volume`, `--set-volume`, +`--mute` (`Mcp/Program.cs:9-32`). Nothing else in the repo opens the pipe. + +**Framing** (identical in both directions): +`[int32 length][MessagePack body]`. The length is `BitConverter.GetBytes(int)`, so +native byte order (little-endian on every platform this builds for, but the code does not +fix it). Valid length is 1 to 1,048,576 bytes; outside that the reader throws +(`DingsPlaybackHost.cs:86-87`, `DingsHostClient.cs:42`). Body is +`MessagePackSerializer` of the keyed records below. One request, one response, then the +server disposes the pipe (`DingsPlaybackHost.cs:24-36`). No streaming, no pipelining, no +request id. + +**Request** `DingsCommand` (`DingsProtocol.cs:13-19`), keyed: + +| key | field | note | +|---|---|---| +| 0 | `Version` | must equal `DingsProtocol.Version` = 1, else status `incompatible` (`DingsPlaybackHost.cs:41`) | +| 1 | `Kind` | `Ping, Play, StopAll, GetVolume, SetVolume, SetMuted` (`:11`) | +| 2 | `EventId` | catalog event id, `Play` only | +| 3 | `InstrumentId` | catalog instrument id, `Play` only | +| 4 | `Value` | `Play`: gain 0..1; `SetVolume`: volume 0..1; `SetMuted`: `>= .5` means muted | + +**Response** `DingsResponse` (`DingsProtocol.cs:21-31`): `Version`, `Succeeded`, +`Status`, `Message`, `PlaybackId`, `Volume`, `Muted`, `HostProcessId`, `SessionName` +(default `"AquaSynth Dings"`). Every response carries current volume and mute, built at +`DingsPlaybackHost.cs:79-80`. + +**Per kind** (`DingsPlaybackHost.cs:39-62`): `Ping` and `GetVolume` are the same branch +(status `ready`). `SetVolume` rejects non-finite or out-of-range values +(`rejected`) and otherwise sets `audio.Volume` (`volume-set`). `SetMuted` -> `muted` / +`unmuted`. `StopAll` -> `stopped`. `Play` (`:64-77`) validates event, instrument and +gain, renders each note through the daemon, queues each WAV with its delay, and answers +`playing` with a fresh `ding-` `PlaybackId`. The answer comes after rendering and +queueing, not after sound. `PlaybackId` is not stored and nothing takes it as input. +Any exception becomes `Succeeded = false, Status = "failed", Message = ex.Message` +(`:32-35`), provided the pipe is still connected. + +**Client behaviour** (`Mcp/DingsHostClient.cs:15-48`): the client validates the +response `Version` (`:46`). Connect timeout 350 ms; on `TimeoutException` or +`IOException` it launches the host (`StartHost`, `:50-56`: newest +`AquaSynthDingsHost.exe` under `tools/AquaSynthDingsHost/bin`, hidden window, working +directory the repo root found by walking up to `AquaSynth.sln`) and retries up to 20 +times at 500 ms connect with 100 ms gaps, all inside a 15 s overall timeout. A +`Succeeded = false` response is turned into `InvalidOperationException(response.Message)` +by `DingService.EnsureSuccess` (`DingService.cs:22-26`). + +**MCP side, for completeness.** Tools (`Mcp/DingTools.cs`): `play_ding`, +`get_agent_instrument`, `claim_instrument`, `list_ding_events`, `list_instruments`, +`stop_dings`, `get_volume`, `set_volume`, `mute_dings`. Transports: stdio by default +(`Program.cs:56-65`); `--http [url]` serves Streamable HTTP at `/mcp`, default +`http://127.0.0.1:17878`, singleton by mutex `Local\GameCult.AquaSynthDings.McpHttp.v1` +(`Program.cs:34-54`). Only `play_ding`, `get_agent_instrument`, `claim_instrument` touch +agent identity; the volume and stop tools address the whole host and are +machine-global. + +**Not authenticated beyond the OS.** `CurrentUserOnly` limits it to the same Windows +user. Any process of that user can send `Play`. There is no per-caller identity on the +pipe: `DingsCommand` carries an instrument, not a caller, so the Host cannot know who +asked. + +## PROPOSED: one owner (a recommendation for the stewarding decision, not decided) + +**Proposal.** `DingCatalog` (`src/AquaSynth.Dings`) becomes the only place that decides +what a voice is, and also the only place that derives the daemon keys from it. Its +instrument gains a content revision (a hash of `Script` plus the parameters that matter), +and one function in that assembly produces `PatchId`, `FaustName` and the revision for +the daemon. Everything else keys on the catalog id and takes the rest from the catalog. + +**What the others become.** +- B (Host/daemon): the Host stops composing `patchId`/`faustName`/`revision`; it calls + the catalog function. `AquaLower` and the test call the same function. The daemon + keeps its session and file naming; those are daemon-internal and derive from what it + is given. +- C (registry): stays in the bridge as a cache of choices, but its assignment function + moves beside the catalog (so a future export can call it) and its key becomes an + agent identity rather than an MCP session id. This depends on the open question + below. +- D (`dings-voices`): stays a separate synthesizer for sender notifications, since its + voices are JS functions with no Faust form. Its link to the owner is by reference: + the neutral fallback names a catalog instrument id instead of a second `warm-bell`. + Whether sender voices should ever become catalog instruments is a product fork, not + part of this change. +- E: Earcon Workshop `IDENTITIES` either read their shared values from a generated + file (as `lowered-dings` already is) or drop the shared ids; the drifted values in + the table above are resolved by whoever owns the workshop's tuning. + +**Why this owner.** A already has the most readers (the list in section A), is the only +definition of the script, and is the only one the host cannot run without. C, D and E +are all consumers or approximations of it by name. + +**Migration order** (each step independent and reversible): +1. Add the catalog function; switch `AquaDaemonClient.cs:126` and `AquaLower` onto it; + delete the test's private copy. Pure refactor; the compile key and so the session id + stay equal for unchanged scripts, which is checkable by comparing receipts before and + after. +2. Decide the agent-identity question (below); change the registry key. Wire-visible: + existing sessions lose their hashed assignment. +3. Remove the duplicate gates in `DingService.cs:9-11` or have the Host's gate be the + single one (the bridge then only forwards). Keep whichever side the tests + (`DingsBridgeTests`) need for fail-fast, but one of them must be the owner. +4. Point the neutral fallback and Earcon Workshop at the catalog. + +**Risks.** +- Step 2 changes what agents hear on first use after deploy; nobody has listened to a + before/after. +- A content revision in the key changes session ids, which are visible in receipts but + not read by anything I found. +- Collapsing 8 senders onto 4 instruments would lose distinctness; the proposal does not + do that, and I am recommending against it without having listened to either set. +- Moving derivation to the catalog assembly makes `AquaSynth.Dings` the home of + daemon-protocol knowledge; if the daemon is expected to run without that assembly, + this is the wrong direction. I did not check whether anything else wants that. + +**Confidence.** Moderate on the owner choice; it follows reader counts and the write +path, not any prior art on identity registries. Lower on step 2, because it depends on +what the MCP bridge can learn about its caller. + +## Could not verify + +- That "four owners" in the follow-up means these four. The count here is mine. +- Whether an MCP client can tell the bridge which agent it is. The only identity read in + source is `server.SessionId` (`DingTools.cs:45`); I did not inspect the MCP SDK for + client-info fields, or any harness config. +- What Ratatoskr's `dings.ts` actually reads and whether it shares any naming with + `DingCatalog`; it is not in this repo. +- Behaviour at runtime: reconnect session ids, store growth under `.aquasynth/dings-host`, + and whether the Host's `Ping`/`GetVolume` have different callers in practice. The + pipe protocol above is from reading, not from a captured exchange. Nothing was built, + run, or listened to for this note. From fb881a971a0a6df4ba8053f22db23d19c52588ce Mon Sep 17 00:00:00 2001 From: Aqua Date: Thu, 12 Nov 2026 09:54:08 +0000 Subject: [PATCH 2/3] docs: correct Dings authority map citations and store claims Co-Authored-By: Claude Sonnet 5.5 --- docs/dings-patch-identity-authority-map.md | 88 ++++++++++++++-------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/docs/dings-patch-identity-authority-map.md b/docs/dings-patch-identity-authority-map.md index 64b77d8..0f78f4f 100644 --- a/docs/dings-patch-identity-authority-map.md +++ b/docs/dings-patch-identity-authority-map.md @@ -32,7 +32,7 @@ code. The grouping above follows who writes the key. ## A. `DingCatalog` instrument id and script - Where: `src/AquaSynth.Dings/DingCatalog.cs:22-28` (four instruments), built by the - private `Instrument(...)` at `:48-50`, which writes the Faust-DSL `Script` string and + private `Instrument(...)` at `:48-50`, which writes the `Script` string (an AquaSynth patch script, `param path=...; voice wave=...`, that `FaustEmitter.EmitScript` lowers to Faust; not Faust source) and fixes `RootFrequency = 261.6256f` for all four. - Keys on: the instrument `Id` string (`warm-bell`, `glass-chime`, `soft-triangle`, `muted-pluck`), case-insensitive dictionary (`:28`). Events are a separate keyed @@ -50,7 +50,7 @@ code. The grouping above follows who writes the key. The Host does not pass the catalog id through. It builds the daemon's identity fields itself, and the daemon builds more keys from them. -- `Host/AquaDaemonClient.cs:126` per note: `patchId = "dings." + instrument.Id`, +- `Host/AquaDaemonClient.cs:20` per note: `patchId = "dings." + instrument.Id`, `faustName = "dings_" + instrument.Id.Replace('-', '_')`, `revision = 1` (a literal), `durationSeconds = 1.5f`, `controls["/ding/frequency"]`. - `src/AquaSynth.Faust/NativeFaustRuntime.cs:596-599`: the compile key is SHA-256 of @@ -59,24 +59,38 @@ itself, and the daemon builds more keys from them. though `revision` stays 1. - `src/AquaSynth.Faust/AquaSynthDaemonService.cs:1136`: session id = `StoreFileName.From(patchId) + "-" + compileKey[..12]`. A recompile with the same - session id disposes the old session (`:365-372`). + session id disposes the old session (`:366-376`). - `src/AquaSynth.Core/StoreFileName.cs:26-54`: the mapping from any id to a file name - (lowercase ASCII passes unchanged, anything else gets a 16-hex SHA-256 suffix). + (ASCII letters, digits, `-`, `_`, `.` pass unchanged unless the id has an uppercase letter, a device stem, a trailing dot, is over 100 characters or already ends in a hash shape; anything else gets a 16-hex SHA-256 suffix, `StoreFileName.cs:37,59-67`). `dings.warm-bell` is a fixed point (pinned by `StoreFileNameTests`). -- What is *not* keyed by patch: render output and receipts. Samples are - `/samples/.wav` (`:660-667`), where - `commandId` is a fresh `ding-` per note (`AquaDaemonClient.cs:125`). Receipts - go through `WriteReceiptAsync` (`:922-937`) under the same command-derived ids. The - store therefore keeps one WAV per note played, none addressable by instrument; the - patch id only appears inside the receipt body. Whether the store ever gets cleaned - I did not find. -- The `dings_` rule is written twice: `AquaDaemonClient.cs:126` and - `tools/AquaLower/Program.cs:19`, plus a third time in - `DingCatalogTests.LoweredDingsFedToTheFeelMeterMatchTheCatalog` (the test builds the +- Keyed by patch: the session document. `WriteReceiptAsync("sessions", sessionId, session)` + (`:407`) runs on every compile, and `SampleAsync` compiles on every note + (`:417-425`), so the store holds `sessions/.cc` for each + distinct `(patchId, compile key)`, rewritten on each note. This is the one store + artifact addressable by instrument. `NativeFaustRuntime.cs:516,628-636` would also write + `.dsp`, but only when `options.DspSourceDirectory` is set; the + daemon builds its native options from sample rate and render limits only + (`AquaSynthDaemonService.cs:330-334`) and nothing else in `src` or `tools` sets it, so + for the Dings host it is off. (Read from source, not observed.) +- Not keyed by patch: render output and the render and compile receipts. Samples are + `/samples/.wav` (`:664-667`), where + `commandId` is a fresh `ding-` per note (`AquaDaemonClient.cs:19`). Receipts go + through `WriteReceiptAsync` (`:922-937`) under command-derived ids, with the patch id + only in the body. The store therefore keeps one WAV per note played, none addressable + by instrument. Whether the store ever gets cleaned I did not find. +- Faust name, three forms: the Host sends `dings_`; the daemon applies + `SafeFaustName` (`NativeFaustRuntime.cs:602-607`, called at + `AquaSynthDaemonService.cs:911` and `NativeFaustRuntime.cs:513`) so the manifest and + emitted source carry the rewritten name, while the compile key (`:596-599`) hashes the + raw `identity.FaustName`. Two raw names that sanitise alike compile alike but key + differently. +- The `dings_` rule is written three times: `AquaDaemonClient.cs:20` and + `tools/AquaLower/Program.cs:19`, and + `DingCatalogTests.cs:45` (`LoweredDingsFedToTheFeelMeterMatchTheCatalog` builds the name with the same expression). Nothing ties the three together except the test. - Writer: the Host (fields) and the daemon (session id, file names). Readers: the daemon's compile/render path and receipts; no Dings code reads the patch id back. -- `Host/AquaDaemonClient.cs:129-134` returns the WAV path from the receipt. The Host +- `Host/AquaDaemonClient.cs:23-28` returns the WAV path from the receipt. The Host never asks the daemon which patch rendered it. ## C. MCP `AgentInstrumentRegistry` @@ -84,7 +98,7 @@ itself, and the daemon builds more keys from them. - Where: `Mcp/AgentInstrumentRegistry.cs:10` (dictionary), `:13-17` `GetOrAssign`, `:19-29` `Claim`, `:31-36` `AssignDeterministically`. - Keys on: `agentSessionId`, which is `server.SessionId ?? "stdio:" + ProcessId` - (`Mcp/DingTools.cs:45`). That is the MCP transport session, not the agent. In HTTP + (`Mcp/DingTools.cs:45`; that `SessionId` is null under stdio is inferred from the `??` fallback, not read from the SDK). That is the MCP transport session, not the agent. In HTTP mode (`Mcp/Program.cs:34-54`, `Stateless = false`) it lives as long as that MCP session; in stdio mode it is the bridge process id. - Value: an instrument id string. Unclaimed sessions get @@ -125,11 +139,11 @@ itself, and the daemon builds more keys from them. ## E. Places that echo the identity without deciding it -- Earcon Workshop `IDENTITIES`, `tools/earcon-workshop/web/earcon.js:22-28`. Same four +- Earcon Workshop `IDENTITIES`, `tools/earcon-workshop/web/earcon.js:22-27`. Same four ids; its comment (`:21`) says values "start from the Dings catalog instruments and were not tuned by ear". The values have drifted from the catalog: - | id | catalog root / decay (`DingCatalog.cs:23-26`) | workshop `rootHz` / `decay` | + | id | catalog root / decay (`DingCatalog.cs:24-27`) | workshop `rootHz` / `decay` | |---|---|---| | warm-bell | 261.6256 / 1.15 | 261.6256 / 0.55 | | glass-chime | 261.6256 / 0.85 | 329.6276 / 0.45 | @@ -142,9 +156,9 @@ itself, and the daemon builds more keys from them. produced by `AquaLower --dings`. `DingCatalogTests` fails when it falls out of date (the test comment at the top of `LoweredDingsFedToTheFeelMeterMatchTheCatalog` names the regeneration command). A mirror guarded by a test; not an independent owner. - `tools/aqua-feel/shelf.mjs:64,70` and `catalog.js:22-24` read it, and - `shelf.mjs:64` picks `warm-bell` as the shelf default (`DEF_INS`), a fifth voice - default beyond the registry's hash. + `tools/aqua-feel/shelf.mjs:64` and `catalog.js:22-24` read it, and + `shelf.mjs:64` defines `warm-bell` as the shelf default (`DEF_INS`, used at + `:71,:75,:76`), a fifth voice default beyond the registry's hash. ## Where owners can disagree @@ -163,7 +177,7 @@ itself, and the daemon builds more keys from them. 4. **Same id, different sound.** `warm-bell` is a catalog instrument (A), a workshop identity (E) and the neutral fallback voice (D). Three definitions, parameter sets that have drifted (table above). -5. **Derivation written three times.** `dings_` in `AquaDaemonClient.cs:126`, +5. **Derivation written three times.** `dings_` in `AquaDaemonClient.cs:20`, `AquaLower/Program.cs:19`, and the test; and the `dings.` patch id appears in `StoreFileNameTests` as a literal. A rename rule changed in one place breaks store-file naming in another without a compile error. @@ -251,11 +265,12 @@ asked. what a voice is, and also the only place that derives the daemon keys from it. Its instrument gains a content revision (a hash of `Script` plus the parameters that matter), and one function in that assembly produces `PatchId`, `FaustName` and the revision for -the daemon. Everything else keys on the catalog id and takes the rest from the catalog. +the daemon (`AquaLower` and the test need only the faust-name part of it, not +`patchId` or revision). Everything else keys on the catalog id and takes the rest from the catalog. **What the others become.** - B (Host/daemon): the Host stops composing `patchId`/`faustName`/`revision`; it calls - the catalog function. `AquaLower` and the test call the same function. The daemon + the catalog function. `AquaLower/Program.cs:19` and `DingCatalogTests.cs:45` call only the faust-name slice of it. The daemon keeps its session and file naming; those are daemon-internal and derive from what it is given. - C (registry): stays in the bridge as a cache of choices, but its assignment function @@ -276,22 +291,31 @@ definition of the script, and is the only one the host cannot run without. C, D are all consumers or approximations of it by name. **Migration order** (each step independent and reversible): -1. Add the catalog function; switch `AquaDaemonClient.cs:126` and `AquaLower` onto it; - delete the test's private copy. Pure refactor; the compile key and so the session id - stay equal for unchanged scripts, which is checkable by comparing receipts before and - after. +1. Add the catalog function; switch `AquaDaemonClient.cs:20` and `AquaLower` onto it; + delete the test's private copy (see the oracle risk below). Pure refactor while + `revision` stays the literal 1: the compile key, and so the session id, stay equal + for unchanged scripts, which is checkable by comparing receipts before and after. 2. Decide the agent-identity question (below); change the registry key. Wire-visible: existing sessions lose their hashed assignment. -3. Remove the duplicate gates in `DingService.cs:9-11` or have the Host's gate be the +3. Content revision: DEFERRED, not scheduled here. Replacing the literal `revision = 1` + with a catalog hash changes every compile key and session id (and the + `sessions/<...>.cc` file names), so it breaks step 1's equality check by design. It + needs its own decision on what feeds the hash and whether old session files are + left or removed. +4. Remove the duplicate gates in `DingService.cs:9-11` or have the Host's gate be the single one (the bridge then only forwards). Keep whichever side the tests (`DingsBridgeTests`) need for fail-fast, but one of them must be the owner. -4. Point the neutral fallback and Earcon Workshop at the catalog. +5. Point the neutral fallback and Earcon Workshop at the catalog. **Risks.** - Step 2 changes what agents hear on first use after deploy; nobody has listened to a before/after. -- A content revision in the key changes session ids, which are visible in receipts but - not read by anything I found. +- The deferred content revision changes session ids and session file names, visible in + receipts but not read by anything I found. +- Oracle loss in step 1: deleting the test's private `"dings_" + id` copy makes + `LoweredDingsFedToTheFeelMeterMatchTheCatalog` compare the shared function against + itself. The test should keep an independent expected value (a literal per instrument + or the checked-in `.dsp` header) so a wrong rule still fails. - Collapsing 8 senders onto 4 instruments would lose distinctness; the proposal does not do that, and I am recommending against it without having listened to either set. - Moving derivation to the catalog assembly makes `AquaSynth.Dings` the home of From f944c05278d0abaa80c1cda023e9b01aa1a6ecca Mon Sep 17 00:00:00 2001 From: Aqua Date: Thu, 12 Nov 2026 09:54:16 +0000 Subject: [PATCH 3/3] docs: cite SampleAsync compile call correctly Co-Authored-By: Claude Sonnet 5.5 --- docs/dings-patch-identity-authority-map.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dings-patch-identity-authority-map.md b/docs/dings-patch-identity-authority-map.md index 0f78f4f..dc09396 100644 --- a/docs/dings-patch-identity-authority-map.md +++ b/docs/dings-patch-identity-authority-map.md @@ -65,7 +65,7 @@ itself, and the daemon builds more keys from them. `dings.warm-bell` is a fixed point (pinned by `StoreFileNameTests`). - Keyed by patch: the session document. `WriteReceiptAsync("sessions", sessionId, session)` (`:407`) runs on every compile, and `SampleAsync` compiles on every note - (`:417-425`), so the store holds `sessions/.cc` for each + (`:622-635`), so the store holds `sessions/.cc` for each distinct `(patchId, compile key)`, rewritten on each note. This is the one store artifact addressable by instrument. `NativeFaustRuntime.cs:516,628-636` would also write `.dsp`, but only when `options.DspSourceDirectory` is set; the