It works. Phase 0 (transport measurement) and Phase 1 (link + shutter + stills) of Aposemati were built and proven on hardware on 2026-08-18. This note is the build record: what exists, what it produced, and what is still unverified.

The first live two-device run — 2026-08-18 23:47

An iPhone 16 Pro captured two photos to a Mac over a link the UI reported as awdl0 — direct. Written to ~/Pictures/Aposemati/2026-08-18/ at 8064×6048 = 48.8 MP (6.3 MB and 5.2 MB HEIC).

That is ~17× the pixels Continuity Camera can deliver — its ceiling was measured at 1920×1440 on this same phone at the start of the project (1. Continuity Camera’s hard ceiling).

Every premise the project rested on is now demonstrated rather than argued: AWDL carries it, the interface pin holds, the pairing gate works, capability negotiation reported the real 48 MP sensor, and a full-resolution photo landed where it should.

For Agents

Repo /Users/levander/coding/aposemati. Branch phase0-phase1-link-and-shutter68 commits, 219 tests, zero build warnings, clean working tree, nothing merged to main, nothing pushed. Both apps are installed and running on real hardware. The review loop is closed, and the final whole-branch review has now run (2026-08-19, six reviewers, entire branch at once) — verdict: no finding blocks the branch. Its findings are not yet applied: aposemati-whole-branch-review. Build/install/run commands: aposemati-build-install-run (and HANDOVER.md, which is current). Source of truth in-repo, in order: HANDOVER.md.superpowers/sdd/2026-08-18-phase0-phase1-link-and-shutter/progress.md (419-line ledger, 37 rulings + 27 deferred minors) → docs/superpowers/specs/2026-08-18-aposemati-design.mddocs/superpowers/phase0-results.md. The ledger is the important one: every non-obvious choice is recorded there with its cost-if-wrong.

Status at a glance

Branchphase0-phase1-link-and-shutter, unmerged, unpushed, clean tree
Commits68
Tests219, zero build warnings under -strict-concurrency=complete -warnings-as-errors
Phase 0PASSED on hardware — see aposemati-phase0-field-measurements
Phase 1Working end to end — shutter → 48 MP still → Mac library
Review loopClosed. Ledger holds 37 rulings + 27 deferred minors — the review found the real count is 29, with no rulings for the last four commits
Whole-branch reviewRAN 2026-08-19 — no finding blocks the branch. 3 Criticals; C1/C2 unreachable through the Phase 1 shutter path, C3 is the POSIX 57 root cause. Nothing applied yetaposemati-whole-branch-review
Live previewNot built. Phase 2 by design — not a bug
Remaining stepApply the review’s ranked fixes (arm D, AuditGapTests.swift, the C2 hybrid), then hardware
AppsBoth installed and running on real hardware

Architecture as built

Four channels, two connections. Preview (droppable datagrams) and control share one QUIC connection; stills and video get their own.

Why two connections and not one

QUIC eliminates head-of-line blocking between streams, but all streams on a connection share one congestion controller, and Network.framework exposes no cross-stream priority — every function in quic_options.h was enumerated and there is no congestion-controller selection, no stream priority, no pacing. nw_content_context_set_relative_priority arbitrates only within a connection.

A multi-megabyte still on the preview’s connection would spike viewfinder latency — reproducing the exact “Camo is laggy” failure this project exists to beat.

Three principles that survived the build unchanged:

  • Never stream the thing you are keeping. Video records on-device at full quality and transfers afterwards, so recording quality is independent of link quality. Camo streams and records the stream, so its recording quality is capped by transport quality.
  • Framing is metadata, never pixels. Crop, zoom, rotation, auto-framing are stored as a transform beside a preserved original.
  • Nothing hardcodes a resolution. Capability is negotiated at pairing — see the corrected device capability matrix.

The wire-format change that mattered

Asset frames carry a 16-byte UUID prefix. Pairing an incoming payload to its announcement used to be positional FIFO — nothing on the asset frame identified its descriptor, and the only cross-check was byteCount. Two announced assets of equal size arriving out of announcement order silently misfiled one photo and dropped the other, while receivedAssets reported success.

