Skip to content

feat(voice-notes): voice notes — a short clip sealed like text, one fixed size on the wire - #340

Open
Fergulati wants to merge 61 commits into
corpetty:mainfrom
Fergulati:feat/voice-notes
Open

Fergulati wants to merge 61 commits into
corpetty:mainfrom
Fergulati:feat/voice-notes

Conversation

@Fergulati

@Fergulati Fergulati commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

From an outside contributor. This PR adds voice notes: a short recorded clip posted in a room, sealed like a text message, and the same size on the wire whatever its length. The design doc is the first commit, and the rest of the PR follows it: docs/design/voice-notes.md. §18 lists the decisions and the questions for you.

Since the review at bbae7d8 (27 commits and a merge of main, up to 04369ce):

  • Should-fix items. A device now starts off the module thread. Junk under voice/ no longer switches the room's proof off, but a note still does until §18's proof question is settled.
  • Worth-fixing items and nits. Each has a change, and two stay partly open: a poll still costs more as voice history grows, and the microphone strip covers the microphone only.
  • Linux. The voice self-test now passes on Linux at 08dfcb3 and again at 04369ce, with the C++ UI's runners on ubuntu-latest, on both fleets. Its earlier failure was the script misreading its logs.

Item-by-item replies follow in the review thread.

What a person sees

  • A mic button in the message row. Press it to record (up to 60 s). A banner shows the elapsed time and the level, with Stop and Discard. While the device starts, it says "Starting the microphone…" and that nothing is recorded yet.
  • On every page, room or none, a strip says "Muster is starting the microphone…" or "Muster is using the microphone", with Stop and Discard. On a page with no room view, what Stop or Discard did is said there (§13). The strip is for the microphone only: it shows nothing for a held draft or for playback.
  • Stop gives a draft with Play, Send and Discard. Under them, before anything is sent, a short note says that everyone in the room can play it, that a voice can identify the speaker, and what the store node sees.
  • In the thread, a note is a card with Play/Stop, its length and a progress bar. A note still arriving or broken says so in plain words, and only a complete note plays.
  • On macOS, Basecamp 0.3.2 can't reach the microphone. It lacks the audio-input entitlement and the usage string, so macOS denies the request without asking: tccd logs "Policy disallows prompt". The capture is all zeros, so Send refuses it as silent, with a note that says why. The button stays. This was seen in Basecamp's window at bbae7d8, before the start thread (§11).

How it works

  • Where the audio lives. Capture, playback and Opus all run in muster_module. The UI only triggers record, stop, send, discard, play and play-stop, and polls voice_status (§8). No voice method returns audio, and no hosted method returns the audio of a note this member's view holds. §10 says what is left: an older client in the room, a copy of a note's bytes posted as junk (below), and bytes a member posts in a message body.
  • Device start. A device starts on a thread of its own, never the module thread. voice_record_start and voice_play answer at once. Until the device runs, voice_status says recording.state: starting for a recording and playing.starting: true for a playback, and ready.capture / ready.playback read unknown.
    • A start that fails ends in its own word, such as no-device.
    • A start still pending 15 s after the call ends in device-start-slow, and is let go.
    • Stop, Discard and the recorder's close never wait on a start. At unload, the module waits at most 2 s for start threads (§8, §9).
  • Codec. libopus at hard CBR 16 kbps, 48 kHz mono, 20 ms frames, DTX and FEC off. Every packet is 40 B (§3).
  • On the wire. Every note is exactly two author-signed room events, voice/<nid>/<author>/<i>/2. The session pads every voice/ frame to 90,112 B of plaintext, 90,153 B on the wire. So a 1 s note and a 60 s note are the same size to the network and the store nodes, though they still see that a note was sent, to which topic and when (§3–§5). The registry carries this as the protects claim "A voice note's length does not show in its size" (requirement FS-9). Its test is probe_voice_fixed_size_stepper.
  • The fold. Both parts must agree and the clip digest must match, or the note is broken. Forged, unsigned, cross-room, edited and squatted parts drop out. Any order or duplication folds the same, and a later joiner folds no earlier note (F-16) (§6). A message/ whose body claims voice-note stays plain text (§7).
  • Polling. Each room's memo keeps every voice part's header by event id, so no part is parsed again after its first read. That covers the thread, the flow, the room map, provenance and every room's last-message time. A poll still copies and hashes each part's whole value, so its cost should still grow with voice history.
    • What was timed: a scratch bench on one Mac, one room with 20 notes of 60 s, warm memo. The four reads that fold voice parts add up to about 12 ms of each second, or 27 ms with the room map open, plus each other joined room's last-message time.
    • What was not timed: the room's other reads, such as intents and the activity feed. Each of them rebuilds the room's view, about 3 ms a call in such a room, so the whole costs more.
    • The fix to try is carrying each event's id from ingest. It changes the log's Event, so it is not made here (§6, §15).
  • coordinate_proof. It refuses voice-notes-present only when the room's authentic view holds a part the fold keeps. An entry under voice/ that is no note's part refuses nothing, and leaves with the proof whole, like any raw entry. That means a copy of a note's bytes leaves too, when this member's view holds none of that note's parts (§10; voice_live_test §26). Any note, even a deliberately broken one, still refuses until §18's proof question is decided.
  • A change outside voice notes: publishAuthored. It now re-seals the member's own after-signed copy of an event the log already holds, not the log's kept copy, for every author-signed kind. authorship_test §12 holds it for a re-announced account disclosure.
  • Devices. miniaudio 0.11.23 is compiled in with four backends (CoreAudio, PulseAudio, ALSA and Null), and dlopens the platform's audio libraries at runtime. The system device never lists Null. It sits behind an AudioIo seam, with synthetic, WAV-file and null implementations for tests, and no mocks (§9). On Linux, reading the code, a nix-built runner most likely finds no device and says so (no-device). It never records zeros (§12).
  • Hosted surface. New lidl methods voice_status, voice_record_start, voice_record_stop, voice_draft_send, voice_draft_discard, voice_play(note_id) and voice_play_stop. Thread rows carry a note in a separate voice field, as metadata only.
  • UI. Both backends implement every new slot and prop (ADR-019): the C++ backend in ui/ and the Nim one in ui-nim/. Their parity run has not happened (below).
    • New QML: CardVoiceNote.qml, RoomVoiceNote.qml, the mic in RoomComposer, and the microphone strip in Main.qml.
    • Polling: Room.qml polls every 250 ms only while a device starts or records, or a note plays. A held draft is read on Main's 1 s tick.
    • Also: labels in FlowView and the room map, and the MUSTER_AUTOVOICE / MUSTER_AUTOVOICEPLAY autopilots.
  • Tests and tooling.
    • run-suite.sh counts a SKIP only for a test in its E2E list. Any other test that prints SKIP <name>: fails.
    • run-suite.sh (both OSes) and grade-specs.sh (which links for Linux only) take the builder pin's libopus 1.5.2, the version the .lgx ships. Both read the pin through module/tools/builder-nixpkgs.sh.
    • scripts/lib/ui_log.py reads the C++ UI's lines out of ui-host's quoted relay and rejoins the lines ui-host cut. In the CI logs it was built on, that was every voice line and 974 of 976 longer SELFTEST lines. scripts/tests/voice-self-test-replay.py checks it, and the self-test's verdict, on lines cut verbatim from a Linux run.
    • Some existing tests changed on purpose:
      • voice_live_test §23's raw-log refusal is inverted, since junk now refuses nothing.
      • The audio tests and voice_device_e2e wait for a start to land.
      • The room-voice-record gallery fixture checks the strip and the poll.
  • Also in scope. It regenerates ui/tests/gallery/FakeBackend.qml, which was already stale on main. It also fixes the thread's fallback for a card kind MusterCard doesn't know: RoomThread drew nothing, and now it shows the message's text.

What ran

On macOS 26.5.2, Apple Silicon:

  • Module suite at 08dfcb3: module/tests/run-suite.sh, 285 passed, 0 failed. The five commits after it change only docs, the claims registry and the QML generated from it, and the wording of two code comments. At fd59219, voice_live_test, room_graph_test, room_graph_live_test, room_graph_scale_test and the host probe pass, and so do the registry gates.
  • Real devices at 08dfcb3 (voice_device_e2e, opt-in), through the start thread. It was launched from a shell whose app is already allowed the microphone, so no prompt stood (§11):
    • Microphone: 2 s captured from Core Audio at 48 kHz mono s16, 47.79 frames/ms, capped at exactly 96,000 frames. Through libopus and back it decoded to the same length, 96,000 samples.
    • Speaker, playing 2 s of silence: 48.10 frames/ms, and done only after the device's tail (1,920 frames, 40 ms).
  • Three headless hosts at 08dfcb3. They ran logosctl 0.3.2 on logos.dev, driven by a scratch harness (not in the tree) that makes the calls the UI's slots make.
    • A's WAV source and B's file sink start at once, with no start thread. C, on the null device, started on the start thread.
    • A recorded a 1 kHz WAV and sent it, and B had the note complete about 1 s later.
    • B played it into a file: 234,912 samples, exactly 4,894 ms × 48, with the tone at 0.99 of the source's power.
    • C was refused silent with the macOS note, and B's thread still held one note.
  • Inside Basecamp 0.3.2's window, at bbae7d8, before the start thread, on throwaway profiles on logos.dev:
    • A note between two profiles. The C++ UI's autopilots drove it, through a WAV source and a file sink, with no real device. A's autopilot recorded a 1 kHz WAV and sent it. B's thread drew it as a card, and B's autopilot played it into a file: 137,808 samples, exactly 2,871 ms × 48, at 0.99 of the source's power.
    • The system microphone. Basecamp was launched the way the Dock launches it (open -n -a) and the mic button was pressed. logos_host survived voice_record_start. tccd denied the microphone without a prompt, so the capture was zeros, Send refused it as silent with the Basecamp note, and nothing was posted.
  • The merge of main (a5e0149, fix(monero): three Send money bugs — a stray Mark received, a used address, the wallet button #341–chore(pebbles): track Muster 0.3.3 #343). Its only conflict was ui/tests/gallery/FakeBackend.qml. That file is generated from the .rep, which main doesn't touch, so it was regenerated, and came out byte-identical to the branch's. After the merge:
    • Every gallery fixture ran on both design systems: 92 and 93 of 101 pass. They fail the same ones as before the merge: popup shots that need an X capture, components on one design system, and room-news, which trips on a sidebar setting left on this Mac. main's new card-mark-received passes.
    • The ui-lint copy found 0 new findings, every gate passed, and the UI .lgx builds.
    • On Linux, in the fork's CI (not part of this PR), the self-test passed again at 04369ce plus the fork's workflow commits, on both fleets (the self-test job of run 40042776538). B's sink held 143,184 and 143,136 samples, and C's zeros were refused as silent.
    • This PR's own checks pass at 04369ce: the module and UI .lgx builds on darwin-arm64, linux-amd64 and linux-arm64, and the merge job.
  • Gallery. The voice fixtures ran against the real Main.qml and the generated FakeBackend.qml (no module), on Qt 6.9.2 (offscreen, software), on both design systems, with 0 QML warnings. That includes the strip and the starting state.
  • Gates. check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check and gen-fake-backend --check all pass. ui-lint's checks were run as a copy against Qt 6.9.2's qmllint, since there is no runner here, and found 0 new findings on both design systems. ui-nim/tests/autovoice/run.sh passes against a fake module.

On Linux:

  • Your review's run on x86_64 at bbae7d8: the module suite gave 285 passed, 0 failed, linking nixpkgs' libopus 1.6.1. The linux-amd64 package loads with its own libopus.so.0 (§12).
  • Fork-only CI (not part of this PR), at 08dfcb3 plus the fork's workflow commits (run 40030788377):
    • The module suite on ubuntu-latest and ubuntu-24.04-arm: 284 of 285 passed on each, four tests at a time. The one failure was room_graph_scale_test's own timing budget beside the other tests (its folds took 5,658 ms and 4,021 ms against 4,000), and it passed alone. On this Mac, alone, three runs each, its folds took 554–605 ms on this branch and 555–562 ms on main at 67701f8, this branch's base, and reduceRoomGraph took 617–646 ms against 590–592 ms, about 6% longer. main has not run on those CI runners (§12). voice_codec_test printed libopus 1.5.2 on both arches.
    • scripts/voice-self-test.sh ran three of the C++ UI's standalone runners on one ubuntu-latest machine: A sends, B plays, and C, on the null device, must be refused. It gave SUCCESS on logos.dev and on MUSTER_FLEET=local.
      • B's sink held 143,280 and 143,328 samples, exactly the note's length × 48, with the 1 kHz tone at 0.99 of the source's power.
      • C's zeros were refused as silent, and B's thread held only A's note.
      • The replay test passed in the same job.

Not verified

  • Basecamp's window since the start thread. The strip, the starting state and the strip's answers have been drawn only in the gallery. The C++ autopilot's wait for recording has run only in the self-test's runners on Linux, where C's null-device start went through starting first.
  • A microphone prompt that stands while a start waits. What happens when the person answers it is inferred, and so is a module unloaded while a start is blocked (§9, §11).
  • The hosted memo wiring. The memo is tested in the recorder. The hosted lines that pass it (coordinate_conversations, coordinate_flow, coordinate_graph, coordinate_provenance, coordinate_leave) are not.
  • Linux devices. PulseAudio and ALSA have not run, and no Record or Play has run inside Basecamp's AppImage. §12 says what is read from code.
  • The Nim UI's runner. make build-nim, then MUSTER_UI=nim scripts/voice-self-test.sh, has not run. Neither has scripts/ui-parity.sh voice, for either UI.
  • scripts/grade-specs.sh. It has not run, since there is no exophial here. Its libopus block ran on its own.

The full list is §15.

Asked of you (§18)

  1. An exo id for the epic, and whether you want the typed spec as derived-exo-<id>. The probes are already in stepper form.
  2. Sign-off on the wire format (§3–§5) before a release. An objection now means reworking S4–S10, and after a release a change strands notes already sent.
  3. Sign-off on exo-526's wider host-probe oracle, which now covers the seven voice methods, coordinate_messages and coordinate_proof. Otherwise a voice spec's own probe takes those checks over.
  4. F-13: is a voice note a message in another medium, or a core vocabulary addition?
  5. coordinate_proof: should the proof export be named as an exception, or should a proof form carry voice parts as digests? Until then a single note switches the room's proof off for good.
  6. A re-send path for a note the fleet half-delivered. Delivery's send is fire-and-forget.
  7. The device start now runs on its own thread, the first of your two options, with a 15 s bound as well. You may still object to the bound, to the word device-start-slow, or to a start thread at all, since macOS did not prompt for a Basecamp 0.3.2 opened the way the Dock opens it (one run, §11).
  8. The microphone strip: its words, its place, and whether a room's view should show both the strip and the room's banner.
  9. Members on 0.3.2 or earlier still serve every note's Opus to the modules loaded beside them. Accept that as a stated gap, or keep voice notes out of a room until every member runs a version that strips them? Nothing in a room says today which version a member runs.
  10. The F-16 claim's last sentence ("Removal rotates the epoch immediately"): reword it, or keep it as it is on main.
  11. Which version should carry this? It bumps none.

The commits use the voice-notes scope until there is an exo id, and the PR's diff against main touches nothing under .pebbles/. Each commit carries a Tested-Behavior or TDD-Exempt trailer.

🤖 Generated with Claude Code

Fergulati and others added 30 commits October 21, 2026 16:04
…ixed size on the wire

The design for voice notes, written before any code so the maintainer can
object to the wire format before anything depends on it. A member records
up to 60 s; muster_module captures it, encodes hard-CBR Opus at 16 kbps
(48 kHz mono, 20 ms, DTX and FEC off) and holds a draft in memory; Send
posts exactly two author-signed voice/<nid>/<author>/<i>/2 events, sealed
under the epoch key like text, each frame padded to 90,112 B of plaintext
through a planned session.publish(padTo), so every note costs 180,306 B on
the wire whatever its length. Audio never crosses IPC.

It covers: why room events rather than FileShare; the clip record and
event format; why the pad goes on the frame (after/afterSig are added after
the value is signed); the size budget; the fold's rules (first copy wins,
no squatting, broken notes); thread rows synthesized from the fold; the
record -> draft -> send state machine and voice_status; the device-callback
rules and the linear capture buffer that enforces the cap; who sees what,
with one narrow FS-9 claim; macOS (Basecamp 0.3.2's logos_host has no
audio-input entitlement and no usage string, read on the installed app, so
an all-zero recording is refused as "silent"); Linux dlopen; co-resident
callers with the access policy off; F-13; and what is not verified.

The leak list from the published design note is folded in and stays true:
a voice is a biometric against FS-7; size and timing are visible to the
store node (only length is hidden); co-resident modules can record, send
and play while the access policy is off; leaving re-keys nothing; no mix
test. Everything not yet built is marked planned. No exo id exists yet,
so the scope is voice-notes and nothing touches .pebbles/; the doc asks
the maintainer for an id and for spec sign-off (§18).

TDD-Exempt: docs — a design document written before any code; no behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tions

Review fixes to docs/design/voice-notes.md (S0), as a new commit on top
of ad3f3c9.

Major: §7 let any member forge a voice-note row. The row's card rode
`body`, and any member or co-resident module can post a message/ body
{"kind":"voice-note","note":"<Alice's nid>"}; RoomThread makes a card of
any body with a kind, so it would draw under the forger's name and Play
would play Alice's audio (against invariant 9). Now the synthesized row
carries the note in its own `voice` field, which reduceMessages cannot
set (it builds rows from author/ts/body only, muster_module.nim:4574-4577);
the card draws from modelData.voice alone; parsedCard refuses voice-note
from a body, so a forged one shows as plain text; and the card names
reduceVoice's author. MusterCard.known no longer gains voice-note. S5's
author probe and an S8 gallery fixture pin the forged case.

Minor:
- §5 used 3,000 packets for 60 s; §3.2's own count gives
  ceil((2,880,000 + 312) / 960) = 3,001. Rows are now 126,119 / 63,060 /
  84,080 / about 85,257 / about 4,855 (recomputed with a script that
  reproduces the old 3,000-packet figures exactly). The voice event
  carries no parents; the worst case assumes []. 500 ms is 1,166 B.
- §4: session.publish pads every voice/ key to VoiceFrameBytes itself and
  raises on another non-zero padTo, so the held re-seal path (a retried
  Send, a re-announce) cannot emit an unpadded frame; the fixed-size
  probe publishes a note twice and asserts both frames are 90,153 B.
- §11/§15: name both inferred macOS outcomes (zeros, or TCC terminating
  logos_host); S11's first check is whether logos_host survives
  voice_record_start under Basecamp 0.3.2, and a "no" goes back to the
  operator. Impersonal phrasing; bundle facts re-read with codesign and
  PlistBuddy.
- §3.1: ctl values as cint, GET arguments as ptr cint; S1's read-back
  discriminates only on darwin-arm64.
- §10: the store node sees the publishing node unless mix is on, never
  which member; a co-resident module can also discard the draft and play
  it aloud (also §13); the "claim [4]" pointer names the registry gap.
- §7: a 0.3.x peer's flow view and room map do show each part.
- Citations: RoomComposer.qml:1254-1314 / :1305-1313, pump list
  muster_module.nim:2838-2845. The design note's absence from the repo
  is stated.

Gates run: check-no-cpp, check-claims-registry, check-readme-rot,
check-run-targets, check-action-corpus, gen-claims-qml --check all pass;
gen-fake-backend --check fails identically at ad3f3c9 (the stale gallery
FakeBackend.qml that S8 regenerates).

TDD-Exempt: docs — a design-document correction; no code exists yet, so no behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What: the codec half of voice notes (docs/design/voice-notes.md §3.1-§3.2, §5), in a
new module/src/audio/:
- opus.nim: libopus bound by importc with no header, in sodium.nim's style. The ctl
  functions are {.varargs.}, every SET value a cint, every GET a ptr cint. Request
  numbers checked against the -dev headers of libopus 1.5.2 (builder pin e9f00bd) and
  1.6.1 (the registry nixpkgs run-suite links): identical.
- voice_clip.nim: the clip record as CDE over src/dcbor, and its strict reader. Pure:
  no codec linked, so the room's fold (S5) can import it alone. Refusals are values
  (codec settings, packets of 1..1275 B, at most 60 s, ceil((samples + preskip)/960)
  packets, exactly nine fields, canonical bytes). maxClipBytes is §5's budget: 126,119
  B at 60 s, 1,166 B at 500 ms.
- voice_codec.nim: an incremental encoder (feed / finish / clip) at VOIP, SIGNAL_VOICE,
  hard CBR 16 kbps, DTX 0, FEC 0, complexity 5, wideband. Every setting is read back
  after it is set, so a binding that set a wrong value refuses to encode instead of
  making clips of the wrong size. A short packet is opus_packet_pad'ded to 40 B; a
  longer one is refused (not-cbr). decodeClip validates through voice_clip, requires
  every packet to be one 20 ms frame, then decodes and trims the preskip.
