C:\Users\decid\Documents\projects\spt-core-delivery\.github\workflows\ci.yml:53:      SPT_ATTACH_IPC_DEADLINE_MS: "30000"
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:105:/// exports `SPT_ATTACH_IPC_DEADLINE_MS=30000` ONLY on the shared self-hosted boxes,
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:117:fn attach_ipc_deadline() -> Duration {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:118:    match std::env::var("SPT_ATTACH_IPC_DEADLINE_MS") {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:605:        let mut w = match Brain::cold_start_pump(&driver_name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:665:        let mut operator = match Brain::cold_start_pump(&attach_name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:691:                // request_attach is read under the pump io_timeout (attach_ipc_deadline);
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:697:                    "JRNL_FLAKE: request_attach net_open_stream ack TIMED OUT after {:?} \
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:955:    let mut viewer = match Brain::cold_start_pump(name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:994:    let mut op = match Brain::cold_start_pump(name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:1124:            if let Ok(c) = Brain::cold_start_pump(name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:1559:    let mut b = Brain::cold_start_pump(name, 1, attach_ipc_deadline()).ok()?;
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:1918:        let mut w = match Brain::cold_start_pump(&driver_name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:1955:        let mut op = match Brain::cold_start_pump(&name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:1976:        let mut operator = match Brain::cold_start_pump(&attach_name, 1, attach_ipc_deadline()) {
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:2003:                // pump io_timeout (attach_ipc_deadline); co-tenant starvation pushes it
C:\Users\decid\Documents\projects\spt-core-delivery\crates\spt-daemon\tests\inject_control_wedge.rs:2007:                    "PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after {:?} \


Clean — all 9 pump sites on `attach_ipc_deadline()`, both `request_attach` ack prints in place (697/2007), no stale names. Re-verify: clippy → `=1` fail-fast-with-new-print → default pass.


Bash

cd /c/Users/decid/Documents/projects/spt-core-delivery && cargo clippy -p spt-daemon --all-targets -- -D warnings 2>&1 | tail -6

clippy re-check after rename/print edits


Command running in background with ID: brm3nowuv. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\brm3nowuv.output. You will be notified when it completes. To check interim output, use Read on that file path.


Clippy running. Will run the `=1`/default verification passes once it's clean.


<task-notification>
<task-id>brm3nowuv</task-id>
<tool-use-id>toolu_01NahWWFCcASveTwR4AscNpY</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\brm3nowuv.output</output-file>
<status>completed</status>
<summary>Background command "clippy re-check after rename/print edits" completed (exit code 0)</summary>
</task-notification>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


clippy clean. Run both verification passes (`=1` expect fail+print, default expect pass):


cd /c/Users/decid/Documents/projects/spt-core-delivery
echo "===== PASS-1: SPT_ATTACH_IPC_DEADLINE_MS=1 (expect FAIL + ack-timeout print) ====="
SPT_ATTACH_IPC_DEADLINE_MS=1 cargo nextest run --test inject_control_wedge --no-capture p0_paste_wedge a_journaled_input_wedge 2>&1 | grep -iE "ack TIMED OUT|PASS|FAIL|Summary"
echo "===== PASS-2: default 2s (expect PASS) ====="
cargo nextest run --test inject_control_wedge p0_paste_wedge a_journaled_input_wedge 2>&1 | grep -iE "PASS|FAIL|Summary"

Verify =1 fails with new print; default passes


Command running in background with ID: budvj5y1p. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\budvj5y1p.output. You will be notified when it completes. To check interim output, use Read on that file path.


Running. Awaiting both-pass result before commit/push.


<task-notification>
<task-id>budvj5y1p</task-id>
<tool-use-id>toolu_01EKq3PosBPcscGKyrAF2EpX</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\budvj5y1p.output</output-file>
<status>completed</status>
<summary>Background command "Verify =1 fails with new print; default passes" completed (exit code 0)</summary>
</task-notification>


1	===== PASS-1: SPT_ATTACH_IPC_DEADLINE_MS=1 (expect FAIL + ack-timeout print) =====
2	JRNL_FLAKE: request_attach net_open_stream ack TIMED OUT after 6.4759ms (io_timeout bound, source=channel)
3	test a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach ... FAILED
4	failures:
5	failures:
6	test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.00s
7	        FAIL [   3.025s] (1/2) spt-daemon::inject_control_wedge a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach
8	  Cancelling due to test failure:
9	     Summary [   3.025s] 1/2 tests run: 0 passed, 1 failed, 8 skipped
10	        FAIL [   3.025s] (1/2) spt-daemon::inject_control_wedge a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach
11	warning: 1/2 tests were not run due to test failure (run with --no-fail-fast to run all tests, or run with --max-fail)
12	error: test run failed
13	===== PASS-2: default 2s (expect PASS) =====
14	        PASS [   3.012s] (1/2) spt-daemon::inject_control_wedge a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach
15	        PASS [   3.044s] (2/2) spt-daemon::inject_control_wedge p0_paste_wedge_parked_write_does_not_starve_attach_or_wedge_broker
16	     Summary [   6.057s] 2 tests run: 2 passed, 8 skipped
17

===== PASS-1: SPT_ATTACH_IPC_DEADLINE_MS=1 (expect FAIL + ack-timeout print) =====
JRNL_FLAKE: request_attach net_open_stream ack TIMED OUT after 6.4759ms (io_timeout bound, source=channel)
test a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach ... FAILED
failures:
failures:
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.00s
        FAIL [   3.025s] (1/2) spt-daemon::inject_control_wedge a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach
  Cancelling due to test failure:
     Summary [   3.025s] 1/2 tests run: 0 passed, 1 failed, 8 skipped
        FAIL [   3.025s] (1/2) spt-daemon::inject_control_wedge a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach
warning: 1/2 tests were not run due to test failure (run with --no-fail-fast to run all tests, or run with --max-fail)
error: test run failed
===== PASS-2: default 2s (expect PASS) =====
        PASS [   3.012s] (1/2) spt-daemon::inject_control_wedge a_journaled_input_wedge_does_not_starve_a_concurrent_rc_attach
        PASS [   3.044s] (2/2) spt-daemon::inject_control_wedge p0_paste_wedge_parked_write_does_not_starve_attach_or_wedge_broker
     Summary [   6.057s] 2 tests run: 2 passed, 8 skipped


Verified:
- **`=1ms`**: a_journaled FAILED fast with new print → `JRNL_FLAKE: request_attach net_open_stream ack TIMED OUT after 6.4759ms (io_timeout bound, source=channel)` — doyle's B confirmed on the right path
- **default 2s**: both PASS
- clippy clean

nextest fail-fast cancelled p0 before it ran at `=1`. Confirm PASTE_FLAKE print fires too (identical code, but hand doyle full proof):


cd /c/Users/decid/Documents/projects/spt-core-delivery && SPT_ATTACH_IPC_DEADLINE_MS=1 cargo nextest run --test inject_control_wedge --no-capture p0_paste_wedge 2>&1 | grep -iE "ack TIMED OUT|FAIL|Summary"

Confirm PASTE_FLAKE print fires at =1


Command running in background with ID: bcgf52nvn. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\bcgf52nvn.output. You will be notified when it completes. To check interim output, use Read on that file path.


<task-notification>
<task-id>bcgf52nvn</task-id>
<tool-use-id>toolu_01JKideUqzFPxRdmx7mZUaEx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\bcgf52nvn.output</output-file>
<status>completed</status>
<summary>Background command "Confirm PASTE_FLAKE print fires at =1" completed (exit code 0)</summary>
</task-notification>


While p0 confirms, prep commit: check diff scope + traceability gate (CLAUDE.md requires `traceable-reqs check` before done):


cd /c/Users/decid/Documents/projects/spt-core-delivery && git status --short && echo "=== diffstat ===" && git diff --stat && echo "=== traceable-reqs? ===" && command -v traceable-reqs 2>/dev/null && echo FOUND || echo "not on PATH"

Diff scope + traceable-reqs presence


 M .github/workflows/ci.yml
 M crates/spt-daemon/tests/inject_control_wedge.rs
=== diffstat ===
 .github/workflows/ci.yml                        | 13 +++++
 crates/spt-daemon/tests/inject_control_wedge.rs | 72 +++++++++++++++++++++----
 2 files changed, 76 insertions(+), 9 deletions(-)
=== traceable-reqs? ===
/c/Users/decid/AppData/Local/Programs/traceable-reqs/traceable-reqs
FOUND
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


Prompt:
<task-notification>
<task-id>bcgf52nvn</task-id>
<tool-use-id>toolu_01JKideUqzFPxRdmx7mZUaEx</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\bcgf52nvn.output</output-file>
<status>completed</status>
<summary>Background command "Confirm PASTE_FLAKE print fires at =1" completed (exit code 0)</summary>
</task-notification>


1	test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.03s
2	     Summary [   3.049s] 1 test run: 1 passed, 9 skipped
3

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.03s
     Summary [   3.049s] 1 test run: 1 passed, 9 skipped


p0 at `=1` PASSED (no print) — the 1ms deadline is **racy** on fast loopback (the `net_open_stream` ack occasionally lands <1ms). a_journaled tripped + printed; p0's print path is byte-identical (line 2007). Retry p0 a few times at `=1` to capture the PASTE_FLAKE print for doyle's both-gates proof:


cd /c/Users/decid/Documents/projects/spt-core-delivery
for i in 1 2 3 4; do
  echo "--- attempt $i ---"
  SPT_ATTACH_IPC_DEADLINE_MS=1 cargo nextest run --test inject_control_wedge --no-capture p0_paste_wedge 2>&1 | grep -iE "PASTE_FLAKE: request_attach.*ack TIMED OUT|test result"
done

Retry p0 at =1 to capture PASTE_FLAKE ack-timeout print


Command running in background with ID: bk99hploh. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\bk99hploh.output. You will be notified when it completes. To check interim output, use Read on that file path.


traceable-reqs binary found. Run the gate (CLAUDE.md requires it before done):


cd /c/Users/decid/Documents/projects/spt-core-delivery && traceable-reqs check 2>&1 | tail -25; echo "exit=${PIPESTATUS[0]}"

traceable-reqs check


       Thread-spanning digest across session boundaries: a per-endpoint session ledger (`<perch>/sessions.log`) appended at first bind and by `api boundary` on `/clear`|`/compact` session rotation, the digest enumerating the last K sessions so its rolling window bridges a boundary, and a distinctive in-timeline boundary marker (DigestEntry::Boundary). The digest follows the live-agent thread, not a single session.
  [OK] REQ-TERM-7  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Two-origin digest merge: spt-owned context-injection entries (psyche_download | echo_mirror | owl_message) appended by spt to the endpoint `digest.log`, timestamp-interleaved with the adapter's extracted activity records into one ordered timeline, via a distinct context-injection record category. Data model only this milestone; GUI collapse/expand and the echo-reads-digest delta loop are deferred to the surfaces that consume them.
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.
  [OK] REQ-UPD-9  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `gh_release` adapter [update] avenue (optional signing): an adapter declares `[update] avenue = "gh_release", repo = "user/repo"` (+ optional `asset`, default `adapter.spt`; + optional Ed25519 `signing_key`); spt-core's ripple compares the repo's LATEST GitHub release version against the installed adapter version and, when newer, auto-updates by fetching the release `.spt` archive (the REQ-INSTALL-9 `--release` fetch primitive) → verifies the `.spt` against `signing_key` if declared, else HTTPS+GitHub first-acquisition trust → re-extracts + re-registers the adapter root. Lets a harness adapter ship updates from its own GitHub releases with NO signing tooling or plugin coupling (removes the perri file_pull/delegated avenue blockers). Acquisition-trust mirrors `--release` + the installer first-fetch; does not alter spt-core self-update (REQ-UPD-1..8).
  [OK] REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       A `rc --view` VIEWER that overflows its broker subscription queue and is EVICTED (OutputLog::append try_send Full → viewers.remove, REQ-HAZARD-VIEWER-ISOLATION session-protection) must SKIP TO LIVE, not die silently. ROOT (v0.13.0, b4 JIT item 2 = p0_paste + post-b4 a_journaled-Linux, ONE root): serve_attach forwards each frame (read_event→b64decode→re-encode AttachRecord→net_stream_send) SLOWER than the drain fans out under flood → its VIEWER_CHANNEL_DEPTH(256) channel overflows → the drain evicts (viewers.remove drops the ViewerSink → drops tx → viewer_writer's rx.recv() Err → the writer returns WRITING NOTHING) → serve_attach's brain.read_event() just STOPS getting Output (no EOF, no error) → serve_attach blocks forever → the operator receives nothing (attach_received_output=FALSE). Eviction-of-a-hopelessly-behind-viewer is CORRECT session-protection (keep it); SILENT+PERMANENT eviction is the bug. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative / exposes no resume cursor). FIX (doyle-gated, skip-to-live = tail -f reconnect): (1) explicit broker→viewer EVICTION SIGNAL (KIND_VIEWER_EVICTED, written in the viewer_writer thread OFF the log lock, DISTINCT from session-exit EOF so serve must NOT tear down on it); (2) serve_attach re-subscribes from the CURRENT ring floor (skip-to-live, replays nothing, sees the next live burst) — resetting the cold serve-brain's next_seq so the post-eviction forward-jump replay is accepted (the legacy reject-gap path, brain.rs:618-626, would otherwise FATAL the forward jump); (3) HARD constraint NO evict→resubscribe busy-loop: serve_attach rate-limits re-subscribes (RESUBSCRIBE_INTERVAL) so under max-flood the operator sees intermittent LIVE bursts, never a CPU spin. (v0.13.0)
  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).
exit=0
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


traceable-reqs check **exit 0** — all REQs covered, no tags disturbed (test-only deadline/print edits). Awaiting p0 `=1` retry to capture PASTE_FLAKE print, then commit + push.


<task-notification>
<task-id>bk99hploh</task-id>
<tool-use-id>toolu_013RDz1SAtjLCWoU8AwGxvqc</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\bk99hploh.output</output-file>
<status>completed</status>
<summary>Background command "Retry p0 at =1 to capture PASTE_FLAKE ack-timeout print" completed (exit code 0)</summary>
</task-notification>


1	--- attempt 1 ---
2	PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 2.8271ms (io_timeout bound, source=channel)
3	test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.01s
4	--- attempt 2 ---
5	PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 10.5404ms (io_timeout bound, source=channel)
6	test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.05s
7	--- attempt 3 ---
8	PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 13.3219ms (io_timeout bound, source=channel)
9	test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 4.98s
10	--- attempt 4 ---
11	PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 15.7111ms (io_timeout bound, source=channel)
12	test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.06s
13

--- attempt 1 ---
PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 2.8271ms (io_timeout bound, source=channel)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.01s
--- attempt 2 ---
PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 10.5404ms (io_timeout bound, source=channel)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.05s
--- attempt 3 ---
PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 13.3219ms (io_timeout bound, source=channel)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 4.98s
--- attempt 4 ---
PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after 15.7111ms (io_timeout bound, source=channel)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 9 filtered out; finished in 3.06s


p0 @ `=1`: **4/4 FAILED with the PASTE_FLAKE ack-timeout print** (the single earlier pass was the rare <1ms loopback-ack race). Both gates trip on the correct path; default passes; clippy clean; traceable-reqs exit 0. Full evidence in hand. Commit + push to delivery-control:


cd /c/Users/decid/Documents/projects/spt-core-delivery && git add crates/spt-daemon/tests/inject_control_wedge.rs .github/workflows/ci.yml && git commit -F - <<'EOF'
test(inject-wedge)+ci: env-tunable per-IPC-call deadline for the request_attach ack-starvation flake

x3 RED reopened p0_paste on the shared Windows box (SERIALIZED, no matrix
stacking): PASTE_FLAKE recv -> (false,false) @2.016s, source=channel,
subscribed=false. Corrected root (doyle gate, converged):

The Duration passed to Brain::cold_start_pump is io_timeout -- the per-IPC-call
READ deadline (call_deadline = now + io_timeout, brain.rs:311-323/:569), NOT a
connect bound (LocalSocketTransport::connect is unbounded). request_attach ->
net_open_stream reads the journaled NetStreamOpened ack under that deadline
(brain.rs:1066-1068, attach.rs:513); under co-tenant scheduler starvation the
SERVICED ack outruns the LOCAL-tight 2s -> read Errs -> request_attach Err ->
the attacher's request_attach match arm sends (false,false) ~2s BEFORE the 30s
attach_gate_watchdog is ever reached. net_stream_subscribe is fire-and-forget
(no ack read) -- never the false. The earlier "connect timed out" / "watchdog
default" pins were both misreads; the ack-read io_timeout is the real bound.

Fix (a DISTINCT bound from the watchdog -- two bounds, two knobs):
- New helper attach_ipc_deadline() <- SPT_ATTACH_IPC_DEADLINE_MS, DEFAULT 2s,
  wired into every result-gating cold_start_pump brain (both attachers, both
  drivers, controller_by viewer, keystroke op, + the W2 helpers
  output_contains_within / observe_with_xon / spawn_xlate_session). Non-result-
  gating connects stay at the 2s literal. One knob.
- Attribution print on the request_attach Err arm of both gates (JRNL_FLAKE /
  PASTE_FLAKE: "request_attach net_open_stream ack TIMED OUT after {elapsed}
  (io_timeout bound, source=channel)") so the next triage reads the ack-timeout
  directly instead of reverse-engineering it from 2.0s. The prior cold_start_pump
  -arm "operator connect TIMED OUT" print is dropped (never fires for this flake).
- CI exports SPT_ATTACH_IPC_DEADLINE_MS=30000 job-level beside the watchdog. A
  real wedge: the ack never lands -> Err at io_timeout -> false -> assert still
  catches (total < the 120s watchdog). KEEP the watchdog knob as the outer ceiling.

Non-vacuous unchanged (subscribed && got_output). Verified: =1 -> both gates
fail fast WITH the request_attach-ack print; default 2s -> both pass; clippy
-p spt-daemon --all-targets -D warnings clean.

[int->REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE]
[int->REQ-HAZARD-PTY-INPUT-WRITER-WEDGE]

Co-authored by: SUBNET SPT_DEV
EOF
echo "=== exit $? ==="; git log --oneline -1

Commit the corrected fix on delivery-control


[v0.13.0-delivery-control 45cc479] test(inject-wedge)+ci: env-tunable per-IPC-call deadline for the request_attach ack-starvation flake
 2 files changed, 76 insertions(+), 9 deletions(-)
=== exit 0 ===
45cc479 test(inject-wedge)+ci: env-tunable per-IPC-call deadline for the request_attach ack-starvation flake
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


cd /c/Users/decid/Documents/projects/spt-core-delivery && git push origin v0.13.0-delivery-control 2>&1 | tail -8; echo "=== exit ${PIPESTATUS[0]} ==="

Push delivery-control branch


To https://github.com/SaberMage/spt-core.git
   73ff016..45cc479  v0.13.0-delivery-control -> v0.13.0-delivery-control
=== exit 0 ===
Shell cwd was reset to C:\Users\decid\Documents\projects\spt-core


Pushed: `73ff016..45cc479` on `v0.13.0-delivery-control`. Ping doyle for gate-read + ×3:


cat <<'EOF' | "$OWL" send doyle 2>&1
PUSHED: v0.13.0-delivery-control 73ff016..45cc479 — A+B applied per your final cut, ready for gate-read + x3.

Diff (2 files, +76/-9):
- Helper attach_connect_deadline -> attach_ipc_deadline; env SPT_ATTACH_CONNECT_DEADLINE_MS -> SPT_ATTACH_IPC_DEADLINE_MS. Doc'd as the pump per-IPC-call READ deadline (io_timeout / call_deadline), explicitly NOT a connect; cites brain.rs:311-323/:569/:1066-1068 + attach.rs:513.
- Wired to all 9 result-gating cold_start_pump brains: both attachers, both drivers, controller_by viewer, keystroke op, + the 3 W2 helpers (output_contains_within :955, observe_with_xon :994, spawn_xlate_session :1124) per your advance ruling. No non-result-gating pump connect left at the 2s literal (there were none).
- B: dropped both cold_start_pump-arm "operator connect TIMED OUT" prints; added on each request_attach Err arm: "JRNL_FLAKE/PASTE_FLAKE: request_attach net_open_stream ack TIMED OUT after {elapsed} (io_timeout bound, source=channel)".
- CI: SPT_ATTACH_IPC_DEADLINE_MS=30000 job-level beside the watchdog (kept); comment rewritten to the corrected mechanism.

Verification (Windows, hfenduleam, nextest process-per-test):
- SPT_ATTACH_IPC_DEADLINE_MS=1: a_journaled FAIL + "JRNL_FLAKE: request_attach net_open_stream ack TIMED OUT after 6.48ms ...". p0_paste FAIL 4/4 + "PASTE_FLAKE: ... after 2.8-15.7ms ...". (Honest note: =1 is borderline-racy on loopback — one isolated p0 run won the <1ms ack race and PASSED; at the real 2s bound that race is gone. The knob+print path is proven on both gates.)
- default 2s: both PASS (3.0s each).
- clippy -p spt-daemon --all-targets -D warnings: clean. traceable-reqs check: exit 0 (int tags intact: REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE, REQ-HAZARD-PTY-INPUT-WRITER-WEDGE).

Watchdog knob kept as the outer ceiling. Over to your x3 serialized.
EOF

Notify doyle of push + evidence


➜ SENT:doyle


SENT to doyle. Pending his ×3 serialized verdict.

This root was misread 3×（connect-timeout, then watchdog-default, then connect again）across two agents — the gotcha is non-obvious. Saving a memory so it's not re-misread:


Write

---
name: cold-start-pump-iotimeout-not-connect
description: Brain::cold_start_pump's 3rd Duration arg is io_timeout (per-IPC-call read deadline), NOT a connect bound — the inject-wedge gate flake bites at request_attach's net_open_stream ack
metadata:
  type: reference
---

`Brain::cold_start_pump(name, now_ms, dur)` (crates/spt-daemon/src/brain.rs:311-323): the `dur` is stored as `io_timeout` and applied as `call_deadline = now + io_timeout` (:569) to every bounded read (`read_event_until`). The socket connect (`LocalSocketTransport::connect`, :312) is **UNBOUNDED**. So cold_start_pump never "times out at 2s" — it Errs only on a real socket failure.

In `inject_control_wedge.rs` (a_journaled / p0_paste gates) the `(false,false)@~2.0s, source=channel, subscribed=false` flake on the shared Windows box (hfenduleam) is **request_attach → net_open_stream reading the journaled NetStreamOpened ack under that io_timeout** (attach.rs:513, brain.rs:1066-1068). Co-tenant scheduler starvation pushes the serviced ack past the local-tight 2s → read Errs → request_attach Err → attacher's request_attach arm sends the false, a full ~2s BEFORE the `attach_gate_watchdog` (30s) is reached. `net_stream_subscribe` (:1200) is fire-and-forget (no ack read) — never the false.

This root was misread 3× (connect-timeout, watchdog-default, connect again). Fix shipped v0.13.0-delivery-control @ 45cc479: `attach_ipc_deadline()` ← `SPT_ATTACH_IPC_DEADLINE_MS` (default 2s, CI=30000), distinct from the watchdog knob — two bounds, two knobs. Wiring probe `SPT_ATTACH_IPC_DEADLINE_MS=1` is borderline-racy (loopback ack occasionally lands <1ms → false pass); the real 2s bound has no such race. Related: [[v013-rc-attach-flood-wedge]].


File created successfully at: C:\Users\decid\.claude\projects\C--Users-decid-Documents-projects-spt-core\memory\cold-start-pump-iotimeout-not-connect.md (file state is current in your context — no need to Read it back)

---
name: cold-start-pump-iotimeout-not-connect
description: "Brain::cold_start_pump's 3rd Duration arg is io_timeout (per-IPC-call read deadline), NOT a connect bound — the inject-wedge gate flake bites at request_attach's net_open_stream ack"
metadata:
  node_type: memory
  type: reference
  originSessionId: 3d725ad6-3a8a-44b3-aad2-0e65f49b6165
---

`Brain::cold_start_pump(name, now_ms, dur)` (crates/spt-daemon/src/brain.rs:311-323): the `dur` is stored as `io_timeout` and applied as `call_deadline = now + io_timeout` (:569) to every bounded read (`read_event_until`). The socket connect (`LocalSocketTransport::connect`, :312) is **UNBOUNDED**. So cold_start_pump never "times out at 2s" — it Errs only on a real socket failure.

In `inject_control_wedge.rs` (a_journaled / p0_paste gates) the `(false,false)@~2.0s, source=channel, subscribed=false` flake on the shared Windows box (hfenduleam) is **request_attach → net_open_stream reading the journaled NetStreamOpened ack under that io_timeout** (attach.rs:513, brain.rs:1066-1068). Co-tenant scheduler starvation pushes the serviced ack past the local-tight 2s → read Errs → request_attach Err → attacher's request_attach arm sends the false, a full ~2s BEFORE the `attach_gate_watchdog` (30s) is reached. `net_stream_subscribe` (:1200) is fire-and-forget (no ack read) — never the false.

This root was misread 3× (connect-timeout, watchdog-default, connect again). Fix shipped v0.13.0-delivery-control @ 45cc479: `attach_ipc_deadline()` ← `SPT_ATTACH_IPC_DEADLINE_MS` (default 2s, CI=30000), distinct from the watchdog knob — two bounds, two knobs. Wiring probe `SPT_ATTACH_IPC_DEADLINE_MS=1` is borderline-racy (loopback ack occasionally lands <1ms → false pass); the real 2s bound has no such race. Related: [[v013-rc-attach-flood-wedge]].


12	- [Test subagent stalls on hanging test](test-subagent-stalls-on-hanging-test.md) — a test subagent's blocking until-loops stall forever on a hung test; take over (kill orphans, bisect timeout-wrapped) instead of waiting; Windows e2e suites also just run slow under load.
13	- [Owl send binary](owl-send-binary.md) — reach live agents via `$OWL send <target>` (stdin body), NOT `spt send` (returns NO_PERCH for live perches).
14	- [v0.13.0 rc-attach flood wedge](v013-rc-attach-flood-wedge.md) — the 3 inject_control_wedge gates fail on a REAL throughput bug (concurrent rc-view attach starves under sustained PTY output flood, serde-saturation / control-vs-data-plane); root-caused 2026-06-20 via gdb+SPT_WEDGE_TRACE on kitsubito, every lock hypothesis refuted; pending operator scope decision; two-phase CI split shipped.
15

- [Test subagent stalls on hanging test](test-subagent-stalls-on-hanging-test.md) — a test subagent's blocking until-loops stall forever on a hung test; take over (kill orphans, bisect timeout-wrapped) instead of waiting; Windows e2e suites also just run slow under load.
- [Owl send binary](owl-send-binary.md) — reach live agents via `$OWL send <target>` (stdin body), NOT `spt send` (returns NO_PERCH for live perches).
- [v0.13.0 rc-attach flood wedge](v013-rc-attach-flood-wedge.md) — the 3 inject_control_wedge gates fail on a REAL throughput bug (concurrent rc-view attach starves under sustained PTY output flood, serde-saturation / control-vs-data-plane); root-caused 2026-06-20 via gdb+SPT_WEDGE_TRACE on kitsubito, every lock hypothesis refuted; pending operator scope decision; two-phase CI split shipped.


Edit

- [v0.13.0 rc-attach flood wedge](v013-rc-attach-flood-wedge.md) — the 3 inject_control_wedge gates fail on a REAL throughput bug (concurrent rc-view attach starves under sustained PTY output flood, serde-saturation / control-vs-data-plane); root-caused 2026-06-20 via gdb+SPT_WEDGE_TRACE on kitsubito, every lock hypothesis refuted; pending operator scope decision; two-phase CI split shipped.

- [v0.13.0 rc-attach flood wedge](v013-rc-attach-flood-wedge.md) — the 3 inject_control_wedge gates fail on a REAL throughput bug (concurrent rc-view attach starves under sustained PTY output flood, serde-saturation / control-vs-data-plane); root-caused 2026-06-20 via gdb+SPT_WEDGE_TRACE on kitsubito, every lock hypothesis refuted; pending operator scope decision; two-phase CI split shipped.
- [cold_start_pump arg is io_timeout not connect](cold-start-pump-iotimeout-not-connect.md) — cold_start_pump's 3rd Duration = per-IPC-call read deadline (call_deadline), connect is unbounded; inject-wedge `(false,false)@2s` flake = request_attach net_open_stream ack starved past io_timeout; misread 3× (connect/watchdog/connect); fixed @45cc479 with SPT_ATTACH_IPC_DEADLINE_MS knob.


The file C:\Users\decid\.claude\projects\C--Users-decid-Documents-projects-spt-core\memory\MEMORY.md has been updated successfully. (file state is current in your context — no need to Read it back)

- [v0.13.0 rc-attach flood wedge](v013-rc-attach-flood-wedge.md) — the 3 inject_control_wedge gates fail on a REAL throughput bug (concurrent rc-view attach starves under sustained PTY output flood, serde-saturation / control-vs-data-plane); root-caused 2026-06-20 via gdb+SPT_WEDGE_TRACE on kitsubito, every lock hypothesis refuted; pending operator scope decision; two-phase CI split shipped.
- [cold_start_pump arg is io_timeout not connect](cold-start-pump-iotimeout-not-connect.md) — cold_start_pump's 3rd Duration = per-IPC-call read deadline (call_deadline), connect is unbounded; inject-wedge `(false,false)@2s` flake = request_attach net_open_stream ack starved past io_timeout; misread 3× (connect/watchdog/connect); fixed @45cc479 with SPT_ATTACH_IPC_DEADLINE_MS knob.

# Memory index

- [Cross-machine test rig](cross-machine-test-rig.md) — enlyzeam (Windows, ssh key) for two-host network spikes + Tailscale-down gotcha; gravity-linux (Ubuntu, david/bigscreen pw-auth + askpass shim) as the Linux/forkpty build host.
- [CI traceability gate](ci-traceability-gate.md) — how CI installs traceable-reqs (TRACEABLE_REQS_TOKEN PAT + latest BigscreenVR release); the v0.1.0-stale-binary gotcha + the v0.1.1 fix; I have admin to cut releases.
- [Attractor build harness](attractor-build-harness.md) — attractor+pi/codex setup at ../attractor; codex model = openai-codex/gpt-5.5 (NOT the SPEC's gpt-5.3-codex); viable for bounded single-machine spt-core tasks (SPIKE-07b gix pilot).
- [Windows UAC binary-name 740](windows-uac-binary-name-740.md) — exe names containing update/setup/install/patch trigger UAC elevation (os error 740); name self-update artifacts + test bins to dodge it.
- [M4 resume state](m4-resume-state.md) — M4 (spt-net) in progress 2026-06-03; D0+D1 done (iroh =0.98.2 endpoint, identity-bound, relay policy, mDNS); spt-net = first async crate; next = D2 pairing.
- [Linux runner migration](linux-runner-migration.md) — replace gravity CI Linux runner with kitsubito (LAN Linux), not WSL (disk cost); deferred until after v0.1.1; retarget `gravity` host-label in ci/release/docs-publish workflows to a role label.
- [Delegate tests to spt-test-engineer](delegate-tests-to-spt-test-engineer.md) — write spt-core tests via the `spt-test-engineer` subagent (Agent tool subagent_type), not inline.
- [spt verification gates](spt-verification-gates.md) — before "done": workspace clippy (CI form), rebuild the real bin (not cargo test), verify terminal fixes live, keep the shared tree compilable.
- [Redirect folded REQ seeds](redirect-folded-req-seeds.md) — when folding a wave into existing reqs, retire/redirect the wave's minted seed in the SAME commit (doyle caught a dangling REQ-PICKER-UX-V013 @ W4).
- [Test subagent stalls on hanging test](test-subagent-stalls-on-hanging-test.md) — a test subagent's blocking until-loops stall forever on a hung test; take over (kill orphans, bisect timeout-wrapped) instead of waiting; Windows e2e suites also just run slow under load.
- [Owl send binary](owl-send-binary.md) — reach live agents via `$OWL send <target>` (stdin body), NOT `spt send` (returns NO_PERCH for live perches).
- [v0.13.0 rc-attach flood wedge](v013-rc-attach-flood-wedge.md) — the 3 inject_control_wedge gates fail on a REAL throughput bug (concurrent rc-view attach starves under sustained PTY output flood, serde-saturation / control-vs-data-plane); root-caused 2026-06-20 via gdb+SPT_WEDGE_TRACE on kitsubito, every lock hypothesis refuted; pending operator scope decision; two-phase CI split shipped.

+- [cold_start_pump arg is io_timeout not connect](cold-start-pump-iotimeout-not-connect.md) — cold_start_pump's 3rd Duration = per-IPC-call read deadline (call_deadline), connect is unbounded; inject-wedge `(false,false)@2s` flake = request_attach net_open_stream ack starved past io_timeout; misread 3× (connect/watchdog/connect); fixed @45cc479 with SPT_ATTACH_IPC_DEADLINE_MS knob.


Done. Pending doyle's ×3 verdict.

## Summary

doyle reopened the v0.13.0 gate ×3 RED. I implemented his fix ruling — but **caught his corrected root was itself a misread** (the 3rd) before shipping it.

**The real bug:** `cold_start_pump`'s `Duration` arg is `io_timeout` (per-IPC-call *read* deadline), not a connect bound. The `(false,false)@2.016s` flake is `request_attach`→`net_open_stream`'s journaled ack starved past the 2s `io_timeout` under co-tenant load — Errs ~2s before the watchdog. Proved empirically (the `=1` connect-Err print never fired). doyle accepted A+B.

**Shipped** (`v0.13.0-delivery-control` `73ff016..45cc479`):
- `attach_ipc_deadline()` ← `SPT_ATTACH_IPC_DEADLINE_MS` (default 2s, CI 30000), wired to all 9 result-gating `cold_start_pump` brains
- Attribution print relocated to the `request_attach` Err arm (both gates)
- Watchdog knob kept as the outer ceiling — two bounds, two knobs

**Verified:** `=1`→both fail fast WITH the ack-timeout print · default 2s→both pass · clippy clean · traceable-reqs exit 0.

Notified doyle with full evidence; he drives the ×3 serialized gate-read next. Saved a memory on the `cold_start_pump`-arg gotcha (3× misread = non-obvious).


<task-notification>
<task-id>bsp4y8a8u</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT-PART seq="1/3" id="542e3e38" type="msg" from="doyle">DIFF GATE-READ: PASS @ 45cc479. All conditions verified in-diff: attach_ipc_deadline() default 2s (Err+parse-fail), doc'd as io_timeout NOT connect w/ brain.rs cites; wired to all 9 result-gating cold_start_pump sites, no 2s literal left; attribution print on the request_attach Err arm (both gates) with the io_timeout/s</EVENT-PART>
<EVENT-PART seq="2/3" id="542e3e38">ource=channel wording, connect-arm print gone; CI SPT_ATTACH_IPC_DEADLINE_MS=30000 beside the kept watchdog knob; asserts untouched (non-vacuous); clippy + traceable clean. Zero product diff. The =1 race caveat is fine (wiring proof; 2s kills it). Approved. Driving x3 serialized now — properly (verifying per-attempt conclusion this time, not</EVENT-PART>
<EVENT-PART seq="3/3" id="542e3e38"> watch-exit alone). Ping you on x3-green.</EVENT-PART></event>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


doyle: **DIFF GATE-READ PASS @ 45cc479. Approved.** Driving ×3 serialized now (per-attempt conclusion this time, not watch-exit), pings on x3-green.

Diff approved, zero product diff, =1 race caveat accepted. Nothing actionable until doyle's x3 result — he'll re-invoke me via Monitor. Holding.


<task-notification>
<task-id>bsp4y8a8u</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/3" id="1a0c09a0" type="msg" from="doyle">x3 CLEAN — GATE PASS. All three runs GREEN on 45cc479 (run 27930772962, serialized via cancel-in-progress, each verified per-ATTEMPT conclusion not watch-exit): attempt1=success, attempt2=success, attempt3=success. Both Windows + Linux legs + n1-gates + traceability green every pass. The io_timeout-deadline fix holds </EVENT-PART>
<EVENT-PART seq="2/3" id="1a0c09a0">under the shared-box load that tripped attempt-2 last round.

My gate is GREEN. Presenting to operator for publish-confirm now. On operator GO -&gt; deployah cuts v0.13.0 (merge PR#26 + version bump + CHANGELOG-as-release-body + Cargo.lock first-party bump + sign+publish). I'll relay the GO. b4 sync stays deferred (cosmetic, post-publish</EVENT-PART>
<EVENT-PART seq="3/3" id="1a0c09a0"> via throwaway worktree). Solid work on refusing the two misreads — the fix landed correct because of it.</EVENT-PART></event>
</task-notification>


doyle: **x3 CLEAN — GATE PASS.** All 3 runs green on 45cc479 (run 27930772962, serialized; attempt1/2/3 = success). Both Windows + Linux legs + n1-gates + traceability green every pass. The io_timeout fix holds under the shared-box load that tripped attempt-2 last round. **Gate GREEN** → presenting to operator for publish-confirm; on GO, deployah cuts v0.13.0.

## Done

doyle's ×3 RED → **GREEN**. Task complete:
- Implemented the env-tunable IPC-deadline fix
- Caught + corrected doyle's root misread (3rd) before shipping — `cold_start_pump` arg is `io_timeout`, flake bites at `request_attach`'s ack read
- Verified (`=1` fail-fast-with-print, default pass, clippy + traceable clean), pushed `45cc479`, gate-read PASS, ×3 serialized PASS

Publish is operator's call (deployah cuts on GO) — not my action. b4 sync deferred (cosmetic, post-publish). Standing by.


<task-notification>
<task-id>bsp4y8a8u</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/7" id="1a900994" type="msg" from="doyle">NEW DISPATCH (v0.13.x DX follow-on, NON-blocking — v0.13.0 already shipped clean). perri found a real DX/parity gap, I triaged it case-3 + verified vs source.

GAP: [message-idle-translation-binary] (ADR-0022, just shipped) has NO author-time proof command, unlike [digest] which has `spt adapter digest-proof` (A</EVENT-PART>
<EVENT-PART seq="2/7" id="1a900994">DR-0019, cmd_adapter_digest_proof @ cli.rs:5958, REQ-TERM-5). An adapter author can only validate their idle-translation binary via a full real-claude spt-hosted PTY bringup + `spt send` while idle. We want the symmetric author-time tool.

BUILD: `spt adapter translate-proof &lt;adapter&gt; --event '&lt;EVENT…&gt;' [--session &lt;id&gt</EVENT-PART>
<EVENT-PART seq="3/7" id="1a900994">;]`
- Resolve the adapter's [message-idle-translation-binary] (mirror how digest-proof resolves [digest]).
- Spawn + feed it EXACTLY as the daemon does at idle-delivery: same init JSON-line then the EVENT JSON-line(s). CRITICAL: reuse the REAL daemon-side driver/framing code (the W2 Layer B/C translation-binary driver), do NOT reimplemen</EVENT-PART>
<EVENT-PART seq="4/7" id="1a900994">t the protocol in the CLI — digest-proof's whole value is it runs the REAL extractor; a drifting reimpl would prove the wrong thing.
- Read stdout, print the emitted {key}/{text}/{delay_ms} command stream in author-readable form; surface parse errors, dropped/invalid records, and nonzero exit.
- Gate like digest-proof: nonzero exit if </EVENT-PART>
<EVENT-PART seq="5/7" id="1a900994">the binary errors / emits nothing / unparseable.
- Fill template keys ({id}/{session_id}) the same way digest-proof does (--session to pin).
- SCOPE: EMIT half only (author-time, no live session). It does NOT exercise the daemon's atomic PTY apply — say so in the --help so it doesn't overstate (apply stays covered by the W2 int gate). </EVENT-PART>
<EVENT-PART seq="6/7" id="1a900994">

TRACEABLE: new requirement → add a REQ (e.g. REQ-ADAPTER-TRANSLATE-PROOF) to traceable-reqs.toml FIRST, then satisfy (doc+impl+unit; an int if you can drive a sample binary). Branch off origin/main d263a94 (NOT the operator's worktree, they're on c84daff). Docs-drift gate: regen the CLI reference (xtask gen), keep M#/W#/REQ out of th</EVENT-PART>
<EVENT-PART seq="7/7" id="1a900994">e clap help.

Ground in digest-proof's shape (cli.rs:5949-6026) + the ADR-0022 binary protocol before you design the exact arg surface; ping me your JIT plan or just build it and I'll gate. No rush — fold it whenever; v0.13.0 is out.</EVENT-PART></event>
</task-notification>


New dispatch from doyle: v0.13.x DX follow-on (non-blocking, v0.13.0 already shipped). Build `spt adapter translate-proof <adapter> --event '<EVENT…>'` — author-time EMIT-half proof for `[message-idle-translation-binary]`, symmetric to `spt adapter digest-proof`. New REQ first, reuse the REAL daemon driver (no protocol reimpl), branch off origin/main d263a94.

Grounding before design (as doyle directed). Set up worktree + read the referenced code:


Bash

git fetch origin 2>&1 | tail -3; echo "=== origin/main ==="; git rev-parse origin/main; echo "=== create worktree off d263a94 ==="; git worktree add -b feat/translate-proof ../spt-core-translate-proof d263a94 2>&1 | tail -4; git -C ../spt-core-translate-proof log --oneline -1

Worktree off origin/main d263a94


=== origin/main ===
d263a940b12fa8d0411c25f45d816acfa117e0dc
=== create worktree off d263a94 ===
Preparing worktree (new branch 'feat/translate-proof')
HEAD is now at d263a94 release: v0.13.0 — bump 0.12.1->0.13.0 + changelog + regen CLI reference
d263a94 release: v0.13.0 — bump 0.12.1->0.13.0 + changelog + regen CLI reference


Worktree up: `feat/translate-proof` @ d263a94 (= v0.13.0). Grounding — read digest-proof shape + locate the real translation driver + ADR-0022:


5949	/// `spt adapter digest-proof <adapter> [--sample <log>] [--session <id>]` — run
5950	/// the adapter's `[digest]` extractor over a sample and surface what it produced
5951	/// and what it dropped (ADR-0019 §diagnostics, REQ-TERM-5). The author-time half
5952	/// of the shared skip-diagnostic engine (the daemon emits the same at runtime),
5953	/// so it fills the same `{id}`/`{session_id}` keys the daemon does — a
5954	/// `{session_id}`-templated extractor (the published shape) proofs here exactly
5955	/// as it runs live. Exit 1 when any line drops or nothing parses — a
5956	/// deliberately-broken extractor never reads as a silent empty.
5957	// [impl->REQ-TERM-5]
5958	fn cmd_adapter_digest_proof(
5959	    adapters: &std::path::Path,
5960	    option: &str,
5961	    sample: Option<&str>,
5962	    session: Option<&str>,
5963	) -> i32 {
5964	    use spt_runtime::registry;
5965	    use std::time::Duration;
5966
5967	    // The record's `source_dir` is the adapter install dir — where a `.spt`
5968	    // ships its extractor binary; resolve the bare program there before PATH so
5969	    // proof runs exactly as the daemon does (REQ-INSTALL-11).
5970	    let (install_dir, manifest) = match registry::resolve_option(adapters, option) {
5971	        Ok((r, m)) => (std::path::PathBuf::from(r.source_dir), m),
5972	        Err(e) => {
5973	            eprintln!("DIGEST_PROOF_FAIL:{option}: {e}");
5974	            return 1;
5975	        }
5976	    };
5977	    let Some(declared) = manifest.digest.clone() else {
5978	        eprintln!("DIGEST_PROOF_NO_SECTION:{option}: adapter declares no [digest] extractor");
5979	        return 2;
5980	    };
5981
5982	    // Force the sample as the source when provided; otherwise use the declared
5983	    // source (which is `{source}`-resolved below from the same key set).
5984	    let effective = match sample {
5985	        Some(s) => spt_runtime::manifest::Digest {
5986	            source: Some(s.to_string()),
5987	            ..declared.clone()
5988	        },
5989	        None => declared.clone(),
5990	    };
5991	    // Fill the SAME substitution keys the daemon fills at runtime
5992	    // (`spt_daemon::digest` activity-span: `{id}` + `{session_id}`) so proof is
5993	    // faithful — an extractor that proofs here works at runtime, and the
5994	    // published `{session_id}`-templated shape is runnable. `{source}` is added
5995	    // by `extract_digest` from the resolved/forced source. The session id is a
5996	    // placeholder unless `--session` pins one (the daemon supplies the live id).
5997	    let proof_session = session.unwrap_or("proof-session");
5998	    let mut keys = std::collections::BTreeMap::new();
5999	    keys.insert("id".to_string(), option.to_string());
6000	    keys.insert("session_id".to_string(), proof_session.to_string());
6001	    let lines = match spt_live::extract_digest(
6002	        &effective,
6003	        manifest.history.as_ref(),
6004	        &keys,
6005	        Duration::from_secs(30),
6006	        Some(install_dir.as_path()),
6007	    ) {
6008	        Ok(l) => l,
6009	        Err(e) => {
6010	            eprintln!("DIGEST_PROOF_EXTRACT_FAIL:{option}: {e}");
6011	            if sample.is_none() {
6012	                eprintln!("  tip: pass --sample <log> to run against a real session log");
6013	            }
6014	            eprintln!(
6015	                "  tip: digest-proof fills {{id}} and {{session_id}} (use --session <id> to pin \
6016	                 the session id); other keys are runtime-only and won't resolve here"
6017	            );
6018	            return 1;
6019	        }
6020	    };
6021
6022	    let config = spt_daemon::resolve_config(Some(&declared), &spt_daemon::DigestOverride::default());
6023	    let (digest, diag) =
6024	        spt_term::project_lines_diagnosed(lines.iter().map(String::as_str), &config);
6025
6026	    println!("=== digest-proof: {option} ===");
6027	    println!("parsed {} record(s), dropped {}", diag.parsed, diag.drop_count());
6028	    println!("\n--- parsed records ---");
6029	    for l in &lines {
6030	        if spt_term::record_to_tagged_result(l).is_ok() {
6031	            println!("  {l}");
6032	        }
6033	    }
6034	    println!("\n--- rendered digest ---");
6035	    let rendered = spt_daemon::render_digest(&digest);
6036	    if rendered.trim().is_empty() {
6037	        println!("  (empty)");
6038	    } else {

/// `spt adapter digest-proof <adapter> [--sample <log>] [--session <id>]` — run
/// the adapter's `[digest]` extractor over a sample and surface what it produced
/// and what it dropped (ADR-0019 §diagnostics, REQ-TERM-5). The author-time half
/// of the shared skip-diagnostic engine (the daemon emits the same at runtime),
/// so it fills the same `{id}`/`{session_id}` keys the daemon does — a
/// `{session_id}`-templated extractor (the published shape) proofs here exactly
/// as it runs live. Exit 1 when any line drops or nothing parses — a
/// deliberately-broken extractor never reads as a silent empty.
// [impl->REQ-TERM-5]
fn cmd_adapter_digest_proof(
    adapters: &std::path::Path,
    option: &str,
    sample: Option<&str>,
    session: Option<&str>,
) -> i32 {
    use spt_runtime::registry;
    use std::time::Duration;

    // The record's `source_dir` is the adapter install dir — where a `.spt`
    // ships its extractor binary; resolve the bare program there before PATH so
    // proof runs exactly as the daemon does (REQ-INSTALL-11).
    let (install_dir, manifest) = match registry::resolve_option(adapters, option) {
        Ok((r, m)) => (std::path::PathBuf::from(r.source_dir), m),
        Err(e) => {
            eprintln!("DIGEST_PROOF_FAIL:{option}: {e}");
            return 1;
        }
    };
    let Some(declared) = manifest.digest.clone() else {
        eprintln!("DIGEST_PROOF_NO_SECTION:{option}: adapter declares no [digest] extractor");
        return 2;
    };

    // Force the sample as the source when provided; otherwise use the declared
    // source (which is `{source}`-resolved below from the same key set).
    let effective = match sample {
        Some(s) => spt_runtime::manifest::Digest {
            source: Some(s.to_string()),
            ..declared.clone()
        },
        None => declared.clone(),
    };
    // Fill the SAME substitution keys the daemon fills at runtime
    // (`spt_daemon::digest` activity-span: `{id}` + `{session_id}`) so proof is
    // faithful — an extractor that proofs here works at runtime, and the
    // published `{session_id}`-templated shape is runnable. `{source}` is added
    // by `extract_digest` from the resolved/forced source. The session id is a
    // placeholder unless `--session` pins one (the daemon supplies the live id).
    let proof_session = session.unwrap_or("proof-session");
    let mut keys = std::collections::BTreeMap::new();
    keys.insert("id".to_string(), option.to_string());
    keys.insert("session_id".to_string(), proof_session.to_string());
    let lines = match spt_live::extract_digest(
        &effective,
        manifest.history.as_ref(),
        &keys,
        Duration::from_secs(30),
        Some(install_dir.as_path()),
    ) {
        Ok(l) => l,
        Err(e) => {
            eprintln!("DIGEST_PROOF_EXTRACT_FAIL:{option}: {e}");
            if sample.is_none() {
                eprintln!("  tip: pass --sample <log> to run against a real session log");
            }
            eprintln!(
                "  tip: digest-proof fills {{id}} and {{session_id}} (use --session <id> to pin \
                 the session id); other keys are runtime-only and won't resolve here"
            );
            return 1;
        }
    };

    let config = spt_daemon::resolve_config(Some(&declared), &spt_daemon::DigestOverride::default());
    let (digest, diag) =
        spt_term::project_lines_diagnosed(lines.iter().map(String::as_str), &config);

    println!("=== digest-proof: {option} ===");
    println!("parsed {} record(s), dropped {}", diag.parsed, diag.drop_count());
    println!("\n--- parsed records ---");
    for l in &lines {
        if spt_term::record_to_tagged_result(l).is_ok() {
            println!("  {l}");
        }
    }
    println!("\n--- rendered digest ---");
    let rendered = spt_daemon::render_digest(&digest);
    if rendered.trim().is_empty() {
        println!("  (empty)");
    } else {


digest-proof's pattern is clear: resolve via `registry::resolve_option`, read the manifest section, fill `{id}`/`{session_id}`, call the **shared real engine** (`spt_live::extract_digest`). For translate-proof I mirror that against the real translation driver. Read it + the manifest section + ADR-0022:


1	//! ADR-0022 idle-delivery **translation-binary** driver (v0.13.0 W2 Layer B).
2	//!
3	//! An adapter may declare a `[message-idle-translation-binary]` (see
4	//! [`spt_runtime::manifest::IdleTranslationBinary`]). It is a **pure
5	//! stdin→stdout JSON-lines filter** spt-core lifecycle-manages: spt-core feeds it
6	//! the inbound `<EVENT>` feed plus content-free user-activity pings, and reads
7	//! back keystroke-commands which spt-core (NOT the binary) applies to the
8	//! broker-held PTY. The binary never touches the PTY — that is what lets injection
9	//! coexist atomically with a live `spt rc` controller (the W1 coordination,
10	//! REQ-HAZARD-INJECT-CONTROL-COEXIST). This module owns only the **process + wire**
11	//! seam (spawn, the two JSON-lines protocols, the send-keys byte map, and a
12	//! bounded reap); the atomic PTY apply + controller-input buffering lives in the
13	//! broker (Layer C).
14	//!
15	//! **Privacy invariant (doyle gate #1):** the `input` ping is CONTENT-FREE — the
16	//! operator's PTY keystrokes are never duplicated into the binary's stdin. See
17	//! [`ToBinary::Input`].
18	//!
19	//! **No-zombie invariant (doyle gate #2):** dropping a [`TranslationChild`] closes
20	//! its stdin (EOF → a well-behaved filter exits), waits a bounded window, then
21	//! kills + reaps — a session-down never leaks a translation child.
22	//
23	// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
24
25	use std::io::{self, BufRead, BufReader, Write};
26	use std::process::{Child, ChildStdin, Command, Stdio};
27	use std::sync::mpsc::Sender;
28	use std::sync::Mutex;
29	use std::thread::{self, JoinHandle};
30	use std::time::{Duration, Instant};
31
32	use serde::{Deserialize, Serialize};
33
34	/// Bounded window a dropped child gets to exit on its own (after stdin EOF)
35	/// before it is killed — the no-zombie reap bound (doyle gate #2). Kept short:
36	/// a translation filter has nothing to flush on EOF.
37	const REAP_GRACE: Duration = Duration::from_millis(500);
38	/// Poll interval while waiting out [`REAP_GRACE`].
39	const REAP_POLL: Duration = Duration::from_millis(20);
40
41	/// One stdin line, spt-core → binary (internally tagged on `type`).
42	#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43	#[serde(tag = "type", rename_all = "snake_case")]
44	pub enum ToBinary {
45	    /// First line: identity. `{"type":"init","endpoint_id":…,"node":…}`.
46	    Init { endpoint_id: String, node: String },
47	    /// Per inbound message: the whole ADR-0020 `<EVENT>` envelope, verbatim.
48	    /// `{"type":"event","envelope":"<EVENT…>"}`.
49	    Event { envelope: String },
50	    /// A CONTENT-FREE ping each time the operator types, so the binary can track
51	    /// user-idle state for its own idle-gated buffering. `{"type":"input"}`.
52	    /// **The PTY input bytes are NEVER included** (doyle gate #1, privacy).
53	    Input,
54	}
55
56	/// One stdout line, binary → spt-core: a single keystroke-command. **Untagged** —
57	/// each line carries exactly one command shape (no discriminator field), matching
58	/// the ADR-0022 wire (`{key}` / `{text}` / `{delay_ms}` / `{commit}`). Unknown
59	/// extra fields on a recognized shape are ignored (forward-compat); a line
60	/// matching no shape is a parse error the reader logs and skips (one bad line
61	/// never kills the binary).
62	#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
63	#[serde(untagged)]
64	pub enum KeyCmd {
65	    /// A named key or `ctrl+<x>` chord → mapped to PTY bytes by [`key_to_bytes`].
66	    Key { key: String },
67	    /// Literal text typed verbatim into the PTY (the message payload).
68	    Text { text: String },
69	    /// Pause this many milliseconds between keystrokes (the choreography's stash
70	    /// settle, e.g. `{"delay_ms":50}`). Carried in the sequence, applied by the
71	    /// broker's atomic applier.
72	    Delay { delay_ms: u64 },
73	    /// Sequence terminator (doyle OPT-1 ruling, pending confirm): the binary
74	    /// emits `{"commit":true}` after the last keystroke of its response to an
75	    /// event, so the broker knows the atomic sequence is complete and can flush
76	    /// the buffered controller input race-free. The degenerate v0.11.0 raw-inject
77	    /// is `{text:payload}{key:"enter"}{commit:true}`.
78	    Commit { commit: bool },
79	}
80
81	/// Map a `{key}` command name to the raw PTY bytes (the "send-keys" API).
82	///
83	/// Returns `None` for an unknown key name — skipped, never a panic (the opaque
84	/// adapter-template stance: a bad key silences itself rather than wedging the
85	/// applier). `Delay`/`Commit` are not keys and are handled by the applier, not
86	/// here.
87	///
88	/// Vocabulary: the common control keys, the four arrows + Home/End/PageUp/
89	/// PageDown/Delete/Insert as xterm CSI sequences, `ctrl+<letter|char>` chords, and
90	/// a single literal character passed through as itself (so `{key:"y"}` works).
91	// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
92	pub fn key_to_bytes(key: &str) -> Option<Vec<u8>> {
93	    let k = key.trim();
94	    let lower = k.to_ascii_lowercase();
95	    let named: &[u8] = match lower.as_str() {
96	        "enter" | "return" | "cr" => b"\r",
97	        "newline" | "lf" => b"\n",
98	        "tab" => b"\t",
99	        "esc" | "escape" => b"\x1b",
100	        "backspace" | "bs" => b"\x7f",
101	        "space" => b" ",
102	        "up" => b"\x1b[A",
103	        "down" => b"\x1b[B",
104	        "right" => b"\x1b[C",
105	        "left" => b"\x1b[D",
106	        "home" => b"\x1b[H",
107	        "end" => b"\x1b[F",
108	        "pageup" | "pgup" => b"\x1b[5~",
109	        "pagedown" | "pgdn" => b"\x1b[6~",
110	        "delete" | "del" => b"\x1b[3~",
111	        "insert" | "ins" => b"\x1b[2~",
112	        _ => {
113	            // ctrl+<x>: a control byte. `ctrl+s` → 0x13, `ctrl+space` → NUL,
114	            // `ctrl+[` → ESC, etc. Only single-char chords are mapped.
115	            if let Some(rest) = lower.strip_prefix("ctrl+").or_else(|| lower.strip_prefix("c-")) {
116	                return ctrl_byte(rest).map(|b| vec![b]);
117	            }
118	            // A single literal character → its own bytes (e.g. `{key:"y"}`).
119	            let mut chars = k.chars();
120	            if let (Some(c), None) = (chars.next(), chars.next()) {
121	                return Some(c.to_string().into_bytes());
122	            }
123	            return None;
124	        }
125	    };
126	    Some(named.to_vec())
127	}
128
129	/// The control byte for a `ctrl+<x>` chord. ASCII letters map to `0x01..=0x1a`;
130	/// the conventional symbol chords (`@ [ \ ] ^ _ space`) map to `0x00..=0x1f`.
131	fn ctrl_byte(rest: &str) -> Option<u8> {
132	    if rest == "space" {
133	        return Some(0x00);
134	    }
135	    let mut chars = rest.chars();
136	    let (c, None) = (chars.next()?, chars.next()) else {
137	        return None;
138	    };
139	    let b = c as u32;
140	    match c {
141	        'a'..='z' => Some((b as u8) & 0x1f),
142	        'A'..='Z' => Some((b as u8) & 0x1f),
143	        '@' => Some(0x00),
144	        '[' => Some(0x1b),
145	        '\\' => Some(0x1c),
146	        ']' => Some(0x1d),
147	        '^' => Some(0x1e),
148	        '_' => Some(0x1f),
149	        '?' => Some(0x7f),
150	        _ => None,
151	    }
152	}
153
154	/// The per-session controller-input buffer + inject-floor flag (W2 Layer C,
155	/// REQ-HAZARD-INJECT-CONTROL-COEXIST / the W1 coordination mechanism).
156	///
157	/// While the floor is **held** (an injected keystroke sequence is mid-flight,
158	/// between a `{type:event}` dispatch and its `{commit}`), the operator's
159	/// keystrokes from `dispatch_input` are **buffered here** instead of hitting the
160	/// PTY; on commit (or the commit deadline) the buffer is flushed to the PTY
161	/// **after** the injected bytes. So a stash/restore choreography
162	/// (`ctrl+s … ctrl+s`) can never be clobbered by an operator keystroke landing
163	/// mid-sequence. spt-core owning every PTY write is what makes this possible.
164	///
165	/// Pure state (no I/O) so it is unit-testable; the broker holds it behind a
166	/// `Mutex` and does the actual `write_input` off-lock (the W1 lesson: never block
167	/// a PTY write under a lock the hot input path needs).
168	#[derive(Debug, Default)]
169	pub struct InjectFloor {
170	    held: bool,
171	    buffer: Vec<u8>,
172	}
173
174	impl InjectFloor {
175	    /// Open the floor for a new injected sequence (clears any stale buffer — a
176	    /// prior sequence always drains to empty before release, so this is belt-and-
177	    /// braces). Subsequent operator input buffers until [`take_or_release`].
178	    ///
179	    /// [`take_or_release`]: InjectFloor::take_or_release
180	    pub fn open(&mut self) {
181	        self.held = true;
182	        self.buffer.clear();
183	    }
184
185	    /// Whether the floor is currently held (a sequence is mid-flight).
186	    pub fn is_held(&self) -> bool {
187	        self.held
188	    }
189
190	    /// Route one operator-input chunk: if the floor is held, buffer it and return
191	    /// `true` (the caller must NOT write to the PTY now — it is flushed after the
192	    /// sequence); if free, return `false` (the caller writes through its normal
193	    /// path).
194	    pub fn buffer_if_held(&mut self, bytes: &[u8]) -> bool {
195	        if self.held {
196	            self.buffer.extend_from_slice(bytes);
197	            true
198	        } else {
199	            false
200	        }
201	    }
202
203	    /// One flush step — call in a loop after `{commit}` / the deadline. If the
204	    /// buffer holds bytes, take them to write **off-lock** while the floor STAYS
205	    /// held (so input arriving mid-flush keeps buffering → strict order); when the
206	    /// buffer is empty, RELEASE the floor and return `None`. Looping until `None`
207	    /// drains everything and leaves the floor free.
208	    pub fn take_or_release(&mut self) -> Option<Vec<u8>> {
209	        if self.buffer.is_empty() {
210	            self.held = false;
211	            None
212	        } else {
213	            Some(std::mem::take(&mut self.buffer))
214	        }
215	    }
216	}
217
218	/// A live translation-binary child process: its piped stdin (for [`ToBinary`]
219	/// lines) plus a reader thread that parses [`KeyCmd`] lines off its stdout and
220	/// forwards them on the caller-supplied channel. The broker owns the receiving
221	/// end and the atomic PTY apply.
222	pub struct TranslationChild {
223	    /// `None` once reaped — behind a `Mutex` so [`terminate`](Self::terminate) is
224	    /// callable through a shared `Arc` (the broker shares the child between the
225	    /// inject worker, which kills it on FAULT, and `dispatch_input`, which fires
226	    /// content-free pings — Layer C).
227	    child: Mutex<Option<Child>>,
228	    /// `None` after [`close_stdin`](Self::close_stdin) / reap has taken it.
229	    stdin: Mutex<Option<ChildStdin>>,
230	    _reader: JoinHandle<()>,
231	    pid: Option<u32>,
232	}
233
234	impl TranslationChild {
235	    /// Spawn `path` as a stdin→stdout filter, forwarding each parsed [`KeyCmd`] on
236	    /// `cmds`. stderr is inherited so an adapter author can see the binary's own
237	    /// diagnostics. Fails closed: a missing/unspawnable binary returns the OS
238	    /// error (the broker logs it and falls back to the degenerate raw inject).
239	    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
240	    pub fn spawn(path: &str, cmds: Sender<KeyCmd>) -> io::Result<Self> {
241	        let mut child = Command::new(path)
242	            .stdin(Stdio::piped())
243	            .stdout(Stdio::piped())
244	            .stderr(Stdio::inherit())
245	            .spawn()?;
246	        let pid = child.id().into();
247	        let stdout = child
248	            .stdout
249	            .take()
250	            .expect("stdout piped at spawn");
251	        let stdin = child.stdin.take().expect("stdin piped at spawn");
252	        let reader = thread::spawn(move || {
253	            let buf = BufReader::new(stdout);
254	            for line in buf.lines() {
255	                let Ok(line) = line else { break }; // child stdout closed
256	                let trimmed = line.trim();
257	                if trimmed.is_empty() {
258	                    continue;
259	                }
260	                match serde_json::from_str::<KeyCmd>(trimmed) {
261	                    Ok(cmd) => {
262	                        if cmds.send(cmd).is_err() {
263	                            break; // broker dropped the receiver — stop reading
264	                        }
265	                    }
266	                    Err(e) => {
267	                        eprintln!("TRANSLATION_BIN_BAD_LINE: {e}: {trimmed}");
268	                    }
269	                }
270	            }
271	        });
272	        Ok(TranslationChild {
273	            child: Mutex::new(Some(child)),
274	            stdin: Mutex::new(Some(stdin)),
275	            _reader: reader,
276	            pid,
277	        })
278	    }
279
280	    /// The child's OS pid (for tests / introspection / the no-zombie assertion).
281	    pub fn pid(&self) -> Option<u32> {
282	        self.pid
283	    }
284
285	    /// Write one [`ToBinary`] line to the child's stdin (newline-terminated,
286	    /// flushed). A closed stdin (post-shutdown) is a no-op `Ok`.
287	    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
288	    pub fn send(&self, msg: &ToBinary) -> io::Result<()> {
289	        let line = serde_json::to_string(msg).map_err(io::Error::other)?;
290	        let mut guard = self.stdin.lock().unwrap();
291	        if let Some(w) = guard.as_mut() {
292	            w.write_all(line.as_bytes())?;
293	            w.write_all(b"\n")?;
294	            w.flush()?;
295	        }
296	        Ok(())
297	    }
298
299	    /// Drop the child's stdin handle, sending EOF — a well-behaved filter then
300	    /// exits on its own. Idempotent.
301	    fn close_stdin(&self) {
302	        let _ = self.stdin.lock().unwrap().take();
303	    }
304
305	    /// Bounded reap (doyle gate #2): close stdin (EOF), give the child
306	    /// [`REAP_GRACE`] to exit, then kill + `wait` it so no zombie outlives a
307	    /// session-down or a FAULT. Best-effort + idempotent — a child already reaped
308	    /// (`None`) returns immediately, and every step swallows errors (a child that
309	    /// already exited is fine). Callable through a shared `Arc` (the worker faults
310	    /// the binary; Drop reaps on teardown).
311	    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
312	    pub fn terminate(&self) {
313	        self.close_stdin();
314	        let mut guard = self.child.lock().unwrap();
315	        let Some(child) = guard.as_mut() else {
316	            return; // already reaped
317	        };
318	        let deadline = Instant::now() + REAP_GRACE;
319	        loop {
320	            match child.try_wait() {
321	                Ok(Some(_)) => {
322	                    *guard = None;
323	                    return; // exited on its own after EOF
324	                }
325	                Ok(None) => {
326	                    if Instant::now() >= deadline {
327	                        break;
328	                    }
329	                    thread::sleep(REAP_POLL);
330	                }
331	                Err(_) => break,
332	            }
333	        }
334	        let _ = child.kill();
335	        let _ = child.wait();
336	        *guard = None;
337	    }
338	}
339
340	impl Drop for TranslationChild {
341	    fn drop(&mut self) {
342	        self.terminate();
343	    }
344	}
345
346	#[cfg(test)]
347	mod tests {
348	    use super::*;
349
350	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the stdin protocol serializes to the
351	    // exact ADR-0022 JSON-lines wire — and the `input` ping is CONTENT-FREE (doyle
352	    // gate #1: it carries `type` ONLY, no PTY bytes can ride it).
353	    #[test]
354	    fn to_binary_serializes_to_wire_and_input_is_content_free() {
355	        assert_eq!(
356	            serde_json::to_string(&ToBinary::Init {
357	                endpoint_id: "doyle".into(),
358	                node: "n1".into(),
359	            })
360	            .unwrap(),
361	            r#"{"type":"init","endpoint_id":"doyle","node":"n1"}"#
362	        );
363	        assert_eq!(
364	            serde_json::to_string(&ToBinary::Event {
365	                envelope: "<EVENT type=\"msg\"/>".into(),
366	            })
367	            .unwrap(),
368	            r#"{"type":"event","envelope":"<EVENT type=\"msg\"/>"}"#
369	        );
370	        let ping = serde_json::to_string(&ToBinary::Input).unwrap();
371	        assert_eq!(ping, r#"{"type":"input"}"#);
372	        // Privacy invariant, structurally: the only field is `type`.
373	        let v: serde_json::Value = serde_json::from_str(&ping).unwrap();
374	        assert_eq!(
375	            v.as_object().unwrap().keys().collect::<Vec<_>>(),
376	            vec!["type"],
377	            "the input ping must carry no field other than `type` — no PTY content"
378	        );
379	    }
380
381	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] each stdout shape parses to its
382	    // KeyCmd variant; an unknown extra field on a recognized shape is ignored
383	    // (forward-compat); a line matching no shape errors (the reader skips it).
384	    #[test]
385	    fn key_cmd_parses_each_wire_shape() {
386	        assert_eq!(
387	            serde_json::from_str::<KeyCmd>(r#"{"key":"ctrl+s"}"#).unwrap(),
388	            KeyCmd::Key { key: "ctrl+s".into() }
389	        );
390	        assert_eq!(
391	            serde_json::from_str::<KeyCmd>(r#"{"text":"hello"}"#).unwrap(),
392	            KeyCmd::Text { text: "hello".into() }
393	        );
394	        assert_eq!(
395	            serde_json::from_str::<KeyCmd>(r#"{"delay_ms":50}"#).unwrap(),
396	            KeyCmd::Delay { delay_ms: 50 }
397	        );
398	        assert_eq!(
399	            serde_json::from_str::<KeyCmd>(r#"{"commit":true}"#).unwrap(),
400	            KeyCmd::Commit { commit: true }
401	        );
402	        // forward-compat: an unknown field alongside a known shape still parses.
403	        assert_eq!(
404	            serde_json::from_str::<KeyCmd>(r#"{"key":"enter","repeat":3}"#).unwrap(),
405	            KeyCmd::Key { key: "enter".into() }
406	        );
407	        // a line matching no shape is an error.
408	        assert!(serde_json::from_str::<KeyCmd>(r#"{"bogus":1}"#).is_err());
409	    }
410
411	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the inject floor: while held, operator
412	    // input buffers (not written); take_or_release drains in order then frees the
413	    // floor; input arriving mid-flush keeps buffering so order is preserved; a free
414	    // floor passes input straight through.
415	    #[test]
416	    fn inject_floor_buffers_while_held_then_drains_in_order() {
417	        let mut f = InjectFloor::default();
418	        // free floor → input passes through (caller writes), nothing buffered.
419	        assert!(!f.buffer_if_held(b"a"), "free floor does not buffer");
420	        assert!(!f.is_held());
421
422	        // open → operator input now buffers.
423	        f.open();
424	        assert!(f.is_held());
425	        assert!(f.buffer_if_held(b"hel"), "held floor buffers");
426	        assert!(f.buffer_if_held(b"lo"), "held floor keeps buffering");
427
428	        // flush step 1 takes the buffered bytes (floor STAYS held) ...
429	        assert_eq!(f.take_or_release(), Some(b"hello".to_vec()));
430	        assert!(f.is_held(), "floor stays held mid-flush so order is preserved");
431	        // ... input arriving mid-flush keeps buffering ...
432	        assert!(f.buffer_if_held(b"!"), "mid-flush input still buffers");
433	        assert_eq!(f.take_or_release(), Some(b"!".to_vec()));
434	        // ... and the empty step RELEASES the floor.
435	        assert_eq!(f.take_or_release(), None);
436	        assert!(!f.is_held(), "drained empty → floor released");
437	        assert!(!f.buffer_if_held(b"z"), "released floor passes through again");
438	    }
439
440	    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the send-keys byte map: named keys,
441	    // the CC choreography chord `ctrl+s` → 0x13, arrows as xterm CSI, a single
442	    // literal char passes through, and an unknown key is skipped (None, no panic).
443	    #[test]
444	    fn key_to_bytes_send_keys_map() {
445	        assert_eq!(key_to_bytes("enter"), Some(b"\r".to_vec()));
446	        assert_eq!(key_to_bytes("tab"), Some(b"\t".to_vec()));
447	        assert_eq!(key_to_bytes("esc"), Some(vec![0x1b]));
448	        assert_eq!(key_to_bytes("backspace"), Some(vec![0x7f]));
449	        // the CC stash/restore chord.
450	        assert_eq!(key_to_bytes("ctrl+s"), Some(vec![0x13]));
451	        assert_eq!(key_to_bytes("ctrl+a"), Some(vec![0x01]));
452	        assert_eq!(key_to_bytes("ctrl+b"), Some(vec![0x02])); // the rc detach byte
453	        // arrows as xterm CSI.
454	        assert_eq!(key_to_bytes("up"), Some(b"\x1b[A".to_vec()));
455	        assert_eq!(key_to_bytes("left"), Some(b"\x1b[D".to_vec()));
456	        // a single literal char passes through.
457	        assert_eq!(key_to_bytes("y"), Some(b"y".to_vec()));
458	        // case-insensitive named keys.
459	        assert_eq!(key_to_bytes("Enter"), Some(b"\r".to_vec()));
460	        // unknown multi-char key → skipped.
461	        assert_eq!(key_to_bytes("nope-not-a-key"), None);
462	    }
463	}
464

//! ADR-0022 idle-delivery **translation-binary** driver (v0.13.0 W2 Layer B).
//!
//! An adapter may declare a `[message-idle-translation-binary]` (see
//! [`spt_runtime::manifest::IdleTranslationBinary`]). It is a **pure
//! stdin→stdout JSON-lines filter** spt-core lifecycle-manages: spt-core feeds it
//! the inbound `<EVENT>` feed plus content-free user-activity pings, and reads
//! back keystroke-commands which spt-core (NOT the binary) applies to the
//! broker-held PTY. The binary never touches the PTY — that is what lets injection
//! coexist atomically with a live `spt rc` controller (the W1 coordination,
//! REQ-HAZARD-INJECT-CONTROL-COEXIST). This module owns only the **process + wire**
//! seam (spawn, the two JSON-lines protocols, the send-keys byte map, and a
//! bounded reap); the atomic PTY apply + controller-input buffering lives in the
//! broker (Layer C).
//!
//! **Privacy invariant (doyle gate #1):** the `input` ping is CONTENT-FREE — the
//! operator's PTY keystrokes are never duplicated into the binary's stdin. See
//! [`ToBinary::Input`].
//!
//! **No-zombie invariant (doyle gate #2):** dropping a [`TranslationChild`] closes
//! its stdin (EOF → a well-behaved filter exits), waits a bounded window, then
//! kills + reaps — a session-down never leaks a translation child.
//
// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]

use std::io::{self, BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::Sender;
use std::sync::Mutex;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};

/// Bounded window a dropped child gets to exit on its own (after stdin EOF)
/// before it is killed — the no-zombie reap bound (doyle gate #2). Kept short:
/// a translation filter has nothing to flush on EOF.
const REAP_GRACE: Duration = Duration::from_millis(500);
/// Poll interval while waiting out [`REAP_GRACE`].
const REAP_POLL: Duration = Duration::from_millis(20);

/// One stdin line, spt-core → binary (internally tagged on `type`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToBinary {
    /// First line: identity. `{"type":"init","endpoint_id":…,"node":…}`.
    Init { endpoint_id: String, node: String },
    /// Per inbound message: the whole ADR-0020 `<EVENT>` envelope, verbatim.
    /// `{"type":"event","envelope":"<EVENT…>"}`.
    Event { envelope: String },
    /// A CONTENT-FREE ping each time the operator types, so the binary can track
    /// user-idle state for its own idle-gated buffering. `{"type":"input"}`.
    /// **The PTY input bytes are NEVER included** (doyle gate #1, privacy).
    Input,
}

/// One stdout line, binary → spt-core: a single keystroke-command. **Untagged** —
/// each line carries exactly one command shape (no discriminator field), matching
/// the ADR-0022 wire (`{key}` / `{text}` / `{delay_ms}` / `{commit}`). Unknown
/// extra fields on a recognized shape are ignored (forward-compat); a line
/// matching no shape is a parse error the reader logs and skips (one bad line
/// never kills the binary).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub enum KeyCmd {
    /// A named key or `ctrl+<x>` chord → mapped to PTY bytes by [`key_to_bytes`].
    Key { key: String },
    /// Literal text typed verbatim into the PTY (the message payload).
    Text { text: String },
    /// Pause this many milliseconds between keystrokes (the choreography's stash
    /// settle, e.g. `{"delay_ms":50}`). Carried in the sequence, applied by the
    /// broker's atomic applier.
    Delay { delay_ms: u64 },
    /// Sequence terminator (doyle OPT-1 ruling, pending confirm): the binary
    /// emits `{"commit":true}` after the last keystroke of its response to an
    /// event, so the broker knows the atomic sequence is complete and can flush
    /// the buffered controller input race-free. The degenerate v0.11.0 raw-inject
    /// is `{text:payload}{key:"enter"}{commit:true}`.
    Commit { commit: bool },
}

/// Map a `{key}` command name to the raw PTY bytes (the "send-keys" API).
///
/// Returns `None` for an unknown key name — skipped, never a panic (the opaque
/// adapter-template stance: a bad key silences itself rather than wedging the
/// applier). `Delay`/`Commit` are not keys and are handled by the applier, not
/// here.
///
/// Vocabulary: the common control keys, the four arrows + Home/End/PageUp/
/// PageDown/Delete/Insert as xterm CSI sequences, `ctrl+<letter|char>` chords, and
/// a single literal character passed through as itself (so `{key:"y"}` works).
// [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
pub fn key_to_bytes(key: &str) -> Option<Vec<u8>> {
    let k = key.trim();
    let lower = k.to_ascii_lowercase();
    let named: &[u8] = match lower.as_str() {
        "enter" | "return" | "cr" => b"\r",
        "newline" | "lf" => b"\n",
        "tab" => b"\t",
        "esc" | "escape" => b"\x1b",
        "backspace" | "bs" => b"\x7f",
        "space" => b" ",
        "up" => b"\x1b[A",
        "down" => b"\x1b[B",
        "right" => b"\x1b[C",
        "left" => b"\x1b[D",
        "home" => b"\x1b[H",
        "end" => b"\x1b[F",
        "pageup" | "pgup" => b"\x1b[5~",
        "pagedown" | "pgdn" => b"\x1b[6~",
        "delete" | "del" => b"\x1b[3~",
        "insert" | "ins" => b"\x1b[2~",
        _ => {
            // ctrl+<x>: a control byte. `ctrl+s` → 0x13, `ctrl+space` → NUL,
            // `ctrl+[` → ESC, etc. Only single-char chords are mapped.
            if let Some(rest) = lower.strip_prefix("ctrl+").or_else(|| lower.strip_prefix("c-")) {
                return ctrl_byte(rest).map(|b| vec![b]);
            }
            // A single literal character → its own bytes (e.g. `{key:"y"}`).
            let mut chars = k.chars();
            if let (Some(c), None) = (chars.next(), chars.next()) {
                return Some(c.to_string().into_bytes());
            }
            return None;
        }
    };
    Some(named.to_vec())
}

/// The control byte for a `ctrl+<x>` chord. ASCII letters map to `0x01..=0x1a`;
/// the conventional symbol chords (`@ [ \ ] ^ _ space`) map to `0x00..=0x1f`.
fn ctrl_byte(rest: &str) -> Option<u8> {
    if rest == "space" {
        return Some(0x00);
    }
    let mut chars = rest.chars();
    let (c, None) = (chars.next()?, chars.next()) else {
        return None;
    };
    let b = c as u32;
    match c {
        'a'..='z' => Some((b as u8) & 0x1f),
        'A'..='Z' => Some((b as u8) & 0x1f),
        '@' => Some(0x00),
        '[' => Some(0x1b),
        '\\' => Some(0x1c),
        ']' => Some(0x1d),
        '^' => Some(0x1e),
        '_' => Some(0x1f),
        '?' => Some(0x7f),
        _ => None,
    }
}

/// The per-session controller-input buffer + inject-floor flag (W2 Layer C,
/// REQ-HAZARD-INJECT-CONTROL-COEXIST / the W1 coordination mechanism).
///
/// While the floor is **held** (an injected keystroke sequence is mid-flight,
/// between a `{type:event}` dispatch and its `{commit}`), the operator's
/// keystrokes from `dispatch_input` are **buffered here** instead of hitting the
/// PTY; on commit (or the commit deadline) the buffer is flushed to the PTY
/// **after** the injected bytes. So a stash/restore choreography
/// (`ctrl+s … ctrl+s`) can never be clobbered by an operator keystroke landing
/// mid-sequence. spt-core owning every PTY write is what makes this possible.
///
/// Pure state (no I/O) so it is unit-testable; the broker holds it behind a
/// `Mutex` and does the actual `write_input` off-lock (the W1 lesson: never block
/// a PTY write under a lock the hot input path needs).
#[derive(Debug, Default)]
pub struct InjectFloor {
    held: bool,
    buffer: Vec<u8>,
}

impl InjectFloor {
    /// Open the floor for a new injected sequence (clears any stale buffer — a
    /// prior sequence always drains to empty before release, so this is belt-and-
    /// braces). Subsequent operator input buffers until [`take_or_release`].
    ///
    /// [`take_or_release`]: InjectFloor::take_or_release
    pub fn open(&mut self) {
        self.held = true;
        self.buffer.clear();
    }

    /// Whether the floor is currently held (a sequence is mid-flight).
    pub fn is_held(&self) -> bool {
        self.held
    }

    /// Route one operator-input chunk: if the floor is held, buffer it and return
    /// `true` (the caller must NOT write to the PTY now — it is flushed after the
    /// sequence); if free, return `false` (the caller writes through its normal
    /// path).
    pub fn buffer_if_held(&mut self, bytes: &[u8]) -> bool {
        if self.held {
            self.buffer.extend_from_slice(bytes);
            true
        } else {
            false
        }
    }

    /// One flush step — call in a loop after `{commit}` / the deadline. If the
    /// buffer holds bytes, take them to write **off-lock** while the floor STAYS
    /// held (so input arriving mid-flush keeps buffering → strict order); when the
    /// buffer is empty, RELEASE the floor and return `None`. Looping until `None`
    /// drains everything and leaves the floor free.
    pub fn take_or_release(&mut self) -> Option<Vec<u8>> {
        if self.buffer.is_empty() {
            self.held = false;
            None
        } else {
            Some(std::mem::take(&mut self.buffer))
        }
    }
}

/// A live translation-binary child process: its piped stdin (for [`ToBinary`]
/// lines) plus a reader thread that parses [`KeyCmd`] lines off its stdout and
/// forwards them on the caller-supplied channel. The broker owns the receiving
/// end and the atomic PTY apply.
pub struct TranslationChild {
    /// `None` once reaped — behind a `Mutex` so [`terminate`](Self::terminate) is
    /// callable through a shared `Arc` (the broker shares the child between the
    /// inject worker, which kills it on FAULT, and `dispatch_input`, which fires
    /// content-free pings — Layer C).
    child: Mutex<Option<Child>>,
    /// `None` after [`close_stdin`](Self::close_stdin) / reap has taken it.
    stdin: Mutex<Option<ChildStdin>>,
    _reader: JoinHandle<()>,
    pid: Option<u32>,
}

impl TranslationChild {
    /// Spawn `path` as a stdin→stdout filter, forwarding each parsed [`KeyCmd`] on
    /// `cmds`. stderr is inherited so an adapter author can see the binary's own
    /// diagnostics. Fails closed: a missing/unspawnable binary returns the OS
    /// error (the broker logs it and falls back to the degenerate raw inject).
    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
    pub fn spawn(path: &str, cmds: Sender<KeyCmd>) -> io::Result<Self> {
        let mut child = Command::new(path)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()?;
        let pid = child.id().into();
        let stdout = child
            .stdout
            .take()
            .expect("stdout piped at spawn");
        let stdin = child.stdin.take().expect("stdin piped at spawn");
        let reader = thread::spawn(move || {
            let buf = BufReader::new(stdout);
            for line in buf.lines() {
                let Ok(line) = line else { break }; // child stdout closed
                let trimmed = line.trim();
                if trimmed.is_empty() {
                    continue;
                }
                match serde_json::from_str::<KeyCmd>(trimmed) {
                    Ok(cmd) => {
                        if cmds.send(cmd).is_err() {
                            break; // broker dropped the receiver — stop reading
                        }
                    }
                    Err(e) => {
                        eprintln!("TRANSLATION_BIN_BAD_LINE: {e}: {trimmed}");
                    }
                }
            }
        });
        Ok(TranslationChild {
            child: Mutex::new(Some(child)),
            stdin: Mutex::new(Some(stdin)),
            _reader: reader,
            pid,
        })
    }

    /// The child's OS pid (for tests / introspection / the no-zombie assertion).
    pub fn pid(&self) -> Option<u32> {
        self.pid
    }

    /// Write one [`ToBinary`] line to the child's stdin (newline-terminated,
    /// flushed). A closed stdin (post-shutdown) is a no-op `Ok`.
    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
    pub fn send(&self, msg: &ToBinary) -> io::Result<()> {
        let line = serde_json::to_string(msg).map_err(io::Error::other)?;
        let mut guard = self.stdin.lock().unwrap();
        if let Some(w) = guard.as_mut() {
            w.write_all(line.as_bytes())?;
            w.write_all(b"\n")?;
            w.flush()?;
        }
        Ok(())
    }

    /// Drop the child's stdin handle, sending EOF — a well-behaved filter then
    /// exits on its own. Idempotent.
    fn close_stdin(&self) {
        let _ = self.stdin.lock().unwrap().take();
    }

    /// Bounded reap (doyle gate #2): close stdin (EOF), give the child
    /// [`REAP_GRACE`] to exit, then kill + `wait` it so no zombie outlives a
    /// session-down or a FAULT. Best-effort + idempotent — a child already reaped
    /// (`None`) returns immediately, and every step swallows errors (a child that
    /// already exited is fine). Callable through a shared `Arc` (the worker faults
    /// the binary; Drop reaps on teardown).
    // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
    pub fn terminate(&self) {
        self.close_stdin();
        let mut guard = self.child.lock().unwrap();
        let Some(child) = guard.as_mut() else {
            return; // already reaped
        };
        let deadline = Instant::now() + REAP_GRACE;
        loop {
            match child.try_wait() {
                Ok(Some(_)) => {
                    *guard = None;
                    return; // exited on its own after EOF
                }
                Ok(None) => {
                    if Instant::now() >= deadline {
                        break;
                    }
                    thread::sleep(REAP_POLL);
                }
                Err(_) => break,
            }
        }
        let _ = child.kill();
        let _ = child.wait();
        *guard = None;
    }
}

impl Drop for TranslationChild {
    fn drop(&mut self) {
        self.terminate();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the stdin protocol serializes to the
    // exact ADR-0022 JSON-lines wire — and the `input` ping is CONTENT-FREE (doyle
    // gate #1: it carries `type` ONLY, no PTY bytes can ride it).
    #[test]
    fn to_binary_serializes_to_wire_and_input_is_content_free() {
        assert_eq!(
            serde_json::to_string(&ToBinary::Init {
                endpoint_id: "doyle".into(),
                node: "n1".into(),
            })
            .unwrap(),
            r#"{"type":"init","endpoint_id":"doyle","node":"n1"}"#
        );
        assert_eq!(
            serde_json::to_string(&ToBinary::Event {
                envelope: "<EVENT type=\"msg\"/>".into(),
            })
            .unwrap(),
            r#"{"type":"event","envelope":"<EVENT type=\"msg\"/>"}"#
        );
        let ping = serde_json::to_string(&ToBinary::Input).unwrap();
        assert_eq!(ping, r#"{"type":"input"}"#);
        // Privacy invariant, structurally: the only field is `type`.
        let v: serde_json::Value = serde_json::from_str(&ping).unwrap();
        assert_eq!(
            v.as_object().unwrap().keys().collect::<Vec<_>>(),
            vec!["type"],
            "the input ping must carry no field other than `type` — no PTY content"
        );
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] each stdout shape parses to its
    // KeyCmd variant; an unknown extra field on a recognized shape is ignored
    // (forward-compat); a line matching no shape errors (the reader skips it).
    #[test]
    fn key_cmd_parses_each_wire_shape() {
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"key":"ctrl+s"}"#).unwrap(),
            KeyCmd::Key { key: "ctrl+s".into() }
        );
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"text":"hello"}"#).unwrap(),
            KeyCmd::Text { text: "hello".into() }
        );
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"delay_ms":50}"#).unwrap(),
            KeyCmd::Delay { delay_ms: 50 }
        );
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"commit":true}"#).unwrap(),
            KeyCmd::Commit { commit: true }
        );
        // forward-compat: an unknown field alongside a known shape still parses.
        assert_eq!(
            serde_json::from_str::<KeyCmd>(r#"{"key":"enter","repeat":3}"#).unwrap(),
            KeyCmd::Key { key: "enter".into() }
        );
        // a line matching no shape is an error.
        assert!(serde_json::from_str::<KeyCmd>(r#"{"bogus":1}"#).is_err());
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the inject floor: while held, operator
    // input buffers (not written); take_or_release drains in order then frees the
    // floor; input arriving mid-flush keeps buffering so order is preserved; a free
    // floor passes input straight through.
    #[test]
    fn inject_floor_buffers_while_held_then_drains_in_order() {
        let mut f = InjectFloor::default();
        // free floor → input passes through (caller writes), nothing buffered.
        assert!(!f.buffer_if_held(b"a"), "free floor does not buffer");
        assert!(!f.is_held());

        // open → operator input now buffers.
        f.open();
        assert!(f.is_held());
        assert!(f.buffer_if_held(b"hel"), "held floor buffers");
        assert!(f.buffer_if_held(b"lo"), "held floor keeps buffering");

        // flush step 1 takes the buffered bytes (floor STAYS held) ...
        assert_eq!(f.take_or_release(), Some(b"hello".to_vec()));
        assert!(f.is_held(), "floor stays held mid-flush so order is preserved");
        // ... input arriving mid-flush keeps buffering ...
        assert!(f.buffer_if_held(b"!"), "mid-flush input still buffers");
        assert_eq!(f.take_or_release(), Some(b"!".to_vec()));
        // ... and the empty step RELEASES the floor.
        assert_eq!(f.take_or_release(), None);
        assert!(!f.is_held(), "drained empty → floor released");
        assert!(!f.buffer_if_held(b"z"), "released floor passes through again");
    }

    // [unit->REQ-MSG-IDLE-TRANSLATION-BINARY] the send-keys byte map: named keys,
    // the CC choreography chord `ctrl+s` → 0x13, arrows as xterm CSI, a single
    // literal char passes through, and an unknown key is skipped (None, no panic).
    #[test]
    fn key_to_bytes_send_keys_map() {
        assert_eq!(key_to_bytes("enter"), Some(b"\r".to_vec()));
        assert_eq!(key_to_bytes("tab"), Some(b"\t".to_vec()));
        assert_eq!(key_to_bytes("esc"), Some(vec![0x1b]));
        assert_eq!(key_to_bytes("backspace"), Some(vec![0x7f]));
        // the CC stash/restore chord.
        assert_eq!(key_to_bytes("ctrl+s"), Some(vec![0x13]));
        assert_eq!(key_to_bytes("ctrl+a"), Some(vec![0x01]));
        assert_eq!(key_to_bytes("ctrl+b"), Some(vec![0x02])); // the rc detach byte
        // arrows as xterm CSI.
        assert_eq!(key_to_bytes("up"), Some(b"\x1b[A".to_vec()));
        assert_eq!(key_to_bytes("left"), Some(b"\x1b[D".to_vec()));
        // a single literal char passes through.
        assert_eq!(key_to_bytes("y"), Some(b"y".to_vec()));
        // case-insensitive named keys.
        assert_eq!(key_to_bytes("Enter"), Some(b"\r".to_vec()));
        // unknown multi-char key → skipped.
        assert_eq!(key_to_bytes("nope-not-a-key"), None);
    }
}


`translation.rs` is the clean reusable seam: `TranslationChild::spawn(path, Sender<KeyCmd>)` + `send(ToBinary::{Init,Event})` + `key_to_bytes()`, with `KeyCmd::{Key,Text,Delay,Commit}` parsing and bounded-reap Drop. And `xlate_choreo_fixture` exists as a ready sample binary for an int test. Feature is well-scoped — mirror digest-proof, drive the real `translation.rs`.

My context is heavy (full wedge saga + this). doyle said no rush. Per CLAUDE.md I'll lay a grounded JIT plan, commune, self-clear, execute fresh. Minimal extra grounding — manifest section, clap wiring, REQ pattern, daemon init call site:


cd /c/Users/decid/Documents/projects/spt-core-translate-proof
echo "===== manifest IdleTranslationBinary struct ====="
grep -n -A14 "struct IdleTranslationBinary\|message-idle-translation-binary\|message_idle_translation_binary" crates/spt-runtime/src/manifest.rs | head -40
echo "===== clap Adapter subcommand (DigestProof variant) ====="
grep -rn "DigestProof\|digest-proof\|digest_proof" crates/spt/src/*.rs | head -20
echo "===== daemon spawns TranslationChild + builds Init ====="
grep -rn "TranslationChild::spawn\|ToBinary::Init\|ToBinary::Event\|message_idle_translation_binary\|idle_translation" crates/spt-daemon/src/*.rs | head -20
echo "===== traceable REQ-TERM-5 (digest-proof) entry ====="
grep -n -B1 -A6 'REQ-TERM-5' traceable-reqs.toml | head -30

Ground manifest/clap/daemon/REQ for translate-proof