The implementer found this itself and called it “the worst possible failure for a capture tool”. It was fixed before Task 12 ossified the wire format — correspondence is now structural, not incidental. Byte order is RFC 4122 network order, so the format is platform-stable.

Frame-size cap is decode-side only

FrameCodec.maxFrameSize is 16 MiB and enforced only on decode. FrameCodec.encode accepts any payload and emits an over-cap frame with no local signal — the sender sees success while the peer’s decoder rejects it and tears the connection down. The sender-side guard lives in the transport, not the codec.

16 MiB rejects ProRAW (25–75 MB). That is a scheduled Phase 4 revisit currently written as settled policy.

Security model

Both sides generate a self-signed identity in memory at launch — never persisted, never in the keychain. Verified: after the full suite plus ~5000 identity generations and a dozen handshakes, every file under ~/Library/Keychains/ was byte-for-byte and mtime-for-mtime identical to the pre-run snapshot.

After the QUIC handshake they run a commit-reveal nonce exchange and each derives the same six digits from both certificate fingerprints and both nonces. The user compares two screens.

The commit-reveal is not decoration — without it a reviewer ground two colliding certificates and ran a working machine-in-the-middle in 0.77 seconds. Full account: aposemati-pairing-security-model.

The four fixes that closed the review loop

All four landed after the live run and after the first round of documentation. Each is verified by execution, not by reading.

aef872b — the transport reads nothing until the application asks for the frames

This was the Critical. An unconfirmed stranger could take a listening iPhone from 18 MB to 196 MB RSS, and four strangers together reached 321 MB — jetsam territory on a phone. The earlier preConfirmationByteBudget did not bound it: it was a one-second sampler on an undrained, unthrottled AsyncStream, so exposure was ~1 second of line rate rather than unbounded-forever.

The fix makes the transport not read from a peer at all until the application asks for its frames.

Verified in a separate victim process, with a control

34.7 MB peak against four flooding strangers, versus 486 MB with the fix reverted. A control run against the pre-fix build is what makes the number mean something.

ed436a0 — the readiness bound raised to 30 s, and instrumented

The field bug from the first live run: could not open the direct link to iPhone: the phone did not finish the handshake in time. An earlier fix round raised a different bound on a premise that contradicted its own error table, so the bug survived a round.

The string the owner actually saw comes only from handshakeTimedOut, thrown from a single site — awaitReady(_:timeout:). Traced there and measured, it was still failing at 10.65 s against the old 10 s bound. Now 30 s, with readiness duration instrumented so the next two-device run measures it.

Reading the diff would have passed this

Only tracing the exact error string back to its one throw site, and then measuring there, caught it. See Trace a symptom to its single throw site, then measure there.

86fb3f1 — deferred reading must not become dropping

aef872b introduced a regression: a late reader lost frames that had arrived before the peer left. The fixed build delivered 0 where both the pre-fix build and LoopbackTransport delivered 1 — the one place the two transports had stopped being interchangeable, a property verified four separate times across the build. A late reader can now still collect what arrived before the peer left.

e1cd333 — the refusal-recovery button no longer loops

Previously the only button on the red “they do not match” screen re-handshook every ~2.1 s forever, making the phone display a new six-digit code every 2 seconds indefinitely, with no in-app recovery. Notable as a case where fixing one thing (making refusal actually stick) created another.

The whole-branch review — it ran, 2026-08-19

Verdict: no finding blocks the branch

Six reviewers, isolated clones, entire branch at once (adc604b961d996). The exploit claim that would have blocked it is refuted. Three handover open questions are closed with measurements. Two of the three Criticals (C1, C2) are real but unreachable through the Phase 1 shutter path — which is why the live run worked — and both sit directly under Phase 2 preview and Phase 4 video. The third, C3, is in the Phase 1 path: it is the POSIX 57 root cause below.

Nothing from the review has been applied. Working artifacts are parked in docs/superpowers/2026-08-19-review-artifacts/. Full account: aposemati-whole-branch-review.

SOLVED: the intermittent POSIX 57 during pairing

