Phase 2 gives Aposemati a live viewfinder: JPEG frames encoded on the iPhone, fragmented into QUIC datagrams, reassembled against a deadline on the Mac, decoded and displayed. It is droppable by design — a lost fragment costs exactly one frame, never the stream, and that was measured on two real QUIC ends rather than asserted.

🟢 STATUS: PHASE 2 COMPLETE at c29f84d — 2026-08-25

33 commits, 418 tests green, both apps building, tree clean. Ten tasks, 23 recorded rulings, ~15 deferred minors, one final whole-branch review plus a fix wave.

The verdict is deliberately blank. docs/superpowers/phase2-results.md is a measurement gate, not a conclusion: the two readings that decide whether JPEG-per-frame is enough — the buffer dimension the phone delivers, and the datagram loss rate on awdl0 — cannot be taken without two devices, and no device build was permitted. The file refuses to manufacture a verdict from loopback and says exactly which numbers flip it each way.

For Agents

Source of truth: /Users/levander/coding/aposemati/docs/superpowers/phase2-results.md (719 lines). Plan: docs/superpowers/plans/2026-08-19-phase2-live-preview.md. Ledger (gitignored, deliberately kept): .superpowers/sdd/2026-08-19-phase2-live-preview/progress.md — holds mutation-verification evidence for ~40 findings that the results doc only summarises. Everything measured here is loopback and single-process unless it says otherwise.

What shipped

PieceTypeNotes
Fragment headerPreviewFragment16 bytes, static let headerSize = 16; decode bounds fragmentIndex against fragmentCount but puts no ceiling on fragmentCount — that belongs in the reassembler
FragmentationPreviewFragmenterat a runtime-supplied usable size, never a hardcoded 1200
ReassemblyPreviewReassemblerdeadline-based, discards partials, forward-only watermark — see aposemati-peer-restart-inference-boundary
Quality ladderPreviewEncodingLadder12 rungs = 3 scales (1, 0.75, 0.5) × 4 qualities (0.6, 0.45, 0.3, 0.2), long edge 1920
Encode/admitPreviewEncodingPipeline, PreviewJPEGEncoderreactive: encodes first, then admit() returns .tooLargeToSend before anything is yielded
TransportNetworkPreviewChannela second NWConnection to a second NWListener on its own port
Phone sidePreviewRelay, PreviewEncoder, PreviewGatemonotonic per-channel sequence, gated on pairing confirmation
Mac sidePreviewViewfinder, PreviewStallWatchdog, PreviewFrameDecoderdecode off the main actor, reset() on session start and on watchdog fire
Projection/toolingPreviewLossProjection, PreviewLadderProbe, aposemati-probe preview-lossthe loss table regenerates from one input

Reassembler bounds, as shipped: deadline 200 ms, inFlightLimit = ceil(deadline × fps) × 2 = 12, frameByteBudget = 512 KiB, reorderingTolerance == inFlightLimit, at most 4096 fragments per frame, per-peer buffered ceiling 2 × 12 × 524288 = 18.0 MiB.

The default frameByteBudget originally rejected the frames the phase exists to carry

It defaulted to 128 KB while the plan’s own Task 9 text said “a 1080p JPEG at ~150 KB”. Measured: 131,072 delivered, 131,073 discarded as malformed — and an over-budget honest frame read as a peer restart through the same drop() → finish() path, so a large legitimate frame would trip the very watchdog meant to detect a restarted peer. Task 3’s default and Task 6’s output were each reasonable alone. This is the “individually coherent, jointly wrong” shape — see Two tasks each correct in scope, jointly leaving a hole neither owned.

The architecture record was wrong about where preview rides

"Connection A datagrams" is not what shipped — correct the spec before Phase 3 reads it

The plan, the dispatch briefs and specs/2026-08-18-aposemati-design.md:206-212 all say preview is datagrams beside the control stream on one connection. The code opens a second NWConnection to a second NWListener on a separate port (NetworkTransport.swift:497, :1274-1291, PreviewRendezvous:115-195).

§4.1’s claim is satisfied — better than described, because preview is isolated from stills rather than sharing a congestion window with control — but the durable spec records an architecture the code does not implement, and that spec is what the H.264 planner and Phase 3+ will open. It also makes the “.interactiveVideo on A against .background on B” sentence half-true: :932 sets .interactiveVideo; nothing sets .background anywhere.

