The permanently frozen viewfinder that aposemati-crop-and-framing recorded as an open defect is root-caused and fixed — and the reason it survived 546 tests, eight task reviews and a whole-branch review turns out to be a property of the harness rather than of anyone’s attention.

The finding, in one line

Over awdl0, a QUIC datagram send into a dead peer SUCCEEDS — the sender learns nothing. Over loopback the same send fails immediately with POSIXErrorCode(rawValue: 57): Socket is not connected. The harness takes the error path the field never takes, so a test written for this defect passes against unmodified code: loopback hands the sender the very error the radio withholds.

For Agents

Fixed in: Sources/AposematiCore/NetworkPreviewChannel.swift (the stateUpdateHandler) and CameraAppModel.streamPreview (the retry loop). Correct prior art in the same file family: NetworkTransport.begin() — it has had the right stateUpdateHandler for the control connection all along. Verified by: hand-run mutation — revert the source fix, keep the tests, watch two named expectations fail. Output quoted verbatim below. Everything here is measured on hardware or by hand-run mutation. Nothing is hypothesis.


The blind spot, stated so it can be reused

Loopback can reproduce only the quiet form of this defect class:

FormWhat it looks likeReproducible on loopback?
Quiet — the peer leaves while nothing is being sentno traffic, connection goes away✅ yes
Live sender — 30 fps of successful sends into a dead peerevery send() returns success forevernot at all

The consequence, stated plainly

Anything that relies on send failure to detect peer death is untestable in-harness. It must be reasoned about, or found on hardware. There is no test you can write on loopback that distinguishes the fixed code from the broken code for the live-sender form — because on loopback the broken code also recovers, via an error that does not exist over the radio.

This is a stronger statement than the project’s existing two-roles-in-one-object lesson. That one said a badly shaped double hides defects. This one says a faithful two-process QUIC harness on loopback still cannot see this class, because the transport underneath behaves differently. The double is not the problem; the medium is.


The defect

Symptom in the field: focus away from the Mac app and the viewfinder freezes permanently — no recovery on refocus, no recovery on a camera switch. The phone’s own readout showed 30 fps throughout: it was still encoding and still sending. Both ends individually believed they were healthy.

Mac-side log, from the instrumentation added in 6607614 — the commit that made this findable at all:

17:34:28.343  opening a preview flow, epoch 1
17:34:28.736  preview flow open, epoch 1
17:34:58.745  frame stream finished after 0 frames, epoch 1
17:34:58.745  retrying a preview flow in 2.0 seconds

A clean 32-second loop, forever. The Mac was correct throughout — it noticed, it gave up, it retried, on schedule. Nothing was wrong on the side that was doing the noticing.

The instrumentation is what turned a hypothesis into a diagnosis

The Mac had no instrumentation at all until this session — it was called “the cheapest high-value work remaining, and it blocks both diagnoses”. It was, and it did: the 32-second cycle above is the diagnosis, and it is unreadable without it.


Root cause — narrower than the previous handover claimed

NetworkPreviewChannel never installed a stateUpdateHandler on its NWConnection. It had exactly two routes to shutDown():

  1. a receive error in receiveNext() — reachable only via startReading(), which is reachable only from frames();
  2. a send error in transmit() — which QUIC datagrams never report over the radio.

The phone only ever SENDS. It never calls frames().

So on the phone the channel had no path to shutDown() whatsoever — not a slow path, not an unreliable path, none. The Mac survived only incidentally, because it happens to be the end that reads.

The asymmetry is the lesson

NetworkTransport.begin() had done the right thing for the control connection all along: a [weak self] stateUpdateHandler tearing the connection down on .failed / .cancelled. The preview flow was simply never given the equivalent.

A correct pattern existed in the same file and was not applied to the sibling connection. That is a different failure from “nobody knew how” — and it is the shape worth grepping for on any codebase that grew a second connection beside a first one.


The fix

Two changes, both needed.

#ChangeWhere
1Install a [weak self] stateUpdateHandler calling shutDown() on .failed / .cancelled. shutDown() nils the handler before the cancel() that re-enters it.NetworkPreviewChannel.init
2Add the retry loop the Mac already had (while !Task.isCancelled, 2 s retry delay)CameraAppModel.streamPreview, mirroring HostAppModel.runPreview

Change 1 is one guard in the shared class both ends route through — so it fixes the Mac too, not only the phone.

Why change 1 alone settles the transport-cache question

NetworkTransport.previewChannel caches the in-flight open, and discardThePreviewFlowIfItDied() clears that cache only when channel.isShutDown. Before the fix the cache handed back the DEAD channel — verified, see the mutation output below. Change 1 makes isShutDown become true, so the cache clears itself and no separate fix was needed.


Mutation evidence — the part that matters

Reverting only the source fix while keeping the tests:

send-only flow after its peer went away: shut down false
  Expectation failed: (noticed → nil) == true
send-only flow reopening: peer offered a replacement, it handed back the dead one
  Expectation failed: (reopened) !== (accepted)

Both fail after ~15 s of timeout. With the fix restored, both pass in 0.073 s.

The second line is independent confirmation of the cache defect

“peer offered a replacement, it handed back the dead one” is the transport cache returning a dead channel, observed rather than argued. That is what makes “change 1 alone is enough” a measurement and not a claim.

Why the mutation was run by hand rather than trusted from a report

This project has a precedent: commit 371b32a, where a fix was later disproved because its test passed identically with and without the change. Combined with the two opposite mutation failure modes already recorded in Mutation testing has two opposite failure modes, a reported mutation result is a claim. The run above was performed by hand for that reason.


A stale cross-reference that had been propagating

The repo’s HANDOVER.md cites “spec §4.3” three times for the rule that silence is peer-controlled and must not be watched.

That section does not exist

docs/superpowers/specs/2026-08-25-preview-crop-design.md has 12 sections and none is 4.3 — the string 4.3 does not occur in the file at all. The rule actually lives at docs/superpowers/specs/2026-08-18-aposemati-design.md:502, and in this vault at The one hard residual.

And its real scope is narrower than the citation implied. It binds the receiver-side reassembler stall watchdog, because keying that on silence reopens an on-demand backward watermark move four rounds were spent closing. It says nothing about connection state.

A stateUpdateHandler is a local transport fact, not silence

Which is exactly why the fix above is compliant with the rule. A miscitation was being used to argue against a category of fix the rule never covered — the citation had drifted to a document that has no such section, and the drift widened the scope in the retelling.


Open — needs hardware

  • Whether awdl0 delivers .failed at all, and how fast. On loopback the peer’s cancel() propagated in ~76 ms. Over the radio the QUIC CONNECTION_CLOSE may be lost, in which case the phone falls back to the 30 s idle timeout and recovery becomes ~32 s instead of ~2 s. Still recovery, where today there is none — so the fix is worth shipping either way, but the number is unknown.
  • CameraAppModel compiles into no test target, so the retry loop is untested by construction — the same as the Mac’s. See aposemati-build-install-run on Apps/ being outside the SPM package.
  • The missing-shutter defect is untouched and still unresolved — one press in seven produced no file. See One shutter press in seven produced no file.
  • Pre-existing and unchanged, but now more visible because it sits inside a retry loop: if the preview task is cancelled while blocked in PreviewRendezvous.claim, the loop swallows cancellation via try?, so exit can take up to the 30 s handshake timeout. Neighbour of swift-uncancellable-continuation-trap in shape, though not the same mechanism.