---
name: idle-edge-w1-bcd-tests
description: IDLE-EDGE W1 legs B/C/D test patterns — digest E2E JSON gotchas, the honest cross-node int rig (twohost can't run on one box), and the N-1 digest-pull hang defect
metadata:
  type: project
---

IDLE-EDGE W1 legs B (activity pull surfaces), C (seal-on-idle), D (cross-node digest
pull) — tests authored 2026-07-24, all GREEN + mutation-falsified.

**Why:** ADR-0048 is the binding contract; doyle gates each leg. Leg C's E2E carries a
doyle-ruled extra gate (the "blink") added mid-task.

**How to apply:** reuse these when touching the digest, activity, or cross-node seams.

## Digest E2E gotchas (cost real time — check these first)

- `spt_term::DigestEntry` is an **externally-tagged** enum: an entry serializes as
  `{"Agent":{"text":…,"seq":1}}`. A JSON accessor doing `entry["seq"]` silently reads
  `None` and the test passes-then-fails confusingly. Reach through the variant wrapper
  (`obj.values().next()` then `"seq"`). `Turn::input_seq` is a plain field — that one
  works directly, which makes the mismatch look like a seal bug.
- `DIGEST_ENTRY_LOGGED:<id>:<n>` — `n` is **bytes written**, NOT a line index. Do not
  read it as the seq.
- Log-less digest path (seq == `digest.log` line index, no `ordinal<<32`) needs an
  adapter with **no `[digest]` section**. That is the simplest E2E seeding door:
  `spt api digest-entry <id> --session-id <sid>` with the record on **stdin**.
- `mock-session` must be prebuilt or the rig panics at the fixture check:
  `cargo build -p mock-adapter --bin mock-session`.

## Cross-node int: twohost.rs cannot run here

`crates/spt-daemon/tests/twohost.rs` is env-gated on `SPT_TWO_HOST=1` + a role + **two
physical machines**; it is a silent no-op on any single box, so a rung there gives zero
LOCAL signal — but it is NOT optional. doyle ruled 2026-07-25: the hermetic rig is
accepted for the leg gate AND a twohost rung is required alongside it, because the
CI two-host ladder runs it on real separate runners at PR. Write both. Locally you can
only verify a twohost rung COMPILES and still silently skips. The honest reachable rig
for actual local signal is the
`crates/spt-daemon/tests/dispatch.rs` family: two hermetic loopback brokers, real QUIC,
B running the production `run_dispatch_loop`, caller uses the real `request_*`. That
proves the whole wire leg (demux + gate + serve + decode). It does NOT prove separate
machines/disks — `SPT_HOME` is process-global, so both "nodes" share one store. Say so
in the module docs rather than implying a two-machine result.
`serve_digest_pull` reaches for the **canonical** `digest_socket_name()` (SPT_HOME-
derived), so the rig must stand a `serve_digest_control` hub up on that exact name.

## DEFECT: a read deadline on a NON-pump Brain is silently discarded

`Brain::read_frame_until` honors its deadline ONLY on `BrainConn::Split` (the pump
carrier); the `BrainConn::Whole` arm calls `read_frame(stream)` and throws the deadline
away. `Brain::cold_start` — every CLI caller — builds `Whole`, and brain.rs states the
coupling outright ("`Some` <=> `BrainConn::Split`"). So passing a deadline to
`read_event_until` on a CLI brain is a NO-OP: any "bound the wait" fix written that way
still hangs, even though the deadline value is computed correctly. Cost two wedged test
runs to find. Consequences for test design: a test needing a bounded observer MUST use
`Brain::cold_start_pump(name, 1, timeout, PumpTrace::Stderr)`; and any "must not hang"
regression test should run the call off-thread behind `rx.recv_timeout` so a red fails
fast instead of wedging the suite. `digest_cross_node.rs` carries the acceptance test
`#[ignore]`d with the exact un-ignore condition.

## DEFECT found: an N-1 peer hangs the caller forever

Removing the `StreamFamily::DigestPull` demux arm (== exactly what any peer at v0.41.1
or earlier does, since the family is brand new) makes the int test **hang** (exit 143),
not fail. Cause: `StreamFamily::Unknown` → `DispatchOutcome::Unknown` → terminal, and
the retire at dispatch.rs is gated on `Served`, so the stream is dropped with **no FIN**;
`request_digest_pull` loops on bare `brain.read_event()` with no deadline. Same shape in
`resthost.rs` + `linkhost.rs` (inherited class gap); `propagate.rs` is the one that does
it right via `brain.reply_read_deadline()` + `read_peer_reply_until`. Field impact:
`spt endpoint digest <id@old-node>` hangs with no output instead of refusing.

## The blink (doyle ruling 2026-07-24) — GREEN, grounds hold

The seal is **stateless** (recomputed per projection), so idle→busy→idle with no new
input record transiently unseals the trailing turn. Ruled shippable because seqs come
from log POSITION (`(ledger_ordinal << 32) | line_idx`). Empirically confirmed at E2E:
`sealed=(false,Some(0),[Some(1)]) -> busy=(true,None,[None]) -> resealed` byte-identical
to `sealed`. Pinned in `idle_edge_seal_e2e.rs` leg 3 — if it ever reddens, that
falsifies the ship ruling and goes to the operator, not to a relaxed assertion.

## Sprint-seam seq rule — RULED, now INTENDED (doyle 2026-07-25)

The apparent collision (a post-seal straggler tool grows a sealed sprint and moves that
sprint's seq) is resolved in favour of last-idx-wins: ADR-0048's "the assigned seq"
means the turn's `input_seq`. The contract is: turn `input_seq` is the stable ANCHOR;
entry seqs may advance FORWARD ONLY on sprint growth; a forward move RE-DELIVERS, never
gaps (first-idx would EAT stragglers from an `--after` consumer). Test promoted from
characterization to intended: `a_sealed_sprint_grows_forward_only_keeping_the_turn_anchor_stable`
in projection.rs.

Related: [[msg-identity-w4-delivery]], [[spt-test-conventions]]