The reason it is two connections at all is that NWConnectionGroup + NWMultiplexGroup did not work — see [[aposemati-network-framework-gotchas#10-nwconnectiongroup—nwmultiplexgroup-on-quic—hard-possibly-not-viable|10. NWConnectionGroup + NWMultiplexGroup on QUIC — hard, possibly not viable]].

The preview port cannot redirect to a third party

previewEndpoint takes the host from the control connection’s own remote endpoint and only the port from the peer, so a hostile port announcement is bounded to another port on the peer’s own machine.

The measurements, and the one thing they establish

Every latency figure is per frame, joined on sequence number. Percentiles are taken over frames. Harness: Tests/AposematiCoreTests/Phase2MeasurementTests.swift, over two real NetworkTransport objects on a real QUIC datagram flow, with the shipping encoder, fragmenter, reassembler and decoder. The scene is CIRandomGenerator noise at the delivered buffer’s own dimension — the worst case for both encode cost and frame size.

Never subtract percentiles. Join on sequence, then take percentiles.

This is stated first because it is how the phase’s most consequential measurement error was made. See Five measurements in one phase answered a different question than the one asked.

Capture-to-pixels, loopback, 45 frames per dimension at a 33 ms cadence

§7.1’s thresholds: target < 50 ms, marginal 50–150 ms, fail > 150 ms.

delivered buffersettlessteady statefull-suite p90isolated p90
4032×2268rung 2, 1920×1080 q0.30283,729 B13.2 – 14.9 ms22.05 – 22.30 ms
1920×1080rung 3, 1920×1080 q0.20482,530 B8.9 – 10.1 ms18.37 – 19.37 ms

The 1.9× spread is isolated-versus-full-suite, not machine-versus-machine

A whole-suite run has the CPU boosted and CoreImage warm before this test starts; an isolated 43-frame --filter run pays that warm-up inside the sample. Reproducible in both directions on one machine. It does not narrow the range — it identifies what moves it, so a rig operator can deliberately reproduce either end. Conclusions are drawn against the 22 / 19 ms end.

Stage breakdown at 4032×2268 (full-suite): encode p90 7.95 · send() p90 1.46 · crossing p90 0.10 · decode p90 4.36 ms. Delivered 43 of 43 and 42 of 42 — nothing lost, reordered or discarded on a quiet loopback. p95 is reported rather than p99 because at n≈43 every percentile above p98 indexes the last element.

send() costs one continuation per fragment — so it scales with fragment count

Every latency block above was taken at loopback's fragment count, which loopback chose, not us

The 9156-byte loopback datagram makes the sensor-sized frame 32 fragments and the preview-sized one 53. At the 1200 B the spec estimates for awdl0, the same two frames are 240 and 408. Measured back-to-back on the same real link, same frames:

framedatagramfragmentssend() p90, full-suiteisolated
283,729 B9156 B321.66 ms3.61 / 3.69 ms
283,729 B1200 B24011.20 ms13.04 / 13.17 ms
482,530 B9156 B531.90 ms5.14 / 5.30 ms
482,530 B1200 B40815.81 ms17.06 / 16.92 ms

Quote the added milliseconds, not the multiple. The ratio swings 3.3×–7.5× across load conditions because the 9156 B baseline is small enough that warm-up dominates it. The stable quantity is the difference within one run: +9.4 to +9.5 ms at p90 for the sensor-sized frame, +11.6 to +13.9 ms for the preview-sized one, reproduced independently to the millisecond.

Consequence: at radio-sized fragments the transport is not “under 3 ms” — it is roughly 11 and 16 ms, and encode+decode falls from 75–90% of the software path to about half.

Adding each frame’s own measured delta and Phase 0’s 18.5 ms one-way radio leg (37.0 ms RTT halved) to the end-to-end p90s:

at 9156 B+ fragment delta+ radio leg
full-suite 4032×226813.6723.241.7 ms
full-suite 1920×10809.2223.141.6 ms
isolated 4032×226822.3031.850.3 ms
isolated 1920×108019.3731.349.8 ms

Warm there is ~8 ms of room; cold the 50 ms target is reached exactly. Nothing is left for sensor-to-buffer and buffer-to-glass, which neither end can instrument.

This is the first latency argument for H.264, and it was invisible while everything was measured at 53 fragments

A smaller frame cuts fragment count, so it buys send cost as well as bandwidth and loss resilience — three returns on one change.

The architectural claim was measured, and it holds — with a caveat one layer down

aStillOnTheOtherConnectionDoesNotDisturbTheViewfinder: one 7 MB still fired mid-stream costs one preview frame, ~1.7 ms on a 33 ms budget, 90/90 delivered, nothing lost or reordered. Saturating the stills connection — 254 stills, ~1.87 GB, a rate no radio reaches — costs latency and never loss. That is the measurement the two-connection split needed, and it is decisive in the design’s favour.

~40% of the interference under saturation is our own serial queue

NetworkTransport.queue is a single serial DispatchQueue and both connections start on it. Giving preview its own queue in a scratch copy: end-to-end p90 9.51 → 5.81 ms, send() p90 8.30 → 4.71 ms (independently reproduced at 6.94 / 3.97). That is the coupling the two-connection split exists to remove, reintroduced one layer below it.

Deliberately not fixed in Phase 2. At the field-measured 156–339 Mbps the saturation that exposes it is unreachable, and splitting queues touches a file carrying two verified Critical fixes. The risk is that a future high-rate mode (H.264 at 60 fps, Phase 4 video) reintroduces viewfinder latency from our own queue rather than from the wire — with the measurement already on record to find it by.

The still result does not transfer to the radio

Loopback has effectively unlimited bandwidth; the two channels compete for CPU and a dispatch queue, not for airtime. On a link where preview is already asking for 75% of measured throughput, a concurrent still contends for the scarce resource. Read it as evidence that the split works, not as evidence that stills are free.

A lost fragment costs one frame — measured under induced loss

aLostFragmentCostsOneFrameAndSurvivalMatchesTheProjectionAtARadioSizedDatagram, over two real QUIC ends, a real 482,530-byte JPEG at 1200 usable bytes = 408 fragments per frame (the radio-sized case, not loopback’s 9156), 100 frames per rate:

p=0.00%   delivered 100/100 = 100.0%, projected 100.0%   discarded 0,  lost 0,   outOfReach 0
p=0.05%   delivered  85/100 =  85.0%, projected  81.5%   discarded 14, lost 16,  outOfReach 0
p=0.20%   delivered  47/100 =  47.0%, projected  44.2%   discarded 49, lost 77,  outOfReach 0
after the lossy stretch a clean run delivered 30/30 with no reset
  1. The analytic model is the real behaviour. Measured survival tracks (1 − p)^408 inside the 3-sigma binomial band at both rates, and exactly at p=0. Mutation-checked: halving the exponent in frameSurvival fails the test. So §3’s loss table is a projection of measured behaviour, not a hopeful formula.
  2. A lost fragment costs its own frame and nothing more. At p=0.2%, 77 lost fragments destroyed 49 frames — 1.57 lost per destroyed frame against the 1.46 independent loss predicts. No cascade, no reference chain. This is JPEG-per-frame’s real advantage and it is the thing H.264 gives back.
  3. fragmentsOutOfReach stayed 0 through every rate, so loss has no false-positive path into the stall watchdog.
  4. The stream recovers with no reset.

The control matters as much as the result: at p=0 the harness delivered 100 of 100, so the losses are the induced ones and not the rig’s.

⭐ One reading on the rig decides two verdict rows

PreviewFrameSize.fitting(withinLongEdge:) returns the source unchanged when it already fits. So if the device hands us a preview-sized buffer, the ladder’s top four rungs do no downscaling at all, and nothing averages away the high-frequency content JPEG pays for.

delivered bufferrung 0 downscales?settlessteady stateheadroomfragments @1200 BMbps @30 fps
4032×2268yes, 2.1×rung 2283,729 B46%24069.0 — passes
1920×1080norung 3482,530 B8%408117.4 — fails

That single fact produces both the fragment count (verdict row 2) and the bitrate (verdict row 4). Read it first on the rig.

The loss rate that costs a tenth of the frames, and the one that costs half:

delivered bufferfrags10% of frames lost athalf lost ateffective fps at p=0.1%
4032×2268240p = 0.044%p = 0.288%23.6
1920×1080408p = 0.026%p = 0.170%19.9

A quarter of one tenth of one percent of datagram loss costs a tenth of the viewfinder

Phase 0’s 0.011% retransmission on a TCP-like stream is a different quantity on a different channel and cannot be substituted for p.

Real scenes span a 4.3× range in frame size (74 KB quietest → 321 KB busiest, from the three 48 MP captures this project actually took) — but those rows sit on the favourable side of two downscales. Only the noise row is generated at the delivered dimension, which is why it is the row quoted in the verdict.

Regenerate the table, never copy it

swift build
.build/debug/aposemati-probe preview-loss --source WIDTHxHEIGHT --usable N --scene ~/Pictures/Aposemati

--source is required and refused if absent. PreviewLossProjection.fragmentsPerFrame is pinned by test to PreviewFragmenter.fragments(…).count across eight frame sizes and three datagram sizes, so the projection cannot drift from the shipping fragmenter.

Bandwidth may bind before loss does

This was on nobody’s list and it should have been.

Phase 0 measured 156.3 Mbps at the Camo-drop spot. The no-downscale case asks 117.4 Mbps sustained75% of the field-measured link, before stills, control or ACKs.

§7.1 as literally written is still satisfied, and the doc says so rather than overclaiming: its only bandwidth-adjacent requirement is a 5 MB still in under 2 s. Preview at 117.4 leaves 38.9 Mbps, and a 5 MB still takes 1.03 s.

previewleft for stills5 MB stillmargin on the 2 s row
none (Phase 0)156.3 Mbps0.26 s7.7×
69.0 Mbps, downscaled87.3 Mbps0.46 s4.4×
117.4 Mbps, no downscale38.9 Mbps1.03 s1.9×

Derived bar: preview must not exceed 50% of measured sustained throughput at the target range

Two reasons, and the second is the stronger one:

  1. 156.3 Mbps was a point measurement at one spot, not a floor — it is a shared, duty-cycling radio, and the whole reason Phase 0 measured a degradation curve is that one reading does not bound the bad case.
  2. §7.1’s own fallback ladder makes “reduce preview ambition” its first rung — and that rung does not exist if preview is the thing saturating the link.

At 156.3 Mbps the bar is 78 Mbps: the downscaled case (69.0) passes, the no-downscale case (117.4) fails. This is a derivation, not a spec threshold — §7.1 has rows for throughput, still latency, preview RTT and interface stability and no row for preview bitrate against measured throughput. A nine-line note saying so was added to the durable spec, because a gap found by measurement belongs where someone sizing Phase 4 video will look.

The H.264 gate

JPEG-per-frame is enough and Phase 2 is done if all four hold:

#ReadingPassing value
1p90 one-way preview latency at the Camo-drop spot< 50 ms
2Datagram loss p on awdl0< 0.026% (408 frags) / < 0.044% (240 frags)
3Effective delivered frame rate≥ 24 fps sustained at the settled rung
3bPreview disturbance during a stilldelivered rate ≥ 80% of the 5 s baseline, no delivered-sequence gap over 3 frame intervalsderived, not a spec row
4Settled preview bitrate vs sustained throughput at that spot≤ 50%derived, not a spec row

Row 4 is the one most likely to fail, and it is the one that is not in the spec.

The baseline H.264 has to beat, stated so it can be checked:

JPEG-per-frame, measuredH.264 1080p30 @8 Mbps, projected
frame bytes, settled482,530 B~33,300 B
fragments at 1200 B408~29
bitrate at 30 fps117.4 Mbps~8.1 Mbps
frame survival at p=0.5%12.9%~86.5%
encode cost2.8–7.6 ms measured23.2 ms measured (1080p60 low-latency)
a lost fragment costsexactly one frame, measuredone frame plus every frame referencing it until the next intra

H.264 buys bandwidth and loss tolerance by spending latency

23.2 ms against JPEG’s 2.8–7.6 ms adds 16–20 ms to a budget already at the line. And the last row is why the spec’s fourth Phase 2 item — periodic intra-refresh rather than waiting for a keyframe — goes live the moment H.264 lands. It is moot under JPEG-per-frame (every frame is independently decodable, measured not asserted) and deliberately deferred, not overlooked. The measured p from the rig is precisely the input that sets the intra-refresh interval. Whoever writes the H.264 plan must carry that forward.

Hardware checklist, ranked

Everything below needs two devices, ordered by what breaks worst if ignored.

  1. 48 MP + zero-shutter-lag stills, with the video data output attached. DeviceCamera.configured() adds AVCaptureVideoDataOutput to the session unconditionally, whether or not preview ever starts — and stills are Phase 1’s shipped, working feature. Verify capability still reports 8064×6048, isZeroShutterLagEnabled still true, a capture still returns 48 MP. If it regresses, the fix is attaching the video output lazily in startPreview — deliberately not built, because building it untested would trade a known-good path for an unverified one.
  2. The dimension AVCaptureVideoDataOutput actually delivers (CVPixelBufferGetWidth/Height in PreviewEncoder.consume). Decides both verdict rows. Take it first.
  3. usableDatagramFrameSize on awdl0. Loopback reads 9156 sending / 9164 receiving; the spec estimates 1200–1350. Every fragment count scales inversely with it.
  4. RSS bound on parked, unconfirmed preview flows. Could not be established in-process — four strangers pushing 73 MB grew the footprint 93 MB with no plateau, and it did not return after cancelling them, so allocator high-water could not be separated from our own buffering. Reachable by a peer that has only completed a TLS handshake, and QUIC DATAGRAM frames are not flow-controlled — nothing pushes back the way the 32 MB stream window pushes back on a stream sender. Given this project’s 18 MB → 196 MB history, this is the highest-value memory question for the rig.
  5. streamLagDuration on the radio. Loopback baseline median 0 ms, p99 1–4 ms, max 2–11 ms. Decides whether arm D is load-bearing or tidiness.
  6. The viewfinder over awdl0 against a real camera, with a shutter press returning a full-res still while preview continues. The two apps have never run against each other on hardware.
  7. Rotation. The angle is read once at startPreview; live re-rotation while streaming is not handled.
  8. Whether awdl0 delivers .failed to a stateUpdateHandler at all, and how fast. Appended 2026-08-26, not re-ranked — it would otherwise sit near the top. 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 preview recovery becomes ~32 s instead of ~2 s. Still recovery, where before the fix there was noneaposemati-loopback-blind-spot.

Item 6 already found a defect no loopback test could have found — added 2026-08-26

The first hardware run of the viewfinder froze permanently on losing focus, because NetworkPreviewChannel had no teardown path on the sending end at all: a QUIC datagram send into a dead peer succeeds over awdl0 while the same send fails with POSIXErrorCode 57 on loopback. Fixed, with hand-run mutation evidence, in aposemati-loopback-blind-spot.

The generalisation this checklist should carry: any item here whose failure mode depends on a send failing cannot be pre-verified in the harness — the medium, not the double, is the limit.

Running the two-device protocol

Four steps, one session: prove stills still work → read the delivered dimension and usableDatagramFrameSize → regenerate the loss table → press the shutter during live preview and measure the disturbance.

capturedAtMillis is monotonic, not wall-clock

It is the sample buffer’s presentation timestamp from the phone’s clock. Subtracting it from a Mac timestamp produces a number with no meaning. Everything measured on loopback shares one ContinuousClock by construction. On two devices: use a control-channel echo, halved, on the Mac’s own clock (state the symmetry assumption), or NWConnection.startDataTransferReport()’s transportSmoothedRTT. If you report a device-local figure, label it device-local and never add it to a Mac-side number.

Deriving p, since no counter reports it

p  ≈  fragmentsLost / ((delivered + discarded) × fragments_per_frame)

The obvious denominator is wrong, and wrong in the direction that forces a false verdict

fragmentsLost / (delivered × n + fragmentsLost) omits every fragment that arrived in a frame that was then discarded — 19,915 of the 39,168 fragments sent for the 96 accounted frames at p=0.2%. On that data it reports 0.400% for an induced 0.200%: a 2.03× overestimate. The bias is exactly 1 / (survival + p), so it is ~11% at the boundary the decision is made on and 2× at half-loss.

And it compounds into a second false finding. An inflated p makes measured survival exceed (1 − p)^n, which the burstiness cross-check then reports as bursty: (1−0.004)^408 = 19.49% against 48.96% measured, a +29.5 point gap, from loss that was independent by construction. One arithmetic slip, two false findings, both pointing at H.264.

Burstiness: check with a band, and know which way it points

Compare measured survival delivered / (delivered + discarded) against (1 − p)^n, and treat a difference as evidence only when it exceeds 3 × √(s(1−s)/N).

Bursty loss makes more frames survive than (1 − p)^n predicts — so the model is a lower bound and §3's column is pessimistic on a bursty link

A frame dies if ≥1 of 408 back-to-back fragments is lost, so destroyed frames ≤ losses, with equality only when losses land in distinct frames. Independent loss already sits essentially at that upper bound (1.57 measured vs 1.46 predicted), so clustering can only push losses-per-destroyed-frame up and destroyed frames down. Robust to burst length.

An earlier draft said “optimistic”. That was backwards, and it would have pushed a link that is better than modelled toward an H.264 verdict. Survival below the prediction is not burstiness at all — look for a second loss mechanism, or non-zero fragmentsMalformed / fragmentsOutOfReach.

Run long enough that the band can see the thing you are looking for — it narrows only as 1/√N

At N≈100 accounted frames the band is ±15 points at 50% survival, wide enough to swallow a burst structure that halves the frames loss destroys, so a short run reports “independent” whatever is true. At the ~90% survival row 2 is decided at, ±5 points needs ≈325 accounted frames (11 s at 30 fps) and ±3 points needs ≈900 (30 s). Hold the preview up for the full half-minute. Also: frame accounting will not close exactly (47+49=96 against 100 sent) — a frame or two is still inside the 200 ms deadline when the counters are read, biasing p upward by under 5%.

What Phase 2 could not do

No device build was permitted, so all latency, loss behaviour and settling figures are loopback and single-process. Specifically not measured: preview over awdl0 at either Phase 0 position; the frame rate an A18 Pro sustains (Mac-side encode is 2.8–7.6 ms against a 33 ms budget — the phone’s margin is unknown, and 15–24 fps may be the honest target); whether a still still lands at 48 MP with the video output attached; one-way latency across two clocks; and the real datagram loss rate (the harness induces loss at chosen rates, it does not discover it).

An in-process RSS assertion cannot distinguish our buffering from anything else in the address space

The new CoreImage-heavy measurement tests made aStrangerNobodyConfirmedCannotGrowThisProcessWithoutBound flake 3 of 5 full-suite runs — it asserts process-wide footprint growth under 128 MB over a 3-second window, and swift-testing runs tests in parallel. Bisected (4/4 green with the file removed, 6/6 in isolation, 3/5 red with it present) and resolved by trimming the measurement windows, not by weakening the assertion: 10 of 10 consecutive full runs green after. The underlying point stands and is not fixed — it is hardware checklist item 4 from the other side.

Known residuals, parked with rulings

  • Cyclic-restart freeze. A peer that restarts numbering every ≤12 frames freezes delivery invisibly. Hard residual, documented, and keying the watchdog on silence must not be the fixaposemati-peer-restart-inference-boundary.
  • §2’s summary sentence reads ~4 ms optimistic (phase2-results.md:132-133 says “19–32 ms”; correct is ~23–32). Parked because nothing downstream consumes it — the 41.7/41.6/50.3/49.8 table three paragraphs later is correct and the verdict is untouched. One-line edit.
  • Markdown lazy continuation at specs:250-261 renders the NWConnectionGroup failure paragraph inside a 2026-08-25 correction blockquote, so “That route” loses its antecedent. Cosmetic; content intact.
  • delivered == offered as a hard 60/60 on the saturating window is the strictest bound in the suite, on a lossy-by-design channel under deliberate stress. Held every run, loopback-only — the likeliest future flake.
  • One suite run failed after the fix wave and five consecutive re-runs passed; the test name was not captured. Consistent with the ~1-in-8 RSS flake but unconfirmed, and recorded as unresolved rather than dismissed.
  • ⚠️ sendCostIsMeasuredAtBothTheLoopbackAndTheRadioSizedDatagram has a SECOND failure signature — added 2026-08-26. Beyond the documented hard 50 ms p90 flake, under load it fails with handshakeTimedOut after 34 seconds — a link that never established at all, which looks like a transport regression rather than a timing flake. Run alone on the same machine it passed in 6.1 s, and a second full-suite run passed all 548. Both signatures are now in A timing test that flakes makes every green run untrustworthy so the next person does not chase the wrong thing.
  • ⚠️ Cancellation can take up to the 30 s handshake timeout to exit the preview retry loop — pre-existing, and now more visible because it sits inside a loop. If the preview task is cancelled while blocked in PreviewRendezvous.claim, the loop swallows the cancellation via try?. Unchanged by the 2026-08-26 freeze fix, which added the retry loop on the phone side — aposemati-loopback-blind-spot, swift-uncancellable-continuation-trap.
  • ⚠️ CameraAppModel compiles into no test target, so the phone’s preview retry loop is untested by construction — the same as the Mac’s HostAppModel.runPreview. See aposemati-build-install-run on Apps/ being outside the SPM package.
  • The SDD workspace was kept rather than deleted at phase end (a deliberate deviation from the skill): it holds the mutation-verification evidence Phase 3 and the H.264 plan will want, and it is gitignored so it costs nothing.