From 086b46fdc9efde76d7b27d03cde9c5545c0385a1 Mon Sep 17 00:00:00 2001
From: "bjorn.ahl"
Date: Sun, 20 Sep 2026 14:18:54 +0200
Subject: [PATCH 1/2] docs: align README with current behavior
---
README.md | 28 +++++++++++++++++-----------
1 file changed, 17 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index c48e2e6..7777a8c 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ foreground while developing, or as a signed per-user LaunchAgent for daily use.
width="640">
-> **Status:** experimental research project, `v0.4.2`, Apple Silicon macOS only.
+> **Status:** experimental research project, `v0.4.4`, Apple Silicon macOS only.
> Single-maintainer, no support or SLA — small fixes welcome, larger changes worth
> discussing first. The hold-to-talk -> transcribe -> insert pipeline works end-to-end
> on either backend. Distribution hardening (hardened runtime, entitlements,
@@ -54,6 +54,10 @@ pins that Utterance to the selected **Transcription Backend** and to the setting
at press time. On release it commits the Utterance and waits for the **Final Transcript** —
the only text ever inserted — then the **Insertion Runner** places it at the **Focused
Target** cursor through either a clipboard-swap paste or synthetic keystrokes.
+The runner compares the frontmost app at Talk Key release with the app just before
+Insertion. A positive change refuses the Insertion and shows a red refuse cue; the
+transcript remains in Recent Insertions for **Re-insert here**, unless its text was
+withheld under Secure Event Input. An unreadable app identity allows Insertion to proceed.
The two backends reach that same Final Transcript by different routes. **OpenAI**
streams: it emits revisable **Partial Transcripts** live, then commits the Final
@@ -90,15 +94,17 @@ one **Insert Worker** thread (dictation first, Undo last), which is what keeps a
from landing in the middle of an Insertion. Nothing here is ever written to disk; the ring
is cleared when the daemon quits.
-One Utterance is deliberately not remembered: one spoken while any app holds **Secure Event
-Input**, the session-wide mode a password field puts macOS into. type-wave probes at both Talk
-Key press and release, and a positive reading on either edge withholds that Insertion Record
-from the ring and keeps the transcript out of the log even under the `.log_transcripts` opt-in.
-The text still lands at the cursor — it is simply never retained, so it cannot be revealed,
-copied, re-inserted, or undone. While Secure Event Input is held the WindowServer also withholds
-key events from every event tap, so the `⌃⌘⌫` chord never reaches the daemon at all; the menu
-bar reports that state rather than leaving you to infer it from a chord that silently does
-nothing.
+An Utterance spoken while any app holds **Secure Event Input**, the session-wide mode a
+password field puts macOS into, has its transcript text withheld. type-wave probes at both
+Talk Key press and release, and a positive reading on either edge keeps both the inserted
+and raw text out of the ring and the transcript out of the log even under the
+`.log_transcripts` opt-in. Insertion still follows the usual rules. The Insertion Record
+retains its outcome, timestamp, App Identity, and grapheme-cluster count, but has no text to
+reveal, copy, or re-insert. Once Secure Event Input clears, **Undo** can use that count to
+delete the newest Insertion, subject to the usual app, permission, and single-shot gates.
+While Secure Event Input is held the WindowServer also withholds key events from every
+event tap, so the `⌃⌘⌫` chord never reaches the daemon at all; the menu bar reports that state
+rather than leaving you to infer it from a chord that silently does nothing.
Across the whole lifecycle the **Utterance Coordinator** owns the state machine from
Talk Key press to a resolved Insertion. The floating **HUD** is silent visual feedback
@@ -242,7 +248,7 @@ Run `--verify-model` for a full offline integrity check of the active Model Inst
It hashes every model byte and reports the exact corrupt metadata/artifact class without
reading credentials or using the network. Run `--repair-model` to verify first and preserve
valid local data; metadata-only damage is rebuilt offline, while missing or invalid artifact
-data requires typing `yes` before authenticated acquisition begins. A helper load failure
+data requires typing `yes` before a credential-free download begins. A helper load failure
performs this same offline verification before type-wave offers Repair for corruption or
runtime Retry for a verified installation that still cannot load.
From b6869858bd78513bd38f3d566882f51ea5d58ba4 Mon Sep 17 00:00:00 2001
From: "bjorn.ahl"
Date: Sun, 20 Sep 2026 14:19:53 +0200
Subject: [PATCH 2/2] docs: record websocket.zig upgrade research
---
docs/research/websocket-zig-bump.md | 312 ++++++++++++++++++++++++++++
1 file changed, 312 insertions(+)
create mode 100644 docs/research/websocket-zig-bump.md
diff --git a/docs/research/websocket-zig-bump.md b/docs/research/websocket-zig-bump.md
new file mode 100644
index 0000000..51e14c5
--- /dev/null
+++ b/docs/research/websocket-zig-bump.md
@@ -0,0 +1,312 @@
+# Is there value in bumping the websocket.zig pin?
+
+Researched 2026-08-18 against primary sources — the GitHub API for
+[karlseguin/websocket.zig](https://github.com/karlseguin/websocket.zig) (commit list, full
+diffs, PR/issue state), the raw source and readme at the `dev` HEAD, the Zig std source
+shipped with the flake's own pinned compiler, and **two compile probes run on this machine**
+with both the pinned nightly and today's zig-overlay `master` nightly. Nothing was built in
+the repo and nothing in the working tree was touched; probes lived in the session scratchpad.
+Policy context: [docs/toolchain.md](../toolchain.md). Background:
+[zig-websocket-tls.md](./zig-websocket-tls.md) §3.5, §9.
+
+Every claim below says whether it is **verified by running**, **verified by reading a primary
+source**, or **inference**.
+
+## Recommendation (TL;DR)
+
+**Don't bump now. Nothing has landed upstream that this repo needs, and nothing we work
+around has been fixed. Fold the bump into the next compiler bump, per the lockstep policy.**
+
+- Upstream `dev` is exactly **3 commits ahead** of our pin and 0 behind (§1). All three touch
+ only `src/client/client.zig` (plus a readme typo). None touch the server, the handshake, the
+ framing/masking path, close frames, the buffer/allocator layer, or any `std.Io` adaptation.
+- The **net functional delta is one new feature and one unreachable one-liner** (§2):
+ a `connect_timeout_ms` config knob (default 10 s) bounding the TCP connect and the TLS
+ handshake, and an INT_MAX clamp on the `poll()` read-timeout cast. We set no read timeout at
+ all, so the clamp is dead code for us.
+- The TLS-read fix we depend on (§3.5, upstream `4b475a8` — our pin) was **accidentally
+ reverted on 2026-07-11 and restored on 2026-08-11** (§3). It is byte-identical at `dev` HEAD
+ to what we already have. This is the one genuinely load-bearing finding: it means "any newer
+ `dev` commit" is *not* automatically safe — only `bfe7619` or later.
+- A bump would **not** force a Zig-nightly bump. `dev` HEAD compiles under our pinned
+ `0.17.0-dev.1267+300116b02`, and both our pin *and* `dev` HEAD compile under today's
+ zig-overlay `master` nightly `0.17.0-dev.1786+75044cb04` (§5, verified by running). The
+ lockstep constraint is not currently binding in either direction.
+- The one thing a bump would actually buy is the **bounded connect** (§4): today a blackholed
+ `api.openai.com` can hang `websocket.Client.init` indefinitely on the supervisor thread, and
+ `daemon.zig`'s shutdown joins that thread. That is a real, if rare, hazard — but it is a new
+ *feature* with new behaviour (a hand-rolled per-address connect loop and a watchdog thread
+ per TLS handshake), not a fix, and it deserves to ride a deliberate bump with a live re-prove,
+ not a drive-by pin change.
+
+**Bump-when:** at the next compiler bump (step 2 of the [bump procedure](../toolchain.md)),
+pinning `bfe761959b05030eaf4943fcf0fd5ecd1daca68a` or later — never an intermediate commit.
+Earlier than that only if we decide the unbounded connect is worth its own ticket.
+
+## Summary table
+
+| Question | Answer | Evidence |
+|---|---|---|
+| How far behind is the pin? | 3 commits, 0 behind; pin `4b475a8` (2026-07-08), `dev` HEAD `bfe7619` (2026-08-11) | §1 |
+| Any bug fix we'd benefit from? | **No.** One clamp fix in a path we don't use (we never call `readTimeout`) | §2.2 |
+| Any fix for something we work around? | **No.** We carry no workaround; the §3.5 patch went upstream *as* our pin | §3, [toolchain.md](../toolchain.md) |
+| Any API change affecting our call sites? | **No.** `init`/`handshake`/`write*`/`writeFrame`/`close`/`deinit`/`OpCode` unchanged; `_reader.pos`/`_reader.static` still present | §4.1 (ran) |
+| Any security fix? | **No.** No auth, masking, cert-validation or parsing change | §2 |
+| Any Zig-compat change? | **No.** No `std.Io` adaptation commit in the range | §5 |
+| Would a bump force a compiler bump? | **No** — `dev` HEAD builds on the pinned nightly | §5 (ran) |
+| Would the *current* nightly force a websocket bump? | **No** — our pin also builds on `0.17.0-dev.1786+75044cb04` | §5 (ran) |
+| Is PR #107 (ours) still open? | No — closed unmerged 2026-07-09; the maintainer's own fix is our pin | §3 |
+| Anything new that matters? | One: `connect_timeout_ms` (default 10 s) bounds `init()`. Useful, not urgent | §4.2 |
+| Trap to know about | `dev` carried a **month-long regression** of the §3.5 fix (2026-07-11 → 2026-08-11) | §3 |
+
+## 1. The exact delta
+
+Verified by reading the compare API
+([`4b475a8...dev`](https://github.com/karlseguin/websocket.zig/compare/4b475a8683f9769bd12f0221e5e87141c9a9175f...dev)):
+`ahead_by: 3`, `behind_by: 0`, `total_commits: 3`.
+
+| SHA | Date | Message | Files |
+|---|---|---|---|
+| [`b225565`](https://github.com/karlseguin/websocket.zig/commit/b22556504169) | 2026-07-11 | "Merge pull request #108 from privkeyio/feat/client-connect-timeout" | `src/client/client.zig` +198/−20 |
+| [`83f8725`](https://github.com/karlseguin/websocket.zig/commit/83f872552942) | 2026-07-11 | "client: clamp read timeout to INT_MAX before passing to poll (#110)" | `src/client/client.zig` +6/−1 |
+| [`bfe7619`](https://github.com/karlseguin/websocket.zig/commit/bfe761959b05) | 2026-08-11 | "fix tls read" | `src/client/client.zig` +17/−1, `readme.md` +1/−1 |
+
+The **net** diff `4b475a8...bfe7619` is `src/client/client.zig` +203/−4 and a one-word readme
+typo fix (`accomodate` → `accommodate`). `build.zig.zon` upstream is unchanged and still
+declares no `minimum_zig_version` and no dependencies
+([raw at `dev` HEAD](https://raw.githubusercontent.com/karlseguin/websocket.zig/bfe761959b05/build.zig.zon)),
+so a bump imposes no new floor and pulls in no transitive package.
+
+Note for orientation: upstream `dev` and `master` are **diverged** (18 ahead / 16 behind,
+verified via the compare API). PRs #108 and #110 both declare `base.ref: master` yet their
+content landed on `dev` — the maintainer applies work to both branches by hand. That is the
+mechanism behind the regression in §3.
+
+## 2. Commit-by-commit, against our surface
+
+### 2.1 `b225565` — bounded connect and TLS handshake timeout (PR [#108](https://github.com/karlseguin/websocket.zig/pull/108), kwsantiago, merged 2026-07-11)
+
+Verified by reading the diff. Adds, all client-side:
+
+- `Client.Config.connect_timeout_ms: u32 = 10000`.
+- `connectTimeout()` — replaces `Io.net.HostName.HostName.connect` with a manual
+ `HostName.lookup` into an `Io.Queue`, then a non-blocking `connect()` gated by `poll()`,
+ with a **single deadline spanning all resolved addresses**.
+- `connectAddrTimeout()` — the per-address non-blocking connect, checking `SO_ERROR` after
+ `poll()` readiness and mapping it to `ConnectionRefused` / `ConnectTimeout` /
+ `NetworkUnreachable` / `ConnectFailed`, then restoring blocking mode via `fcntl`.
+- `HandshakeGuard` + `initTLSClientTimeout()` — a watchdog thread parked on a `pipe2()` wake
+ fd that `shutdown()`s the socket if the blocking TLS handshake overruns, surfacing
+ `error.TlsHandshakeTimeout`. POSIX-only; Windows falls through to the unbounded path.
+- An `errdefer net_stream.close(io)` in `init`, closing the fd if anything after the connect
+ fails (TLS handshake, buffer-provider create, `reader_buf` alloc).
+
+The stated bound is DNS + connect + handshake, where connect and handshake are each bounded by
+`connect_timeout_ms` (so ~2× in the worst case) and **DNS is explicitly not bounded** — the
+Threaded `Io` cannot cancel `HostName.lookup`. `0` disables the timeout.
+
+This is the only commit in the range with any behavioural weight for us; see §4.2.
+
+### 2.2 `83f8725` — clamp read timeout to INT_MAX (PR [#110](https://github.com/karlseguin/websocket.zig/pull/110), merged 2026-07-11)
+
+Verified by reading the diff. One statement in `Stream.pollReadable`:
+
+```zig
+const poll_ms: i32 = @intCast(@min(self.read_timeout_ms, @as(u32, std.math.maxInt(i32))));
+```
+
+replacing a bare `@intCast(self.read_timeout_ms)`. It only bites when a caller sets a read
+timeout above `INT_MAX` ms (~24.9 days). **Unreachable for us**: `grep` over `src/` finds no
+call to `readTimeout` or `writeTimeout` anywhere in type-wave (verified by reading). Our only
+timeout is the `handshake` `timeout_ms: 10_000` at `src/session.zig:421`, which the handshake
+loop bounds by its own deadline.
+
+Related but *not* in the range: issue
+[#109](https://github.com/karlseguin/websocket.zig/issues/109) — `readTimeout` is silently a
+no-op on Windows since the `poll()`-based rewrite. Open, Windows-only, irrelevant to a macOS
+daemon.
+
+### 2.3 `bfe7619` — "fix tls read" (2026-08-11)
+
+Verified by reading the diff. Re-adds `hasBufferedTlsRecord` and its guard in `Stream.read`,
+restoring the code we already have. See §3.
+
+### 2.4 Nothing else
+
+No commit in the range touches `src/server/`, `src/proto.zig` (framing/masking), the handshake
+request/reply composition, close-frame handling, `src/buffer.zig`, or any Zig-version-gated
+code. Verified by reading the per-file diff list: the only files changed across all three
+commits are `src/client/client.zig` and `readme.md`.
+
+## 3. The trap: `dev` reverted the §3.5 fix for a month
+
+**This is the most important finding, and it argues for care rather than for bumping.**
+
+Our pin `4b475a8` ("Don't poll if tls has buffered client",
+[commit](https://github.com/karlseguin/websocket.zig/commit/4b475a8683f9769bd12f0221e5e87141c9a9175f),
+Karl Seguin, 2026-07-08) is the fix for the TLS-read starvation described in
+[zig-websocket-tls.md](./zig-websocket-tls.md) §3.5 — the one thing standing between us and a
+handshake read starving against Cloudflare/api.openai.com's bursty delivery.
+
+`b225565` has **a single parent, `4b475a8`** (verified via the commits API — despite the
+"Merge pull request" subject it is a flat cherry-pick of the PR's file, not a two-parent
+merge). PR #108 was written against a base that predated the TLS fix, so applying its version
+of `client.zig` wholesale **deleted `hasBufferedTlsRecord` and its call site**. Verified by
+grepping the raw file at each SHA:
+
+| SHA | occurrences of `hasBufferedTlsRecord` |
+|---|---|
+| `4b475a8` (our pin) | present |
+| [`b225565`](https://raw.githubusercontent.com/karlseguin/websocket.zig/b22556504169/src/client/client.zig) | **0** |
+| [`83f8725`](https://raw.githubusercontent.com/karlseguin/websocket.zig/83f872552942/src/client/client.zig) | **0** |
+| [`bfe7619`](https://raw.githubusercontent.com/karlseguin/websocket.zig/bfe761959b05/src/client/client.zig) | 2 |
+
+A third party re-reported the bug three weeks later as PR
+[#111](https://github.com/karlseguin/websocket.zig/pull/111) ("fix: TLS Stream.read() ignores
+buffered-but-undecrypted bytes", Adrigamer2950, 2026-07-30). The maintainer closed it unmerged
+on 2026-08-11 with: *"Thanks. I fixed this in a separate commit..This was already fixed and then
+got accidentally reverted. I prefer the original fix."* — and `bfe7619` restores exactly the
+original. Verified by reading the PR and its comment.
+
+Consequences for us:
+
+1. **We were never exposed.** The pin is `4b475a8`; the regression only ever lived downstream
+ of it.
+2. **`dev` HEAD is byte-identical to our pin in that function.** The net diff `4b475a8...bfe7619`
+ does not mention `hasBufferedTlsRecord` at all — proof by absence that the restore is
+ verbatim. So a bump buys **zero** on the axis we care most about.
+3. **A future bump must land on `bfe7619` or later**, and the "find a `dev` commit built against
+ the same nightly" instruction in [toolchain.md](../toolchain.md) step 2 is not by itself
+ sufficient — the §3.5 re-check that step already recommends is load-bearing, not
+ belt-and-braces. It caught a real month-wide hole.
+
+Also settled: our PR
+[#107](https://github.com/karlseguin/websocket.zig/pull/107) was **closed unmerged on
+2026-07-09** (verified via the pulls API), consistent with what
+[toolchain.md](../toolchain.md) records except that it is now closed rather than open.
+
+## 4. Cross-check against our actual call sites
+
+### 4.1 API surface: unchanged (verified by running)
+
+`src/session.zig`'s `WebsocketTransport` (lines ~405–480) is our entire consumption surface:
+`websocket.Client.init`, `.handshake`, `.readLoopInNewThread`, `.write`, `.writePing`,
+`.writePong`, `.writeFrame(websocket.OpCode.close, …)`, `.close`, `.deinit`, plus
+`scrubHandshakeBuffer` reaching into the library's private `client._reader.pos` and
+`._reader.static` (`src/session.zig:449`) — deliberately, so a vendor bump that restructures
+those fields fails the compile instead of silently skipping the key scrub.
+
+Two scratchpad probe modules were compiled against the `dev` HEAD source tree with the pinned
+compiler: one taking the address of every method above and referencing `OpCode.close`, one
+replaying `scrubHandshakeBuffer`'s exact field access plus `writeFrame` and `close(.{})`. Both
+compiled clean (exit 0). So **no signature, no field name, and no enum we touch has moved.**
+`Reader` still declares `static: []u8` and `pos: usize`
+([`src/proto.zig`](https://raw.githubusercontent.com/karlseguin/websocket.zig/bfe761959b05/src/proto.zig)
+lines 72/78), and `Client._reader` is still the field name.
+
+`WebsocketTransport.connect` returns `!void` with an inferred error set and its one caller
+(`src/session.zig:665`) uses plain `try` with no exhaustive `switch` on the error — verified by
+reading. So the new `ConnectTimeout` / `TlsHandshakeTimeout` / `NetworkUnreachable` /
+`ConnectFailed` members would not break the compile.
+
+### 4.2 The one thing that would actually change: bounded connect
+
+Today `websocket.Client.init` → `HostName.connect` blocks with no upper bound. Our connect runs
+on the supervisor/maintenance thread, and `src/daemon.zig`'s shutdown does
+`supervisor_thread.join()` "so it is not mid-connect" before the graceful websocket close. So a
+blackholed or stalling `api.openai.com` — a captive portal, a hostile relay, a route that drops
+SYNs — can in principle wedge quit indefinitely. **Inference**, not something reproduced here:
+the hazard follows from the unbounded `init` plus the unconditional join, but no hang has been
+observed and there is no open type-wave issue about it (`gh issue list` finds none mentioning
+connect/shutdown/hang/timeout — verified).
+
+Upstream's default of `connect_timeout_ms = 10000` would bound it at ~10 s per phase without any
+code change on our side, which is a genuine improvement.
+
+Against that, the same commit is the largest single behaviour change in the range and carries
+real risk to weigh at bump time (**inference**, from reading the diff):
+
+- It **replaces `std.Io.net.HostName.connect` with a hand-rolled per-address loop**. Whatever
+ address ordering, dual-stack handling or future Happy-Eyeballs behaviour std provides is
+ bypassed; the loop walks the resolver's `LookupResult` stream in order and takes the first
+ address that connects. On a dual-stack Mac with a degraded IPv6 path this could plausibly be
+ *slower* to first byte than what we have.
+- Each TLS connect now **spawns a watchdog thread and a pipe pair**, joined on `disarm`. For a
+ daemon that reconnects on a 60-minute session cap that is cheap, but it is new machinery on
+ the reconnect path.
+- The watchdog works by `shutdown()`ing the socket underneath a blocking handshake. The code
+ handles the race where it fires just as the handshake completes (deinit + report timeout),
+ but this is exactly the class of teardown race `forceClose` already documents on our side.
+
+None of that is a reason not to take it eventually; it is a reason to take it as a deliberate,
+live-re-proved change rather than as a hash swap.
+
+## 5. Zig compatibility: the lockstep constraint is not binding right now
+
+[toolchain.md](../toolchain.md) requires the compiler and websocket.zig `dev` to move together,
+because `dev` chases zig-master and `std.Io` churns. Both directions were probed empirically in
+the scratchpad with `zig build-exe --dep websocket -Mroot=probe.zig -Mwebsocket=/src/websocket.zig -lc`:
+
+| Library tree | Compiler | Result |
+|---|---|---|
+| `dev` HEAD `bfe7619` | pinned `0.17.0-dev.1267+300116b02` | **compiles clean** (ran) |
+| pin `4b475a8` | current nightly `0.17.0-dev.1786+75044cb04` | **compiles clean** (ran) |
+| `dev` HEAD `bfe7619` | current nightly `0.17.0-dev.1786+75044cb04` | **compiles clean** (ran) |
+
+(Current zig-overlay `master` resolves to `0.17.0-dev.1786+75044cb04`, dated 2026-08-17 —
+verified against [ziglang.org/download/index.json](https://ziglang.org/download/index.json);
+both toolchains were already in the local nix store, so no download was needed.)
+
+That is a *compile* result on a probe module that instantiates the client type and touches every
+method we call — it is not a link, a test run, or a live wss round-trip, and the readme's
+warning still applies. But it is enough to settle the question asked: **a websocket.zig bump
+would not force a compiler bump**, and equally, a compiler bump to today's nightly would not
+force a websocket bump.
+
+Consistent with that, none of the three commits is a Zig-version adaptation. The std APIs the
+new connect path uses all exist in our pinned compiler (verified by reading the flake compiler's
+own std source): `Io.net.HostName.lookup` and `LookupResult`
+(`lib/std/Io/net/HostName.zig:150,163`), `Io.Queue.getOneUncancelable` (`lib/std/Io.zig:2382`),
+`Io.Timestamp.now(io, clock)` (`lib/std/Io.zig:926`), `Io.net.Stream.close(io)`
+(`lib/std/Io/net.zig:1255`), and `IpAddress.Ip6Address.interface` (`lib/std/Io/net.zig:441`).
+Everything else it uses (`posix.Address`, `getOsSockLen`, `pipe2`, `fcntl`, `shutdown`) comes
+from websocket.zig's **own** `src/posix.zig` shim, not std — so it is insulated from std churn
+by construction.
+
+## 6. Upstream health, read but not verified
+
+- The readme at `dev` HEAD
+ ([raw](https://raw.githubusercontent.com/karlseguin/websocket.zig/bfe761959b05/readme.md))
+ still says: *"The master branch targets the latest stable of Zig (0.15.1). The dev branch
+ targets the latest version of Zig"*, and, of the 0.16 line, *"This ZIG 0.16 version is not
+ well tested. Like Zig 0.16 itself, consider this experimental!"* The `dev` branch is
+ described as one *"which may or may not be up to date with zig dev."* Note the drift: the
+ readme's stable-branch claim (0.15.1) no longer matches its own Zig-version section (0.16.0).
+ Either way, nothing here changes the [toolchain.md](../toolchain.md) decision to stay on the
+ nightly pair.
+- Issue [#84](https://github.com/karlseguin/websocket.zig/issues/84) — *"Unit tests in `dev`
+ branch are not passing"* — has been **open since 2025-09-30**. So upstream's own test suite is
+ not a signal we can lean on when validating a bump; our live end-to-end re-prove is.
+- No open upstream issue reports breakage of the `dev` client on a recent nightly. Issues open
+ in the window are #109 (Windows `readTimeout`), #99 and #93 (Windows server), and #90 (client
+ proxy support) — none of which touch a macOS TLS client.
+
+## 7. What a bump would cost, if we do it
+
+Mechanical, from [toolchain.md](../toolchain.md) step 2 — recorded here so the "when" is cheap
+to execute later. The pin appears in **two** files that must stay identical:
+`build.zig.zon` and `prototypes/cli-dictation/build.zig.zon` (verified by grep; the other four
+prototype `build.zig.zon`s carry `minimum_zig_version` but no websocket dependency). Upstream's
+`.paths` still omits `LICENSE`, so `packaging/share/type-wave/LICENSES/websocket.zig-MIT.txt`
+must be re-checked by hand on any bump. And the live wss re-prove against
+`wss://api.openai.com/v1/realtime` is the acceptance test — doubly so if the bump brings
+`connect_timeout_ms`, since that rewrites the connect path this repo has never exercised.
+
+## 8. Bottom line
+
+Leave the pin at `4b475a8`. The honest summary of two months of upstream work, from this
+repo's point of view, is: one feature we could use but have not asked for, one fix in a code
+path we never enter, and a month-long regression of the one fix that matters to us — now
+repaired back to exactly the state we already ship. Revisit at the next compiler bump, pin
+`bfe761959b05030eaf4943fcf0fd5ecd1daca68a` or later, and keep the §3.5 re-check in the
+procedure: §3 is the proof it earns its place.