It was never a flake. NetworkTransport.swift:369-372 — the QUIC listener delivers two NWConnections per incoming connection (streamIdentifier == 1 is the one the listener was handed; 0 is the peer’s real stream). accept cancels the sibling, and cancelling any one stream resets the whole QUIC connection.

The shipped listener therefore tolerates under 250 ms between its connection arriving and the peer’s stream settling, and past that it fails totally, not intermittently (0/8 at 250 ms, 900 ms, 1100 ms, 1500 ms). The ~1.8% POSIX 57 rate was that cliff grazed by jitter. The margin is thin, not absent — the 2026-08-18 live run is positive evidence the gap came in under 250 ms on real AWDL.

Fix is arm D: park the sibling keyed by 4-tuple, release on the peer’s stream, reap orphans after handshakeTimeout + pairingTimeout. 1800 pairings, zero failures. Do not use the naive return nil — it trades 1.8% instant failures for 0.56% thirty-second hangs. See 4. A QUIC listener hands you TWO connections per peer — and cancelling either resets both.

86fb3f1 does not deliver its guarantee, and it cannot be made to

The late-reader test sends 10 bytes. The guarantee holds to 256 KB and not one byte further — a 4 MB photo vanishes silently while carriedTraffic() reports the bytes acknowledged. Worse, C1 and C2 are mutually exclusive: a photo survives a late read iff budget ≥ maxFrameSize (16 MB), so any budget that saves a 48 MP HEIC is one an unconfirmed stranger can hold. The recommendation is to drop the C1 guarantee, delete its test, and document that LoopbackTransport and NetworkTransport are not interchangeable across a peer close — see ⭐ C1 and C2 are mutually exclusive — a decision, not a tuning problem.

Still untested on hardware

What needs a human

  1. Re-test the handshake at the new 30 s bound. ed436a0 is unverified in the field.
  2. Walk to the spot where Camo drops, with the apps. Phase 0 proved the probe holds awdl0 there at 156 Mbps — but that was two Macs moving zeros. The phone and the Mac pairing and transferring a real photo at that spot is the last untested product claim, and it is the entire reason the project exists.
  3. Log the stream-lag on that same run — the interval at the listener between the connection being delivered and the peer’s stream becoming ready. It is the exact quantity the 250 ms tolerance applies to, it is unmeasured on any real link, and it costs nothing. Single-digit ms ⇒ the shipped margin is fine; tens or hundreds ⇒ arm D is load-bearing.
  4. Deliberately press “They do not match”, then reconnect. It will offer the same Mac again — the refusal blacklist is per-process-lifetime by design (Known and accepted weaknesses).

Deliberately not built

Phase 1 was always shutter-and-stills. No live preview — that is Phase 2, and the four-channel design exists so it can be added without touching the transfer path. Also deferred: video recording (Phase 4), manual controls (5), framing (6), and the CoreMediaIO virtual camera (7), which is where “mirrors Camo” actually becomes true.

Sources/AposematiProbe/ is the Phase 0 measurement tool. It still carries the uncancellable-continuation shape that cost two fix rounds in the real transport — see swift-uncancellable-continuation-trap.

Corrected 2026-08-19 — Probe's continuations are safe

The whole-branch review disproved this as a live defect: those continuations are never awaited directly, so they cannot hang anything. HANDOVER.md:172-173 gives the wrong reason for the same conclusion — what matters is not “it has a cancellation handler” but “is the continuation ever awaited directly”. Probe.swift is separately 0.00% covered (0/666 lines) and got a grep rather than a bounds review.

How to build

swift test                                    # 219 tests
cd Apps/AposematiCamera && xcodegen generate  # .xcodeproj is not committed
cd Apps/AposematiHost   && xcodegen generate

Two traps that cost real time

The iOS product is Aposemati.app, not AposematiCamera.app — the scheme name and the product name differ. And the xcodebuild -destination id= is not the devicectl device identifier: the same phone has two different UUIDs.

Full working commands: aposemati-build-install-run. Signing is Apple Development for team NZZ28CZ49H and does not work out of the box on a fresh machine — see aposemati-apple-signing-gotchas.