docs: Dings patch identity authority map #40

Merged
aqua merged 3 commits from aqua/dings-authority-map into main 2026-11-12 09:55:17 +00:00

View file

@ -0,0 +1,340 @@
# 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 `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
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: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
`"{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 (`:366-376`).
- `src/AquaSynth.Core/StoreFileName.cs:26-54`: the mapping from any id to a file name
(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`).
- Keyed by patch: the session document. `WriteReceiptAsync("sessions", sessionId, session)`
(`:407`) runs on every compile, and `SampleAsync` compiles on every note
(`:622-635`), so the store holds `sessions/<StoreFileName(sessionId)>.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
`<StoreFileName(patchId)>.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
`<store>/samples/<StoreFileName(commandId + "-render")>.wav` (`:664-667`), where
`commandId` is a fresh `ding-<guid>` 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_<id with - as _>`; 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_<id>` 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:23-28` 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 `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
`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/<sender>/<motif>.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-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: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 |
| 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` 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
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_<id>` in `AquaDaemonClient.cs:20`,
`AquaLower/Program.cs:19`, and the test; and the `dings.<id>` 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-<guid>` `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 (`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/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
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: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. 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.
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.
- 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
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.