- metadata.json: codegen.nim.link gains "opus", nix.packages.runtime "libopus".
- run-suite.sh: libopus rides the same nix build as libsodium on both OS branches
  (with nixpkgs' gcc on Linux), MUSTER_OPUS overrides it, MUSTER_OPUS_LIB is exported
  as MUSTER_SODIUM_LIB is, and FLAGS links it for every test.
- src/audio/CLAUDE.md (the claude-md-coverage hook wants one in a new source
  directory; S2 adds the device-thread rules) and tests/README.md (the runner now links
  libopus; the two tests).

Why: a note's fixed wire size (§4, §5) rests on CBR making a clip's size a pure
function of its length, and on libopus decoding room-supplied bytes only after a check
that needs no codec. Nothing in muster_module calls this yet (S3 links it into the
plugin).

Tested-Behavior: voice_clip_test §1-§3 and voice_codec_test §1-§5 (module/tests/run-suite.sh voice_clip | voice_codec, darwin-arm64, nim 2.2.4). Red first: both failed to compile before the modules existed ("cannot open file: ../src/audio/voice_clip", "cannot open file: ../src/audio/opus"). The ctl read-back, by mutation: with the SET path bound fixed-arity, §1 is red on darwin-arm64 (all SETs fixed: "opus ctl 4024: invalid argument"; SET_BITRATE alone: "opus ctl 4002: invalid argument"); the silent case the prototype saw, a SET_BITRATE that lands 300000 with OPUS_OK, is red twice: newClipEncoder raises "opus ctl 4003 read back 300000, want 16000", and with that guard removed §1 fails `e.readBack(OPUS_GET_BITRATE_REQUEST) == VoiceBitrate` 300000. Green: voice_clip_test all OK; voice_codec_test all OK on libopus 1.6.1 and, with MUSTER_OPUS, on 1.5.2 (preskip 312, P(1k)/P(3.1k) ~5e8, lag-0 correlation 0.907, 60 s within 126,119 B); the full suite, module/tests/run-suite.sh, 277 passed, 0 failed (of 277).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackend (S2)

What: the device half of voice notes (docs/design/voice-notes.md §9), in module/src/audio/:
- audio_io.nim: the AudioIo seam, in the shape of Transport and BlobStore. An AudioBuffer is
  the only thing a device thread touches: allocShared0, sized to its cap, linear (it never
  wraps), Atomic write and read indices, `capped`, `done` and the peak. captureInto and
  playFrom are the device side, under {.push stackTrace: off, checks: off.}, gcsafe,
  raises: [], with no GC and an explicit min() on every index (inbound_queue.nim:1-11's
  rule). Capture stops writing at the cap and sets `capped`. Playback writes zeros past the
  end and sets `done`. startCapture / stopCapture / startPlayback / stopPlayback / pump /
  close return "" or a refusal word (busy, no-device, no-source, wav-format, wav-truncated,
  no-sink, sink-write), with one stream at a time. Two in-process implementations whose pump
  runs that same device side 10 ms at a time on a clock: SyntheticAudioIo (a source PCM on a
  VirtualClock, and a collecting sink) and FileAudioIo (a 48 kHz mono s16 WAV source and a
  raw little-endian PCM sink, for S9's MUSTER_VOICE_SOURCE / MUSTER_VOICE_SINK).
- miniaudio.nim: MiniaudioIo(abSystem | abNull) over miniaudio 0.11.23. maDir finds
  miniaudio.h on the --path at compile time (querySettingSeq(searchPaths)). One global passC
  carries -I, MA_ENABLE_ONLY_SPECIFIC_BACKENDS (CoreAudio, PulseAudio, ALSA, Null) and the
  MA_NO_* switches; {.compile.} builds miniaudio.c. Every header type stays in this module,
  and MiniaudioIo's fields are plain pointers. The callbacks are cast to an importc'd
  ma_device_data_proc. The context is made on the first start and reused until close.
  abSystem lists CoreAudio (macOS), or PulseAudio then ALSA (Linux). It never lists Null and
  never passes an empty list (miniaudio's default order ends in Null), so with no backend the
  answer is no-device, never a recording of zeros.
- metadata.json: codegen.nim.packages gains mackron/miniaudio at f40cf03 (tag 0.11.23),
  sha256-ZrfKw5a3AtIER2btCKWhuvygasNaHNf9EURf1Kv96Vc=, nixpkgs' miniaudio.src. The builder's
  own fetchFromGitHub with that hash (nixpkgs e9f00bd) gives the same store path,
  /nix/store/hbgp7rm4383pyz4krdivaka6ssm6ig75-source. tools/nim-closure.sh fetches the new
  entry with no change, and its checkout equals that store path (diff -rq, .git aside).
  probe_return_marshalling_host's closurePaths picks it up from metadata.json.
- run-suite.sh: FLAGS gain --path:"$P/miniaudio". The checkout holds no .nim, .nims or
  nim.cfg, so the path changes no other test's imports.
- src/audio/CLAUDE.md: the two files; the device-thread rules; never import miniaudio
  outside this directory; abNull only in tests. tests/README.md: the two tests and $MA.

Checked by hand, not committed: the seam and MiniaudioIo built into an --app:staticlib
--noMain --mm:orc -d:useMalloc -d:release archive with the builder's nim 2.2.4 and clang
19.1.7, the miniaudio store path on --path. The archive defines ma_context_init and leaves
dlopen/dlsym/dlclose undefined. The module .lgx was not built (S3).

Why: the voice surface (S7) records and plays without the module thread touching a device or
the device thread touching the GC, and its tests need a device path that runs with no
microphone and no permission prompt. No C is tracked (scripts/check-no-cpp.py green). Nothing
in muster_module imports this yet; S3 links it into the plugin.

Tested-Behavior: audio_io_test §1-§5 and audio_io_null_test §1-§4 (module/tests/run-suite.sh audio_io_test | audio_io_null, darwin-arm64, nim 2.2.4, clang 19.1.7). Red first: both failed to compile before the modules existed ("audio_io_test.nim(9, 20) Error: cannot open file: ../src/audio/audio_io", "audio_io_null_test.nim(10, 20) Error: cannot open file: ../src/audio/audio_io"). Each behaviour was also turned red by mutation. With `capped` never set, audio_io_test §1 fails "`buf.capped == (w == buf.cap)` 4800" and audio_io_null_test §2 fails "not capped within 1002 ms (9600 frames)". With `done` never set, audio_io_test §3 fails "`buf.done and buf.readIndex == 2000`". With no peak, §2 fails "`buf.takePeak() == 1000`". MiniaudioIo without its busy check fails audio_io_null_test §4 "`io.startCapture(b) == "busy"`". Green: audio_io_test all OK; audio_io_null_test all OK on the null backend (backendName "Null"; first frames after 15-22 ms, all zero; capped at exactly 9,600 after 191-205 ms; 49,133-50,880 frames/s; a 100 ms buffer done after 78-86 ms) on 6 runs; probe_return_marshalling_host PASS with the new pin; the full suite, module/tests/run-suite.sh, 279 passed, 0 failed (of 279).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no nil buffer

What: the S2 review's findings on the audio seam (docs/design/voice-notes.md §9).
- miniaudio.nim reads every stream back after ma_device_init (S1's read-back rule). It binds
  ma_device's sampleRate and the client-side format and channels of its anonymous playback
  and capture structs. A start whose device reads back as anything but 48 kHz mono s16 is
  refused with "device-format", and `streamFormat(io)` exports the read-back. Before this,
  a wrong config (44.1 kHz, f32, stereo) passed every test. The null device only ever
  delivers zeros, and a 36,000..60,000 band measured from the start accepts 44,100.
- `done` now means heard, not handed over. An AudioBuffer gains a `tail`, the silence the
  device side writes after the last sample before it sets `done`, and a `silenceAfter`
  count. beginPlayback(tail) sets the tail. MiniaudioIo reads it back at start: miniaudio's
  intermediary buffer, plus the device's internalPeriods x internalPeriodSizeInFrames at the
  client rate, which is 1,920 frames on the null device. ma_device_uninit drops what those
  hold. The voice pump (S7) has no clock to wait on, so it can stop on `done` and drop only
  silence. The in-process sinks queue nothing, so their tail is 0 and they behave as before.
- Every start refuses a nil buffer with "no-buffer", on the module thread, before a context
  or device is made. On miniaudio's thread the nil would be dereferenced with checks off, a
  SIGSEGV in logos_host. SyntheticAudioIo and FileAudioIo refuse it too.
- `backendNames(b)` reads a backend list through ma_get_backend_name, with no context and no
  device, so the rule "abSystem never lists Null, never empty" is now tested.
- backendsOf returns a fixed array plus a count instead of a seq[ma_backend]. The seq's hooks
  had put an #include of miniaudio.h into system's C (seen in the pre-fix nimcache: a
  `ma_backend data[]` seq struct and `NIM_ALIGNOF(ma_backend)`). In a fresh nimcache of
  audio_io_null_test, and of an --app:staticlib build, only miniaudio.nim.c includes the
  header now. The header comment is corrected on three points: which files include the
  header and why; that nim links nothing into an --app:staticlib (the archive leaves dlopen,
  dlsym and dlclose undefined, rechecked with nm, for the plugin's own link in S3); and that
  the leftover symbols are three, not one.
- src/audio/CLAUDE.md and tests/README.md describe the new rules and checks.

Not changed: the review's last item, voice_device_e2e in run-suite's E2E list. The plan
and §17 put that test in S11, and src/audio/CLAUDE.md already says so.

Why: a regression in the stream config would have played notes at the wrong pitch, or
recorded garbage, on real devices with every test green. A stop on `done` could cut what the
device still held (40 ms on the null device's defaults) off a note's end. A caller's nil buffer would have crashed the
host from a thread Nim does not own.

Tested-Behavior: audio_io_test §3 (the tail: done only once 1,200 zeros followed the last sample, not in the callback that handed it over) and §6 (no-buffer from every in-process start, nothing opened); audio_io_null_test §1 and §3 (streamFormat == 48 kHz mono s16 on capture and playback; playback 48,000 frames/s ±5% between two reads 600 ms apart; a short buffer done only after its 1,920-frame tail of silence), §4 (no-buffer before any context) and §5 (backendNames: abNull is ["Null"], abSystem ["Core Audio"] on macOS, never Null, never empty). Run with module/tests/run-suite.sh unit audio_io (darwin-arm64, nim 2.2.4, clang 19.1.7). Red first: both tests failed to compile ("audio_io_null_test.nim(20, 13) Error: undeclared identifier: 'StreamFormat'", "audio_io_test.nim(133, 15) Error: undeclared field: 'tail' for type audio_io.AudioBuffer"). With everything but the nil checks in, both crashed at the new no-buffer blocks ("SIGSEGV: Illegal storage access. (Attempt to read from nil?)" after audio_io_test §5 and after audio_io_null_test §3). Mutations after the fix, each restored and cmp-checked: cfg.sampleRate 44,100 fails §1 "`io.startCapture(buf) == """; 44,100 with the refusal removed fails §1 "`io.streamFormat == Clip` (rate: 44100, channels: 1, s16: true)"; capture f32 stereo fails §1 at the start; Null added to abSystem fails §5 "`sys == @["Core Audio"]` @["Core Audio", "Null"]"; no tail at start fails §3 "`short.tail == 1920` 0"; done set with the last sample fails audio_io_test §3 "`not buf.done` done with the last samples still queued" and audio_io_null_test §3 "`short.silenceAfter >= short.tail` 0 frames of silence"; no nil check in SyntheticAudioIo.startCapture fails §6 with SIGSEGV. Green: audio_io_test 1-6 OK and audio_io_null_test 1-5 OK. Over 29 null runs, 16 of them 8 at a time, the rate was 47,211-49,159 frames/s and done came 111-130 ms into a 100 ms buffer, after 1,920-2,400 frames of silence. Gates green: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. gen-fake-backend --check is red, and equally red on HEAD and origin/main (FakeBackend.qml stale; no ui/ input touched here). The full suite was not rerun: only these two tests import src/audio/audio_io or miniaudio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… length no clip has

What: three of the S1 review's findings on the codec (docs/design/voice-notes.md §3.1, §3.2, §5).
- run-suite.sh links the libopus the .lgx ships. On macOS that is nixpkgs#libopus at the
  builder's pin (metadata.json nix.packages.runtime "libopus": 1.5.2 at e9f00bd), read off
  the same lock the Darwin branch already read for nim. builder_nixpkgs moves out of the
  no-nim branch, and builder_pin reads it once into NIXPKGS_PIN for both uses, so a Mac with
  nim on PATH still gets the pin's libopus. libsodium is unchanged (nixpkgs'). On Linux
  libopus stays where libsodium comes from, the one nix build with nixpkgs' gcc, so it shares
  that gcc's glibc. With MUSTER_SODIUM set and MUSTER_OPUS unset, Linux now stops with a
  message naming MUSTER_OPUS instead of linking nixpkgs' libopus under the host's compiler.
  MUSTER_OPUS still overrides on either OS. voice_codec_test §1 prints
  opus_get_version_string, now without a doubled "libopus".
- maxClipBytes raises ValueError for ms < 0 or ms > VoiceMaxMs. A negative ms wrapped
  through cbUint into a 121-byte budget, and an over-cap one sized a clip decodeRecord
  refuses (or overflowed). S4/S5 size the frame from this budget.
- Comments, corrected to what the code does:
  - MaxPacketBytes: 1275 is the largest Opus frame (RFC 6716 §3.2.1). A single-frame packet
    can be 1,276 bytes, its TOC plus the frame (opus.h's repacketizer doc says so); §3.2
    still refuses over 1,275.
  - Decode does not require "one 20 ms frame". It requires 20 ms of audio, 960 samples as
    opus_packet_get_nb_samples counts them off the TOC byte (and a code-3 packet's frame
    count), so several frames adding up to 20 ms pass. Fixed in voice_codec.nim's header and
    decodeClip doc, src/audio/CLAUDE.md's rule, voice_codec_test's header and §4, and
    tests/README.md. Checked against libopus 1.5.2's src/opus_decoder.c: opus_decode refuses
    only a packet longer than its frame_size.
  - The fixed-arity ctl: the silent 300000 read-back is what the prototype saw (§3.1). In
    this build on darwin-arm64 a fixed-arity SET failed with OPUS_BAD_ARG instead, since
    va_arg reads whatever is on the stack. Fixed in opus.nim, voice_codec_test §1 and
    src/audio/CLAUDE.md.
- run-suite.sh's header, tests/README.md and src/audio/CLAUDE.md say which libopus the
  runner links.

Not changed: the review's §3 alignment finding (a trim off by a multiple of 48 samples
passes voice_codec_test §3), which is outside this follow-up.

Why: the suite tested the codec on 1.6.1 while the .lgx ships 1.5.2. The hard-CBR 40-byte
packets, the 312-sample lookahead and "none padded" can change between versions. The budget
function would have sized a frame from a nonsense length without complaint. The comments
are rules S5 and S6 will read.

Tested-Behavior: voice_clip_test §3 (maxClipBytes refuses -1, -20, low(int), VoiceMaxMs + 1 and high(int) with ValueError) and voice_codec_test on the builder pin's libopus (module/tests/run-suite.sh voice_clip | voice_codec, darwin-arm64, nim 2.2.4 and clang 19.1.7 from the builder pin). Red first: voice_clip_test failed "voice_clip_test.nim(158, 5) `r == "refused"` maxClipBytes(-1) = 121 [AssertionDefect]"; voice_codec_test under the old runner printed "libopus libopus 1.6.1, preskip 312" and the link warned "libopus-1.6.1/lib/libopus.dylib was built for newer macOS version (14.0) than being linked (11.3)". Green: voice_clip_test 1-3 OK ("3. maxClipBytes(60 s) = 126,119, (500 ms) = 1,166; a negative or over-cap length refused OK"); voice_codec_test 1-5 OK printing "libopus 1.5.2, preskip 312" (/nix/store/f3kcnzp35b1wy1fg8cd4f2354xhww150-libopus-1.5.2, no libopus link warning), and MUSTER_OPUS=<the 1.6.1 store path> still runs 1.6.1. The pin read with no flake.lock (a fresh clone) gives the same github:NixOS/nixpkgs/e9f00bd8…; the Linux MUSTER_SODIUM-without-MUSTER_OPUS stop was checked as an isolated line with HOST_OS=Linux (exit 1 with the message; exit 0 with MUSTER_OPUS set or on Darwin), not on a Linux host. Full suite on the new runner (module/tests/run-suite.sh): "── 279 passed, 0 failed (of 279) ──"; voice_clip and voice_codec rerun green on the final tree. Gates green: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (the existing WARN), gen-claims-qml --check. gen-fake-backend --check was not run; it is red on origin/main and nothing under ui/ is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and miniaudio (S3)

What: the native build gate of docs/design/voice-notes.md (§8, §9, §17 S3).
- muster.lidl gains `voice_status() -> tstr`, and tools/regen.sh regenerated muster_gen.nim
  (its forward declaration, dispatch case and get_methods entry; 105 methods, the other 104
  entries unchanged). muster_gen.nim was not edited by hand.
- coordination/voice_live.nim, the hosted half's stub: a VoiceLive over an AudioIo, and
  `voiceStatus`, which returns §8's JSON: ready.codec from libopus (opus_get_version_string,
  through voice_codec), ready.capture and ready.playback "unknown" since nothing has opened
  a device, recording idle with capMs 60000, playing null. It opens nothing.
- muster_module.nim hosts musterVoiceStatus. It makes the voice half on the first voice call
  over newMiniaudioIo(abSystem), which makes miniaudio's context only on a first start, so
  libopus and miniaudio are both linked into the plugin and neither is opened.
- The host-return probe (derived-exo-526) calls voice_status. Its harness requires the method
  to be in the module's method table as QString, the JSON to name the libopus the harness
  itself links, and both devices to read "unknown". The probe gains opusTokens(), which finds
  libopus as sodiumTokens() finds libsodium (MUSTER_OPUS_LIB, which run-suite.sh already
  exports, then pkg-config, then the store; under the grader .probe-env/opus).
  closurePaths() already puts miniaudio's pin on the inner --path. scripts/grade-specs.sh
  links .probe-env/opus from the same nix build as its libsodium and gcc, or MUSTER_OPUS.
- src/audio/CLAUDE.md: muster_module.nim is the one place outside src/audio that imports
  miniaudio (it picks the implementation; voice_live codes against AudioIo), and voice_live
  is the one coordination file that links the codec. miniaudio.nim's header records the
  darwin plugin link. tests/README.md describes the probe's new call and libopus.

Not done here: the plan's push to the fork and `gh workflow run lgx-variants.yml` (this run
does not push). So the linux-amd64 and linux-arm64 plugin links have not been run.
voice_status does not drive a voice pump yet; that, and the six other methods, are S7.

Why: until this, nothing in the shipping module referenced libopus or miniaudio, and macOS
links a plugin with -undefined dynamic_lookup, so a missing library would show only at load.

Tested-Behavior: probe_return_marshalling_host (derived-exo-526): a host client calling voice_status on the module compiled into the host harness observes a typed QString whose JSON names the libopus linked ("libopus 1.5.2") with capture and playback "unknown". Run with module/tests/run-suite.sh probe_return_marshalling_host (darwin-arm64, nim 2.2.4, clang 19.1.7). Red first, twice: (1) the harness's new check with no libopus on its link failed to build: 'Undefined symbols for architecture arm64: "_opus_get_version_string", referenced from: _voiceStatusTyped… ld: symbol(s) not found for architecture arm64'; (2) with opusTokens() added and no voice_status in the module, the client observed {"call":"voice_status","expect":"voice_status","client_type":"empty","client_value":"","client_return_typed":false} and the probe failed "`allTyped` a host client observed a non-typed (bool/empty/default) return". Green: PASS probe_return_marshalling_host 17s, 16 of 16 observations typed, lp_stub_calls 0, voice_status's client_value {"ready":{"capture":"unknown","playback":"unknown","codec":"libopus 1.5.2","note":""},"recording":{"state":"idle","elapsedMs":0,"capMs":60000,"peak":0,"capped":false,"silent":false,"error":""},"playing":null}. Mutations after the fix, each restored and cmp-checked: opusTokens() left off the link fails the build with _opus_get_version_string undefined, referenced from _opusVersion in voice_codec.nim.c.o (the module's own reference) as well as from the harness; "codec": "libopus" in voice_live fails the probe with client_return_typed false. Full suite: module/tests/run-suite.sh, 279 passed, 0 failed (of 279). The gate, on the tree this commit holds (staged, then built): cd module && nix build .#lgx-portable -o ../.run/mac/voice-module-lgx, exit 0 in 92 s at 3.3 GB peak; .lgx sha256 9b18872e7c679ec0f018bcbe88bb0c107e0d44fde779ed54585994a53e419379, one variant darwin-arm64. otool -L muster_module_plugin.dylib lists @loader_path/./libopus.0.dylib beside @loader_path/./libsodium.26.dylib, no /nix/store load path in any bundled dylib, and no CoreAudio, AudioToolbox or AudioUnit; libopus.0.dylib names itself @loader_path/./libopus.0.dylib and links only libSystem. nm: "T _ma_context_init", "U _opus_get_version_string" (from libopus); nm -u shows no _ma_ and no _logos_module_ symbol. Headless, in a fresh LOGOSCTL_CONFIG_DIR with logosctl 0.3.2 (aarch64-macos): daemon config set - ({}), daemon start --detach, package install of this .lgx and of the eight .lgx in .run/basecamp/catalog-0.3.2, module load muster_module ("status":"ok", eight dependencies loaded), then `logosctl call muster_module voice_status` printed exactly: {"method":"voice_status","module":"muster_module","result":"{\"ready\":{\"capture\":\"unknown\",\"playback\":\"unknown\",\"codec\":\"libopus 1.5.2\",\"note\":\"\"},\"recording\":{\"state\":\"idle\",\"elapsedMs\":0,\"capMs\":60000,\"peak\":0,\"capped\":false,\"silent\":false,\"error\":\"\"},\"playing\":null}","status":"ok"}; health answered "ok"; daemon stop. Gates green: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (its one existing WARN), gen-claims-qml --check. gen-fake-backend --check is red, as on origin/main (FakeBackend.qml stale; nothing under ui/ touched).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ens a device, libopus apart

What: the S3 review's findings (all minor), against docs/design/voice-notes.md.
- §8's type rule now says what voice_status returns: integers, strings, booleans and null,
  never a float, and never audio. capped and silent are flags (voice_record_stop's draft
  carries silent too) and playing is null when idle, so the doc is amended rather than the
  JSON. muster.lidl's description says the same words, and voice_live's doc comment matches.
- A device opens in voice_record_start or voice_play, not "on the person's own action": with
  the access policy off any co-resident module can make those calls (§13). Reworded in
  muster.lidl, muster_module.nim's voice block, voice_live.nim, miniaudio.nim's
  newMiniaudioIo, and §9's device lifetime, which said the same and contradicted §13.
- miniaudio.nim's header: the darwin plugin binds dlopen, dlsym and dlclose to libSystem
  (nm -m on the S3 .lgx, sha256 9b18872e…9379, shows all three "(from libSystem)"). And
  nothing calls close yet: muster_module has no unload hook (muster_gen.nim exports seven
  logos_module_* entry points, none an unload), and nothing in it starts a device before S7.
  §9 and §17's S7 now carry close-before-unload as S7's, since a context left alive when the
  host unloads the plugin is a crash risk; S7's voice_live_test holds §8's type rule in every
  state.
- scripts/grade-specs.sh resolves libopus in a nix build of its own, after the libsodium and
  gcc pair. Before, one build named all three under `|| true`, so a libopus that failed to
  fetch or build left every libsodium probe without its library and the nix gcc as well.
- The doc's muster_module.nim citations follow the S3 lines and this commit's one added
  comment line: :2776 to :2778 (fileshare_files' pump), :2838-2845 to :2854-2861
  (coordinate_intents' pump list), :4566-4578 to :4582-4594 and :4574-4577 to :4590-4593
  (musterCoordinateMessages), :4574 to :4590, and :4624-4625 to :4640-4641
  (coordinate_conversations' lastTs; the review missed these two).
- §15's Linux-only checks gain grade-specs.sh exo-526 (the .probe-env/opus path no Mac run
  takes) and the Linux plugin links through lgx-variants.yml: each variant's lib dir must hold
  libopus.so.0, its NEEDED and RUNPATH must resolve it, since a plain load of libopus that
  fails takes all of muster_module down, and dl* must resolve from glibc. §18 asks the
  maintainer to sign off on exo-526's wider oracle (its criterion names health, propose and
  status; S3 added voice_status), or to move those checks to a voice spec's probe.

Not done: no lgx-variants dispatch (nothing is pushed), so the Linux plugin links are still
unrun; grade-specs.sh's oracle was not run (exophial is not installed on this Mac); the .lgx
was not rebuilt (no compiled code changed: comments, the LIDL description and docs only).

Tested-Behavior: scripts/grade-specs.sh exo-526, run from the repo with a stand-in `nix` first on PATH (an external subprocess: it prints one store path per installable and, with STUB_OPUS_FAIL=1, fails the whole build when libopus is named), a stand-in exophial python (the oracle is not installed here, so nothing was graded) and KEEP_PROBE_ENV=1, then reading module/.probe-env. Red, at HEAD with libopus failing: "warning: libsodium not found (set MUSTER_SODIUM); probes that need it will not build" and the libopus warning, one call `nix build nixpkgs#libsodium.out nixpkgs#gcc.out nixpkgs#libopus.out --no-link --print-out-paths`, and .probe-env with no sodium, no opus and no cc link. Green, after the fix with libopus failing: only "warning: libopus not found (set MUSTER_OPUS); exo-526's host probe will not build", two calls (`nix build nixpkgs#libsodium.out nixpkgs#gcc.out …` then `nix build nixpkgs#libopus.out …`), and .probe-env/sodium and .probe-env/cc linked; with libopus building, all three linked; with MUSTER_OPUS set, no libopus build and .probe-env/opus is MUSTER_OPUS's lib. bash -n clean. The slice's probe after the edits: module/tests/run-suite.sh probe_return_marshalling_host, PASS 17s (18s again after a last comment reflow in voice_live.nim), 16 observations typed, lp_stub_calls 0, voice_status {"ready":{"capture":"unknown","playback":"unknown","codec":"libopus 1.5.2","note":""},"recording":{"state":"idle","elapsedMs":0,"capMs":60000,"peak":0,"capped":false,"silent":false,"error":""},"playing":null}. The edited muster.lidl through the SDK's lidl-gen (the binary tools/regen.sh built for S3): "105 methods", output cmp-identical to the committed muster_gen.nim. Full suite: module/tests/run-suite.sh, 279 passed, 0 failed (of 279). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (its one existing WARN), gen-claims-qml --check, all exit 0; gen-fake-backend --check exit 1 (FakeBackend.qml stale), as on origin/main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e session (S4)

What: fixed-size sealed frames, docs/design/voice-notes.md §4 as revised (22d9cb6), §3.4, §17 S4.
- session.publish(ev, padTo = 0). With padTo > 0 the event's JSON is followed by ASCII
  spaces up to exactly padTo bytes before it is sealed. padTo = 0 is today's frame, byte
  for byte, for every key but one.
- A voice/ key is always padded to VoiceFrameBytes (90,112), so every voice frame is
  1 + 24 + 16 + 90,112 = 90,153 B on the wire. The key decides, never the caller: publish
  pads the new event and the held copy it re-seals alike, so a retried Send or a
  re-announce cannot go out unpadded. Any other non-zero pad for a voice key, and any
  negative pad, raises ValueError. "Voice key" is the key's first segment, as authorOf
  classifies a kind, so a bare "voice" is one and "voicemail/…" is not.
- FrameTooLarge (a CatchableError): an event whose JSON is over its pad raises before it is
  ingested, journalled or sent, so the log, its heads, the journal and the topic are as
  they were.
- The pad is computed after the held/new resolution and before s.log.ingest; the ingest,
  sealedIn, seal, journal and transport steps are otherwise unchanged. encodeEvent stays
  private; decodeEvent and the ingest path are untouched.
- VoiceFrameBytes lives in session.nim, the layer that enforces it. S5's fold can import it
  from there.
- Not done, as the revised doc says: publishAuthored gains no padTo, and no call site passes
  a pad. The plan's caller-supplied pad is superseded. authorship.nim is unchanged: the
  authorOf voice branch is S5's, so a voice part cannot be author-signed yet, and §5 of the
  test signs a message/ event instead.

Measured, not arithmetic (§5): the worst-case event JSON (a 63,060 B half as 84,080 base64
characters, a 128-hex author in the key, "parents":[], 8 heads in after, afterSig) is 85,257
B, 4,855 B under the pad, which agrees with §5's figures.

Found on the way: at nim 2.2.4 (the builder pin's nim-wrapper), parseJson accepts trailing
spaces and also trailing NUL bytes, and refuses a trailing letter or "}". §4's "refuses
trailing garbage" holds for those, not for NULs. Nothing pads with NULs. The test now finds
the JSON's end by re-serializing what parseJson read, so a NUL pad fails it (below).

Tested-Behavior: session_pad_test (new), over LocalTransport with a plain subscriber recording every frame on the topic and the session's journal recorded: (1) every voice/ frame is exactly 90,153 B with no pad passed, for a 1 KB and an 84 KB value with 0, 1 and 8 (MaxHeads) heads and again on the held path, the plaintext the JSON then spaces only; a bare "voice" key padded, "voicemail/1" not; (2) a padded frame decodes through the unchanged ingest path, a member's live receive and a replay of the journal, into the same event (id, after, afterSig); (3) FrameTooLarge for a voice event over 90,112 B and for an event over an explicit padTo, ValueError for a voice key with padTo 4096 and for padTo -1, each leaving the log, its heads, the journal, the topic and the other member unchanged; a pad of exactly the JSON's length fits with no space, one byte less raises; (4) padTo 0 is today's frame byte for byte, held to three literal JSON plaintexts; (5) an author-signed message published with padTo 8192 reaches the other member, whose roomEvents keeps it with its signed after. Run with module/tests/run-suite.sh session_pad (darwin-arm64, nim 2.2.4). Red first, twice: before any session change, with sections 1-2 only, "session_pad_test.nim(99, 7) `frame.len == WireBytes` 1 KB, 0 heads: a 1554 B frame [AssertionDefect]"; with every section, "session_pad_test.nim(120, 12) Error: undeclared identifier: 'VoiceFrameBytes'". Green: PASS session_pad_test, 1-5 OK, printing "1. every voice/ frame is 90153 B: 1 KB and 84 KB values, 0, 1 and 8 heads, the held path; worst-case event JSON 85257 B, margin 4855 B OK". Mutations of session.nim, each restored and cmp-checked: no key rule fails §1 ("1 KB, 0 heads: a 1554 B frame"); padding after ingest fails §3 ("a voice/ event over VoiceFrameBytes: the log holds it"); the held path unpadded fails §1 ("held path: a 1554 B frame"); padding every key fails §1 ("voicemail/ is not a voice key"); refusing at equality fails §3 (FrameTooLarge "an event of 54 B does not fit a 54 B frame"); a NUL pad first got to §5, then, after jsonEnd was changed to re-serialize, fails §1 at `b == byte(' ')`. Full suite: module/tests/run-suite.sh, "── 280 passed, 0 failed (of 280) ──" (279 before plus session_pad_test). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (exit 0, its existing WARNs on unverified claims in authored family files), gen-claims-qml --check, all exit 0. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es its author in its key (S5)

What: docs/design/voice-notes.md §3.3, §6, §7 as revised (22d9cb6), §17 S5.
- coordination/voice.nim (new), pure: it imports voice_clip for the record's structural
  checks and never libopus or a device. clipDigest is a muster.voice.clip.v1 hash-input
  record over the record's bytes; noteIdOf(author, clip digest, ts) is 16 bytes (32 hex) of a
  muster.voice.note-id.v1 record. voiceEvents(author, clip, ts) gives the nid and exactly two
  unsigned parts, voice/<nid>/<author>/<i>/2, value {v, ts, durMs, digest, part}: integers
  and strings only, the record's halves (the larger first) as base64, no parents. The author
  signs and publishes each with publishAuthored; session.publish pads the frame itself (S4).
  voicePartEvents builds parts from any bytes, for what a fold must refuse. VoiceParts 2 and
  VoiceMinMs 500 (S7 refuses too-short) live here.
- reduceVoice(events) folds the room's authentic view into {nid, author, ts, durMs, state,
  parts} ordered by (ts, nid), state complete | arriving | broken, by §6's rules: the key
  exactly voice/<32 hex>/<128 hex>/<0|1>/2, lowercase; of each part, the first well-formed
  copy in canonical order stands; a note is kept only when a part's own (digest, ts) derives
  its nid from its author (squatting dropped); both parts must agree on ts, durMs and digest,
  and the record they make must match the digest, pass decodeRecord and be its own length.
  `parts` names the standing parts' event ids, for S7's verdict memo. voiceRecordOf(events,
  nid) is a complete note's record, as checked.
- threadRows(events) merges reduceMessages (unchanged) with one row per note, ordered by
  (ts, id), a message first at an equal (ts, id). A note's row has an empty body, the fold's
  author, and the note in its own field; rowJson writes {id, author, ts, body} and adds
  `voice` {v, note, durationMs, state} on a note's row only, so no message/ event, whatever
  its body or value claims, puts a voice field on a row. threadLastTs counts a note.
- authorship.authorOf: a `voice` branch, first, that reads the author off the key's third
  segment and never parses the 84 KB value; any `voice` shape is author-bearing, so one too
  short to name an author never verifies. The header table gains the row. authorship_test
  gains §11, appended before its final echo (invariant tests are append-only), and a header
  line for it.
- Where the doc is silent, decided here: a malformed part (key or value: extra fields, v other
  than 1, a non-integer ts or durMs, durMs outside 0..VoiceMaxMs, a digest that is not 64
  lowercase hex, base64 that is not canonical) is dropped as if never sent, never raised on;
  well-formed parts that disagree make the note broken. The squat check is per note, not per
  part, so parts with different digests under one nid read broken rather than arriving.
  voiceEvents raises ValueError for an author that is not 128 hex. authorSig is optional in
  the fold, the room's view having verified it.
- Probes, as oracles for the spec that waits on the maintainer's exo id:
  probe_voice_fixed_size_stepper (0.5, 1, 10, 30, 59.98, 60 s x 0, 1, 8 heads x epochs 0, 1,
  each note also sent again on the held path: every frame 90,153 B, part 0's after names the
  heads, Bob folds it complete at that epoch, its record byte-identical),
  probe_voice_author_stepper (genuine, forged, unsigned, other room, edited, squatted x
  either part, plus Mallory's signed message/ claiming voice-note with Alice's nid, with and
  without Alice's note: no voice row but the fold's), probe_voice_order_stepper (every order,
  doubling and subset of p0, p1, a copy of p0 and an equivocating p1' that sorts first),
  over the shared fixture probes/voice_room.nim (synthetic CDE clips of 40-byte packets, so
  no probe links libopus; probes/config.nims and grade-specs.sh unchanged).
- tests/README.md lists voice_test and the probes; audio/CLAUDE.md names voice.nim;
  voice_live.nim's S7 note says threadRows goes into coordinate_messages there.

Not done: threadRows is not wired into coordinate_messages or coordinate_conversations'
lastTs, and there is no memo of complete-note verdicts (both S7, the hosted layer); flow,
room map and provenance rows for voice/ (S6); no spec JSON, so grade-specs.sh grades none of
the three probes (and exophial is not installed on this Mac); the .lgx was not rebuilt
(nothing in muster_module imports voice.nim yet; authorship.nim compiled into the module via
probe_return_marshalling_host, PASS).

Tested-Behavior: voice_test (new, 11 sections over LocalTransport, synthetic clips, no libopus), authorship_test §11 (appended), and three new stepper probes, run one at a time with module/tests/run-suite.sh <substring> on darwin-arm64 (nim 2.2.4). Red first, before any source change: authorship_test ran sections 1-10 OK, then "authorship_test.nim(209, 5) `authorOf(e).authored` author-bearing shape: voice/0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f/884b…3f50/0/2 [AssertionDefect]"; voice_test "voice_test.nim(49, 27) Error: cannot open file: ../src/coordination/voice"; probe_voice_author_stepper, probe_voice_fixed_size_stepper and probe_voice_order_stepper each "voice_room.nim(17, 30) Error: cannot open file: ../../src/coordination/voice". Green: PASS voice_test, 1-11 OK ("4. a note of 0.5 s, 10 s or 60 s leaves as two frames of 90153 B OK", "10. threadRows: messages and notes by (ts, id), a metadata-only voice field, lastTs counts a note, a forged voice-note body stays its author's plain row OK", "11. a member admitted after a note cannot fold it; one sent in their epoch they hold OK"); PASS authorship_test with "11. a voice/ part names its author in its key: every shape is author-bearing, only that member's signature counts OK"; PASS probe_voice_author_stepper, probe_voice_fixed_size_stepper, probe_voice_order_stepper (each run by hand checks every case; in stepper form each prints {"successors":[{"case":…,"decision_correct":true}]}). Mutations, each restored and checked: voice.nim with no squat check fails voice_test §6 and the author probe (case 10, squatted); the last copy standing fails §8 and the order probe; no digest check fails §8 ("bytes that miss the digest: complete") and the order probe; no length check fails §8 ("the wrong length: complete"); accepting non-canonical base64, or any n in the key, fails §7; no agreement check fails §8 ("another ts: complete"); a message body of kind voice-note made a voice row fails §10 and the author probe (case 12, forged row); session.nim padding no voice key fails voice_test §4 ("500 ms: a 1527 B frame") and the fixed-size probe (case 0); authorOf reading a voice part's author from its value fails authorship_test §11, voice_test and the author probe. Full suite: module/tests/run-suite.sh, "── 284 passed, 0 failed (of 284) ──" (280 before, plus voice_test and the three probes). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, all exit 0. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m, audio over graph and proof

What: the S5 review's findings (docs/design/voice-notes.md §6, §10, §13, §15, §17, §18).
- Blocker. A member could sign a voice part that crashed the fold. std/base64's decode
  (Nim 2.2.4, lib/pure/base64.nim:252-262) skips whitespace inside its 4-character loop and
  then reads past the end of the string: "    AB" and "AAAA     AAA" (a multiple of 4 long)
  each raise IndexDefect, which `except CatchableError` never caught, so reduceVoice,
  threadRows, threadLastTs and voiceRecordOf all aborted. voice.nim now decides canonical
  base64 before any decode: a length that is a multiple of 4, the standard alphabet (a
  table), `=` only as final padding, zero spare bits. A string that fails is no part
  (dropped), as before for non-canonical base64; one that passes is the one spelling of its
  bytes, so the `encode(raw) == s` re-check is gone.
- The fold is now two passes. The header pass (parsePart, gather, header) checks key and
  value shape, base64 spelling included, decodes nothing, and decides which parts stand and
  which notes are kept. The verdict pass (verdict) decodes the halves and checks agreement,
  digest and record. reduceVoice(events, memo = nil) and threadRows(events, memo = nil) take
  an optional VoiceMemo (newVoiceMemo, len, hits): verdicts, complete and broken alike, keyed
  by the two standing parts' event ids, so a part that comes to stand in another's place is
  judged afresh. S7's hosted layer keeps one per session; nothing hosted uses it yet.
  threadLastTs uses the header pass alone. Measured on darwin-arm64, -d:release, 20 notes of
  60 s in the authentic view, 30 calls each (scratch, not committed): before, reduceVoice
  45.68, threadRows 46.92, threadLastTs 47.13 ms a call; after, reduceVoice 43.63 (no memo),
  12.10 (warm memo), threadRows 44.95 / 13.18, threadLastTs 13.20. What a warm memo still
  pays is parsing each 84 KB value and hashing its id.
- Major (doc). coordinate_graph copies every field of a value into an entry's `content`
  (room_graph.nim:198, :348) and coordinate_proof returns every raw value (proof.nim:79,
  muster_module.nim:3230), so either would hand any caller a note's Opus, against "audio
  plaintext never crosses IPC". §10 now says so, with §1, §13 and §15. S6 (§10, §17) strips
  `part` from a voice/ entry's room-map content and pins it field by field. The proof is put
  to the operator in §18 (Still open): name the proof export as an exception, or a new proof
  form that carries voice parts as committed digests (today's event id hashes the value's text,
  log.nim:48-58, so the field cannot simply be dropped). No code change for either here:
  room_graph is S6's, and the proof needs that decision.
- Minor, tests. §7: whitespace in the base64 ("    AB", "AAAA    A", "  ABC",
  "AAAA\n\n\n\nAB", "AAAA     AAA", "AAAA\n\n\n\n\nAAA", "    AAAA"), URL-safe, set spare bits,
  padding inside or alone, durMs -1 and VoiceMaxMs+1 beside Alice's genuine part 0, each signed
  by her, read arriving; a part 0 of -1 or VoiceMaxMs+1 ms alone names no note; and 4,000
  random strings over "ABQRgw+/=-_ \n\r" as part 1's half read broken exactly when std/base64
  round-trips them (handed only strings without whitespace) and arriving otherwise, never
  raising. §8: part 0 the odd one (another ts, another clip, bytes that miss the digest) beside
  a genuine part 1 reads broken, ts and durMs read off part 1. §10: a note whose nid sorts
  before a message id at one ts and one after (a message id is "0x"-prefixed hex, so only a
  nid starting "0" sorts first; salts tried until each exists), in (ts, id) order. §12 (new):
  the memo. probe_voice_author_stepper gains ten malformed cases (five whitespace strings x
  either part), appended after the forged-row cases so case numbers 0-13 are unchanged.
- tests/README.md's voice_test and author-probe entries say so.

Not done: S6's room-map stripping and the operator's answer on coordinate_proof; the hosted
memo (S7); roomEvents' authorship cost over 84 KB values is not measured. Seen, not touched:
transport/received.nim:50 and bitcoin/psbt.nim:167 also hand strings to std/base64's decode;
whether either can be handed whitespace is not checked here.

Tested-Behavior: voice_test (§7, §8, §10 extended; §12 new) and probe_voice_author_stepper (ten malformed cases), run with module/tests/run-suite.sh <substring> on darwin-arm64, nim 2.2.4, -d:release. Red first, before any source change: voice_test "voice_test.nim(558, 14) Error: undeclared identifier: 'newVoiceMemo'"; the same test without §12, on an export of HEAD, ran 1-6 OK then "Error: unhandled exception: index 6 not in 0 .. 5 [IndexDefect]" in §7; probe_voice_author_stepper on that export, "Error: unhandled exception: index 6 not in 0 .. 5 [IndexDefect]". Green: "── 5 passed, 0 failed (of 5) ──" for voice_test, probe_voice_author_stepper, probe_voice_order_stepper, probe_voice_fixed_size_stepper and authorship_test; voice_test 1-12 OK ("12. a memo of verdicts keyed by the standing parts' ids changes no fold; another part standing is judged afresh; threadLastTs is the newest row's ts OK"); the author probe in stepper form, {"case": 23} gives {"successors":[{"case":0,"decision_correct":true}]}. Mutations on a scratch copy of the final tree, each run with run-suite.sh all voice_test probe_voice: the review's M1 (rows by ts alone) fails voice_test at 518 (the equal-ts order); M2 (no durMs range check) fails at 289 ("durMs -1"); M5 (the note read off part 0 whenever it is in) fails at 141 ("one note …, not 0", §8's part-0 cases); a base64 screen that admits any character, and one that admits only whitespace besides the alphabet, each make voice_test and probe_voice_author_stepper die on "index 12 not in 0 .. 11 [IndexDefect]" ("AAAA     AAA"); a memo keyed by (nid, author) fails at 587 ("not the old pair's verdict"). The order and fixed-size probes pass under all six, and the author probe under M1, M2, M5 and the memo mutant (voice_test kills those). Full suite: module/tests/run-suite.sh, "── 284 passed, 0 failed (of 284) ──" (no test added: voice_test grew from 11 sections to 12). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, all exit 0. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nce; the map carries no audio (S6)

What: docs/design/voice-notes.md §10 and §17 S6, and the S5 review's major finding
(e755164): coordinate_graph served every voice part's half of the clip, as base64 Opus,
to any caller.
- flow.nim: a voice/ branch before the unknown-prefix fallback. Each part gives exactly
  room-member voice-note and voice-note-length (the members who held its epoch's key) and
  store-node timing, topic and fixed-size; no other observer, and no new Observer. It goes
  by the key's prefix, as session.publish pads every voice/ key (S4), and never parses
  the value.
- room_graph.nim: "voice" joins AuthorSigned, so a part provenance lists is author-signed
  and credited to the member its key names. Beside the file/ branch, an attribution
  branch: an authentic entry under voice/ that is no note's part (so not in provenance)
  is still author-signed by the key's author, uncounted. A voice part's content is
  voiceContentOf: v, ts and durMs where each is an integer, the digest where it is 64
  lowercase hex, and nothing else (no part, no authorSig), so no audio rides the map
  coordinate_graph serves, whatever a part holds. facetsFor gains a voice case (who sent
  it: imperative, its author's signature; what it says: motivational, its author) where
  the map said "no guarantee is declared for this kind of entry".
- intents.nim logProvenance: a voice item, icPeerMessage, kind voice, "a voice note (part
  i of 2)", account the key's author, accountable, the guarantee text a message carries,
  its epoch from sealedIn. It is classed by the fold's own key rule (voice.nim's partKey,
  now exported as voicePartKey) and never by the value; a voice/ key that is no note's
  part is not listed.
- coordinate_proof is unchanged: it returns raw events by design, and §18 leaves the
  audio in a proof export to the operator.
- Docs: voice-notes.md's §10 row for co-resident modules and §15's bullet now say the room
  map is closed and the proof still returns the audio; room-map.md's "content, all of it"
  and room_graph.nim's header name the voice exception; tests/README.md's voice_test entry.

Decided here, where the doc and plan are silent:
- Provenance lists a part by its key's shape alone, so a voice entry's "counted" in the
  map means an authentic part of a note's key shape, not the fold's standing part: an
  equivocating copy, a squatted nid or a malformed value is still listed. Telling those
  apart needs the value, which the plan keeps these three folds from parsing.
- The map type-checks each field it keeps, so a part whose digest is not 64 hex (in the
  test, one holding its own half of the clip) shows no digest.
- The voice facets above.

Not done: coordinate_graph is not driven through the hosted module here (the room map is
tested at reduceRoomGraph and its toJson, which is what coordinate_graph serves beside
"dropped"; voice_live_test is S7); labels for the new flow fields and the voice kind in
FlowView and the room map's view (S8); coordinate_proof (the operator's decision, §18).

Tested-Behavior: voice_test §13-15, appended before its final echo, run with module/tests/run-suite.sh all voice_test on darwin-arm64 (nim 2.2.4). Red first, before any source change: "voice_test.nim(627, 3) `fields(obRoomMember) == @["voice-note", "voice-note-length"]` @[] [AssertionDefect]" (§1-12 OK). A scratch copy with §14 replaced by a non-aborting probe, run against the same tree and deleted uncommitted, printed "RED-PROBE 14: 16 of 16 voice parts' halves are in the served map; a voice entry's content keys: @["authorSig", "digest", "durMs", "part", "ts", "v"]" and then §15 failed with "zz_s6_red_scratch_test.nim(692, 5) `mine.len == 1` voice/74e3…/2df0…/1/2: 0 [AssertionDefect]". After the flow branch alone: "13. … OK", then "voice_test.nim(675, 5) `n.attrs["basis"].getStr() == "author-signed"` voice/afc3…/af06…/0/2: "sealed" [AssertionDefect]". After the room map alone: "voice_test.nim(678, 5) `n.attrs["counted"].getBool() == part` …: counted false [AssertionDefect]". Green after provenance: PASS voice_test, 1-15 OK, "voice_test: all OK". Mutations, each applied alone and restored, each FAIL in voice_test: the flow without fixed-size (628, `fields(obStoreNode)` got @["timing", "topic"]); voice rows to the storage peer too (630, "storage-peer learns of a voice note"); the map's content unstripped (691, keys); no digest check (691, keys); voice not in AuthorSigned (675, basis); provenance listing any voice/ key (675, basis); no fallback attribution (675, basis); no voice facets (682, abouts); provenance's epoch read by position (729, `it.epoch == bobS.sealedIn[...]`). Related run (voice_test, flow_test, room_graph, provenance, epoch_attribution, swarm_test, probe_room_map, authorship_test, probe_voice): 20 of 21 passed; room_graph_scale_test missed its timing budget there under 8-way load (1875.5 vs 797.1 ms), then passed alone twice (550.4 vs 522.1 ms; 551.0 vs 522.6 ms), as on the tree before this change (550.1 vs 537.5 ms). Full suite: module/tests/run-suite.sh, "── 284 passed, 0 failed (of 284) ──", room_graph_scale_test 959.7 vs 934.8 ms and probe_return_marshalling_host (which compiles nim-lib/muster_module.nim, intents.nim now importing voicePartKey) PASS. Gates, all exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view; an older client's audio

What: the S6 review's findings on 9ad9963 (docs/design/voice-notes.md §6, §7, §10, §13, §15, §18).
- Major. The room map counted voice parts the voice fold refuses: a value of the wrong shape, an
  equivocating later copy and a squatted note id were counted, against room_graph's own rule that
  an entry the fold refused is marked so (invariant 9). The reason given in 9ad9963 ("the plan keeps
  these three folds from parsing") did not hold: voiceContentOf already parsed every value.
  voice.nim gains voiceStanding(events): the header pass's standing parts of the notes the fold
  keeps, by event id, exactly the ids reduceVoice's notes name in `parts`, whatever each note's
  state (rules 2 to 4; it decodes no clip). logProvenance lists a voice item only for a standing
  part, and the room map counts what provenance lists, so its `counted` is now the fold's word.
  voicePartKey is private again: no other fold reads a part off its key alone.
- Major (doc). A member on Muster 0.3.x or earlier returns every note's Opus through their own
  coordinate_graph and coordinate_proof: on main, authenticEventsWith passes every event authorOf
  does not mark authored (authorship.nim:238-240) and authorOf has no voice/ branch, decodeEvent
  reads a padded frame unchanged (session.nim:82-90), and contentOf copies every field
  (room_graph.nim:198, :348). This PR cannot reach that code. voice-notes.md now says so in the §10
  table (a new row) and as "What still leaks" item 6, in §7 (the 0.3.x map entry's content carries
  `part`), §13, §15, §18 (a question for the operator) and the S10 gap list (exposed; the party is
  the modules loaded beside an older member's Muster). room-map.md says the same.
- Minor. The flow gave an entry under voice/ that is no note's part the room-member rows. It now
  gives `voice-note` and `voice-note-length` only to a standing part; any other voice/ entry gets
  the store node's timing, topic and fixed-size alone (the sending client's session.publish pads
  every voice/ frame, whatever its shape).
- Minor. An uncounted entry the map still names its signer of (author-signed by authorship.nim)
  had the facet "the fold refused it, so it counts for nothing and names no one". facetsFor takes
  `attributed`, and such an entry now reads "the fold counts it for nothing; its author signed it,
  bound to this room". That covers voice/ entries that are no note's part, and, beside them, every
  file/ entry, account disclosure and ceremony join, which provenance never lists and the map has
  always named. RoomMap.qml's lead for such an entry says the same instead of "so it names no one".
- Minor. Comments: room_graph.nim's header and room-map.md now say the map strips audio "so that
  no hosted method but coordinate_proof returns audio"; voicePartKey's doc names rule 2's key half;
  intents.nim and the map's fallback name rules 2 to 4; flow.nim says fixed-size is padded by the
  sending client and not checked on ingest, so for another member's entry it is what an honest
  client sends.
- Minor, test. §14's leak check took the whole half and one 48-character window at its midpoint.
  It now looks up every 48-character window of every half, at a 24-character step and at its end,
  among all the served map's 48-character windows (a set), so a leaked stretch of 71 characters or
  more is found wherever it sits. The doc and tests/README.md say exactly that.
- Minor. The room map's store-node facet for a voice entry names the one fixed size, which marks a
  voice note but not its length.
- Minor (doc). §10's account of coordinate_graph is in the past tense, "What the code adds" is
  marked built with the test that pins each item, the status line and §15's lead say S1-S6 are
  built, and the new code is cited "on this branch".

Not taken as given: the review's citation finding. The doc says every file:line is to main at
67701f8, and on main room_graph.nim:69 is AuthorSigned, :327 the author-signed line, :198 and :348
contentOf's copy and its use, flow.nim:121-124 the unknown-prefix fallback and intents.nim:829
logProvenance, so those citations were right. New code is now cited "on this branch".

Not done: coordinate_proof (the operator's decision, §18); a 0.3.x client is not run against a voice
note, its behaviour is read from main's source; RoomMap.qml's new lead is checked by qmllint only,
not seen on a display; the cost of two header passes in a room-map read (provenance and the flow
each run voiceStanding) is not measured, and coordinate_graph, coordinate_flow and
coordinate_provenance are each called on demand, not polled.

Tested-Behavior: voice_test §13-15 and swarm_test §8, run with module/tests/run-suite.sh all voice_test swarm_test on darwin-arm64 (nim 2.2.4). The tests add four genuinely signed voice/ entries that are no note's part: a key the fold refuses (i "x"), a value it refuses (a digest field holding a half), an equivocating second copy of Bob's part 0 that sorts after his first, and Mallory's own clip under Bob's note id (squatted). Red first, the final tests against 9ad9963's four source files (put back afterwards, cmp-checked): "voice_test.nim(689, 7) `mine == @[("store-node", "fixed-size"), ("store-node", "timing"), ("store-node", "topic")]` voice/…/x/2, no note's part: @[("room-member", "voice-note"), ("room-member", "voice-note-length"), ("store-node", "fixed-size"), ("store-node", "timing"), ("store-node", "topic")] [AssertionDefect]" (§1-12 OK), and "swarm_test.nim(190, 7) … {"about":"this entry","class":"not-applicable","party":"","mechanism":"the fold refused it, so it counts for nothing and names no one"} [AssertionDefect]". A scratch copy whose §13-15 doAsserts report and go on (its lines 6 past the test's), run in the same go and deleted uncommitted, gave 36 failures: §13 all four extras given room-member rows (x4); §14 the bad value, the equivocating copy and the squat counted (x3) with a counted part's facets (x3), no voice entry's store-node facet naming the fixed size (x18), the uncounted facet saying "names no one" or absent (x4), Bob's part 0 counted twice (x1); §15 the same three listed in provenance (x3). Built up in steps before the squat was added: after voiceStanding in the flow and provenance, "13. … OK", then "voice_test.nim(731, 5) `"one fixed size" in exists["mechanism"].getStr() and …` [AssertionDefect]", the map's counted already the fold's. Green on the final tree: PASS voice_test ("13. …", "14. …", "15. … OK", "voice_test: all OK") and PASS swarm_test ("8. flow rows and room-map attribution for file entries OK"). The new leak check discriminates: with a scratch mutation adding the first 100 characters of the 0.5 s note's part 0 to its node as an extra attr (room_graph.nim put back, cmp-checked), voice_test FAILs at "voice_test.nim(770, 7) `half[k ..< k + W] notin windows` voice/…/0/2: the clip from 0 rides the room map", while a scratch copy holding §14's old check (the whole half and its midpoint window) PASSes ("14. … OK"); with every half's first 100 characters leaked, both fail. Related run (voice_test, flow_test, room_graph*, provenance*, epoch_attribution, swarm_test, probe_room_map*, authorship_test, probe_voice*, accounts_test, frost_ceremony_room_test; JOBS=4, before the squat was added): 23 of 24 passed, the failure lez_frost_account_e2e, a chain-bound test the "account" filter caught ("LEZ getLastBlockId: Could not connect to remote host"), outside the default suite. Full suite, module/tests/run-suite.sh: before the squat case and two comment edits, "── 284 passed, 0 failed (of 284) ──" (room_graph_scale_test and probe_return_marshalling_host PASS); on the final tree (docs edited after it only), "── 284 passed, 0 failed (of 284) ──" again, room_graph_scale_test and probe_return_marshalling_host (which compiles nim-lib/muster_module.nim with intents.nim and flow.nim importing voiceStanding) PASS. qmllint (qtdeclarative 6.9.2 from the nix store) on RoomMap.qml exits 0, as on HEAD's copy. Gates on the final tree, all exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. gen-fake-backend --check not run: red on origin/main, left to round 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read's rows carry notes (S7)

The hosted voice surface of docs/design/voice-notes.md §8-§9, and its state machine.

- coordination/voice_live.nim: VoiceRecorder (renamed from S3's VoiceLive) over the AudioIo
  seam, the room's session and the member's keystore. States idle, recording and draft, and a
  playback of a note or the draft beside idle or draft. The pump, which voice_status and
  coordinate_intents drive, moves what is due, drains the capture buffer into the incremental
  Opus encoder, ends a recording at the device side's `capped` (the 60 s cap) and a playback
  at `done`, and releases the device: every stream that ends closes the AudioIo, which for
  miniaudio uninitialises the device and the context, so between streams nothing is open.
  Send refuses an all-zero recording as `silent` with the platform note as `detail`
  (platformNote: on macOS "Basecamp 0.3.2 can't use the microphone on macOS yet: it lacks the
  microphone permission.", hostOS choosing; ready.note says it too), one under 500 ms as
  `too-short`, no room as `not-joined`, and a part that does not fit its frame (FrameTooLarge)
  as `too-large`; the draft is kept on every refusal. The note's ts is fixed at the draft's
  first Send, so a retry after a failure between the parts re-sends the same note. Play
  refuses `unknown-note`, `incomplete`, `broken` (the fold's verdict, or a clip libopus
  refuses), `busy`, `no-draft`, `not-joined`. One VoiceMemo per room serves the thread's rows
  (`rows`). voiceIoChoice: MUSTER_VOICE_SOURCE / _SINK give FileAudioIo, MUSTER_VOICE_BACKEND=null
  the miniaudio null device, anything else the system backends.
- muster.lidl: voice_record_start, voice_record_stop, voice_draft_send, voice_draft_discard,
  voice_play(note_id), voice_play_stop, each -> tstr; coordinate_messages' and
  coordinate_conversations' descriptions say a voice row carries metadata only. muster_gen.nim
  regenerated by tools/regen.sh (logos-nim-sdk 6077eb70a870, "111 methods"), not edited.
- muster_module.nim: the six methods; the AudioIo from voiceIoChoice; an exit proc
  (std/exitprocs) that closes the recorder, since the generated surface has no unload hook;
  voicePump in coordinate_intents' pump list; coordinate_messages over threadRows with the
  room's memo (rowJson plus alias and self); coordinate_conversations' lastTs from threadLastTs.
- Docs: voice-notes.md (status S1-S7 built; §6 the hosted reads measured; §8 the refusals as
  built; §9 device lifetime as built; §13 the peak served raw; §15 what is unverified,
  including the held-path variant that makes a retried part too-large for good, which is
  session.nim's to fix and is not fixed here; §17, §18), tests/README.md, src/audio/CLAUDE.md,
  miniaudio.nim's header.

Measured outside the repo (20 notes of 60 s, LocalTransport, -d:release, darwin-arm64), per
call: roomEvents about 37 ms cold, then 3 ms; the rows coordinate_messages reads about 48 ms
with a cold memo, then 16 ms; threadLastTs(roomEvents) 16 ms; threadRows with no memo 48 ms.

Not done: coordinate_proof still returns a voice part's `part` (the operator's decision, §18);
the peak is not coarsened (open, §13); the exit proc running at a plugin unload with a device
open is inferred, not observed; nothing on Linux; no UI (S8).

Tested-Behavior: module/tests/voice_live_test.nim (§1-§15, two members over LocalTransport, recorders on SyntheticAudioIo with a virtual clock, FileAudioIo and miniaudio's null device; libopus for real). Red first: `module/tests/run-suite.sh voice_live_test` against S6's voice_live.nim: "voice_live_test.nim(100, 16) Error: undeclared identifier: 'VoiceRecorder'". §15 (the retry) written after the rest went green, so it was run red first against a mutant whose Send takes a new ts every time: "voice_live_test.nim(487, 3) `sent{"note"}.getStr() == first[0].nid` the retry sends the same note: {"note":"267a73a990e851b00021008415e6c073"} [AssertionDefect]", then green on the real code. Twelve mutants of voice_live.nim, each restored and cmp-checked, every one caught: no finish at the cap (§2, "the cap made the draft"), no silent refusal (§3), 500 ms refused (§4, "500 ms sends"), encoding at Stop only (§9, "1 s of pumps: 0 ms encoded"), done never ending playback (§1, "done: idle again"), the device kept open between streams (§13, "the context is released when the stream ends"), libopus's refusal ignored (§7), start beside a draft (§5), no memo (§11), a new ts per Send (§15), the peak not reset on read (§1), send with no room (§12 crash). Green: "PASS voice_live_test" with "1. a 3 s tone recorded, sent as two 90153 B frames, played by the other member: the clip's samples exactly, 1 kHz; idle after done OK" through "15. a Send refused between its parts keeps the draft; the retry sends the same note, complete, nothing stranded OK" and "8. 231 JSON replies, every state: no part, no base64 run over 64, no float, under 1 KB OK". probe_return_marshalling_host, which compiles nim-lib/muster_module.nim with the new methods, PASS (voice_status still {"ready":{"capture":"unknown","playback":"unknown","codec":"libopus 1.5.2","note":""},...}). Full suite, module/tests/run-suite.sh: "── 285 passed, 0 failed (of 285) ──". Gates, all exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, ui/tools/gen-claims-qml.py --check. gen-fake-backend --check not run: red on origin/main, left to round 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
§15's "the voice methods through a host" said the hosted glue was called through a host "as
the S7 commit records"; it was not, since the host run came after that commit. It now says
what one headless run of the S7 .lgx in logosctl 0.3.2's logos_host showed (darwin-arm64, a
throwaway room joined on logos.dev, FileAudioIo from MUSTER_VOICE_SOURCE / _SINK, then
MUSTER_VOICE_BACKEND=null), and that no note was sent from a host. "The exit proc at unload"
now records the unload with a null-device recording open (no crash report, a reload idle),
that the exit proc's running is not observed, and the delivery_module crash at daemon stop,
which is not this code.

TDD-Exempt: docs only; the run it records is a manual headless check, its commands and
output in the S7 report, not a committed test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a draft kept to its room, every call pumps first

The S7 review of 5eca325 and 1af0a0c (docs/design/voice-notes.md §8, §10, §15, §18). All ten
findings applied; none disputed.

- coordinate_proof returned every raw log value, a voice part's base64 Opus among them, to any
  caller. That broke the fixed decision that no hosted method returns audio. It now refuses
  {error: "voice-notes-present", detail} for a room whose raw log holds any voice/ entry,
  unsigned or malformed ones included. The check is voice_live.roomProof, using
  session.isVoiceKey (now exported), the same rule that pads a voice frame. A room with none
  exports its proof as before. The operator still has to choose between an exception and a
  proof form that commits a part by digest (§18).
- A draft belongs to the room it was recorded in. recordStart stores the room's topic, and a
  Send through another room's session is refused as `room-changed`: nothing is published and
  the draft is kept.
- Start, Send and Play now pump first, as Stop already did. A playback that played to its end
  with no status read since is no longer `busy`, and Send finds the draft the cap made.
- Send now checks too-short before silent. A recording of nothing (Start, then Stop at once)
  is `too-short` and raises no microphone note. In the draft JSON and in voice_status,
  `silent` now means a draft that has samples, all of them zero. ready.note is set only for a
  draft that Send refuses as silent.
- coordinate_messages' row JSON moved into voice_live.messagesJson with the same shape. The
  hosted method calls it, so voice_live_test pins the row shape.
- The wording now matches what the code does. A retry sends the same note after `too-large`
  or after a transport that raises. Both parts take the held path, because the log takes a
  part before the transport sees it. DeliveryTransport's send is fire-and-forget, so a frame
  the fleet loses goes unseen: Send returns {note}, the draft is gone, and the note can stay
  arriving for other members. This is in §8, §15, voice_live.nim's header and the lidl
  descriptions; §18 gains a re-send path and the draft's room in voice_status as open items.
  §15 also records:
  - the sender's thread showing a note complete while the room holds half;
  - that a UI before S8 draws a note as an empty chat line;
  - that the delivery_module crash at daemon stop was not investigated, and no run of
    origin/main was made to compare.
  The voice_record_stop, voice_draft_send and coordinate_proof descriptions changed in
  muster.lidl. tools/regen.sh regenerated muster_gen.nim ("111 methods"); the output is
  byte-identical, since descriptions do not reach it.
- A correction to 5eca325's trailer: the no-room mutant crashed in §6, not "§12". Its red
  against S6's voice_live.nim now reads at (118, 16), not (100, 16), because the Refuser
  helper added before §15 moved the first use of VoiceRecorder.

Tested-Behavior: module/tests/voice_live_test.nim §15 (amended: the sender's fold complete, both parts in its log, the other member's arriving) and §16-§23 (too-short before silent, each call pumping first, room-changed, device release on Send and Discard while the draft plays, on a sink-write past an RLIMIT_FSIZE limit, on a refused start and on close, messagesJson's rows, roomProof's refusal), plus probe_return_marshalling_host, whose harness now calls the six voice methods, coordinate_messages and coordinate_proof with no room joined through the generated dispatch, each checked to the byte. Red first against 5eca325's voice_live.nim: `module/tests/run-suite.sh voice_live_test` gave "voice_live_test.nim(657, 15) Error: attempting to call undeclared routine: 'messagesJson'"; with §22-§23 disabled, "(535, 3) ... {"draft":{"durationMs":0,"silent":true,"peak":0}}"; with §16 disabled too, "(555, 3) `a.play(aliceS, nid1).ck.ok` a playback heard to its end is not busy to Play"; with §17 disabled as well, "(577, 3) `r.err == "room-changed"` Send in another room: {"note":"9e8b85476156461d380ef55be93c3e20"}". §19-§21 and the probe pin behaviour 5eca325 already had, so their red comes from mutants. Each mutant was applied alone, the file restored afterwards, and its md5 checked unchanged. All 16 voice_live.nim mutants were caught: Send and Discard leaving the draft playing (595, 601), a refused start not closing the io (640), close not closing it (643), the pump ignoring sink-write (626, "playback":"ok"), no pump in Play, Start and Send (555, 557, 561 {"error":"no-draft"}), silent before too-short (543), silent counting an empty draft (535), the note for a short draft (538), no room check (577), roomProof with no voice check (679, with keys format, events, ids, ...), roomProof checking the authentic view only (691), self and alias wrong (663, 661). All 3 muster_module.nim mutants were caught by the probe: voice_play polling a nil session ("host harness exited non-zero (139)"), voice_record_stop wired to recordStart (observed {"error":"not-joined"}), and voice_draft_discard returning JSON (observed {"ok":"ok"}). Green: "PASS voice_live_test", with "16. a recording of nothing, or 300 ms of zeros, is too-short, with no microphone note OK" through "23. coordinate_proof refuses voice-notes-present for a room with any voice/ entry; a room with none exports a proof that verifies OK", and "8. 267 JSON replies, every state: no part, no base64 run over 64, no float, under 1 KB OK". "PASS probe_return_marshalling_host" showed all 11 voice observations typed, among them voice_record_start {"error":"not-joined"}, voice_record_stop {"error":"not-recording"}, voice_draft_send {"error":"no-draft"} and coordinate_proof {"error":"not-joined"}, with voice_status still "unknown" for both devices afterwards and lp_stub_calls 0. The slice's tests passed 14/14 (voice*, session_pad, authorship, coordination_messages, log_proof, audio_io). The full suite, module/tests/run-suite.sh, gave "── 285 passed, 0 failed (of 285) ──". All six gates exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus and ui/tools/gen-claims-qml.py --check. gen-fake-backend --check was not run: it is red on origin/main and left to round 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the gaps voice leaves (S10)

What: docs/design/voice-notes.md §10's claim and its four gaps, as entries in
contracts/claims/registry.json (step open-room, appended after the Monero entries), and
ui/src/qml/ClaimsRegistry.qml regenerated from it by ui/tools/gen-claims-qml.py.
- protects, FS-9, imperative: "A voice note's length does not show in its size". Muster sends
  every note as two frames of one fixed size, each sealed under the room's epoch key as text
  is, so a store node learns that a note was sent, on which topic and when, not its length;
  the body names the gap that still stands. Its test is probe_voice_fixed_size_stepper (S5):
  0.5 to 60 s x 0, 1, 8 heads x two epochs, each note also re-sent on the held path, every
  frame 90,153 B, the other member folding it complete under that epoch. The title is the
  doc's wording, which wins over the plan's ("size does not tell its length").
- gap, exposed, status none: "A voice can identify the speaker" (FS-7 scopes identity to the
  room, a voice is not scoped; party: everyone who plays the note).
- gap, exposed, partial: "Modules loaded beside Muster can record and send as you" (with the
  access policy off; they cannot read the audio back: no voice method returns it, the room
  map carries none, a proof of a room holding a note is refused). Partial because the fix,
  --access-policy enforce with infra/access-policy.json, ships in the host but is off by
  default, and voice has not run under it.
- gap, exposed, partial: "Leaving a room re-keys nothing". coordinate_leave announces nothing,
  no method removes a member, and EpochCrypto.admit wraps each later epoch to every member of
  the current set, the one who left included. Partial because the key layer rotates on
  removal (EpochCrypto.removeMember) and no room path calls it. The doc calls this a
  room-wide gap no entry named.
- gap, exposed, partial: "An older Muster in the room hands voice notes to the modules beside
  it" (§10's item 6; the doc lists it among the S10 gaps, beside the three the task names).
- The existing gap "The store node still sees the conversation graph" is unchanged.

Not changed: the F-16 protects claim's "Removal rotates the epoch immediately" is true of the
key layer (probe_epoch_removal_immediate); the new gap says no room path reaches it. Whether
these gaps stay entries of their own is still the maintainer's call (§18).

Tested-Behavior: the protects claim's evidence is probe_voice_fixed_size_stepper, and the registry gate binds each new entry to it. Red first: after the registry edit, before regenerating, `python3 ui/tools/gen-claims-qml.py --check` exited 1 with "FAIL  ui/src/qml/ClaimsRegistry.qml has drifted from contracts/claims/registry.json — run ui/tools/gen-claims-qml.py". Green: after `python3 ui/tools/gen-claims-qml.py` ("wrote ui/src/qml/ClaimsRegistry.qml (85 lines from 44 claims)"), --check printed "ui/src/qml/ClaimsRegistry.qml is in sync with the registry." and exited 0; `python3 scripts/check-claims-registry.py` printed "claims registry OK — 44 claims across 6 steps, all evidence resolves." (39 before). `module/tests/run-suite.sh probe_voice_fixed_size` (darwin-arm64, nim 2.2.4): "PASS probe_voice_fixed_size_stepper", run with no argv, so all 36 cases doAsserted, then {"successors":[{"case":1,"decision_correct":true}]}. The gate discriminates: in scratch copies of the checker, FURPS and the registry (module/ and ui/ linked, the repo untouched), each mutation applied alone to the committed registry fails it: the probe path renamed ("test does not resolve — file 'module/tests/probes/probe_voice_fixed_size_gone.nim' does not exist"), the claim made motivational ("a protects claim (requirement + test) is imperative by construction"), requirement FS-99 ("is not a FURPS id"), the biometric gap with no party ("a exposed claim must name its party"), the leaving gap with no fix ("a gap must point at a fix that would close it"); the unmutated copy passes. qmllint (qtdeclarative 6.9.2) on the regenerated file exits 0, with the same import-path warnings as HEAD's copy. Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus and gen-claims-qml --check exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce gate, and what S10 built

What: S10's docs (docs/design/voice-notes.md §17).
- docs/labbook/voice-notes-audio-stack.md (new): the traps S1-S7 found, each with what
  happened, why, the fix and the test that holds it: the libopus ctl functions are variadic
  and only Apple arm64 shows a fixed-arity binding (the prototype's silent 300000, this
  build's OPUS_BAD_ARG); miniaudio's -D flags in one global passC, and no generic over a
  header type (a seq[ma_backend] put miniaudio.h into system's C); clang 19 and the
  ma_device_data_proc cast; miniaudio's runtime linking kept (MA_NO_RUNTIME_LINKING needs
  frameworks on a flat, all-platform link list), and macOS's -undefined dynamic_lookup hiding
  a missing symbol until load; the device read back as 48 kHz mono s16 (44.1 kHz f32 stereo
  passed every test before), with `done` meaning heard and the nil buffer; std/base64's decode
  reading past the end of a string with whitespace (an IndexDefect no CatchableError catches);
  the suite linking libopus 1.6.1 while the .lgx ships 1.5.2; parseJson taking a NUL as end
  of input. A last section lists what is not found out yet (TCC, Linux, the exit proc).
- CLAUDE.md Layout: a src/audio/ line; voice + voice_live in the coordination list; session
  padding every voice/ frame itself. The Current-phase section is unchanged, so
  check-readme-rot needs no re-stamp.
- module/tests/README.md: probe_voice_fixed_size_stepper is the registry claim's test; no
  test opens a real device; voiceIoChoice's MUSTER_VOICE_SOURCE / _SINK / _BACKEND; the
  real-device test voice_device_e2e (S11, not built) behind MUSTER_VOICE_REAL, by name only,
  its microphone leg only with consent. module/src/audio/CLAUDE.md points at the labbook.
- voice-notes.md: status S1-S7 and S10 built, S8, S9 and S11 not; "planned" made "built"
  where the slice is (§4 S4, §6 and §7 S5, §8's methods, §9's seam and build); §5 says what
  of the budget is measured (S4's worst-case JSON, S5's 60 s frames, both over
  LocalTransport) and what is S1's arithmetic; §10 quotes the claim as the registry has it
  and gives each gap's status; §15, §17 and §18 follow. §4's "refuses trailing garbage" is
  corrected, the S4 review's finding left for a later doc commit: parseJson takes a NUL as the
  end of its input and accepts anything after one.

Not done: the plan's optional mock of the mic and the voice card in
ui/prototype/desktop-v3.html, left for S8.

TDD-Exempt: docs only, no code or test changes. The two stdlib facts the labbook re-checks were run at the builder pin's nim 2.2.4 in a scratch program outside the repo: decode("    AB") "IndexDefect: index 6 not in 0 .. 5", decode("AAAA     AAA") "index 12 not in 0 .. 11", decode("  ABC") "index 5 not in 0 .. 4"; parseJson accepted "{}     ", "{}\0\0" and "{}\0garbage", and refused "{} x" and "{}}" (JsonParsingError). Gates, all exit 0: check-no-cpp, check-claims-registry (44 claims), check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. Full suite on this tree (the claim commit plus these docs): module/tests/run-suite.sh, "── 285 passed, 0 failed (of 285) ──".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn by the feature, a size per frame

What: the S10 review's findings on fbad5d5 and 80438c6 (docs/design/voice-notes.md §10, §18).
- Major. The five new registry entries put words on screen that the plain-language pass of
  scripts/ui-lint.sh (exo-364) refuses: FS-7, F-16 and muster_module, which are never on
  screen, and "epoch" 6 times and "policy" 7 times past ClaimsRegistry.qml's baseline of 4 and
  2. They are reworded with no spec id and no module name. "The room's key" (its current key,
  the key when a note was sent, each new room key) stands for the epoch key, and "access
  checks" for Basecamp's access policy. The co-resident fix now reads "Basecamp run with its
  access checks enforced and Muster's list for them, which lets only Muster's own interface
  call Muster". ui/tests/qmllint-baseline.json is unchanged, and ClaimsRegistry.qml is
  regenerated. The same pass found one more "fold" in RoomMap.qml, added by c30fa60 (S6's fix:
  "Not counted: the fold counts this entry for nothing, though its author signed it."). It
  now reads "Not counted: this entry counts for nothing, though its author signed it.", so
  the whole of ui/src/qml is back at its baseline.
- Major. The older-client gap drew its line as "Muster 0.3.x or earlier" and said "This
  version", though this branch is 0.3.2 and muster-v0.3.2 is released. The line is now the
  feature itself. Title: "A Muster from before voice notes hands them to the modules beside
  it". Body: "a Muster from before voice notes (0.3.2 or earlier)", and "A Muster with voice
  notes keeps audio out ...". voice-notes.md §4, §7, §10 (the table, item 6, the gap list),
  §13, §15 and §18, and room-map.md, say the same. No version is bumped, because RELEASING.md
  step 1 bumps at release time. Instead §18 asks the maintainer to number the release that
  carries voice notes above every 0.3.x (0.4.0, say), and notes that a release cut from main
  before this PR merges would move "0.3.2".
- Minor. §18 gains an item for the maintainer. The F-16 protects claim's "Removal rotates the
  epoch immediately." is true of the key layer only, and no room path removes a member (the
  new gap). Reword it or keep it; this PR leaves it as it is on main. This also corrects
  fbad5d5's body: probe_epoch_removal_immediate exercises crypto/epochs.nim's Group model,
  and EpochCrypto.removeMember (epoch_crypto.nim:135) is exercised by
  epoch_crypto_test.nim:61. §10's gap list cites both.
- Minor. §4 and labbook §8 now say that parseJson at nim 2.2.4 also accepts trailing `//` and
  `/* */` comments, treats a NUL as the end of its input, and refuses any other trailing text.
  The pad is spaces, so behaviour does not change.
- Minor. The protects claim is worded per frame: "Muster sends each voice note as two parts,
  each one frame of one fixed size whatever the note's length, and a re-send is the same
  size." §10's quote mirrors it. Its test paragraph now says that the probe asserts four frames
  per note (both parts, sent twice), each 90,153 B, where it said "both frames".

Not done: scripts/ui-lint.sh itself was not run, because there is no runner at .run/runner
and none was built. Its plain-language pass was run instead (below), and its qmllint and
palette passes were not. The walkthrough with the new words has not been seen on a display.

Tested-Behavior: the plain-language gate of scripts/ui-lint.sh. Its NEVER, TERMS and QSTR regexes were copied verbatim into a scratch script outside the repo, which grades ui/src/qml/*.qml against the plain-language keys of ui/tests/qmllint-baseline.json and exits 1 on a new finding, as ui-lint.sh does. Red on 80438c6, exit 1: "NEW 6x ClaimsRegistry.qml · plain-language · developer term: epoch", "NEW 7x ... policy", "NEW 1x ... never on screen: F-16", "... FS-7", "... muster_module", "NEW 1x RoomMap.qml · plain-language · developer term: fold", then "plain-language: 104 finding(s), 87 in the baseline: 17 new, 0 fixed". At 90fcbd4 only the RoomMap finding showed; on origin/main, exit 0 with 87 of 87. Green on this tree after `python3 ui/tools/gen-claims-qml.py` ("wrote ui/src/qml/ClaimsRegistry.qml (85 lines from 44 claims)"): "plain-language: 87 finding(s), 87 in the baseline: 0 new, 0 fixed", exit 0. gen-claims-qml --check failed with "has drifted" after the registry edit and passed with "is in sync with the registry." after regenerating. qmllint (qtdeclarative 6.9.2): ClaimsRegistry.qml exits 0 with no warnings, and RoomMap.qml's 748 warnings (unresolved imports without the runner's Logos library) are identical before and after the edit. The parseJson facts come from a scratch program at nim 2.2.4 (arm64-apple-darwin nim-wrapper): it accepted "{}     ", "{}\n\t\r ", "{} // trailing comment", "{} /* c */", "{}\0\0" and "{}\0garbage", and refused "{} x", "{}}", "{} /", "{} /* c */ x" and "{} // c\nx" with "EOF expected". The claim's test, `module/tests/run-suite.sh probe_voice_fixed_size`: "PASS probe_voice_fixed_size_stepper". Full suite on this tree, `module/tests/run-suite.sh` (darwin-arm64): "── 285 passed, 0 failed (of 285) ──". Gates, all exit 0: check-no-cpp, check-claims-registry ("claims registry OK — 44 claims across 6 steps, all evidence resolves."), check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssage row, both backends (S8)

docs/design/voice-notes.md §7, §8 and §17 S8. The view only triggers and polls: no audio
crosses the contract in either direction.

- The contract (ui/src/muster_ui.rep): SLOTs loadVoice, startVoiceRecording,
  stopVoiceRecording, sendVoiceDraft, discardVoiceDraft, playVoiceNote(noteId) and
  stopVoicePlayback; PROPs voiceJson (voice_status) and voiceResultJson (the last call's
  answer). Both backends implement them, as ADR-019 asks: the C++ in FileShare's pattern,
  the Nim through the generated mm.voice_*Async twins. Send re-reads the thread in both.
- CardVoiceNote.qml: pure render from a row's `voice` field, the author the fold
  verified, Play and Stop as signals, m:ss, how far it has played, and words for a note
  still arriving or whose parts disagree; only a complete note plays. RoomThread draws it
  for a row with `voice` and owns the calls.
- A message cannot pose as a note: RoomThread refuses kind voice-note from a body, so such
  a message is plain text, its raw words under its poster's name. MusterCard.known does not
  gain voice-note.
- The unknown-kind fallback is fixed: a body whose kind MusterCard does not draw is the
  message's text (RoomThread's isText). Before, the thread hid the text and the card drew
  nothing.
- The mic is an IconAction in the message row, beside Send, not a tile of "Propose
  something" (§14). RoomVoiceNote.qml above the row: a banner whenever voice_status says
  recording, whoever started it (§13), with a red dot, the time on a QML clock, the level,
  Stop and Discard; the draft with Play, Send, Discard and §8's disclosure line before
  anything is sent; and the module's refusal or note in an InlineNotice, the detail as the
  module gives it (on macOS, the silent refusal's Basecamp 0.3.2 note).
- Room.qml parses voiceJson and voiceResultJson once, reads voice_status on its 1 s tick,
  and every 250 ms only while a note records or plays.
- Words for the voice fields and kind in FlowView ("voice note", "voice note length", "one
  fixed size") and the room map (kinds "voice note" and "shared file", which it lacked; the
  store node's one fixed size when the map holds a voice entry).
- Icons mic, play and stop, in the style of the others.
- ui/tests/gallery/FakeBackend.qml regenerated. `gen-fake-backend.py --check` was already
  red on main (FileShare's slots were never generated); this regeneration also adds them.
- Gallery fixtures room-voice-forged, -notes, -record and -flow; Components.qml shows the
  card in its four states. Their voice rows, voiceJson, voiceResultJson and flowJson are
  typed from the shapes the module's tests pin, over a recorded snapshot, as
  room-thread-times does: no runner records a voice row until S9.
- voice-notes.md: S8 built, and what is not verified (§15).

Not done here: which room holds the draft is still not in voice_status (§18), so the draft
surface shows in whichever room is open and says whose it is only when Send is refused.

Tested-Behavior: the gallery fixtures ui/tests/gallery/fixtures/room-voice-{forged,notes,record,flow}.json, run by ui/tests/gallery/Gallery.qml against the real Main.qml and FakeBackend.qml with Qt 6.9.2's qml (offscreen, software) on darwin-arm64, against both design systems: Basecamp 0.3.2's (88330d0) and the runner's (94092b0). Red first: against 2dbcd12 the three voice fixtures give "GALLERY-ERROR not a contract PROP: voiceJson", then "no object named voiceNoteCard_6f1d0c2b9a8e7d6c5b4a39281706f5e4" (forged, notes) and "no object named voiceRecording" (record); with the .rep, FakeBackend and only the plain text's objectName added, the forged body gives "GALLERY-ERROR messageText_0x7a11ce5f0a9ed0b0d4f0a1b2.visible.true is false" and the unknown kind "messageText_0x5011a5ed0c7b1e2f3a40c9d8.visible.true is false"; the flow fixture, on this tree with FlowView's matrix fields as before, "flowFields_room-member.text.voice note, voice note length is "voice-note, voice-note-length"". Green: all four exit 0 on both design systems (5, 16, 18 and 2 GALLERY-PASS lines, among them the expected slot calls playVoiceNote [note], stopVoicePlayback, loadVoice from the poll, stopVoiceRecording, playVoiceNote ["draft"], sendVoiceDraft, discardVoiceDraft and startVoiceRecording). Every other fixture gives the same result as on 2dbcd12 in the same environment (7 need an X display for a popup there); room-news and settings-btc-payer each failed once on one tree by timing and passed twice on both trees on rerun. The C++ backend against the new .rep: `nix build "git+file:///Users/demetrickferguson/Downloads/muster?dir=ui#lgx-portable" --override-input muster_module "git+file:///…?dir=module"` red first, "error: allocating an object of abstract class type 'MusterUiBackend'" with "unimplemented pure virtual method 'loadVoice'" through 'stopVoicePlayback'; green, the darwin-arm64 .lgx, whose plugin names voice_status through voice_play_stop and whose qml/ and icons/ match the tree file for file. The Nim backend: ui-nim builds on Linux only, so a scratch harness type-checked ui-nim/app/backend.nim with Nim 2.2.4 against stubs made from the .rep (every SLOT a forward-declared Impl) and muster.lidl (every method's Async twin in the SDK's a710a31 shape): red with 2dbcd12's backend.nim, "Error: implementation of 'harness.stopVoicePlaybackImpl(o: MusterUi)' expected"; green, exit 0. `python3 ui/tools/gen-fake-backend.py --check`: red on 2dbcd12 and after the .rep change ("FakeBackend.qml is stale"), green after regeneration. scripts/ui-lint.sh's gate (qmllint 6.9.2 plus its python checks, plain language included) on each design system: "87 finding(s), 87 in the baseline: 0 new, 0 fixed". check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus and gen-claims-qml --check exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le it holds, the draft waits

The S8 review's six findings, on top of 6ce5b4a.

- The forged body (major). room-voice-forged checked only that the forged row shows as text.
  It now also checks that neither the forged row nor the unknown-kind row draws a voice card,
  that the forged row has no enabled Play for the note it names, and that its header names
  its poster (§17 S5: "S8 pins the render side"). The header's author text gets an objectName
  for it, messageAuthor_<row id>.
- The poll claim (minor). The 250 ms read of voice_status runs while not idle: a note
  recording, a draft held, or a note playing. A draft can be held for hours and is read at
  that rate all the while. 6ce5b4a's comment and message said "only while a note records or
  plays"; the code was right (the doc says "while not idle") and is kept, and the Room.qml
  comment and §8 now say so. The Timer gets an objectName, voicePoll, so room-voice-record
  and room-voice-notes check it runs while recording, with a draft held and while a note
  plays, and stops once idle.
- The room map's voice words (minor). New fixture room-map-voice: a voice note's part is a
  "voice note by Bob" entry, a FileShare entry a "shared file", and the store node's fields
  and detail row say one fixed size. room-map-leaks checks that a room without a voice note
  shows only timing and topic. The store node's field text and the inspector's rows get
  objectNames for it.
- The draft's Play (minor). While a note in the thread plays, the draft's Play is disabled,
  as the card's Play and the mic are; it stays enabled as Stop while the draft itself plays.
- A refusal that outlives its cause (minor). Room.qml keeps the last voice call's answer
  with the room it was made in and where voice_status stood then (the recording's state and
  the note playing). Once another room opens, or voice_status moves on, the answer is no
  longer shown, even when voice_status comes back to where it was. New fixture
  room-voice-refused: a busy refusal goes once the other caller's recording has ended, and a
  room-changed refusal goes once another room opens and stays gone on coming back.
- The prototype (minor). §17's S10 line said the optional mock in desktop-v3.html was "left
  for S8". It now says the mock is dropped from this PR as optional, as FileShare's card has
  no mock there either.

No .rep change, so FakeBackend.qml is unchanged and `gen-fake-backend.py --check` stays green
(6ce5b4a fixed the drift it already had on main). Neither backend changes: the fix is QML that
both backends serve (ADR-019).

Tested-Behavior: the gallery fixtures ui/tests/gallery/fixtures/room-voice-{forged,record,notes,refused}.json, room-map-voice.json and room-map-leaks.json, run by ui/tests/gallery/Gallery.qml against the real Main.qml and FakeBackend.qml with Qt 6.9.2's qml (offscreen, software) on darwin-arm64, against both design systems: Basecamp 0.3.2's (88330d0) and the runner's (94092b0). Red first, the new steps against 6ce5b4a's QML: forged "no object named messageAuthor_0x7a11ce5f0a9ed0b0d4f0a1b2", record and notes "no object named voicePoll", refused "voiceNotice.visible.false is true", room-map-voice "no object named roomMapRow_Voice notes", room-map-leaks "no object named roomMapFields_o:store-node". With only the objectNames added, the two behaviour changes are red: record "voiceDraftPlay.enabled.false is true", refused "voiceNotice.visible.false is true". Each other new step was shown red on a one-line mutant of the tree. The review's mutant, RoomThread drawing CardVoiceNote from a voice-note body: "voiceNoteCard_0x7a11ce5f0a9ed0b0d4f0a1b2.visible.false is true". The poll never running: "voicePoll.running.true is false" (record, notes); running whenever joined: "voicePoll.running.false is true" (record, notes). RoomMap without the word for voice: "roomMapInspectorTitle.text.voice note by Bob is "voice by Bob""; without the word for file: "roomMapInspectorTitle.text.~shared file by is "file by You""; holdsVoice false: "no object named roomMapRow_Voice notes"; the store node's fields without one fixed size: "is "timing, topic""; holdsVoice always true: room-map-leaks "is "timing, topic, one fixed size"". The refusal not sticky, without the room check, or brought back on returning to the room: refused red at the idle, room-switch and return steps; the draft's Play unguarded, or disabled while the draft itself plays: "voiceDraftPlay.enabled.false is true", "voiceDraftPlay.enabled.true is false". Green: all six exit 0 on both design systems (10, 26, 18, 15, 5 and 5 GALLERY-PASS lines), and room-voice-flow and room-map as before (2, 4). Every other fixture gives the same result as in the review's run of every fixture on 6ce5b4a, on this Mac with the runner's design system (7 need an X display for a popup there); room-news failed once by timing and passed twice on this tree and twice on 6ce5b4a's QML on rerun. scripts/ui-lint.sh's gate (qmllint 6.9.2 plus its python checks, plain language included) on each design system: "87 finding(s), 87 in the baseline: 0 new, 0 fixed". check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check and gen-fake-backend --check exit 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's file, the autopilot in both backends (S9)

docs/design/voice-notes.md §17 S9. The UIs still only trigger and poll voice_status; the
audio stays in the module (FileAudioIo reads the WAV and writes the PCM).

- scripts/voice-self-test.sh, in fileshare-self-test.sh's shape: two offscreen runners in one
  room, a scratch HOME each. A records VOICE_SECS (3) seconds from a 3 s 1 kHz WAV the script
  writes (MUSTER_VOICE_SOURCE) and sends; B plays the note into a PCM file
  (MUSTER_VOICE_SINK). Passes when A logs the note it sent and delivery refused none of A's
  sends after it, B's thread holds the note complete and B logs it playing then nothing
  playing, B's file holds exactly the draft's samples with the 1 kHz tone (Goertzel: at least
  half the source's power, over 100 times that at 500 Hz, 1.5 kHz and 3.1 kHz), and no log
  has a QML TypeError or ReferenceError. Then C joins on MUSTER_VOICE_BACKEND=null: its Send
  must be refused silent and B must still hold one note (VOICE_SILENT=0 skips C).
- The autopilot in both backends (ADR-019), on the autojoin tick: MUSTER_AUTOVOICE=<seconds>
  is the mic's Start, Stop and Send once someone else is in the room; MUSTER_AUTOVOICEPLAY=1
  is the card's Play for a complete note another member sent, while nothing plays, a refused
  Play tried again up to five times. Each tick logs VOICE (voice_status), VOICE-NOTES (the
  thread's voice fields) and each call's VOICE-RESULT. The Nim backend gains Do forms of the
  voice slots, which the slots now call.
- ui-parity.sh: a voice entry.
- scripts/lib/ui-build.sh launches and cleans up on macOS too: there is no setsid there (a
  python3 os.setsid stands in), its ps has no session id (it prints its keyword list
  instead, so cleanup kills the process group, which is the runner's pid either way), and
  bash 3.2 calls an empty array unbound under set -u. Linux takes the same path as before.
- Docs: voice-notes.md (S9 built and not run, and what did run), the macOS runbook (the
  runner does not build on a Mac: below), CLAUDE.md's commands, scripts/lib/CLAUDE.md.

ui/tests/gallery/FakeBackend.qml needs no regeneration here: the .rep is unchanged, and
gen-fake-backend.py --check, red on origin/main ("FakeBackend.qml is stale"), stays green
since 6ce5b4a (S8) regenerated it.

Not run: the self-test itself, on either UI. The standalone runner does not build on this
Mac: `BUILD_MEM= make build` (1 h 28 min from cold) stopped in lez_core 0.5.0's
logos-execution-zone-wallet-ffi-deps-0.1.0, where risc0-sys 1.5.0's build script runs
`xcrun --sdk macosx metal`, the Command Line Tools have no Metal compiler ("xcrun: error:
unable to find utility "metal", not a developer tool or in PATH"), and risc0-build-kernel
2.0.1 panics "Could not build metal kernels". So neither MUSTER_FLEET=local nor logos.dev was
tried, and the gallery fixtures for the card's states and the message row's recording and
draft are not recorded (ui-screenshot.sh record needs a runner, Xvfb and Qt's xcb platform).

Tested-Behavior: scripts/voice-self-test.sh is this behaviour's test, and it has NOT run (no runner builds on darwin, above); it is the maintainer's Linux run, with MUSTER_UI=cpp and nim. What ran, on darwin-arm64: (1) the Nim autopilot, in a scratch harness (not committed) that includes ui-nim/app/backend.nim against stubs made from ui/src/muster_ui.rep and muster.lidl (every method's Async twin answering later, from a fake module with voice-notes.md §8's state machine) and drives its timers in real time: red on 9cc1005's backend.nim, "HARNESS-CALLS []" and no VOICE line in either role; green, the recorder "HARNESS-CALLS ["voice_record_start()","voice_record_stop()","voice_draft_send()"]" with VOICE-RESULT start {"ok":true}, stop {"draft":…}, send {"note":"n1"}, and the player "HARNESS-CALLS ["voice_play(n9)"]" over a thread holding an arriving note, its own note, another's complete note and a message whose body claims a note, its VOICE lines naming n9 playing then null; four mutants each caught (no self check: plays n8 too; no played set: n9 three times; no complete check: plays n7; Play while one plays: n10 twice). (2) The C++ autopilot compiles: `nix build "git+file:///Users/demetrickferguson/Downloads/muster?dir=ui#lgx-portable" --override-input muster_module "git+file:///…?dir=module"` exit 0, the darwin-arm64 muster_ui_plugin.dylib carrying MUSTER_AUTOVOICE, MUSTER_AUTOVOICEPLAY, VOICE-NOTES and the four VOICE-RESULT lines; it has not run. (3) The script under macOS's /bin/bash 3.2 against a stand-in runner (MUSTER_RUNNER, not committed) writing the autopilot's lines by role, with B's sink the tone round-tripped through the shipping libopus 1.5.2 (run-suite's toolchain): SUCCESS ("1 kHz power 66644011 of the source's 67095156 · 500 Hz 0.9, 1.5 kHz 0.2, 3.1 kHz 2.1"); FAIL for a sink one millisecond short ("151152 samples, not the draft's 3150 ms x 48 = 151200"), a quarter of the amplitude, a send delivery refused ("1 accepted, 1 refused"), C's note not refused ("C's Send was not refused as silent") and A never sending ("B never played A's note (sent: none"); the audio check alone also fails silence, a 1.5 kHz tone, a 3.1 kHz tone added, and a row arriving, absent or of another length; no process left after any run. (4) ui-build.sh on darwin with a stand-in runner: red on 9cc1005, "env: setsid: No such file or directory", nothing launched, and a second ui_cleanup "UI_PIDS[@]: unbound variable" (exit 127); green, two instances launched, each process group 3 processes then 0 after ui_cleanup, a second ui_cleanup exit 0. Gates exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, gen-fake-backend --check; ui-lint's plain-language check alone, 87 findings, 0 new; shellcheck -S warning: the new script's SC2164 (the cd every self-test opens with) and ui-build.sh's SC2034 at :105, which 9cc1005 has too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in its own thread, the window from the note, the Nim autopilot's check in the tree

S9's review (68d6db4) found holes in scripts/voice-self-test.sh, a false line in the macOS
runbook, and Nim autopilot evidence that existed only in a scratch harness.

- voice-self-test.sh, the delivery check: the count of A's send results now starts at A's
  `VOICE-RESULT stop`, which both backends log before calling voice_draft_send (cpp:1523;
  Nim's stop callback). Before, it started at the Send's own line, which both backends log
  only after voice_draft_send's re-reads. A send's result comes back on delivery's thread
  whenever it does (delivery.nim publish, lp_invoke_async, onSendResult), so it could land
  above the Send line and go uncounted. It now also wants at least two accepted (a note is
  two frames, one send each) beside none refused, and prints both. "0 accepted, 0 refused"
  used to pass.
- voice-self-test.sh, the null-device leg: C's Stop must call the draft silent. C's own thread
  after its Send (the VOICE-NOTES lines after C's Send line) must hold no note of its own,
  which is a check that does not wait on the fleet, since a sender's fold shows its note at
  once (§8). On macOS (uname Darwin) the refusal must carry a detail, the module's
  platformNote; elsewhere the module gives none, so nothing is asserted there. The check
  that B holds one note stays.
- voice-self-test.sh, VOICE_SECS: the Goertzel window now runs from 0.25 s to 0.25 s before the
  shorter of the note and the WAV, sized in whole 500 Hz periods. Before, it was fixed at
  0.25 s to 2.25 s, so any VOICE_SECS under 3 failed "too short for the window" whatever B
  heard. VOICE_SECS must now be a whole number of seconds, 1 or more, checked before anything
  launches. Both backends read MUSTER_AUTOVOICE as an integer, and anything else as 0, which
  records nothing, so a 2.5 used to wait out the 240 s loop.
- ui-nim/tests/autovoice/ (new; gen.py, fake_module.nim, run.sh, CLAUDE.md): the scratch
  harness behind 68d6db4's Nim autopilot evidence, now in the tree. gen.py builds a harness
  that includes ui-nim/app/backend.nim whole against stubs generated from muster_ui.rep and
  muster.lidl. fake_module.nim answers every voice_* call as the module does (voice_live.nim's
  shapes), one turn of the loop later. run.sh builds it with the nim on PATH, else ui-nim's own
  nixpkgs pin's (nim 2.2.12, read off ui-nim/flake.lock without evaluating that flake), and
  checks two roles. The recorder: Start, Stop about VOICE_SECS later, Send, once each, the
  sent note its own in the thread. The player: Play for complete notes from another member,
  one at a time, once each; never an arriving note, one's own, or a message whose body
  claims a note; a refused Play tried five times, no more. It runs on a Mac, where ui-nim's
  flake does not build. The fake stands in for muster_module only, the process boundary.
- scripts/ui-parity.sh refuses a bash older than 4 (exit 2, naming `nix shell nixpkgs#bash -c
  scripts/ui-parity.sh`). Under macOS's /bin/bash 3.2 it failed on its first associative array
  ("line 41: card: unbound variable") and exited 0 having run nothing.
- docs/runbooks/macos.md: the line that read as if ui-parity.sh would run on a Mac once the
  runner builds now says it needs bash 4 and how to give it one.
- voice-notes.md: §17 S9 has the new pass conditions and the harness. §15 has what ran,
  including the four holes, each shown red then green. It also has the maintainer's Linux
  runs that close S9: the self-test with MUSTER_FLEET=local, the same on the Nim build,
  ui-parity.sh voice, and a red run on a runner built from 9cc1005, which must FAIL "sent:
  none". The PR stays a draft until those exist. A new §15 entry names the voice gallery
  fixtures as an open breach of ui/tests/gallery/CLAUDE.md's "recorded, never hand-typed".
  CLAUDE.md and ui-nim/CLAUDE.md list the harness.

Not fixed here, because the fixes cannot run on this Mac: the review's two majors. The
self-test has still not run on either UI, and the voice gallery fixtures are still typed,
not recorded. Both need the standalone runner, which does not build here: `xcrun --sdk macosx
-f metal` still finds no Metal compiler (Command Line Tools only), which is where 68d6db4's
`BUILD_MEM= make build` stopped after 1 h 28 min, so it was not run again. The recording
also needs Xvfb and Qt's xcb platform. Both are now written into §15 as the maintainer's
steps, and S9 is not called done.

Tested-Behavior: voice-self-test.sh against a stand-in runner (scratch, not committed: it writes each role's autopilot log lines and, as B's sink, the 1 kHz tone round-tripped through the shipping libopus 1.5.2), under macOS's /bin/bash 3.2. Red, 68d6db4's script: SUCCESS when a refused frame's result landed before the Send's line ("1 accepted, 0 refused"), with no send result at all ("0 accepted, 0 refused"), with C's Stop calling its draft not silent, with C's thread holding a note of its own, and with C's macOS refusal carrying no detail; FAIL "too short for the window" for VOICE_SECS=2 with a good 2.1 s sink. Green, this script: each of those five FAILs ("delivery must accept at least the note's two frames and refuse none" twice, "C's Stop did not call its draft silent", "C's thread holds a note of its own", "C's refusal has no detail on macOS"); VOICE_SECS=2 and 1 pass ("from 0.25 s to 1.85 s", "to 0.85 s"); VOICE_SECS=0 and 2.5 refused up front (exit 2); the good run SUCCESS ("2 accepted, 0 refused", "from 0.25 s to 2.75 s, 1 kHz power 66557984 of the source's 67095156 · 500 Hz 2.6, 1.5 kHz 0.1, 3.1 kHz 2.3"); and still FAIL for a sink one millisecond short, a quarter of the amplitude, a refused send after the Send line, C's note not refused, and A never sending ("sent: none"); no process left. ui-nim/tests/autovoice/run.sh: red on 9cc1005's backend.nim and sched.nim (recorder and player "calls []", no VOICE line); green on this tree's ("recorder: [voice_record_start(), voice_record_stop(), voice_draft_send()], Stop 1073 ms after Start", "player: [voice_play(n9), voice_play(n10), voice_play(n12) x5] · playing [None, n9, None, n10, None]"); six mutants of backend.nim each FAIL (no self check: plays n8; no played set: n9 five times; no complete check: plays n7; Play while one plays: n10 twice; no five-try limit: n12 ten times; Stop not waiting VOICE_SECS: "Stop 171 ms after Start"). ui-parity.sh: red, /bin/bash 3.2 "line 41: card: unbound variable", exit 0; green, exit 2 with the message, and under `nix shell nixpkgs#bash` (5.3.15) the voice entry runs (red: "build the runner first: make build", exit 1). Gates exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, gen-fake-backend --check (green since 6ce5b4a regenerated FakeBackend.qml; the .rep is unchanged); ui-lint's plain-language check alone, 87 findings, 0 new (no QML changed); shellcheck -S warning, nothing new (the new run.sh clean). Nothing compiled into the .lgx changed (no file under ui/ or module/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the system backend behind MUSTER_VOICE_REAL (S11)

docs/design/voice-notes.md §9 and §17 S11. The person consented to the microphone leg.

- module/tests/voice_device_e2e.nim (new): MiniaudioIo on the system backend (Core Audio here;
  PulseAudio, then ALSA, on Linux) through the AudioIo seam the module uses, no mock.
  - The gate: MUSTER_VOICE_REAL unset (or 0) prints "SKIP voice_device_e2e: ..." and exits
    0. 1 (or all) runs both legs, mic or speaker one, a comma list both; any other value
    fails, naming the values.
  - The microphone leg captures 2 s into a 96,000-frame buffer. It must read back as 48 kHz
    mono s16, frames must arrive at 48 a millisecond within ±5% (measured over 1 s, 200 ms
    after the first frames), the write index must only grow and never pass the cap, and the
    cap must stop it at exactly 96,000 frames with capped set, still there 100 ms later, after
    at least 1.5 s. A second start while it runs is busy. The device side's peak must equal
    the samples' own. Then the module's own libopus path at the fixed settings
    (encodeClip, encodeRecord, decodeClip): 101 packets of 40 B, a record within
    maxClipBytes, decoded back to the same length. It prints the peak and RMS and no other
    number from the audio, and writes no file. An all-zero capture fails last, naming the
    likely cause: on macOS the microphone permission of the app macOS holds responsible, or
    no input device.
  - The speaker leg plays digital silence by default; only MUSTER_VOICE_REAL_AUDIBLE=1 plays
    0.5 s of 440 Hz at -30 dBFS peak (1,036), with 10 ms fades. As audio_io_null_test §3:
    read back as 48 kHz mono s16, 48,000 frames a second ±5% (over 1 s, once the device took
    its first frames), and a 100 ms buffer done only after the device's own tail of silence.
  - A watchdog thread arms before each device start, stop and close. Past 20 s (10 s for a
    stop) it prints what it waited on and exits 1. A leg's failure is recorded and the next
    leg still runs.
- module/tests/run-suite.sh: voice_device_e2e joins the E2E list, so no default, unit or
  probes run selects it. A test that exits 0 with a line "SKIP <name>:" is counted SKIP, not
  PASS, and the summary then says "n skipped". Checked under macOS's /bin/bash 3.2.
- module/tests/README.md: the real-device paragraph no longer says "not built yet": how to run
  it, both legs, the watchdog; a row in the E2E table; the test in the voice list and the
  $OPUS and $MA groups.
- voice-notes.md: §17 S11 has the test and this run; the status line, §9, §11 ("Outside
  Basecamp", now seen) and §15 follow it. A new §15 entry: a first voice_record_start can
  hold the module thread on a Mac (inferred from the run below; no timeout guards a start).
- docs/labbook/voice-notes-audio-stack.md §9: an unanswered microphone prompt holds the
  capture start; module/src/audio/CLAUDE.md names the test and that rule.

The run on this Mac (macOS 26.5.2, arm64, outside Basecamp, from Claude Code in the Claude
desktop app), MUSTER_VOICE_REAL=1: the microphone leg got no frame. startCapture had not
returned after 20 s, and the watchdog ended the test. tccd logged the request charged to
com.anthropic.claude-code (Claude Code's own claude.app, started by the desktop app's
disclaimer helper), not the Claude desktop app: AUTHREQ_PROMPTING for kTCCServiceMicrophone,
then no result. A preflight a minute later still read it undecided (authValue=1). It was the
middle of the night, so nobody answered, and the leg was not run again. So there is no peak
or RMS from a real microphone yet. The speaker leg, run alone (MUSTER_VOICE_REAL=speaker),
passed on Core Audio. After the microphone run, only the messages (to name Claude Code) and
the point the cap's pace is timed from (the start's return) changed, neither at the step
where it stopped.

Tested-Behavior: the gate, red then green: before the E2E entry, `run-suite.sh voice_device` selected the test in the default run and counted it "PASS voice_device_e2e" while it ran nothing; after, `run-suite.sh voice_device` and `unit voice_device` find "no tests match", and `e2e voice_device_e2e` and `all voice_device` without MUSTER_VOICE_REAL give "SKIP voice_device_e2e 0s (MUSTER_VOICE_REAL is not set)" and "0 passed, 0 failed, 1 skipped (of 1)"; MUSTER_VOICE_REAL=yes FAILs "say 1 (both legs), mic, speaker, or mic,speaker". Two scratch mutants of the test on the null device (not committed), red as they must be: abNull for abSystem FAILs both legs "backend Null not in @["Core Audio"]"; with the backend checks also removed, the microphone leg FAILs "every one of the 96000 samples is zero (peak 0): the microphone delivered silence. On macOS ..." after every other check passed (48.13 frames/ms, capped at exactly 96000 after 2010 ms, peak 0, RMS 0.0, 101 packets of 40 B, a 4318 B record decoded back to 96000 samples), and its speaker leg passes (48.12 frames/ms, tail 1920, done after 120 ms). The real devices, MUSTER_VOICE_REAL=1: FAIL by the watchdog, "mic: the capture device did not start within 20 s. On macOS this is most likely a microphone permission prompt nobody answered", with tccd's AUTHREQ_PROMPTING for com.anthropic.claude-code and no result (recorded, not re-run). MUSTER_VOICE_REAL=speaker, green twice: "Core Audio, read back (rate: 48000, channels: 1, s16: true); 48480 frames in 1010 ms = 48.00 frames/ms", "100 ms done after 130 ms" (127 ms on the committed text), "1920 frames of silence after it (the device's tail: 1920 frames, 40.0 ms)". No regression: run-suite.sh voice audio session_pad, 10/10 PASS (voice_codec_test "libopus 1.5.2, preskip 312", audio_io_test, audio_io_null_test "29280 frames in 609 ms (48007/s)", voice_live_test, voice_clip_test, voice_test, session_pad_test, probe_voice_fixed_size_stepper, probe_voice_author_stepper, probe_voice_order_stepper). Gates exit 0: check-run-targets, check-readme-rot, check-no-cpp, check-claims-registry; shellcheck -S warning on run-suite.sh finds only the SC2164 at `cd "$MODULE"` it found before. Not run: the whole default suite, the microphone leg with the permission granted, and anything on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s two frames leave back to back

Two findings of the whole-diff review, both in the publish path.

- A member's planted copy no longer blocks a retried Send (privacy-protocol-1). The held path
  re-sealed the copy the log keeps (smallest afterDigest), and Log.ingest takes a copy with an
  `after` of any length, unsigned, from any member. A member who received part 0 of a Send that
  failed between its frames could plant a copy whose `after` alone overflows the 90,112 B pad
  and sorts first; every retry then raised FrameTooLarge, returned `too-large`, and the room
  kept half the note for good while the sender's thread called it complete.
  - session.publish gains `asGiven`: seal the copy passed, on either path.
  - authorship.copyToSeal picks the copy publishAuthored seals: a new event as before; a held
    one as this member signed its `after` (the smallest by afterDigest, as roomEvents picks),
    else the event with no ordering claim. Never whichever copy the log keeps.
  - log.variantsOf reads one id's copies; the `after` check roomEvents caches is shared.
- A note's two frames leave back to back (privacy-protocol-2). Send published one part at a
  time, so between the two transport calls ran part 1's signing, hashing and encoding, about
  20-30 us more per second of audio, and each Waku message carries its publisher's timestamp.
  - session.publishTogether: each event made once the previous is in the log (so part 1's
    `after` still names part 0), padded, recorded, journalled and sealed; only then every
    frame goes to the transport, back to back. FrameTooLarge from any is raised before any
    frame of the burst is sent.
  - authorship.publishAuthoredTogether; voice_live.draftSend sends both parts through it.
- voice-notes.md §4 (which copy the held path seals; a part that does not fit is refused before
  any frame), §8 (the retry), §10 item 2 (the spacing, measured before and after), §15 (the
  variant entry is gone; a new one: the delivery node's own spacing is not measured).
  causal-parents.md §3.2 and the voice_live.nim header follow; tests/README.md lists §24, §25.

Tested-Behavior: voice_live_test §24 and §25, appended. Red on the unfixed code: §24 "the retry is not blocked: {"error":"too-large"}" (Mallory's 1,400-id copy, ground to sort first, kept by Alice's log); §25, with §24 skipped, "at the first send of a 1 s note: (logged: 1, journalled: 1, frames: 2)". Green after: "24. ... re-seals the author's own copy, two frames of 90,153 B, complete for Bob OK", "25. both parts of a 1 s and a 59 s note are logged and journalled before the first frame leaves ... OK". A scratch probe (not committed) timing the two transport calls on a transport that does not deliver back, 40 trials per length: one part at a time, medians 354 / 550 / 981 / 1618 us for 0.5 / 10 / 30 / 60 s notes; publishAuthoredTogether, under 1 us (0) at every length, frames [90153, 90153]. No regression: run-suite.sh all voice session_pad authorship audio causal swarm, 21 passed, 1 skipped (voice_device_e2e, gate closed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est's gate, hints and words

The whole-diff review's native-build-2 and the S11 device-test review.

- audio_io_null_test §3 read `short.silenceAfter` for its echo after freeAudioBuffer(short):
  a heap use-after-free of the allocShared buffer in the suite's own test of those lifetime
  rules. The value is read into a local before the frees.
- voice_device_e2e:
  - MUSTER_VOICE_REAL of separators alone (`,`, ` , `) named no leg, printed its header and
    exited 0, so run-suite counted PASS with nothing run. It now fails, naming the values.
  - The microphone hints were one text for two failures. A start that delivers no frame in
    5 s now names an input whose clock does not run (a virtual or aggregate device); an
    all-zero capture names the permission, or a default input that hears nothing (muted,
    volume zero, BlackHole or a Zoom or Teams device with nothing playing into it), and no
    longer "no input device", which is the no-device failure's own message.
  - The tccd hint reads the last hour, AUTHREQ_PROMPTING or _ATTRIBUTION: on a re-run while
    an earlier prompt still waits, tccd logs only a preflight, so five minutes found nothing.
  - The messages name the responsible app in general terms (the terminal, or the app that
    started the shell), not one tool's bundle id; the run record in §17 keeps the specifics.
  - The header says the watchdog ends the whole test, the legs after it unrun, and the
    speaker leg's tail check guards audio_io's done rule rather than measuring latency.
- tests/README.md, labbook §9 and voice-notes.md §11 and §17 S11 say the same. §11 and the
  labbook no longer say the start is held "until they answer": what was seen is a start
  still not returned after 20 s with the prompt unanswered; its return on an answer is
  inferred. The tail of 1,920 frames is miniaudio's buffer and periods, not Core Audio's
  output latency.

Tested-Behavior: audio_io_null_test under ASan/UBSan (nim 2.2.4, Apple clang 21, -d:useMalloc --debugger:native, -fsanitize=address,undefined): red, "AddressSanitizer: heap-use-after-free ... READ of size 8 ... audio_io_null_test.nim:112; freed by ... :110", rc 1; green, all five sections OK ("28800 frames in 600 ms (47997/s); 100 ms done after 125 ms, 2400 frames of silence after it"), rc 0; and PASS in run-suite.sh's own build. The gate: red, MUSTER_VOICE_REAL="," and " , " gave "1 passed, 0 failed (of 1)" with the log only "legs: "; green, both FAIL "MUSTER_VOICE_REAL=, names no leg: say 1 (both legs), mic, speaker, or mic,speaker", "yes" FAILs as before, "0" and unset SKIP. A scratch null-device mutant (not committed: abNull, backend checks removed) prints the new all-zero message after every other check passed, and its speaker leg passes ("100 ms done after 117 ms, 1920 frames of silence"). The real devices were not opened: the no-frames and stall hints are unrun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y's store node, and the refusals in plain words

The whole-diff review's ui-scripts-1, -4 and -5 and docs-claims-1 and -8.

- A refusal the person dismissed showed nothing when the next call was refused in the same
  words: InlineNotice keyed a dismissal on its words, and a real backend's second, identical
  answer does not even change voiceResultJson. InlineNotice gains `occasion` (empty: the
  words alone, as before for every other notice) and its × an objectName,
  `<notice>Dismiss`. Room counts its voice calls (`voiceCalls`), and RoomVoiceNote keys a
  refusal by that count, so a dismissed `silent` shows again on the next Send; a platform
  note stays dismissed until its words change.
- RoomMap's `holdsVoice` scanned every entry, ignoring the replay position, so a map scrubbed
  back to the admission said the store node had learned "one fixed size" and showed the
  "Voice notes" row. It now counts voice entries up to `k`, as `learned()` and `heldCount`.
- The refusal copy: `codec` on Play names the decoder, not the encoder; `device-format` is
  a sentence; any other device or encoder word reads "The microphone did not work (<word>)."
  and a dropped recording "the voice encoder could not encode it (<word>)", the word after
  the words; `too-large` no longer promises that Send again works ("It was too large to send.
  Discard it and record it again."), since a retry fails the same way and no draft under the
  cap gets it; `broken`, here and on the card, "it arrived damaged", true of parts that
  disagree, a record that fails its checks and a clip the decoder refuses, where "its parts
  don't match" was not.
- RoomVoiceNote's header, voice-notes.md §8 and §13: the recording banner is part of a room's
  view, so on Home, Settings or Contacts nothing in Muster shows the microphone is on; §18
  lists an indicator outside the room as open. §8 says how a dismissed refusal comes back;
  §15's half-sent entry no longer says the surface asks for Send again.

Tested-Behavior: the gallery (Qt 6.9.2 qml, offscreen, software, Basecamp 0.3.2's design system). New steps, red on the committed QML: room-voice-refused "voiceNotice.visible.true is false" after dismissing `silent` with the × and sending again, then (one at a time, on HEAD's QML) "text.~decoder is \"The voice encoder could not start on this device.\"", "text.!Send again is \"It did not fit to send. Press Send again; ...\"", "text.~would not record is \"The microphone refused: device-format\"", "text.~encoder is \"The recording stopped: not-cbr\""; room-voice-notes "This voice note can't be played: it arrived damaged. is \"...its two parts don't match.\""; room-map-voice "roomMapFields_o:store-node.text.timing, topic is \"timing, topic, one fixed size\"" at k=0. Green: all five voice fixtures and room-map-voice GALLERY-DONE, the latter also "timing, topic, one fixed size" at k=1, and a dismissed platform note staying dismissed across a call. Every fixture: 90 of 98 GALLERY-DONE; the other 8 (popups wanting an X capture, and `components`) fail the same way on the unchanged QML, since this Mac has no Xvfb. qmllint (Qt 6.9.2, both trees): no gated warning before or after. ui-lint's plain-language and harness-name checks alone (no runner here): 87 findings, 87 in the baseline, 0 new. The .lgx is rebuilt after the last commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… docs and claims the review found untrue

The whole-diff review's ui-scripts-2, -3 and -6, native-build-1 and docs-claims-2 to -6.

- scripts/voice-self-test.sh:
  - B playing the note: both autopilots log voice_status once a tick, before they decide to
    Play, and never the re-read after it, so a note of a second could end before B's next
    tick logged it playing, and the script waited out 240 s and failed. A Play's own answer,
    `VOICE-RESULT play <nid> {"ok":true}`, now counts as B playing it, beside a voice_status
    naming it; a voice_status with nothing playing must still follow.
  - VOICE_SECS must be 1 to 60: past the cap the module stops while the Stop still waits, and
    the 240 s wait could not fit.
  - The QML check holds the voice QML only where the view loads (MUSTER_UI=nim,
    MUSTER_QPA=xcb): the header says so, and a C++ offscreen run prints a NOTE saying no voice
    QML ran.
- The co-resident gap (registry, ClaimsRegistry.qml regenerated) and §10, §13: such a module
  can make any room current (coordinate_join, one it founds or whose members it admitted) and
  record and send there, so the voice reaches a party of its choosing.
- The device past its stream's end (native-build-1, at the reviewer's minor; no watchdog
  built): the cap and `done` are noticed by a pump alone, so with nothing calling the module
  the microphone stays open past 60 s. voice-notes.md §9, §13 and a §15 entry,
  src/audio/CLAUDE.md and the voice_live.nim header no longer call the device "bounded" or
  say every stream releases its device "at the cap".
- room-map.md §3 and the room_graph.nim header said coordinate_proof returns audio; since
  90fcbd4 it refuses `voice-notes-present`. Both now say no hosted method returns audio.
- voice-notes.md: muster_module.nim cites re-pointed to main (4566-4578, :4574, :4575-4577,
  :4624-4625, :2776, :2838-2846; the metadata.json ones were right and stay), "except those
  marked on this branch"; §9 names dlopen, dlsym and dlclose; §15's prototype bullet keeps
  only what is still prototype-only; the status line and §18 say S4-S10 are built on the wire
  format, so an objection means reworking them; §17 says S9's self-test has run only against
  a stand-in; §15 lists which typed fixtures voice-self-test's recording can replace, and that
  the rest are a recorded snapshot with one row or answer edited; §15's "1 and 2 pass" is
  scoped to the stand-in, with the two holes this commit closes.

Not changed: docs-claims-7 (the real-device test cited as present) is already true at this
branch's HEAD, since 2cc3c53 built voice_device_e2e and put it in run-suite's E2E list.

Tested-Behavior: voice-self-test.sh against a stand-in runner (scratch, not committed: S9's, writing each role's autopilot lines and B's sink of the tone round-tripped through libopus 1.5.2), with a new mode in which B's Play is answered ok and no voice_status ever names the note playing. Red, the committed script: that mode "FAIL: B never played A's note (sent: ab12cd, played: 0)" after ~240 s; VOICE_SECS=61 accepted and run. Green, this script: that mode SUCCESS; the good run SUCCESS ("2 accepted, 0 refused", "ok 151200 samples (3150 ms)", C refused silent), with the NOTE that no voice QML ran; a sink one sample short still FAIL; VOICE_SECS=60 SUCCESS; 0, 2.5 and 61 refused with exit 2 before anything launches; no process left. shellcheck -S warning: only the SC2164 at `cd` it found before. Gates exit 0: check-no-cpp, check-claims-registry (44 claims), check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, gen-fake-backend --check; ui-lint's plain-language and harness-name checks alone, 87 findings, 0 new. The docs changes have no test of their own; each was checked against the code it describes (muster_module.nim at 67701f8 for the cites).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wer arrives

The check of 2656f71..2828cc1 found that ec43eb0 keyed a dismissed voice refusal on Room's
call counter, which moves when a call starts, while Room.voiceResult keeps the previous answer
until the new one lands. So a refusal the person dismissed came back the moment they made
another call (a dismissed busy, then Start, showed "Recording did not start." for the whole
round trip, even when that Start succeeded) and stayed until the answer arrived.

- The view contract gains PROP voiceResultSeq ("0"), a decimal count of voice answers: both
  backends put the answer in voiceResultJson and then count it (the C++ voiceAnswer, the Nim
  voiceDone), so an answer identical to the last one still reads as new. A real backend's
  second identical answer leaves voiceResultJson as it was, which is why the count is needed.
  FakeBackend.qml is regenerated from the .rep.
- Room records the count when it makes a voice call (voiceCallAnswers) and, until the count
  moves, shows no answer: the last one answered an earlier call, and under the new call's
  title it said the wrong thing ("Nothing played." over the Send's silent). RoomVoiceNote keys
  a refusal's dismissal by the count (voiceAnswers) instead of the call counter, which is gone.
- voice-notes.md §8 says how a dismissed refusal comes back now.

Tested-Behavior: the gallery (Qt 6.9.2 qml from the store, offscreen, software). room-voice-refused gains three cases, each red on the committed QML (with the new FakeBackend and fixture): a dismissed busy, Start pressed again, "GALLERY-ERROR voiceNotice.visible.false is true" (the checker's probe); a dismissed silent, Send pressed again, the same error; silent showing, Play pressed, the same error (the Send's answer shown under "Nothing played."). Green on this QML: the notice stays hidden through the call and a 300 ms wait, and shows again once the same refusal is answered with the count one more; every answer in room-voice-refused and room-voice-record now carries its count, and room-voice-record checks the Send's own refusal words, not only the platform note they share. All six voice fixtures GALLERY-DONE on both design systems (Basecamp 0.3.2's 88330d0 and the runner's 94092b0). Every fixture on both: the same failures as HEAD's tree and no other (seven popups wanting an X capture, and `components` on 88330d0 only); room-news failed once under a parallel run and passes alone on both trees. qmllint (Qt 6.9.2): no warning on RoomVoiceNote.qml, Room.qml's nine unchanged; ui-lint's gate 87 findings, 0 new, on both. ui-nim/tests/autovoice/run.sh (backend.nim compiled whole against the new contract, 65 props): SUCCESS. gen-fake-backend --check: clean. The C++ backend's change has no test of its own here; the ui .lgx is rebuilt after the last commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and each hint its own causes

The check of 2656f71..2828cc1 found two of voice_device_e2e's messages untrue or unusable.

- The stalled start's hint (WhoWasAsked) grepped tccd's log for every AUTHREQ_PROMPTING and
  AUTHREQ_ATTRIBUTION line, and tccd logs an attribution for every process's request: 143
  lines in the last hour on this Mac, the test's own among them. It now keeps the microphone
  prompts and the lines naming the test's binary (`identifier=voice_device_e2e`, the name
  taken from the source file, since `nim r` adds a hash), and says that `responsible=` in them
  is the app macOS asked. It also writes /usr/bin/log in full: in zsh, macOS's default shell,
  `log` is zsh's own builtin, and the committed `log show ...` failed with "too many arguments".
- NoFramesHint and ZeroHint named the same virtual devices (BlackHole, a Zoom or Teams device)
  as the cause of both a capture with no frames and an all-zero one. A loopback driver runs
  its own clock and gives frames of silence while nothing plays into it, so it is a cause of
  the all-zero failure only. ZeroHint keeps it, as a virtual loopback device; NoFramesHint says
  a start that gives no frames at all has not been seen, so its cause is not known (an
  aggregate device whose clock device is missing is named as an unchecked guess), and asks for
  another input. Linux's says the same of its audio server.
- tests/README.md says the same, and how the stall's message finds the app asked; labbook §9
  gives the narrowed command, the counts, and zsh's `log`.

Tested-Behavior: the hint's command, printed by a scratch program built from the test's own const block (not committed). Red, the committed text: in zsh "zsh:log:1: too many arguments"; through /usr/bin/log its grep printed 143 lines for the last hour. Green, this text: in zsh, with --last 12h, 9 lines (1 AUTHREQ_PROMPTING for kTCCServiceMicrophone, 8 AUTHREQ_ATTRIBUTION naming voice_device_e2e_C701DB…, every one naming com.anthropic.claude-code), 0 for the last hour. The no-frames and all-zero messages were read as printed; neither can be reached here (no input here gives no frames, and the microphone leg was not run). MUSTER_VOICE_REAL=speaker module/tests/run-suite.sh e2e voice_device_e2e: PASS on Core Audio (48.17 frames/ms; 100 ms done after 124 ms, the device's tail 1,920 frames), playing silence; the microphone leg not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@corpetty corpetty left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is careful work. Here's a review at bbae7d8, with the Linux checks §18 asked for. In short: the core is sound, two things should be fixed before merge, and the §18 decisions are still open.

Linux checks (asked in §18)

  • Module suite on Linux x86_64: module/tests/run-suite.sh gives 285 passed, 0 failed, including voice_clip_test, voice_codec_test, audio_io_test, audio_io_null_test, session_pad_test, voice_test, voice_live_test and the three voice stepper probes.
  • The Linux module package loads. I checked the lgx variants run (39776903474):
    • linux-amd64 carries libopus.so.0 beside the plugin, with RPATH $ORIGIN;
    • ldd resolves every NEEDED library, libopus from the package itself, against the Qt in Basecamp 0.3.2's AppImage. The worry that a missing codec takes down all of muster_module doesn't apply to this package.
  • Not run here: the standalone runner, scripts/voice-self-test.sh, the voice parity entry, or the Linux device backends.
    • miniaudio dlopens libpulse.so.0 / libasound.so.2 by name, and a nix-built runner's glibc doesn't read /etc/ld.so.cache, so make run will likely report no device.
    • That's safe, because Null isn't in the system list, so it never records zeros. But it should be stated as current Linux behaviour.

What checks out

  • Wire: every voice/ frame is padded to 90,112 B inside the sealed plaintext, on both send paths. A wrong pad on a voice frame raises, and an oversize frame is refused before ingest.
    • Non-voice frames are byte-identical to main, and the event id and signatures are unchanged.
    • Receive is unchanged, so the size is fixed for honest senders, as the flow.nim comment says.
  • The fold:
    • A member can't forge or squat another's note: the key format, the author check in the authentic view, and the content-derived nid all hold.
    • Disagreeing parts or a digest mismatch give broken, never complete.
    • Order and duplicates fold the same.
    • Malformed base64, CBOR, packet sizes and sample counts are all bounded. I found no crash, overflow or unbounded allocation.
    • A message/ that claims voice-note stays text, in the module and in the QML.
  • No existing test was weakened. The diffs to authorship_test, swarm_test, the host harness and grade-specs.sh only add.
  • Audio FFI:
    • Opus buffer sizes and TOC checks are right.
    • Encoder and decoder lifetimes are right.
    • The device callbacks don't allocate, and use release/acquire positions with clamped copies.
    • Buffers are freed only after ma_device_uninit.
    • The miniaudio pin f40cf03 is tag 0.11.23, with a fixed hash.
  • UI parity: all 7 new SLOTs and 3 PROPs exist in both the C++ and the Nim backends. gen-fake-backend.py --check passes, and existing card kinds render as before.
  • Privacy: recording starts only from the mic button. A remote member can't start recording or playback, and nothing is written to disk.

Should fix before merge

  1. One member can switch off coordinate_proof for the whole room, for good (module/src/coordination/voice_live.nim:361-364).
    • roomProof refuses when the raw log (s.log.allEvents()) holds any voice/ key, so an unsigned or malformed junk entry is enough.
    • The log is append-only, so the refusal is permanent.
    • Minimum fix: refuse only when the authentic view holds a part the fold stands. Better: settle §18 Q4, a proof form that commits a part by digest.
  2. Starting a device can hold the module thread (voice_live.nim:258 startCapture, :409 startPlayback; miniaudio init/start has no timeout).
    • On macOS a pending microphone prompt held the start for over 20 s, as §15 and src/audio/CLAUDE.md record.
    • Every module call queues behind it, and in the C++ UI startVoiceRecording is a synchronous voice_record_start, so the view freezes too.
    • This is §18 Q6. I'd fix it before merge: start the device off the module thread and report through voice_status, or give the start a timeout.

Worth fixing

  • Every poll re-parses every voice part (voice.nim:212-228, gather → parsePart: about 84 KB of JSON per part, from coordinate_messages and from threadLastTs for every joined room).
    • The memo caches verdicts, not the parse, so the cost grows with each room's whole voice history, on the one thread.
    • Memoising the header parse by event id would fix it. play also gathers twice and decodes without a memo.
  • Microphone consent with co-resident modules. With Basecamp's access policy off, any loaded module can call voice_record_start. The only sign is the banner in the room view; Home, Settings and Contacts show nothing.
    • At least record this as an open gap in the claims registry.
    • A global recording indicator in Main.qml would close most of it.
  • publishAuthored changes for every author-signed kind (authorship.nim:290-315).
    • A re-announce now re-seals the member's own after-signed copy instead of the log's.
    • I think it's an improvement, and convergence holds. But it changes non-voice kinds, so it deserves a line in the description and a test for a non-voice kind on that path.
  • Nits:
    • run-suite.sh reports SKIP for any test that prints ^SKIP <name>:, which could hide a non-run.
    • The suite links unpinned nixpkgs#libopus (1.6.1) while the package ships 1.5.2.
    • A held draft keeps the 250 ms poll running indefinitely.

Still open

  • Unverified: a real microphone capture, a note drawn in Basecamp's window from a real room, and the two-runner self-test.
  • The §18 decisions: these are for @corpetty to answer here: the exo id and spec, sign-off on the wire format, F-13, the proof question, the re-send path, older clients, and the version.

Not mergeable yet. The two should-fix items and the §18 answers are what's left.


Generated by Claude Code

Fergulati and others added 28 commits October 22, 2026 12:35
…, not for junk under voice/

The PR 340 review's first should-fix. coordinate_proof refused a room whose raw log held any
`voice/` key, so one unsigned or malformed entry from any member switched the room's proof
off, and the log is append-only, so it stayed off.

- voice_live.roomProof now refuses only when the room's authentic view holds a part the fold
  stands (voiceStanding over roomEvents: a part of a note complete, arriving or broken, as
  every member's own note is), with the same error and detail as before. Junk under
  `voice/` (unsigned, a forged author, a malformed value, a squatted note id, a key of the
  wrong shape) refuses nothing and leaves with the proof whole, like any raw entry (exo-f76).
- What is left, written down in voice-notes.md §10 and pinned by the test: junk that copies a
  note's bytes carries them out of a room whose view holds none of that note's parts (an
  earlier epoch's note re-posted, or a note whose parts the fleet lost while the copy
  arrived); a member who can play a note can also post its bytes in a message's body.
- voice-notes.md §10, §15 and §18's proof item say what changed and the residual; the
  voice_live.nim header, session.isVoiceKey's comment, musterCoordinateProof's comment and
  tests/README.md follow. The claims registry already says "a room holding a voice note".

Tested-Behavior: voice_live_test §26, appended, and §23's last assertion turned to the new rule (it asserted that an unsigned `voice/not-a-note` refuses). Red on the old roomProof: §23 "a voice/ entry no fold stands refuses nothing: \"voice-notes-present\""; with §23's new lines held back, §26 "junk alone refuses nothing: {\"error\":\"voice-notes-present\",...}". Green after: "26. a room whose raw log holds only junk under voice/ (...) exports a proof that accounts for every entry; a note arriving, complete or broken refuses voice-notes-present as before OK". No regression: run-suite.sh all voice session_pad return_marshalling, 9 passed, 1 skipped (voice_device_e2e, gate closed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keeps each part's header

The PR 340 review's first worth-fixing item. The memo held verdicts only, so every
coordinate_messages poll, and coordinate_conversations' lastTs for every joined room,
parsed each part's value (about 84 KB of JSON for a 60 s note) again, and Play gathered
twice, the second time with no memo, and judged its note again.

- voice.nim: VoiceMemo keeps each part's header fields by event id (ok, ts, durMs, digest),
  never its half. A pass parses only a part the memo has not seen and keeps a fresh part's
  half for that pass alone; a verdict or a decode reads a half back from its event (once per
  pair judged, once per Play). Every fold takes the memo: reduceVoice, threadRows,
  threadLastTs, voiceStanding, voiceRecordOf, and the new voiceNoteOf, which reads one note,
  and its record when complete, from one header pass. Two counting seams for tests,
  voiceParses and voiceHalfReads, beside VoiceMemo.hits.
- voice_live.nim: Play reads its note through voiceNoteOf with the room's memo; lastTs reads
  threadLastTs with it; forgetRoom drops a room's memo, memoRooms counts them.
- muster_module.nim: coordinate_conversations' lastTs goes through the room's memo, and
  coordinate_leave drops it (a forward-declared voiceForgetRoom), so the memos are the joined
  rooms' alone, each bounded by its room's append-only log.
- voice-notes.md §6 (the memo: headers, verdicts, where it lives, memory, the counts, the
  numbers before and after), §15's fold cost, §17's S7 line; tests/README.md.

Measured (scratch bench, not committed; Apple M5, -d:release, 20 notes of 60 s and 20
messages, per call, before / after): threadRows with a warm memo 13.2 / 3.5 ms, threadLastTs
13.3 with no memo / 3.5 with a warm one, Play's lookup 26.0 / 3.0 ms; with no memo 44 ms
either way.

Tested-Behavior: voice_test §16 and voice_live_test §27, appended. Red first, on the counting seam with no header cache: voice_test "each part parsed once: 144, headers 0"; voice_live_test "over an unchanged log coordinate_messages' rows and threadLastTs parse 0 parts: (57, 0)". Green after: "16. a memo of part headers changes no fold output, in any order or as the set grows; a part is parsed once, and a new part alone is OK", "27. the room's memo keeps each part's header: an unchanged log parses 0 parts, a new part alone is parsed, Play gathers once and reads back the two halves it decodes; a room left drops its memo OK". Mutants, each red: Play's old two passes ("Play: (21, 0)"), lastTs with no memo ("(38, 0)"). The probe_voice_* steppers, voice_test §1-15 and voice_live_test §1-26 unchanged and green; run-suite.sh all voice session_pad return_marshalling flow provenance room_graph room_map swarm, 31 passed, 1 skipped (voice_device_e2e, gate closed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's own copy for a kind that is not voice

The PR 340 review's last worth-fixing item: 2656f71 changed publishAuthored for every
author-signed kind, not voice alone. A re-announce or a retried publish of an event the log
already holds now re-seals the copy whose `after` its author signed (copyToSeal), where main
re-sealed the copy the log keeps, the smallest afterDigest, which any member who received the
event can plant. No test held it outside voice.

- authorship_test §12, appended: Victor discloses an account; Mallory sends a copy of it with
  an `after` she made up and nobody signed, ground to sort first, so every log keeps hers;
  Carol is admitted in a new epoch and Victor discloses the account again. Carol receives
  Victor's own copy (his `after` and `afterSig`), and Alice, Victor, Mallory and Carol each
  read the disclosure once, Victor's, with his order.
- voice-notes.md §4 says the change covers every author-signed kind; publishAuthored's comment
  and tests/README.md say so too. causal-parents.md §3.2 already described it.

Tested-Behavior: authorship_test §12. Red on main's publishAuthored (a scratch mutant, not committed): "the author's own copy, not the one the log keeps: @[\"abab…01\", \"cdcd…cd\"]", Mallory's planted after. Green on this branch: "12. a re-announced account disclosure re-seals the author's own copy, not a member's planted one the log keeps; every member, a later joiner included, reads it with the author's order OK". No regression: run-suite.sh all authorship after_signed causal_publish voice_live swarm, 12 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l carries

After 6aa1793 a proof is refused only for a note the room's view holds, so an entry under
`voice/` that is no note's part leaves with the proof, a member's copy of a note this
member's view does not hold included (§10, "What is left"). §10's row for a module loaded
beside Muster and §13's paragraph on the two raw-log methods said the proof refuses a room
holding a note and stopped there; each now points at that case.

TDD-Exempt: docs only, two sentences brought in line with §10 and voice_live_test §26; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osted method returns

The second pass of PR 340's review. Since 6aa1793, voice-notes.md §10 says a proof carries
every entry under voice/ that is no note's part, junk that copies a note's bytes included
when this member's view holds none of that note's parts, and words the fixed decision as "no
hosted method returns a note's audio from this member's view". room-map.md §3 and
room_graph.nim's header still said "So no hosted method returns audio", and that the proof
refuses "a room holding a voice note". Both now qualify it as §10 does, and point at §10,
"What is left".

TDD-Exempt: docs and a code comment only, brought in line with voice-notes.md §10 and voice_live_test §26; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the room's memo

The second pass of PR 340's review. The worth-fixing item "every poll re-parses every voice
part" was still unmet for two polled methods, and §6 called them read on demand:
coordinate_flow runs on the room's 5 s tick and coordinate_graph every 2 s while the map is
open, in both UIs, and each folded with no memo. So every poll parsed every part's value
again: once in the flow, and three times in the map (its provenance, its flow and each
entry's content).

- reduceFlow, logProvenance and reduceRoomGraph take an optional VoiceMemo and hand it to
  voiceStanding. The map hands it to its provenance, its flow and each voice/ entry's content.
- The map's content of a voice/ entry moves to voice.voiceContentOf, the same for every
  entry. For a part whose header the memo holds, it is read off that header, not parsed. An
  entry that holds no part's value (a key of no part's shape, a value the fold refuses, an
  equivocating later copy) is still parsed on each call, as a message is, and voiceParses
  counts that parse too.
- voice_live: the hosted methods call recorder procs that pass the room's memo, so the memo
  wiring is code the tests run. flowRows serves coordinate_flow, graphOf coordinate_graph,
  provenanceOf coordinate_provenance. lastTs(s, events) takes the events
  coordinate_conversations already read, and replaces lastTs(s), which no production code
  called.
- voice-notes.md: §6 (where the memo is used, what coordinate_proof and junk entries still
  cost, the tests), §10's line references, §15 (the fold's cost, and a new line: the hosted
  lines that call these procs, and coordinate_leave's voiceForgetRoom, are built but no test
  runs them), §17's S7 line. tests/README.md too.

Tested-Behavior: voice_test §17 and voice_live_test §28, appended; voice_live_test §27's four lastTs calls now pass the events. Red first, with the memo parameters added and not passed on: voice_test "the map, cold: each part's value once, for its provenance, its flow and its content: 42"; with lastTs(s, events) given no memo, §27 "over an unchanged log ... parse 0 parts: (38, 0)"; with lastTs wired and flowRows, graphOf, provenanceOf not, §28 "cold: each part once across the four reads: 126". Mutants, each red: the map's content ignoring the memo (voice_test "...: 28"), the header-built content missing `v` ("the map with a memo is the map without one"). Green after: "17. the flow, provenance and the room map take the memo: each output is the memo-less one, in any order; a cold memo parses each part once, a warm one none, and the map parses only an entry that holds no part's value OK", "28. the flow, the map, provenance and lastTs go through the room's memo, the thread's own: the memo-less output; each part parsed once across them, none on the next poll OK". run-suite.sh all voice session_pad return_marshalling flow provenance room_graph room_map swarm: 31 passed, 1 skipped (voice_device_e2e, gate closed). Whole suite (run-suite.sh): 285 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never holds the module thread

PR 340's review, should-fix 2 (voice-notes.md §18's device-start question): MiniaudioIo
started a device on the module thread with no timeout, and on a Mac Core Audio held a
capture start behind an unanswered microphone prompt for over 20 s (S11), so every module
call, and the C++ UI's synchronous voice_record_start, would have queued behind it. Fixed
the way the review preferred: the device starts off the module thread and voice_status
reports it.

- audio_io: a start job. startCapture / startPlayback return at once with the stream
  `starting`; the blocking half runs on a detached pthread that keeps the device thread's
  rule (no GC, no raise, checks off, C's heap and atomics only). The two threads meet at one
  atomic by compare-and-swap: the start thread swaps pending to running or failed; a stop
  or close before then swaps pending to let go and forgets the job, never waiting, and the
  start thread then stops what it started and frees it. AudioBuffer is calloc'd and counted
  by reference, so whichever side finishes last frees it. At most four starts wait at once;
  past them a start is refused device-start-slow. waitStarts(ms) waits a bounded while.
- SyntheticAudioIo takes a StartGate: its start waits on the gate on a start thread until
  the test releases it (run, or fail with a device word). FileAudioIo stays synchronous.
- MiniaudioIo: ma_context_init, ma_device_init, the read-back and ma_device_start run in the
  start job; the pump settles it (the io takes the context and device, or reports the
  failure's word once); the context moves with each start and back when it runs.
- voice_live: recording.state `starting` until the device runs, then `recording`, the
  elapsed time and the 60 s cap counted from the device's start; a start that fails ends
  idle with recording.error and ready.capture its word; one still pending 15 s after the
  call (StartBoundMs: time to answer a prompt, no longer than anyone waits on "Starting the
  microphone…") ends with device-start-slow, let go. Stop while starting is not-started,
  Discard and close let the start go. Play the same: playing carries `starting` until the
  speaker runs, at position 0, and the pump's done detection is unchanged.
- The hosted exit proc closes the recorder, then waits up to 2 s for start threads, so close
  at unload never hangs on a start that never returns (logos_host runs one module per
  process: its exit ends that thread).
- UI (shared QML; both backends pass voiceJson through): RoomVoiceNote's banner says
  "Starting the microphone…" with Stop and Discard, no time and no level; a start that
  failed or was too slow, and a Play whose speaker did not start, say so in words; a card
  says "Starting…" while its speaker starts; nothing in the thread plays while a recording
  starts. Both autopilots (MUSTER_AUTOVOICE) time the recording from the first voice_status
  that says recording. Gallery fixture room-voice-starting; room-voice-notes and -record
  carry the playing shape's new `starting`.
- voice_device_e2e pumps until a start lands, under its watchdog, and fails a start call
  that takes 100 ms or more to return.
- Docs: voice-notes.md §8 (the state machine, the methods, voice_status's shape), §9 (the
  start thread, the buffer, device lifetime, the exit proc), §11, §15 (the blocked start is
  fixed, with what ran and what has not), §17 (S7, S11) and §18 (done in this PR; the
  maintainer may still object); the labbook's §9 with the three traps; src/audio/CLAUDE.md's
  rules for the start thread; muster.lidl's descriptions; tests/README.md; the layout line.

Tested-Behavior: audio_io_test §7-§9 and voice_live_test §29-§31, written first and run red on the seam as it was, where the gated start blocked its caller: audio_io_test "§7 the start returns at once while the device start is held: 1774 ms", voice_live_test (its §1 playing shape held back) "§29 voice_record_start answers at once while the device start is held: 1760 ms, {\"ok\":true}", a safety net releasing the held start after 1.5 s. Green after: "29. voice_record_start answers at once while the device start is held: starting, the module answering meanwhile; released, recording, timed and capped from the device start OK", 29b (Stop not-started, Discard, close, a failed start no-device, one past 15000 ms device-start-slow; buffers counted), 30 (Play), 31 (close and the bounded wait); audio_io_test 7-9. ui-nim/tests/autovoice/run.sh against a fake that starts for 1.5 s: red on the committed backend ("recorder: the fake never said recording (-1 ms)", Stop answered not-started), green after ("recording 1994 ms after Start, Stop 3036 ms after Start"). audio_io_null_test §6 on the null device thread (held green across the change): "80 of 80 let go before their start returned", every buffer freed; §1-§4 now wait for the start to land. Whole suite: run-suite.sh, 285 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p saw it, then a stop

S2's review, its first major: of the three orders a start and a stop can meet in, the
committed tests held two. The third is a start whose thread has returned (swapped pending to
running or failed) when a Stop, Discard or close arrives before any pump saw it: letGo is
false, so the module thread must finish the job itself (SyntheticAudioIo's letGoStart, and
MiniaudioIo's settle then closeStream). The recorder reaches it: draftDiscard and close drop
a starting recording without pumping first, and on the null device most starts have returned
within a few ms. Two mutants of that branch left every committed test green; a regression
there would leave the microphone open and the job and buffer leaked.

- audio_io_test §8b: a gated start released (run, or "no-device"), waited out with no pump,
  then stopCapture / stopPlayback / close: idle at once, nothing reported later, the
  caller's free the last one, nothing stopped by the start thread.
- audio_io_null_test §6b: a null-device start waited out with no pump, frames moving, then a
  stop or a close: the device closed at once (frames stop), the context kept by a stop and
  released by a close, every buffer freed.
- voice_live_test §29c: Discard and close while the recorder still says starting, after the
  start returned (synthetic run, synthetic failed, the null device): idle at once, the buffer
  freed at once, the null device's context released.
- voice-notes.md §9 names the three orders and the section holding each, and says the tests
  do not race the two threads at the swap itself; §15 lists the new sections and the mutant
  runs. src/audio/CLAUDE.md and tests/README.md name them too.

The code is unchanged: it was right in every order.

Tested-Behavior: written against two mutants in a scratch copy of module/src, red: M1 (SyntheticAudioIo.letGoStart `discard letGo(io.job)`): audio_io_test "§8b stop-capture: the module thread finished the start: buffers left 1", voice_live_test "§29c synthetic discard: the buffer freed at once: 1 left"; M2 (MiniaudioIo.letGoStart forgetting the job instead of `discard io.settle()`): audio_io_null_test "§6b stop capture: a stop keeps the context its start made, a close releases it: ''", voice_live_test "§29c null discard: the buffer freed at once: 1 left". Green on the code as committed: "8b. … the module thread finishes it, every buffer freed, nothing reported later OK", "6b. … the device closed at once, the context kept by a stop, every buffer freed OK", "29c. … the buffer freed at once, the null device's closed OK"; run-suite.sh unit audio_io voice_live: 3 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the device on

S2's review, a minor: MiniaudioIo's start thread never read the start's phase, so a start
given up (Stop, Discard, close, or the 15 s bound) whose ma_device_init then returned still
called ma_device_start, and only afterwards did startMain's failed swap stop it. On a Mac
where the person answers the prompt after giving up, the microphone would come on, its
indicator lit, after the room said the recording ended.

- audio_io: wasLetGo(job), a start-thread read of the phase (atomics only), and StartLetGo,
  the code a run returns when it starts nothing; gateWait is exported for a hold a test puts
  in another implementation's start.
- miniaudio: maRun reads wasLetGo just before ma_device_start and, let go, releases the
  device and context it made and returns StartLetGo. A start let go after that read still
  starts the device, and its thread stops it at once, as before. newMiniaudioIo takes an
  optional StartGate, a test's hold between the device's init and its start (the module
  passes none), and devicesStarted() counts the ma_device_start calls that started a device.
- audio_io_null_test §7: a gated null-device start, stopped or closed while held (capture
  and playback), then released: no device starts, every buffer freed; a gated start the io
  keeps starts exactly one.
- voice-notes.md §9 says a start let go before its device starts never starts it, what is
  left (a let-go in the instant after the read, or while ma_device_start itself waits), and
  that which Core Audio call holds a prompt is not known; §11 says the same for a prompt
  answered after giving up. src/audio/CLAUDE.md's start-thread rule names the read.

Tested-Behavior: audio_io_null_test §7, written with the hold and the count in place and no read of the phase, red: "stop capture: a start let go before its device started started it: 1 device(s)". Green with the read: "7. a start let go while held before its device starts never starts it; one the io keeps does OK"; audio_io_test, audio_io_null_test and voice_live_test 3 passed; voice_device_e2e compiled and skipped with its gate closed, and MUSTER_VOICE_REAL=speaker passed on Core Audio (silence: 48.15 frames/ms, done after the 1,920-frame tail).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e word the last start left

S2's review, a minor: recordStart set ready.capture only on an immediate refusal or a
synchronous start, and play() ready.playback likewise, so after a start that failed or was
too slow, the next Record reported ready.capture "device-start-slow" (or "no-device") while
voice_status said starting. No banner shows it today (noticeText reads ready.capture only
when recording.error is set), but any other reader of ready during a start would show a
failure that is not current. The gallery fixture room-voice-starting, typed from the
module's shapes, already said "unknown" there, so it did not hold what the module gave.

- voice_live: recordStart's starting branch sets ready.capture to unknown, and play's sets
  ready.playback to unknown, until the pump sees the device run (ok) or fail (its word).
  DeviceUnknown's doc says so, and play's names device-start-slow among the device seam's
  immediate words.
- voice_live_test §29d: a start past the bound, then another Record while the gate still
  holds it: starting, ready.capture unknown; the same for Play and ready.playback.
- room-voice-starting: the second Play's starting step says playback unknown, as the module
  now does (it said ok, the first Play's word); its about names §29d.
- voice-notes.md §8 says what ready says while a start is on its way; §15 lists §29d.

Tested-Behavior: voice_live_test §29d, written first, red on the recorder as it was: "a capture start on its way says unknown, not the last start's word: {"ready":{"capture":"device-start-slow",…},"recording":{"state":"starting",…}}". Green after: "29d. while a start is on its way, ready.capture and ready.playback say unknown, not the word the last start left (device-start-slow) OK"; voice_live_test and probe_return_marshalling_host pass; the gallery's room-voice-starting 40 passes, 0 QML warnings, on design systems 88330d0 and 94092b0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…was recorded

S2's review, a minor: Stop while starting is answered {error: "not-started"}, but by then
voice_status has moved to idle, and Room drops an answer once voiceAt changes, so
RoomVoiceNote's "Nothing was recorded: the microphone had not started yet." never showed;
the person saw only the banner go. That move is the Stop's own doing, so its answer is
about it, not stale.

- Room.qml: onVoiceAtChanged lets the answer follow voice_status from starting to idle when
  the call was a Stop, whichever of the move and the answer comes first; every other move
  still drops the last answer, as before (room-voice-refused unchanged).
- room-voice-starting: the first Stop while starting (the move, then the answer) now expects
  the notice with those words and a shot of it; a second one gives the answer first, while
  voice_status still says starting, then the move, and the words stay. Later answers count
  on from there.
- voice-notes.md §8 says Stop while starting ends the banner and says so, and why this one
  answer follows voice_status.

Tested-Behavior: room-voice-starting, its expectations written first and run red on the old Room.qml: "GALLERY-ERROR voiceNotice.visible.true is false" for the first Stop (the move, then the answer), and the same for the second (the answer, then the move) in a scratch copy without the first's expectations. Green after on design systems 88330d0 and 94092b0: room-voice-starting 48 passes, 4 shots, 0 QML warnings; every other room-voice-* fixture and room-map-voice unchanged (flow 2, forged 10, notes 18, record 27, refused 42, map 8); ui-lint's python gate "87 finding(s), 87 in the baseline: 0 new, 0 fixed" on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… state

S2's review, a minor: muster.lidl's descriptions lagged the recorder. voice_play's busy read
"recording or playing", though play also refuses while a recording is starting, and it did
not name device-start-slow, which a fifth start gets while four are pending; voice_draft_
discard did not say it drops a recording that is starting; and voice_status did not say that
capture and playback read unknown again while a start of that device is on its way (since the
previous commit).

Descriptions only: no method, argument or type changes, and nothing generated carries them
(muster_gen.nim, the .rep and FakeBackend.qml hold no description), so no regen.

TDD-Exempt: description strings in the contract only; no behaviour changes and no generated surface reads them (check-action-corpus, gen-fake-backend --check and gen-claims-qml --check pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ophone, as observed

S2's review, its second major: the docs still called open, or contradicted, what was
established on this Mac on 2026-10-22, and S2's own new text built on that: §11's two
outcomes "inferred, not tested" and "Launching Basecamp from Terminal does not help", §15's
"Neither outcome of §11 is observed" and "got no frame", the window checks "still open", and
three sentences (§15, §18 Q6, the labbook's §9) leaving "whether Basecamp's logos_host is
asked at all" to a check that has since run.

Now written as observed (at bbae7d8, before the start thread):
- §11: Basecamp launched as the Dock does (open -n -a): logos_host survives
  voice_record_start; tccd's "Prompting policy for hardened runtime; service:
  kTCCServiceMicrophone requires entitlement com.apple.security.device.audio-input but it is
  missing for responsible=LogosBasecamp", "Policy disallows prompt", authValue=0; the banner
  "The microphone is on, but no sound has reached it yet.", Send's silent refusal with the
  Basecamp 0.3.2 note. A profile launched from a shell under Claude Code is charged to
  com.anthropic.claude-code (allowed) and captures real audio, so a terminal launch is not a
  person's Basecamp. The inferred outcomes are kept as one sentence saying which held.
- §8, §9, §11, §18 Q6, the labbook §9, StartBoundMs's doc and src/audio/CLAUDE.md: Basecamp
  0.3.2's logos_host is never prompted, so its start does not wait; the start thread and the
  15 s bound guard hosts whose responsible app can prompt (a shell or terminal launch) and a
  future Basecamp with the entitlement and a usage string.
- §15: the microphone entry is what a person's Basecamp does; a new entry for the note
  between two Basecamp 0.3.2 profiles in its window (A's C++ autopilot from the 1 kHz WAV,
  B's card and autopilot: exactly 137,808 samples, 2,871 ms × 48, the tone at 0.99 of the
  source's power); the S8 and S9 entries say the C++ UI and autopilot ran there, at
  bbae7d8, and what has not (the Nim UI, S2's starting state and wait); the speaker leg has
  run on the start thread, the microphone leg not.
- §17 S11: the window check is done; the microphone leg's first run got no frame and it
  passed later that day (Core Audio 48 kHz mono s16, 48.37 frames/ms, capped at exactly
  96,000, peak -41.2 dBFS, the codec round trip keeping 96,000 samples); what is left is that
  leg on the start thread, with the person's consent. The status line at the top says so.
- The labbook's open list drops what is found out and keeps the start-thread microphone run
  and which Core Audio call waits on a prompt.

Still true and kept: the microphone leg has not run on the start thread (it needs the
person's consent), and nothing of S2 has run in a host.

TDD-Exempt: documentation and code comments only (voice-notes.md, the labbook, src/audio/CLAUDE.md, StartBoundMs's doc comment); no behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…other that says SKIP fails

PR 340's review, a nit: run-suite.sh counted SKIP for any test whose log held a line
"SKIP <name>:", so a test outside the gated list that printed one and exited 0 would read as
skipped, a test that never ran hidden in the summary.

- run_one counts SKIP only for a test in the E2E list (the gated, chain-bound and device
  tests: voice_device_e2e without MUSTER_VOICE_REAL among them). Any other test that prints
  the line FAILs, saying why: it printed "SKIP <name>:", but only a gated test in
  run-suite.sh's E2E list may skip, with its log's path. The list reaches the xargs
  subshells as E2E_LIST, a space-padded string (bash arrays do not export; bash 3.2 runs it).
- module/tests/README.md and voice-notes.md §17 S11 say so.

Tested-Behavior: a scratch test (module/tests/zz_skip_scratch_test.nim, not committed: it prints "SKIP zz_skip_scratch_test: nothing ran, and this test is not gated." and exits 0), red on the old runner: "SKIP zz_skip_scratch_test 1s  (nothing ran, and this test is not gated)", "── 0 passed, 0 failed, 1 skipped (of 1) ──", exit 0. Green on the new one, under macOS's /bin/bash 3.2: "FAIL zz_skip_scratch_test 0s  (it printed "SKIP zz_skip_scratch_test:", but only a gated test in run-suite.sh's E2E list may skip: …/zz_skip_scratch_test.log)", "── 0 passed, 1 failed (of 1) ──", exit 1; the gated one still skips: run-suite.sh e2e voice_device_e2e "SKIP voice_device_e2e 1s  (MUSTER_VOICE_REAL is not set)", "── 0 passed, 0 failed, 1 skipped (of 1) ──", exit 0. Whole suite: "── 285 passed, 0 failed (of 285) ──".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and §12 says what Linux does with the devices

PR 340's review, a nit and its Linux checks.

The nit: on Linux run-suite.sh linked nixpkgs' current libopus (1.6.1), from the one nix
build with nixpkgs' gcc, while the package ships the builder pin's 1.5.2 (macOS already took
the pin's, c2b2dc7). It was left unpinned there for glibc: a nix-built library needs a glibc
at least as new as the one it was built on. The pin's runs under that gcc:
- for x86_64-linux, "$NIXPKGS_PIN#libopus.out" evaluates to
  /nix/store/39jlnb9vgammhav2skbf3dvkbciqsh14-libopus-1.5.2.drv (out
  /nix/store/mzksc9fpj2hvhvr8ggzqcy0j96fyqr79-libopus-1.5.2), built on glibc-2.40-66, while
  nixpkgs#gcc's libc is glibc-2.44-25 (nix eval / nix path-info --derivation, no build);
- its libopus.so.0, read from cache.nixos.org's copy (nix store cat), needs only libm.so.6 and
  libc.so.6, at GLIBC_2.2.5 to GLIBC_2.29, with RUNPATH the pin's glibc: glibc 2.44 runs it;
- a test binary links libc and libm itself (Nim's std/math, which strutils imports, passes
  -lm on Linux), so the gcc's glibc is loaded before libopus's RUNPATH is read and only one
  glibc runs.
So libopus now comes from the builder pin on both OSes: the libopus lines are the same on
macOS and Linux, and ran here. Not run on Linux: voice_codec_test §1 prints the version a run
used ("libopus 1.5.2, preskip 312" here). MUSTER_SODIUM still needs MUSTER_OPUS on Linux.
scripts/grade-specs.sh still takes nixpkgs' libopus for exo-526's host probe; not changed.

The Linux checks, in voice-notes.md §12, as the code behaves today, with what is known and
what is not: the maintainer's Linux run (285 passed; the linux-amd64 package carries
libopus.so.0 with RPATH $ORIGIN and ldd resolves it against Basecamp 0.3.2's AppImage Qt);
miniaudio dlopens libpulse.so.0 / libasound.so.2 by soname, and a nix-built runner's glibc
does not read /etc/ld.so.cache, so make run likely reports no device: Record answers {ok},
voice_status says starting, and the next pump ends it with recording.error and ready.capture
no-device ("No microphone was found on this device."). Null is not in the system list, so it
never records zeros. Inside Basecamp's AppImage it is unverified. §15 says the same, and
that the linux-amd64 link is checked; tests/README.md, src/audio/CLAUDE.md and the labbook
§7 say which libopus the suite links.

TDD-Exempt: test-runner infrastructure on a path only Linux takes, which cannot run on this Mac (evaluated for x86_64-linux and read off the cache's ELF instead); the libopus lines it now shares with macOS ran in the whole suite here: "── 285 passed, 0 failed (of 285) ──", voice_codec_test "libopus 1.5.2, preskip 312". The rest is documentation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d a held draft polls at the ordinary tick

PR 340's review, "Microphone consent with co-resident modules": with Basecamp's access policy
off any loaded module can call voice_record_start, and the only sign was the banner in a
room's view; Home, Settings and Contacts showed nothing. And a nit: a held draft kept the
room's 250 ms poll running indefinitely.

- Main.qml: appVoicePoll reads voice_status every second whenever the backend is up, room or
  none (loadVoice needs no room in either backend: the C++ one calls voice_status, the Nim
  one voice_statusAsync, and the hosted method makes the recorder, opening nothing). While it
  says starting or recording, a strip above every page (contentArea now starts below it; its
  height is 0 otherwise, so every other shot is unchanged) says "Muster is starting the
  microphone…" or "Muster is using the microphone", the time of the cap, "If you did not
  start it, press Discard.", and Stop and Discard, through the room's own calls so their
  answers show in the room. MusterStyle / Logos.Theme tokens, as the room's banner.
- Room.qml: voicePoll runs only while the microphone starts or records or a note plays; a
  held draft and idle are read on Main's 1 s tick, and the room's 1 s tick no longer calls
  loadVoice (Main's does).
- Claims: the gap "Modules loaded beside Muster can record and send as you" says what the
  strip covers and what it does not (Muster's screens not running, a person not looking, a
  short note started and sent between two reads, who started it and for which room);
  ClaimsRegistry.qml regenerated.
- voice-notes.md §8 (the polling), §13 (the mitigation and what it leaves; the open device
  needs a pump, which the view's 1 s read now gives on every page), §15 (fixture lists), §17
  S8 and §18 (the indicator done, what it leaves). RoomVoiceNote's header comment says so.

Tested-Behavior: gallery fixtures, written first and run red on the old QML (Qt 6.9.2 qml offscreen, software, design systems 88330d0 and 94092b0): the new home-voice-indicator and room-voice-record's new expectations, "GALLERY-ERROR no object named voiceIndicator" on both; and room-voice-record with only the held-draft step, "GALLERY-ERROR voicePoll.running.false is true". Green on the new QML on both systems: home-voice-indicator 28 passes (idle: no strip and loadVoice from appVoicePoll with no room joined and the room's poll off; starting and recording on Home, then Settings, Contacts, Learn and the Wallet; Stop and Discard make their calls; a draft and idle hide it), room-voice-record 35 (the strip in a room while recording, gone for a draft; the 250 ms poll off for a held draft, on while it plays, off again), 0 QML warnings. Every fixture: 92 and 93 of 100 pass, the same failures as HEAD's tree (popup shots needing an X capture, and components on 88330d0); idle-voice shots pixel-identical to HEAD's. ui-lint's python gate "87 finding(s), 87 in the baseline: 0 new, 0 fixed" on both; gen-claims-qml --check, gen-fake-backend --check and check-claims-registry pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…window, at bbae7d8, and what has not

The lead's facts from this Mac (2026-10-22) were folded into §11, §15 and §17 S11 by
61242ee. Two places still summarised them short:
- the status line: the window checks ran at bbae7d8, before §9's start thread; macOS never
  asks for the microphone and denies it, so the capture is zeros and Send refuses it silent
  with the Basecamp 0.3.2 note; the note between two profiles was drawn and played; and S9's
  two-runner self-test and the start thread in any host have not run;
- §15's opening: the same, and that nothing since bbae7d8 has run in Basecamp's window.

TDD-Exempt: documentation only (docs/design/voice-notes.md's status line and §15's opening paragraph).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ux run says what B did

On the fork's Linux CI (runs 39776743154, 39822900956, 39836679442, at bbae7d8) the script ran
on two real runners and failed both legs, "B never played A's note (sent: 48410f05…, played:
0)", while B had played it: in 39836679442's kept logs B's thread held the note complete, its
Play answered ok, voice_status named it playing at 990 and 1,990 ms and then nothing, and B's
sink held exactly 143,280 samples (2,985 ms x 48) with 0.99 of the source's 1 kHz power;
delivery accepted A's two frames and refused none. The C++ UI's lines reach the logs through
ui-host's relay, `ui-host [ "muster_ui" ]: "<chunk>"`, QDebug-quoted, several to a chunk, and
the script only turned \" into ", so every VOICE line kept a closing quote or a joined line and
every json.loads failed, silently. The same logs show ui-host also cutting lines: a chunk
ending inside one of the UI's lines (210 to 240 times a log; the cut line then a whole number
of 128 bytes so far, 906 of 908 times), and another process's timestamped line landing inside
a relayed one (20 to 30 times a log). The product was right; the script was not.

- scripts/lib/ui_log.py, `ui_log <log>` in ui-build.sh: the log with each of the UI's lines
  whole on a line of its own: the relay's quoting undone (\xHH as UTF-8, "" after a hex
  escape), a line cut across two chunks joined, another process's line cut out of a chunk and
  printed after the lines written before it; every other line unchanged. On the four CI logs
  all 1,960 voice lines read as JSON (the diagnostic run's reader: 8 do not) and every bare
  line comes out unchanged and in order. ui-build.sh's contract header and scripts/lib's
  CLAUDE.md say how the C++ UI's lines arrive.
- scripts/voice-self-test.sh reads through it. A line it cannot read is the script's error,
  named with the line, never "B never played": A's Send not JSON after three reads ("the
  script could not read A's Send"), A's Send naming no note (refused) at once, A's Stop or B's
  notes the audio check cannot read ("bad: the script could not read …"), and a NOTE counting
  B's VOICE lines that were not JSON. Lines logged every tick (B's notes, C's Send, Stop and
  notes) are taken from the last one that reads as JSON, since a runner may still be writing
  the last. C's checks read JSON instead of grepping for substrings, and B's thread must hold
  exactly A's note. The pass criteria are otherwise unchanged.
- VOICE_REPLAY=<dir> gives the verdict on a finished run's files and launches and deletes
  nothing; VOICE_OS names the OS the runners ran on.
- scripts/tests/voice-self-test-replay.py, with lines cut verbatim from 39836679442's logs
  (scripts/tests/fixtures/voice-relay/), holds ui_log.py on each genuine kind of cut and the
  script's verdict through VOICE_REPLAY: SUCCESS on the run as it was and with B's lines cut as
  ui-host cuts them, FAIL for B never idle, a refused send, one frame, a short sink, a refused
  or unreadable Send, an unreadable Stop, C not refused, no detail on macOS, a second note at
  B, and the same on the logs written bare, as the Nim UI writes them.
- voice-notes.md: the status line, §15 (what the CI runs showed, the fix, what has not run),
  §17's opening and S9; CLAUDE.md's command list.

Not changed: ui/src/muster_ui_backend.cpp, the Nim backend, fileshare-self-test.sh (it still
reads with the sed and grep -o, which the relay's quoting happens to leave alone there).

Tested-Behavior: scripts/tests/voice-self-test-replay.py, written first. Red on bbae7d8's script ("── 24 failed ──": no ui_log, no replay mode, "build the runner first: make build"); red on this script with bbae7d8's lg() put back ("── 14 failed ──", the run as it was "FAIL: the script could not read A's Send (its reading of the log, not A's): {"note":"48410f05599e54b38ffd8b515ba4017e"}""); red with the diagnostic run's decoder as ui_log.py ("── 8 failed ──": every genuine cut, B's lines cut as ui-host cuts them "FAIL: B never played A's note (sent: 48410f05…, played: 1)", and it rewrites the Nim UI's bare SELFTEST lines). Green: "── all passed ──", 31 checks. Replayed on 39836679442's whole kept logs and sinks: "SUCCESS: B played A's voice note (48410f05599e54b38ffd8b515ba4017e) …", sink "ok 143280 samples (2985 ms) … 1 kHz power 66639028 of the source's 67095156", and the local leg (442b7dba…, 143232 samples). Against a scratch stand-in runner that relays the autopilot's lines through an emulated ui-host (128-byte pieces, a VOICE line across two chunks every other tick, another thread's timestamped lines landing inside 22 of A's 36 relayed lines), under macOS's bash 3.2: SUCCESS with C's leg ("the null device's zeros were refused as silent"), SUCCESS bare as the Nim UI writes, SUCCESS with VOICE_SECS=1, "FAIL: A's Send names no note: {"error":"busy","detail":""}" at once, "played: 1" when B never stops playing; bbae7d8's script against it "FAIL: B never played A's note (sent: 3b760bcd… 3b760bcd…, played: 0)", as on CI; no stand-in process left. Module suite: "── 285 passed, 0 failed (of 285) ──". Gates pass; ui-lint's python gate "87 finding(s), 87 in the baseline: 0 new, 0 fixed" on both design systems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f off for good

The final check of PR 340's review fixes found §18 overclaiming: it said that since the
review "no member can switch a room's proof off for good". Since 6aa1793 junk under voice/
refuses nothing, but a note still refuses, whatever its state, and the log is append-only:
any member can switch the proof off for good by sending one note, a broken one included
(two parts of their own, signed, whose clip fails its checks; voiceStanding keeps a broken
note's parts, and voice_live_test §26 asserts that a broken note refuses). §10 said "now only
a note does", which is true; it now says the rest too.

- §18 "Still open", the proof item: junk can no longer do it, a note does, until the proof
  question is decided; a proof form that commits a part by digest would close both.
- §10: any member can still do it by sending one note, a broken one included, until then.

TDD-Exempt: documentation only; the behaviour it states is held by voice_live_test §26 as it stands ("a note arriving, complete or broken refuses voice-notes-present as before").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… off the same pin as run-suite

PR 340's review, the libopus nit, as the final check of its fixes found it: 0a82be1 moved
run-suite.sh to the builder pin's libopus 1.5.2 (the .lgx's) on Linux, but
scripts/grade-specs.sh still built nixpkgs' current libopus for exo-526's host probe, and
voice-notes.md §15 did not say so.

- module/tools/builder-nixpkgs.sh: run-suite.sh's lock reader, moved out unchanged, so both
  scripts read one pin (module/flake.lock, else the builder's own lock via nix flake
  metadata; never an evaluation of the flake). It prints
  github:NixOS/nixpkgs/e9f00bd893984bc8ce46c895c3bf7cac95331127 here, as the old function
  did, from module/, from another directory, and in a scratch copy with no flake.lock.
- run-suite.sh's builder_pin calls it; nothing else in run-suite changes.
- grade-specs.sh builds "$PIN#libopus.out" with that pin, warning when the pin cannot be read.
  For x86_64-linux it evaluates to /nix/store/mzksc9fpj2hvhvr8ggzqcy0j96fyqr79-libopus-1.5.2,
  where it took /nix/store/ah26jmgs60s1dzphf0vi4f9pqmlpl9y6-libopus-1.6.1 (nix eval). The
  block run alone here gives /nix/store/f3kcnzp35b1wy1fg8cd4f2354xhww150-libopus-1.5.2.
- voice-notes.md §15, the labbook §7, tests/README.md's manual recipe and CLAUDE.md's
  layout say so.

TDD-Exempt: test-runner infrastructure. grade-specs.sh cannot run on this Mac (no exophial; it links libsodium.so and libopus.so, Linux's names), so its libopus block was run alone and evaluated for x86_64-linux instead; run-suite.sh's refactored pin reader ran: "PASS voice_codec_test", "libopus 1.5.2, preskip 312", and the whole suite below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ppened on the page they were pressed on

The final check of PR 340's review fixes found that on a page with no room view (Home,
Settings, Contacts, Learn, the Wallet) the strip's Stop and Discard went through the room's
calls, whose answer only a room's view shows: a Stop while the microphone was still starting
("Nothing was recorded…"), or any refusal, said nothing where it was pressed, and the strip
just went.

- Main.qml: the strip's buttons call micCall(op), which notes the room's answer count and
  calls the room as before; the first answer after it (Room.voiceAnswers moves) is the
  call's. Off a room's view, a recording kept or discarded is said once in the toast
  ("Stopped. The recording is kept, unsent: you send it from its room." / "Discarded.
  Nothing was sent."), and a refusal shows in the strip's place as an InlineNotice
  (voiceIndicatorAnswer), in the room banner's words, until it is dismissed, the microphone
  comes on again, or a room's view opens. In a room's view nothing changes: the room's
  banner says it.
- MusterStyle.voiceRefusalWords: RoomVoiceNote's refusal words, moved unchanged, so the
  banner and the strip say one thing; RoomVoiceNote.refusalWords calls it.
- voice-notes.md: §13, §15, §17 S8 and §18 say what the strip's
  buttons say off a room's view, and §18 names the room view's two Stop/Discard pairs (the
  strip and the banner, gallery room-voice-starting) as the maintainer's call, kept.

Not changed: the C++ and Nim backends (the QML is shared, ADR-019 parity holds), the claims
registry (its gap says nothing about where an answer shows).

Tested-Behavior: gallery fixture home-voice-indicator, extended first: a Stop from Home kept as a draft (the toast), a Discard from Settings (the toast), a Stop while starting answered not-started (the notice, its words and tone), still shown on Contacts, dismissed, shown again for a new answer, gone when the microphone comes on again and stays gone, gone in a room's view and after it. Red on HEAD before the change: "GALLERY-ERROR newsToast.text.Stopped. The recording is kept, unsent: you send it from its room. is """. Green after: "home-voice-indicator: 48 pass, 6 shot(s), 0 qml warning line(s)" on both design systems (88330d0, 94092b0). Mutants, each red: without the room view's clearing, "voiceIndicatorAnswer.visible.false is true" at the Home step after the room; without onMicOnChanged's, the same after the microphone came on and went. Every other voice fixture passes on both systems (room-map-voice 8, room-voice-flow 2, -forged 10, -notes 18, -record 35, -refused 42, -starting 48 passes, 0 QML warnings), and each of their 19 shots, and home-voice-indicator's four earlier ones, is byte-identical to HEAD's (cmp). ui-lint's python gate on both systems: "87 finding(s), 87 in the baseline: 0 new, 0 fixed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0 full notes, measured

The final check of PR 340's review fixes noted that the header memo removed the re-parse the
review named, but that each poll still does work growing with the room's voice history on
the module thread (eventId's memo key copies and hashes each part's ~84 KB value; the
authentic view and each pass copy every event), and that this was unmeasured. §6 also said
"a room of 20 full notes now costs a few ms a second, not tens", which nothing had timed.

Measured at 199f7bd on this Mac (Apple M5, run-suite's -d:release flags), in a scratch bench
outside the repo: two members over LocalTransport, 20 notes of 60 s and 20 messages, read as
the second member, warm memo, per call:
- eventId of one 84,346 B part from its memo 22.6 µs (a message's 1.3 µs, uncached 470 µs);
  the 40 parts 0.9 ms;
- roomEvents 3.0 ms; rows (coordinate_messages) 6.36 ms; lastTs 6.35 ms; threadRows alone
  3.35 ms; canonicalOrder 1.09 ms; flowRows 7.62 ms and graphOf 26.7 ms over the view;
- the same room with notes of 1 s (1,745 B parts): rows 0.40 ms, lastTs 0.40 ms, flowRows
  0.54 ms, graphOf 1.88 ms.
So about 12 ms of each second with the room map closed and 27 ms with it open, for one room:
the values' bytes, not parsing. §6 now gives these numbers in place of the untimed sentence,
and that the growth with each full note is inferred (one room size, two note lengths); §15's
"The cost of the fold" says what is measured and what is not (the rest of
coordinate_conversations, larger rooms, other hosts), and names the fix to try, an event id
carried with the event from ingest, which changes the kind-agnostic log's Event and is not
made here.

TDD-Exempt: documentation only; the numbers come from a scratch bench that is not committed (timings are not a test), whose output is quoted above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stops saying they have not run

The doc at 08dfcb3 still said the microphone leg had not run on §9's start thread, that the
start thread had not run in any host, and that S9's self-test, as it reads its logs now, had
not run on a runner. All three ran on 2026-10-23 at 08dfcb3:
- voice_device_e2e, both legs, on this Mac (macOS 26.5, Apple Silicon), launched from a
  shell under Claude Code, whose responsible app is allowed the microphone, so no prompt
  stood; both starts through MiniaudioIo's start thread. Microphone: 48000/1/s16, 47.79
  frames/ms, capped after 1996 ms at exactly 96000 frames, peak -45.6 dBFS, libopus 1.5.2
  round trip keeping 96000 samples. Speaker: 48.10 frames/ms, 100 ms done after 131 ms past
  the 1920-frame tail. PASS.
- Three headless logosctl 0.3.2 hosts on logos.dev (the official aarch64-macos release), the
  scratch harness making the UI slots' module calls: A (FileAudioIo) recorded 4,894 ms and
  sent; B played exactly 234,912 samples at 0.99 of the 1 kHz power; C on the null device,
  its start on the start thread inside logosctl's logos_host, was refused silent with the
  macOS note, and B still held one note.
- The fork's CI, run 40030788377 (08dfcb3 plus fork-only workflow commits, ci/voice-notes-
  linux at 7fe3812): the replay "all passed", the runner built, and voice-self-test.sh
  SUCCESS on logos.dev (143,280 samples, 2,985 ms x 48) and on MUSTER_FLEET=local (143,328,
  2,986 ms x 48), C refused silent, C's thread empty, B's holding A's note.

What changes in docs/design/voice-notes.md:
- The status line, §15's opening and its device-start, real-microphone, headless-hosts and
  S9 entries, §17's opening, S9's header and S11, and §18 say what ran where on 2026-10-23
  and what still has not: a start while a permission prompt stands, anything in Basecamp's
  window since the start thread, the Nim UI's runner, ui-parity.sh voice, any device on Linux.
- §12 opens with the fork's CI as well as the maintainer's run: run 39836679442 (bbae7d8's
  tree, both arches, JOBS=4) 284 of 285 on each, room_graph_scale_test failing on its own
  timing and passing alone; and run 40030788377's suite jobs, still running when this was
  written (their first step, the voice and audio tests, passed on both arches; the logs are
  not readable until a job ends, so the libopus version printed there is not read).
- §15's delivery and size-budget entries no longer say a note crossed logos.dev once.
- §18: the start thread is the first of the review's two options, with a 15 s bound as well,
  not "the way the review preferred" (the review ranked neither); the proof question and
  older clients are labelled for the maintainer, as his review lists them, and §10, §13,
  §15 and S7 say the maintainer decides the proof; the Linux-only item lists what is still
  not run on Linux instead of asking who runs it.
- docs/labbook/voice-notes-audio-stack.md: its header, §7's Linux libopus note, §9's last
  paragraph and "Not found out yet" carry the same facts.

Not changed: the registry gap and §18's sentence on what it covers (the next commit), and
the CLAUDE.md files and code comments that still say the operator decides the proof.

TDD-Exempt: documentation only; the runs it records are the evidence logs named above (voice_device_e2e.log, hosts-08dfcb3.log, the CI job log), not tests added here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thing for a held draft or for playback

The claims gap "Modules loaded beside Muster can record and send as you" says such a module
can send or discard your draft and play your draft or any note aloud, then lists what the
microphone strip does not cover: Muster's view not running, a person not looking, a short
note started and sent between two reads, who started it or for which room. It left out what
§13 already says: "a draft held, or a note or the draft played aloud: the strip is for the
microphone only" (Main.qml shows the strip only while voice_status says starting or
recording). So the gap promised more of the strip than it does.

- contracts/claims/registry.json: the gap's body ends "And it shows nothing for a draft that
  is only held, which such a module can still send or discard, or for a note or your draft
  played aloud: the strip is for the microphone only."
- ui/src/qml/ClaimsRegistry.qml regenerated (python3 ui/tools/gen-claims-qml.py).
- docs/design/voice-notes.md §18, "An indicator outside the room": its list of what the gap
  says the strip does not cover names that case too.

Ran: python3 scripts/check-claims-registry.py (OK, 44 claims), python3
ui/tools/gen-claims-qml.py --check (in sync), check-readme-rot, check-run-targets and
check-no-cpp (all pass). The gallery's walkthrough fixture, which renders the registry, ran
on both design systems (88330d0, 94092b0): ok, 1 shot, 0 QML warnings; its one shot is the
top of the page, so it does not show this gap's text.

TDD-Exempt: copy only, in the claims registry and the QML generated from it; check-claims-registry.py and gen-claims-qml.py --check hold its shape and the generated file, and no test reads a gap's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat the self-test and the fork's CI have run

Two reviews of 762099e and 175c7cf found sentences that said more, or less, than the runs
show. 762099e dated both device legs at bbae7d8, before the start thread. Only the
microphone leg ran then. The speaker leg passed on 2026-10-22 before bbae7d8 (2cc3c53 is
04:09, bbae7d8 07:13), and it ran on the start thread in S2's review (588e327) and its
fixes, as §17 S11 says. Two entries still said no harness in the tree joins a room.
scripts/voice-self-test.sh does join one, and its C++ autopilots made the voice calls
through the runner's host at 08dfcb3 (ci-selftest-08dfcb3.log: "A's Start: {"ok":true} ·
Stop: ... · Send: {"note":"89d79b72..."}"). "Any device on Linux" left out miniaudio's null
device, which has run there.

What changes in docs/design/voice-notes.md:
- The status line and §15's opening: the microphone leg at bbae7d8, before the start
  thread; the speaker leg on that thread in S2's review; both legs again at 08dfcb3. §15's
  device-start entry gets back the speaker leg's S2 runs on the start thread.
- §15's opening list is a selection now ("Among what has still not run"). It adds Record
  and Play in Basecamp's AppImage, S9's red run and grade-specs.sh, and it says "any real
  device on Linux (PulseAudio, then ALSA; only miniaudio's null device has run there)".
- §12: the maintainer checked the package's load as well as running the suite. Run
  40030788377's first test step is the voice and audio tests; its first four steps are setup.
  Its ubuntu-24.04-arm job has ended: 284 of 285 passed, four at a time.
  room_graph_scale_test failed on its timing (folds 4,021 ms, bound 4,000) and passed alone.
  Its ubuntu-latest job was still running when this was written. The arm job's log gives
  PASS lines only, and gh returned 404 BlobNotFound for the running job's log, so which
  libopus either loaded is not read. The text no longer says a log cannot be read until a
  job ends. §15's libopus entry, its "audio facts" entry and its Linux-only list match this.
- §15, the voice methods through a host (S7): the hosted glue with a room joined has run
  in one test in the tree, S9's self-test. Its autopilots call voice_status, record_start,
  record_stop, draft_send and play, but not draft_discard or play_stop. Outside any test, the
  same calls ran in Basecamp's window at bbae7d8 and from the headless harness. The room's
  memo entry: no test runs those hosted lines on a room holding a note. The self-test calls
  only coordinate_conversations, once per runner as it joins, before any note is in the
  room (ui/src/muster_ui_backend.cpp joinRoom).
- §15, the headless entry: its heading gains 2026-10-23. The 2026-10-23 run is "a harness
  making the same module calls", since the 2026-10-22 harness is not on disk to compare. The
  locked screen is given as the reason for 2026-10-22 only.
- §15's Linux-only list: voice-self-test.sh passed on the C++ UI's runner on both fleets at
  08dfcb3. The Nim UI's runner has not run it, and ui-parity.sh voice has not run for either
  UI.
- §17 S11: macOS 26.5.2 (sw_vers), not 26.5.
- §18: the Linux-only item names what is still not run there instead of "the rest of §15's
  Linux-only checks". The re-send path is labelled for the maintainer, as his review lists
  it with the proof question, older clients and the version.
- Lines from 762099e and 175c7cf that ran past the doc's width are rewrapped.

docs/labbook/voice-notes-audio-stack.md: the plugin's dlopen/dlsym/dlclose link has run on
Linux. That covers the linux-amd64 package of the lgx variants run (39776903474), the
suite's miniaudio tests and the CI runner at 08dfcb3, though which library supplied them is
not read. §7's libopus note stops saying the tests linked the pin "that way": run-suite's
code says so, and the version is unread. "Not found out yet" scopes linux-amd64 to the
plugin links too.

room_graph.nim:23 and voice_live.nim:479: the comments say the maintainer decides the
proof, as §10 and §18 now do. These are word swaps in comments, and no line moves.

Not changed: the root CLAUDE.md layout line ("until the operator decides") and
module/src/audio/CLAUDE.md's "on Linux, not yet run there". Both wait on the person's
go-ahead for a CLAUDE.md edit.

TDD-Exempt: documentation and two code comments only; the runs it records are the evidence logs (voice_device_e2e.log, hosts-08dfcb3.log, ci-selftest-08dfcb3.log, run 40030788377's arm suite job log), not tests added here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the libopus they linked

Run 40030788377's two module suite jobs finished after 762099e and 1598563 were written:
- ubuntu-latest and ubuntu-24.04-arm each gave 284 of 285, four at a time. The one failure
  was room_graph_scale_test's own timing beside the other tests (its folds 5,658 ms and
  4,021 ms, against a 4,000 ms bound); re-run alone it passed (1,197 ms and 1,497 ms).
- voice_codec_test §1 printed "libopus 1.5.2, preskip 312" on both arches, read from the
  run's kept logs (suite-logs-ubuntu-latest, suite-logs-ubuntu-24.04-arm). So the suite links
  the shipping 1.5.2 on Linux, which §12, §15, §18 and the labbook said was not read.

To tell load from a regression, room_graph_scale_test ran alone on this Mac, three times on
this branch and three on main's 67701f8: folds 554-605 ms against 555-562 ms, the whole
reduceRoomGraph 617-646 ms against 590-592 ms, about 6% slower here. §12 says so, and that
main has not run on those runners.

Also two lines the doc commits left, since agents may not edit CLAUDE.md files: the root
CLAUDE.md's layout line now says the maintainer decides the proof question, as §10 and §18
do, and module/src/audio/CLAUDE.md says voice_codec_test has run on Linux and printed 1.5.2.

TDD-Exempt: documentation only; the numbers are read from run 40030788377's logs and from run-suite.sh room_graph_scale on this Mac, quoted above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved three PRs past this branch's base 67701f8 (corpetty#341, corpetty#342, corpetty#343: Send money fixes,
Pay with Monero Wallet filling its Send tab, the pebbles for 0.3.3), which left PR 340
conflicting. The one conflict was ui/tests/gallery/FakeBackend.qml, which both sides had
regenerated with FileShare's props and slots; it is generated from ui/src/muster_ui.rep,
which main does not touch, so it is regenerated here (python3 ui/tools/gen-fake-backend.py)
and comes out byte-identical to this branch's. Main.qml, Room.qml, RoomThread.qml,
RoomComposer.qml, MusterCard.qml and CardProposal.qml merged on their own.

TDD-Exempt: a merge of main; the regenerated fake backend is checked by gen-fake-backend.py --check, and the merged QML by the gallery fixtures run after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as voice_status pumps first

§12 said a Record on a nix-built runner with no device library answers {ok}, voice_status
says `starting`, and "the next pump" ends it with no-device. voice_status pumps before it
answers (voice_live.nim voiceStatus), so the recording ends at the first status read after
the start thread returns, and `starting` shows only to a read that lands before that. The
review reply was corrected the same way; §12 now matches it. Read from the code, not run
on Linux, as §12 already says.

TDD-Exempt: documentation only; one paragraph of §12 reworded to match voice_live.nim's voiceStatus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Fergulati

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for the Linux runs. Every point has an answer at 04369ce: 27 commits on top of bbae7d8, with none rewritten, and a merge of main (#341–#343), whose one conflict was the generated FakeBackend.qml. Three stay partly open: a note still switches the proof off, a poll still costs more as voice history grows, and the microphone strip covers only part of the co-resident gap, which the claims registry still lists as partial. The §18 decisions are yours. What is still open is at the end. Point by point:

Should fix before merge

  1. One member could switch coordinate_proof off with junk (6aa1793). This is your minimum fix.

    • roomProof now refuses only when voiceStanding(s.roomEvents()) is non-empty, that is, when the authentic view holds a part the fold keeps.
    • Entries under voice/ that are no note's part refuse nothing: unsigned, a forged author, a malformed value, a squatted note id, or a key of the wrong shape. The proof carries each one whole, like any raw entry, and still verifies (voice_live_test §26). A mutant with the old raw-log rule fails §23.
    • What this doesn't fix: any note still switches the room's proof off for good, whether complete, arriving or broken, including a member's own deliberately broken pair.
    • The cost of the change: junk that copies a note's bytes leaves with the proof when this member's view holds none of that note's parts. Examples are a note from an epoch they never held, or one whose parts the fleet lost; §26 pins this case. A member who can play a note can already post its bytes in a message.
    • Your answer to §18's proof question ends the refusal either way. A proof form that commits a part by digest would also keep a copied note's bytes out. §10 and §18 say so.
  2. A device start could hold the module thread (588e327, cb68157, 6ab2800, 61e97e3, a93c728, f070958; what Basecamp 0.3.2 does with the microphone is in 61242ee). I took the first of your two options, starting the device off the module thread and reporting through voice_status, and added a 15 s bound as well.

    • miniaudio's context and device init, the format read-back and the device start run on a detached thread, one per start. The job passes between the start thread and the module thread at one atomic, the start's phase, by compare-and-swap. Apart from that, they share only atomic counts: the starts in flight and the buffer's references.

    • voice_record_start and voice_play answer at once. Until the device runs, voice_status says recording.state: starting for a recording and playing.starting: true for a playback, and ready.capture / ready.playback read unknown.

    • A start that fails ends in its own word. One still pending 15 s after the call ends in device-start-slow and is let go. At most four starts wait at once; past that, a start is refused device-start-slow at once.

    • Stop, Discard and the recorder's close never wait on a start. At unload, the module waits at most 2 s for start threads. A Stop while starting answers not-started, and the room says "Nothing was recorded: the microphone had not started yet." That wording has been seen in the gallery only.

    • A start let go before the start thread's last check, just ahead of ma_device_start, never turns the device on (audio_io_null_test §7). One let go after that check, or while ma_device_start itself waits, turns the device on for a moment, and its own thread stops it at once (voice-notes.md §9; on the synthetic seam, audio_io_test §8).

    • What this changes in the FFI you checked: AudioBuffer is now calloc'd and refcounted, so a start that is let go frees its buffer on its own thread. Suppose a host unloaded the module's image without ending its process, while a start was still blocked inside miniaudio past the exit proc's 2 s wait. That start's thread would return into unmapped code. That is not tested, and whether any host does it is not known (voice-notes.md §9).

    • Tests. A test sets up each of the three orders a start and a stop can meet in, one at a time:

      • the pump saw the start run;
      • a stop came before the start returned;
      • a stop came after it returned, but before any pump.

      They are audio_io_test §7, §8, §8b and §9, audio_io_null_test §6, §6b and §7, and voice_live_test §29–§31. No committed test races the two threads at the swap itself. Only the scratch race test below did, and only at 588e327.

    • Mutants. With the start run on the module thread, voice_record_start took 1,986 ms and the seam's start 1,951 ms, where the tests allow under 100 ms. Without the let-go check, a start let go before its device started still started one. Mutants of the two module-thread finish paths go red too.

    • Sanitizers. At 588e327, audio_io_test and audio_io_null_test ran clean under ASan and TSan, and voice_live_test under ASan, with no reports. So did a scratch race test: 600 release-then-stop races and 600 null-device starts under ASan, and 300 of each under TSan. They have not been re-run since 6ab2800 added the let-go check to the start thread.

    • Real devices, on 2026-10-23 at 08dfcb3, through the start thread. This ran from a shell whose app is already allowed the microphone, so no prompt stood:

      • The microphone captured 2 s from Core Audio at 47.79 frames/ms, capped at exactly 96,000 frames.
      • The speaker played 2 s of silence at 48.10 frames/ms.
    • What Basecamp 0.3.2 does. Opened the way the Dock opens it, macOS did not prompt for its logos_host. tccd logged that the audio-input entitlement is missing for responsible=LogosBasecamp, then "Policy disallows prompt". That was one run on this Mac, macOS 26.5, at bbae7d8. So in such a Basecamp the start doesn't wait on a person: the capture runs, records zeros, and Send refuses it as silent.

    • The wait we saw comes from a shell or terminal launch, where the responsible app can prompt. The start thread is there for that case, and for a future Basecamp that carries the audio-input entitlement and an NSMicrophoneUsageDescription (§11). It has been tested with a start held by a test gate, but not yet with a real permission prompt standing (§15).

Worth fixing

  • Every poll re-parsed every voice part (d1d2220, cdc2913; the remaining cost is measured in 08dfcb3).
    • Each room's memo keeps every part's header by event id. The thread, threadLastTs, the flow, the room map and provenance all go through it, and an unchanged log parses 0 parts (voice_test §16–§17, voice_live_test §27–§28). Play makes one pass with the memo and decodes once.
    • A warm poll still copies and hashes each part's whole value; it no longer parses it. So the cost should still grow with voice history. Only one room size was timed, at two note lengths.
    • Timed per call in a scratch bench on one Mac (one room, 20 notes of 60 s, warm memo), the four reads that fold voice parts add up to about 12 ms of each second, or 27 ms with the map open, plus each other joined room's lastTs. Those reads are coordinate_messages, lastTs, the flow and the map.
    • The room's other reads on its 1 s tick were not timed: coordinate_intents, the FROST and Monero multisig lists, the activity feed and the split pump. Each rebuilds the room's view, about 3 ms a call in such a room. That list is read from the code. §6 and §15 add what else was not timed: coordinate_conversations' other folds, a room with more notes, and any host but this Mac. So the whole costs more.
    • The fix to try is carrying each event's id from ingest. It changes the log's Event, so I left it out of this PR.
  • Microphone consent with co-resident modules (59b34f6, 199f7bd, 175c7cf).
    • Main.qml reads voice_status every second on every page. While it says starting or recording, a strip above every page says "Muster is starting the microphone…" or "Muster is using the microphone", with Stop and Discard. On a page with no room view, the strip's answer is said there.
    • The claims gap "Modules loaded beside Muster can record and send as you" now says what the strip doesn't cover:
      • Muster's view not running;
      • a person not looking;
      • a short note started and sent between two reads;
      • who started a recording, or for which room;
      • a held draft or a note played aloud. The strip is for the microphone only.
    • The strip and its answers have been seen only in the gallery, against the fake backend, on both design systems.
    • A room's view shows both the strip and the room's banner. That is yours to decide (§18).
  • publishAuthored for every author-signed kind (87eeaf9). authorship_test §12 covers a re-announced account disclosure. The author's own copy is re-sealed, not a member's planted one, and a later joiner reads it in the author's order. With main's publishAuthored put back, §12 fails. The updated PR description has the line.
  • Nits.
    • SKIP counts only for a test in the E2E list. Any other test that prints SKIP <name>: fails, saying why (714b00c).
    • The suite now links the builder pin's libopus 1.5.2 on Linux too, as it already did on macOS (0a82be1). grade-specs.sh takes the same pin for exo-526's host probe, which it builds on Linux only (2e34aa2). It has not run since that change on any OS; only its libopus block ran on its own, on this Mac. Both read the pin through module/tools/builder-nixpkgs.sh. On this Mac voice_codec_test prints libopus 1.5.2. In the fork's CI at 08dfcb3, voice_codec_test printed libopus 1.5.2 on both Linux arches.
    • A held draft is read on Main's 1 s tick. The 250 ms poll runs only while a device starts or records, or a note plays (59b34f6).

Linux

  • Your device-backend point. §12 now states it as current Linux behaviour (0a82be1). This is read from the code, not run on Linux:
    • A nix runner's glibc doesn't read /etc/ld.so.cache, so a dlopen by soname most likely finds neither library.
    • Record then answers ok at once. Once the start thread returns, voice_status shows the recording idle, with recording.error and ready.capture both no-device.
    • Null is never listed, so it never records zeros. The fix to try is libpulseaudio in nix.packages.runtime.
  • The module suite ran in fork-only CI on ubuntu-latest and ubuntu-24.04-arm, at 08dfcb3 plus the fork's workflow commits (run 40030788377): 284 of 285 passed on each, four tests at a time. The one failure was room_graph_scale_test's timing budget beside the other tests (folds 5,658 ms on x86_64 and 4,021 ms on arm64, against 4,000), and it passed alone (1,197 ms and 1,497 ms). It did the same at bbae7d8's tree. To tell load from a regression, I ran it alone on this Mac, three times on the branch and three on main at 67701f8, this branch's base. Its folds took 554–605 ms against 555–562 ms, and the whole reduceRoomGraph took 617–646 ms against 590–592 ms, about 6% longer. main has not run on those runners.
  • scripts/voice-self-test.sh first ran in the fork's CI at your review's tree: bbae7d8 plus the fork's workflow, in runs 39776743154, 39822900956 and 39836679442. Each run started two standalone runners, A and B, on one ubuntu-latest machine. The script starts C, on the null device, only once B has played, so C did not run at bbae7d8.
    • Each run failed both legs with "FAIL: B never played A's note (…, played: 0)"; the last run's logos.dev leg read "sent: 48410f05…". In that run's kept logs the product had worked. B was admitted, held the note complete and played it. Its sink held exactly as many samples as the note, 143,280 (2,985 ms × 48), with the 1 kHz tone at 0.99 of the source's power.
    • Why the script misread the log. ui-host relays the C++ UI's output a chunk at a time, QDebug-quoted, with several of the UI's lines joined by a literal \n. A line can also be cut, in two ways: across two chunks, at a multiple of 128 bytes in 906 of the 908 cuts seen, or by another process's line written into it. The old sed undid only \", so every json.loads in the play check failed and its error was swallowed. The audio check would have crashed on the same lines.
    • The fix is 7ffcaeb. scripts/lib/ui_log.py undoes the quoting, joins a line split across two chunks, and moves another process's line out of the chunk it landed in. If the script can't read A's Send or Stop, or B's notes, it now reports that as its own error, never as "B never played". B's VOICE lines it can't read are counted in a NOTE.
    • VOICE_REPLAY gives the verdict on a finished run's files. scripts/tests/voice-self-test-replay.py checks the decoder and the verdict on lines cut verbatim from that run. It is red with the old reader and green with the new one.
    • At 08dfcb3 (run 40030788377): SUCCESS on logos.dev and on MUSTER_FLEET=local. B's sink held 143,280 and 143,328 samples, exactly the note's length × 48, at 0.99 of the tone's power. C's zeros were refused as silent. The replay test passed in the same job.
    • At 04369ce, after the merge of main, plus the fork's workflow commits (the self-test job of run 40042776538): SUCCESS again on both fleets. B's sink held 143,184 and 143,136 samples, and C's zeros were refused as silent.

Still open, from your list

  • A real microphone capture. It ran from a shell (above). In Basecamp 0.3.2 launched the way the Dock launches it (open -n -a), in one run at bbae7d8, every captured sample was zero. Send refused that as silent, with the note saying Basecamp lacks the microphone permission, and posted nothing (§11).
  • A note drawn in Basecamp's window from a real room. It ran at bbae7d8, with two throwaway Basecamp 0.3.2 profiles on logos.dev, driven by the C++ UI's autopilots.
    • A recorded a 1 kHz WAV and sent it. B's thread drew it as a card, and B played it into a file sink: 137,808 samples, exactly 2,871 ms × 48, with the tone at 0.99 of the source's power. No real microphone or speaker was used.
    • Nothing has run in Basecamp's window since the start thread. So the strip and the room's starting view have been drawn only in the gallery.
  • The two-runner self-test. See Linux, above. The Nim runner (MUSTER_UI=nim) has not run, and neither has scripts/ui-parity.sh voice, for either UI.

Yours to decide (§18)

  • The exo id and the typed spec.
  • The wire format.
  • exo-526's wider oracle.
  • F-13.
  • The proof question.
  • The re-send path.
  • Older clients.
  • The F-16 claim's last sentence.
  • The version.
  • New since the review: the start thread (its 15 s bound, its word, or having one at all) and the microphone strip's words and place.

§18 also keeps four smaller questions open that are not put to you: which room holds the draft, whether to coarsen voice_status's peak, the draft surface's disclosure wording, and whether §10's gaps stay their own registry entries.

The updated PR description lists them.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants