Skip to content

Stage S2 — Honest pair flow

Status: reviewed — forward plan, active

Phases A, B, and C have landed. Phase C's self-research queue (Q1-Q5) is fully resolved; Q3's replay-based investigation closed across iter-0 through iter-4: reference dispatch table built from pcap (iter-0), upfront replay rejected with PROTOCOL_ERROR (iter-1), event-driven dispatch clears HTTP/2 but REPLY_CHANNEL still closes (iter-2), strict pcap ordering disproves the ordering hypothesis (iter-3), dropping our reciprocal #9 RST_STREAM(s1) removes CDS's application-layer teardown and CDS reaches a quiescent state (iter-4). Pure replay has reached its ceiling. Further s2c progress belongs to Phase D backend work (dynamic response to CDS's post-handshake RemoteXPC service calls). Phase D (Go-only Option δ backend) is specified but not yet implemented.

Date: session 10 (post S2.B landing) Supersedes: the original Option α/γ framing of this document, which Phase B empirically falsified. Philosophy: CDS must be told the truth about device state, and the code it runs to make things happen must either actually work, or be actively shimmed through a backend we know works. No bypass, no lies.

The Phase A realization (unchanged, landed)

Stage S1 finished with devicectl list devices returning the virtual iPhone end-to-end. Opening Xcode's Devices window revealed that four pieces of DeviceInfo had been force-written by the inject to make CDS believe the device was further along its lifecycle than it was:

  • DeviceInfo.pairingState = .paired
  • DeviceInfo.preparednessState = .all
  • DeviceInfo.areDeveloperDiskImageServicesAvailable = true
  • DeviceInfo.state = .connected

Phase A removed the first three outright. The fourth (state) was temporarily removed and then restored when empirical test showed Xcode filters devices whose DeviceInfo.state defaults to .unavailable (tag 0) — the link physically exists because an RSDDeviceWrapper with a live tunnel is in process memory, so .connected is a truthful link-state value, not a lie about pairing. See inject/iosmux_inject.m around the visibility_setter / state_setter block and the tombstone comment there.

Phase A landed in commit 18331ca as "Stage 2.A: stop lying about device state".

The Phase B realization (new, course-correcting)

With the three lies removed, the Pair-button smoke test was run. Findings are fully documented in docs/research/s2b-pair-attempt-log.md; the short version is:

  • Xcode correctly shows a Pair button, state=connected (no DDI), pairingState=pairingInProgress — all the honest pre-pair values.
  • Clicking Pair causes CDS to invoke PairActionImplementation through our S1.B passthrough trampoline. No crash, no SIGSEGV.
  • CDS's next step is to open an nw_connection on utun4 directly to [fdc5:8480:2949::1]:57346 (the RSD endpoint address it fetched from tunneld's HTTP / API) and speak HTTP/2 on it.
  • The connection is immediately reset by the peer. Retries identical. com.apple.remotepairing subsystem never emits a single event, because CDS never gets past the TCP-level failure.
  • The tunnel itself is not broken. A pymobiledevice3 Python client using the same tunneld instance and the same RSD endpoint successfully pulls the full handshake, queries DDI mount state, and lists the iPhone's root filesystem via the DVT developer service. User-confirmed: DDI mount, service calls, and filesystem access have all worked repeatedly via pymobiledevice3 in previous sessions.

The pre-Phase-B assumption (Option α: "let the natural CDS pair path work through the existing tunnel") is now empirically false. CDS and pymobiledevice3 are two independent client implementations of the RSD-family transport, and CDS cannot speak to a channel produced by pymobiledevice3 tunneld. Option γ ("audit our existing DYLD_INTERPOSE so the pair service is forwarded correctly") is also falsified: the pair path does not flow through remote_service_create_connected_socket or any other MobileDevice/RSD C API at all — it goes straight through Network.framework (nw_connection), bypassing everything we currently interpose.

Option β (transplant Linux host's pair record) remains rejected. The problem is not pairing, the problem is that CDS does not speak the tunnel's transport; handing it a pair record would not give it a different transport layer.

Option δ — CDS → pymobiledevice3 backend shim

This is the new direction. The short statement is:

Stop trying to make CDS talk to the iPhone. Make CDS talk to pymobiledevice3, and let pymobiledevice3 talk to the iPhone.

The precedent already exists in the current inject:

  • iosmux_md_proxy.m interposes MDRemoteServiceSupport.retrievePropertiesForUUID: and retrieveNameForUUID: and answers them from an in-memory cache built from the pymobiledevice3 handshake. CDS never actually queries the device for properties — the inject serves them from Python-captured data. This works. No crash, no lie — just a translation layer.

Option δ generalizes that pattern to every CDS → iPhone call. When CDS tries to open a service socket, do a pair handshake, mount a DDI, install an app, or attach a debugger, the request is caught by our inject and routed to a local pymobiledevice3 session that does the real work on its existing working tunnel. The return value (socket fd, response bytes, Codable envelope, whatever) is handed back to CDS in the shape CDS expects.

This reframes Stage 2 from "observe the real pair flow" to "build the smallest shim that makes CDS's attempts succeed by delegating them". It is more work than α/γ would have been, but α/γ were physically impossible given the transport mismatch — this is the first plan that is actually compatible with the ground truth.

Phase B — done

Deliverable was docs/research/s2b-pair-attempt-log.md. Landed alongside the earlier rewrite of this plan. Identified three blockers — X (client-side protocol), Y (which PID runs the call), Z (sandbox SCM_RIGHTS capability) — that Phase C was tasked with resolving.

Phase C — self-experiment (done) + remaining research queue

What the self-experiment closed

Full write-up in docs/research/s2c-self-experiment/FINDINGS.md. Short version:

  • X (client side) — CDS speaks plain prior-knowledge HTTP/2 cleartext (h2c) to the tunnel endpoint. No TLS, no ALPN. The 24-byte HTTP/2 connection preface arrives first, then a stock SETTINGS frame whose values (MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=1 MiB) match pymobiledevice3's own reverse observations byte for byte. Above HTTP/2 CDS uses RemoteXPC DATA frames on stream 1 (ROOT_CHANNEL) and stream 3 (REPLY_CHANNEL) with XpcWrapper / XpcPayload framing whose magic bytes (0x290bb092, 0x42133742) match pymobiledevice3's remote/xpc_message.py exactly. The empty-HEADERS stream-open pattern CDS uses is unusual but compatible with golang.org/x/net/http2 at the framer level.
  • Y — every nw_connection_* call observed in the experiment came from the same CoreDeviceService PID our LC_LOAD_DYLIB inject loaded into. No helper process, no delegation to remotepairingd. The earlier "three CoreDeviceService PIDs" observation turned out to be concurrent launchd-managed instances of the same binary (user session / system session / log-stream-ephemeral), and the one handling each request is whichever the caller's XPC connection resolves to. Our current injection reaches it.
  • Z — CDS's sandbox permits, at runtime, each of the four Shape B primitives we probed: socket(AF_UNIX, SOCK_STREAM), socketpair(), bind() to /tmp/*.sock, and sendmsg() with an SCM_RIGHTS ancillary block. All returned errno=0. The devil's advocate attack ranked "sandbox denies SCM_RIGHTS" as a 55% chance of killing Shape B — it is now 0%, empirically verified.

Shape B is therefore architecturally viable. The hook site exists in the right process, the sandbox allows the required primitives, and the client-side transport is standard enough for a Go HTTP/2 server to speak.

Phase C research — all five questions resolved

The self-experiment did NOT tell us what the server side of the conversation should look like — our listener never replied to CDS's first DATA frame, so the session stalled at that point. Five research questions (Q1..Q5) were tracked in a dedicated standalone file: plan-stage2-phase-c-queue.md. All five are resolved as of 2026-04-18, on havoc, self-contained, no guessing. Short summary:

  • Q1 (verified) — decoded the full 204-byte CDS → spike capture via pymobiledevice3.remote.xpc_message.XpcWrapper.parse. Classification: generic RemoteXPC connection init, byte-exact with pymobiledevice3's own _do_handshake. See Q1-decode.md.
  • Q2 (verified)tcpdump on havoc's utun4 during a live pymobiledevice3 remote rsd-info round-trip shows plaintext HTTP/2 prior-knowledge (h2c), no TLS. See Q2-utun4-capture.md.
  • Q3 iter-0 (verified) — reference dispatch table built from the Q2 pcap. The iPhone unilaterally sends a single 14 KB MessageType="Handshake" DATA frame on stream 1 (62 services + 46 properties) right after h2c init, then RSTs the stream with NO_ERROR. Stream 3 (REPLY_CHANNEL) stays open. See Q3-iter-00-pcap-dispatch.md. iter-1+ is implementation work — modify the spike listener to replay this Handshake at CDS, observe CDS's next request, iterate.
  • Q4 (verified) — pymobiledevice3 ships no "pretend to be iPhone" server fixture, but its XpcWrapper.build + _build_xpc_* helpers are direction-agnostic and reusable for server-side replies. See Q4-server-side.md.
  • Q5 (closed without work) — the post-TLS hook was predicated on Q2 showing TLS. Q2 showed plaintext; Q5 has nothing to do. See Q5-post-tls.md.

Every answer is tied to a raw artifact on disk. Any residual unknown is named explicitly in the per-question doc (the 0x200 unnamed flag bit and the message_id=1 skip are the two minor gaps still in the books; neither blocks Phase D).

Still open — carried over from earlier

C.7 is backlog-priority until we see Phase D behavior

We assume the DeviceIdentifier.uuid(UUID, String) second slot does not affect pair-flow correctness, only identity-UI presentation. This is unverified — the assumption would be promoted to verified by either a disasm note in Phase D's CoreDevice work or by an empirical test that toggles the string and observes Xcode's reaction. Until then treat any Phase D bug that names this slot as potentially related.

C.7 DeviceIdentifier.uuid(UUID, String) second slot — what does CDS do with the String payload? This is a small identity-UI question that only matters once the pair flow otherwise works. It stays in the backlog and gets answered by the same disasm work that will show up in Phase D if we discover a related behavior while implementing the backend.

Closed by Phases B + C

  • "iOS 17+ pair service endpoint — find name and port" → the endpoint is the unified RSD endpoint [tunnel-address]:tunnel-port, not a per-service port.
  • "does CDS reach the pair service via remote_service_create_connected_socket" → no, it goes through nw_connection directly.
  • "what does default unpaired DeviceInfo look like on a real iPhone" → answered empirically: removing our three lies produces Xcode's "unpaired, connected, no DDI, Pair button visible" UI, which is exactly a fresh iPhone's baseline.
  • "does the physical iPhone accept pair from a new host while already paired with Linux" → moot; Option δ routes through the existing pair record that pymobiledevice3 tunneld already holds.
  • "CDS's own service-open call chain for PairAction" → resolved empirically by the wire logger. The path is PairActionImplementation.invoke → Swift async continuation → RemotePairing.framework → Network.framework nw_connection_create(_with_connected_socket). All in-process in the same CDS instance.
  • "CDS's expected client-side protocol" → plain prior-knowledge h2c + RemoteXPC DATA frames, per FINDINGS.md §X.
  • "Best interposition point" → the spike override in pymobiledevice3 tunneld is already sufficient to redirect CDS without an inject hook on nw_connection_create_* at all, and the Go backend will own the redirected listener endpoint directly. See Phase D for the concrete shape.
  • "Backend architecture: embedded Python vs sibling daemon vs Go"Go, not Python, as a first-class principle. Rationale in Phase D.
  • "Surface area of pair flow in pymobiledevice3" → known: the RemoteXPC message types we need to handle are the ones CDS actually sends on streams 1 and 3 of its h2c session. Q1-Q3 will enumerate them one by one.
  • "pymobiledevice3 pair record storage and survival" → already answered in an earlier research pass: Ed25519 pair records live in ~/.pymobiledevice3/remote_{udid}.plist, survive tunneld restarts, trigger the physical Trust prompt only on the very first pair. The Go backend does not touch pair records directly — the existing pymobiledevice3 tunneld subprocess keeps owning that state.

Phase D — implementation (shape locked by Phase C findings)

Phase D delivers the Option δ shim. Its shape is now fixed by what the self-experiment found.

Principle: new code is Go, Python stays where it already is

All new production code for Phase D is Go. The project has been Go-based from the start; introducing a second production language on a component we plan to delete in Phase E (the "remove Python" milestone) would create exactly the inertia we want to avoid. Python involvement is strictly limited to:

  • pymobiledevice3 remote tunneld — the existing subprocess we already run on havoc. It keeps owning TLS 1.2 PSK handshake to the real iPhone, pair identity management, and the utun tunnel lifecycle. Go talks to it over HTTP on loopback, exactly as it does today. We do not ship any NEW Python production code.
  • Spike/research tooling in docs/research/s2c-self-experiment/ (the Python HTTP/2 listener, one-shot capture scripts). These are research artifacts, never run in production, deleted with the rest of Stage S2's spike infrastructure when the Go backend ships.

If a future step genuinely needs server-side XPC logic that is only implemented in pymobiledevice3 today, the contained fallback is a narrow RPC subprocess (single pymobiledevice3-backed Python process driven by Go over a Unix socket with a few RPC methods) — but only as a contained compatibility shim with an explicit Go porting plan per method. We do not write a full Python backend and call it "temporary".

Components

The Go backend is a new command under cmd/iosmux-backend/ in this repo. It is a single long-lived process, launched from iosmux-restore.sh (or a LaunchAgent on havoc once we productize), that:

  1. Listens on a loopback TCP endpoint — the one tunneld advertises via its HTTP API response. During spike/research this is driven by the IOSMUX_SPIKE env-var patch (docs/patches/pymobiledevice3/0001-iosmux-spike-tunneld-endpoint-override.patch); in production the same mechanism can be generalized to an always-on IOSMUX_BACKEND_ADDR override, or tunneld can be replaced altogether by a Go component that owns the HTTP API. Either way, CDS learns the shim's address through the same GET / mechanism it already uses.
  2. Speaks prior-knowledge h2c using golang.org/x/net/http2. Not net/http.Server (that wants a full request/response model) but the lower-level Framer interface, because RemoteXPC uses persistent streams and DATA frames rather than HTTP semantics. Example prior art: Caddy's h2c handler, gRPC-Go's h2c mode — both production.
  3. Implements RemoteXPC on top of the raw frame layer:
  4. XpcWrapper / XpcPayload encode+decode (magic bytes, flags, size, msg_id) — ported to Go from pymobiledevice3/remote/xpc_message.py with the decoded bytes in research/s2c-self-experiment/FINDINGS.md as a ground-truth test vector.
  5. bplist16 decoding for XPC object payloads. howett.net/plist (already in go.mod) handles bplist00 and may handle bplist16 too; if not, the missing bits are a small extension.
  6. Stream routing — messages on stream 1 = ROOT_CHANNEL, stream 3 = REPLY_CHANNEL, one open per channel, handled as two persistent async loops. Matches the empirically-observed stream topology from the S2.C capture.
  7. Delegates the actual device work to its internal session, which is held open against tunneld's HTTP API for the lifetime of the backend. Every CDS request is translated into either:
  8. A native Go implementation of the equivalent RemoteXPC call (for calls we fully understand), or
  9. A pymobiledevice3-backed fallback over a narrow subprocess RPC (for calls that are only implemented in pymobiledevice3 today, pending a Go port per call).
  10. Materializes replies as XpcWrapper-framed DATA frames written back to CDS on the correct stream. Every reply must come from real data (Phase A's "never lie about state" rule stays binding here).

Concrete sub-tasks

  • D.0 — Checkpoint current work (this commit). ✅ done.
  • D.1 — Finish the Phase C research queue (Q1-Q5 in the section above) until either the server-side protocol is fully understood or a concrete empirical gap is named. ✅ done (Q1-Q5 verified, Q3 iter-1 through iter-4 closed under replay — see iter-04/findings.md).
  • D.2 — Bootstrap cmd/iosmux-backend/ with a skeleton that binds a configurable address, logs every accepted connection, and shuts down cleanly on SIGTERM. No HTTP/2 yet. Verify it fits the existing go build ./... flow. ✅ done — cmd/iosmux-backend/main.go + internal/backend/{backend.go, backend_test.go}. Default listen [::1]:34719 matches the SPIKE tunneld advertisement; go test -race ./internal/backend/ passes; SIGTERM shutdown verified under 1s.
  • D.3 — Wire the Go HTTP/2 framer to the listener. Replicate the empirically-observed server handshake (SETTINGS{MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=1 MiB}, WINDOW_UPDATE(983041), auto-SETTINGS-ACK). Reuse the existing spike listener's session logs as regression fixtures. ✅ done — golang.org/x/net v0.53.0 added; raw http2.Framer used directly (not http2.Server{}.ServeConn — keeps CDS's HEADERS len=0 minor RFC deviation working). Byte-exactness with the iter-01 iPhone fixture locked by a dedicated test (TestServerHandshakeExactBytes). Preface mismatch path classified (TLS ClientHello via 0x16; HTTP/1.x via G/H/P). ReadFrame is not ctx-aware, so a watcher goroutine closes the conn on ctx cancellation and the framer loop exits on net.ErrClosed. All 7 tests pass under -race.
  • D.4 — Port XpcWrapper / XpcPayload / bplist16 decoders to Go. The hex blob in FINDINGS.md §X is a unit test vector: the decoder either produces the same object graph pymobiledevice3's parser produces (Q1 decode) or the decoder is wrong. ✅ done — internal/xpc/ from earlier project stages already implements the full codec (wrapper.go, types.go, decode.go, encode.go). Note: the stage2.md "bplist16" language is imprecise — XPC uses its own binary format (magic 0x42133742, type codes 0x00001000/0x00002000/...), not Apple's bplist00/bplist16, so howett.net/plist is not involved. Four iter-01 regression fixtures added to xpc_test.go: 44 B empty-dict mirror, 24 B sync 0x0201, 24 B INIT_HANDSHAKE, 14124 B big Handshake (62 services, 46 properties, MessageType="Handshake", MessagingProtocolVersion=7). All four round-trip byte-exact through UnmarshalMessage / MarshalMessage.
  • D.5 — Implement the ROOT_CHANNEL state machine: accept the first XPC request, dispatch to a stubbed handler, emit a reply, watch CDS proceed to the next step. Dialog-bisect forward using the Q3 methodology. ✅ done — internal/backend/dispatcher.go implements the iter-4 dispatch table as a per-session state machine; iter-01 payloads embedded via //go:embed in internal/backend/fixtures/. TestDispatcherReproducesIter04 drives the full 8-frame CDS client sequence and asserts the server emits exactly 14313 bytes byte-exact vs. iter-04/results/session-01-send.bin, asserting no 10th frame (i.e. no #9 RST_STREAM(s1)). Cross- compile GOOS=darwin GOARCH=amd64 produces a 5.47 MiB binary ready for havoc smoke test. Smoke test executed 2026-04-19 with real CDS: server-side output byte-exact vs iter-4 (cmp-clean 14313 B), quiescent state reached identically. See iter-05-go-backend/findings.md.
  • D.6.0Research step before service handlers. Original D.6 framing ("per-service handlers land one at a time") was too optimistic: D.6.0-A added verbose XPC logging (commit 5d47ae5), D.6.0-B deployed it and let CDS run past the iter-4 observation window. Finding: CDS disconnects with peer EOF ~30 s after our #8 big Handshake. The iter-4 "quiescent" claim was observation-window bias — the byte-level work from iter-1 through iter-4 is correct, but the session is semantically rejected by CDS, not accepted. Before D.6.1 can land any handler, we need to identify and fix whatever CDS disqualifies our handshake on. Four hypotheses ranked in iter-06-pair-trigger/findings.md.
  • D.6.1H1 confirmed: zero UUID was the gate. D.6.1-A (commit ffe7c29) added env-gated UUID patching in the dispatcher (IOSMUX_HANDSHAKE_UUID=random generates a fresh RFC 4122 v4 UUID per session at offset 0x371C of the #8 big Handshake fixture). D.6.1-B deployed it on havoc and let the session run 130 s: zero EOF, TCP ESTABLISHED throughout, only 15 s TCP keepalives. H2/H3/H4 rendered moot. The iter-4 and D.5 wire-level findings were always correct; only the 16-byte placeholder UUID (redacted at source in iphone_replay_bytes.py) blocked CDS's session-validity check. See iter-07-uuid-patched/findings.md. D.6.2 (next) picks up the "CDS now accepts but is silent — what does it expect from us?" question.
  • D.6.2H1c falsified: trigger-type is not the variable. Tested devicectl manage pair and unpair+pair fallback against the UUID-patched backend on 2026-04-23. Four sessions captured, every one hit the 40-line handshake baseline with zero post-handshake frames. Mercury 1000 error surface identical to iter-6. Three trigger classes now tested (device info, manage pair, unpair+pair) — all produce identical silent-hold; the missing variable is on our backend side, not in the client trigger choice. D.6.3 widens the tcpdump lens to lo0 with no port filter to see whether CDS back-connects to advertised service ports (H1b) or is waiting for a server-side follow-up frame (H2-reply). See iter-08-pair-trigger/findings.md.
  • D.6.3H1b FALSIFIED decisively. Single wide-filter pcap capture on 2026-04-24 during the same manage pair trigger. Exactly one TCP connection observed (CDS to our backend on [::1]:34719), zero SYNs to any other local port. CDS does not attempt to back-connect to any of the 62 service ports we advertise. H2-reply is the surviving hypothesis: CDS is waiting for a server-initiated follow-up frame on the existing HTTP/2 stream that the iter-01 replay corpus doesn't cover (Q2 pcap was a one-shot rsd-info; real pair-flow emits more than 10 frames). D.6.4a will temporarily swap tunneld out of SPIKE mode and run Q2-methodology capture on utun4 to observe the authoritative post-handshake bytes from a real iPhone. See iter-09-wide-pcap/findings.md.
  • D.6.4aH2-reply FALSIFIED; parallel service discovered. Temporary SPIKE → stock tunneld swap on 2026-04-24 + utun4 tcpdump during three non-destructive triggers. Apparatus restored to SPIKE cleanly. Real iPhone greeting-stream output is byte-identical to our SPIKE backend (same 10 frames, same silence after #9 RST_STREAM). H2-reply is falsified. Real finding: devicectl opens a parallel non-HTTP/2 TCP service on iPhone:50367 (~9.5 KB iPhone→client, likely lockdown-over-TCP). SPIKE backend has no listener at any equivalent port — tunneld in SPIKE mode advertises only tunnel-port:34719. CDS hangs trying to reach that parallel service. D.6.5 decodes the 50367 byte stream and identifies the service to emulate. Raw pcap at /home/op/backups/iosmux/pcaps/ (contains real UDID, gitignored). See iter-10-real-iphone-ref/findings.md.
  • D.6.5Protocol identified + service advertised. Pure local decode of the iter-10 held-local pcap on 2026-04-24. Protocol on iPhone:50367 is classic lockdown-over-TCP: plaintext XML plist, 4-byte BE length prefix, 3-verb API (RSDCheckin, QueryType, GetValue). Port 50367 is already advertised in our Handshake Services dict under com.apple.mobile.lockdown.remote.trusted (UsesRemoteXPC=False, ServiceVersion=1). Full Services census: 62 entries total, 40 classic-lockdown (UsesRemoteXPC=False) + 22 RemoteXPC (UsesRemoteXPC=True). One Go-backend handler unlocks 40 endpoints. D.6.6 implements: bind [::1]:50367, read length-prefixed XML plists, dispatch 3 verbs, serve cached 88-key device dict for GetValue. ~1 day estimated. See iter-11-lockdown-decode/findings.md.
  • D.6.5-controlLockdown back-connect hypothesis FALSIFIED. Re-ran iter-09 wide-pcap on SPIKE backend with devicectl device info details trigger (the one that iter-10 proved causes lockdown traffic against real iPhone). Result: exactly one SYN (client→our-backend :34719), zero SYNs to any advertised service port, zero RSTs. CDS does NOT back-connect in SPIKE mode regardless of trigger. The "implement lockdown handler → CDS connects" plan is necessary but not sufficient. Revised causal model: CDS evaluates a pair/trust signal after the Handshake and bails upstream of Services dict consultation when it reads "unpaired / untrusted" (which SPIKE apparently signals). iter-10's :50367 flow was opened by pymobiledevice3 (no trust gate), not devicectl (has trust gate) — explains why iter-10 saw :50367 traffic but iter-9 and iter-12 (both SPIKE + devicectl) don't. D.6.6 plan revised: research-a (Properties dict diff between SPIKE and real-iPhone Handshakes) + research-b (env-gated per-field bisection patching) precede the lockdown-handler implementation. See iter-12-spike-control/findings.md.
  • D.6.6-impl — three concrete sub-tasks landed by ADR-0009 + the iter-11/iter-18 evidence chain. The empirical questions that Phase D.6.6-impl will answer (and that the prose below assumes but does not verify) are catalogued in d66-research-questions.md — every prediction in the sub-task descriptions has a stable Q-ID and test plan there. Sub-task 1 deploy resolves Q-D66-1 (lockdown handler reachability); sub-task 2 capture resolves Q-D66-2 (Codable Output bytes); sub-task 3 deploy resolves Q-D66-3 and Q-D66-5.

The three sub-tasks: 1. Lockdown handler at [::1]:50367 in cmd/iosmux-backend/ (synthetic listener bind address — chosen for parity with our greeting fixture's advertised port; whether CDS reaches it is Q-D66-1). Wire format [BE-u32 length][XML plist]. Three verbs (RSDCheckin, QueryType, GetValue). The 88-key device-info dict is sourced by self-bootstrap at backend startup using a three-step chain (commit b2da3f6):

   1. resolve tunnel-address + tunnel-port via tunneld's HTTP
      API at `127.0.0.1:49151`
      (`internal/lockdown/tunneld.go:ResolveTunnelAddress`);
   2. open an h2c RemoteXPC greeting client to
      `[tunnel-address]:tunnel-port`, receive the iPhone's
      unilateral 14 KB big-Handshake DATA frame on stream 1,
      decode the wrapper payload via `internal/xpc/`, return
      the 62-entry Services dict
      (`internal/lockdown/rsd_discovery.go:FetchServices`);
   3. look up `com.apple.mobile.lockdown.remote.trusted` to
      get its **per-session** port number, dial that port,
      run the iter-11 3-verb dialog as a client, cache the
      GetValue Value response, then bind the synthetic
      listener
      (`internal/lockdown/rsd_discovery.go:FindServicePort` +
      existing `internal/lockdown/client.go:FetchDict`).

   The lockdown port number is **not** a constant in code —
   iOS 17+ allocates Services-dict ports per tunneld session;
   see Q-D66-13 in
   [`d66-research-questions.md`](d66-research-questions.md)
   for the empirical cross-session evidence.

   Note: `pymobiledevice3 remote rsd-info` is NOT a possible
   dict source — it returns the 46-key Handshake Properties
   dict (per
   [`s1a-properties-audit.md`](../research/coredevice-internals/s1a-properties-audit.md)
   §"What MobileDevice expects"), which is a different dict
   and lacks the trust-adjacent fields (`HostAttached`,
   `TrustedHostAttached`, `PairRecords`, `ActivationState`,
   `PasswordProtected`) that GetValue carries. Wire-protocol
   reference:
   [iter-11/findings.md §"The 50367 dialog"](../research/protocol/s2c-self-experiment/iter-11-lockdown-decode/findings.md)
   — note that the "50367" in iter-11's tables is the iter-10
   pcap's per-session value, not a stable port.
2. **Mercury Codable Output capture (BLOCKING work item).**
   The byte layout of every `*ActionDeclaration.Output`
   Codable struct CDS expects on the reply XPC connection is
   currently undocumented. Capture is a Mercury logging
   interpose against any working CDS+device pair (logging-only
   `xpc_connection_send_message`), redact identity fields,
   commit the resulting per-action byte sequences as
   fixtures under `internal/backend/fixtures/`. Without
   this capture, the Mercury Codable interceptor in the
   inject cannot encode replies — ADR-0006 forbids inventing
   byte shapes. Highest-priority action to capture is
   `AcquireDeviceUsageAssertionActionDeclaration.Output` because
   its successful reply is what flips
   `_shadowUseAssertion.fulfilled` Xcode-side and hides the
   Pair button (per
   [pair-button-and-cfnetwork.md](../research/coredevice-internals/pair-button-and-cfnetwork.md)
   §"Pair button gating").
3. **Mercury Codable interceptor in inject.** New patch on
   `inject/iosmux_inject.m` that recognises a small set of
   `mangledTypeName` values and replies with empty-but-typed
   Codable Output structs derived from the captures in
   sub-task 2. Initial action set: AcquireDeviceUsageAssertion
   (priority), then Pair, Unpair, Connect, Disconnect, then
   the broader static-success set per ADR-0009 §Consequences.
  • D.6.6-impl-phase1 — sub-task 3 landed across session 14 (iter 1-17 PairAction Codable schema, commits b808eae49c8f1e) and session 16 (per-action dispatch + AFM endpoint side-channel, commits 5de52be28d7d89). Phase 1 milestone: spinner gate broken (Q-D66-14 RESOLVED — kvoCache_isPaired Bool ivar driven by RDSUE broadcasts filtered on monotonicIdentifier, fixed by seeding the counter from mach_absolute_time()). Per-action dispatch replaces blanket DeviceConnectionChangeResult with per-aid Output types empirically derived in notes/iosmux-actions-output-probe.md. AFM endpoint emit (xpc_connection_create_from_endpoint on Input.endpoint, send AFM with xpc_connection_send_barrier deferred cancel) verified on our side but Apple-side acceptance is the remaining cosmetic gap — see Q-D66-15 in d66-research-questions.md.
  • D.6.6-impl-phase2 — close the IDEDeviceSymbolsCoordinator non-empty osBuildUpdate.name assertion + any non-display gates uncovered after Phase 1. Plan in notes/iosmux-implementation-plan.md §4. Deferred until Phase 1 cosmetic AFM gap resolution determines whether _shadowUseAssertion=nil actually blocks anything beyond UI category.
  • D.6.6-impl-phase3 — structured DeviceInfo display fields (osVersionNumber / osBuildUpdate structs replacing the bare strings, internalStorageCapacity UInt64) + remaining action Output handlers for actions whose Output is not DeviceConnectionChangeResult. Plan in same notes file §5.
  • D.6 — Other per-service handlers (DDI mount, app install, etc.) land one at a time, each gated on real empirical data from either Q2/Q5 captures or dialog bisection.
  • D.7 — Remove the IOSMUX_SPIKE tunneld patch once the Go backend owns the address CDS is told to connect to; replace with either a generalized tunneld override or a tunneld replacement owned by iosmux itself.

Non-goals (still binding):

  • Faking any reply out of thin air. Every byte the backend sends on the wire must either come from a real data source or be a well-defined framing artifact (header magic, length field, etc.). If a request arrives for a shape we do not know, we halt and research it — we do not invent a reply.
  • Setting any DeviceInfo field to a value we have not observed from a real source.
  • Hooking any Swift action-dispatch path beyond the existing S1.B passthrough trampoline. The S2.C findings show the inject hook is unnecessary for redirecting the transport — the tunneld address override does that alone — so the inject stays minimal.

Phase E — post-pair (DDI, developer mode, install, debug)

Same shape as the old Phase E, except all of it flows through the same Option δ shim. DDI mount, developer-mode query, app install, debug attach — each is its own set of pymobiledevice3 calls exposed to the backend, each is its own pair of CDS-interposition points. We may find additional missing services to proxy as we walk the flow.

Risks and unknowns we are accepting

  • Server-side protocol gap — now much narrower. The S2.C capture gave us every byte CDS sends but no byte CDS expects back from a server. Q1-Q5 closed the structural gap: Q2's live pcap plus Q3 iter-0's decoded dispatch table are the reference bytes for the h2c + handshake phase. Unknowns that remain are scoped to iter-N+1 specific commands: whenever a Q3 iteration hits a CDS request for which the iter-0 dispatch table has no match, we re-run the matching pymobiledevice3 remote ... command on havoc under tcpdump and extract authoritative iPhone bytes for it. This is fully self-contained on havoc — no external party needed.
  • Go http2 framer edge cases. CDS opens streams with HEADERS len=0, which is a minor RFC deviation. net/http Server rejects this; the lower-level Framer API accepts it. Phase D.3 must drive the framer directly, not go through http2.Server{}.ServeConn with a standard Handler. If some other frame-level quirk we have not yet seen exists, the spike listener's capture-and-halt methodology will surface it — no production code runs on unverified assumptions.
  • Backend lifecycle. The Go backend must survive CDS restarts, respond quickly enough that CDS does not time out, and not leak tunnel state across device reconnects. The simplest answer: one long-lived backend process launched at the same time as tunneld, per havoc session, managed by the same iosmux-restore.sh flow that already owns tunneld's lifecycle.
  • bplist16 coverage in howett.net/plist. If the existing Go plist library does not handle bplist16 (the XPC variant), D.4 gains a "port bplist16 from pymobiledevice3" sub-task. Bounded, ~200-400 LOC Go at most.
  • pymobiledevice3 version drift. We pin upstream via docs/patches/pymobiledevice3/UPSTREAM_VERSION and treat any upgrade as a deliberate research task. Patches under docs/patches/pymobiledevice3/ are all research-scoped; none of them define production behavior.

Open questions that do not need research but need decisions

  • Do we keep tunneld as an independent long-running Python subprocess and make the Go backend talk to it over its HTTP API, or do we eventually bring the tunneld state machine into Go alongside the backend so there is one less moving part? The Python version stays for Stage S2; Go replacement is a separate track.
  • Where does the Go backend's logging go, and who watches it? A file under /tmp/iosmux-backend.log with log/slog-structured records is probably enough for the spike runs; productization adds proper rotation later.
  • At what point do we freeze "Stage 2 is done"? Pair working is the minimum bar; DDI + developer mode + install + debug are incremental over the same backend and can slip into Stage 3 if the pair plumbing takes longer than expected.

What comes AFTER this roadmap

After Phase E works end-to-end:

  • Promote from PoC to proper config (remove hardcoded UUIDs, service names, paths).
  • Productize iosmux-backend into a daemon that ships with the project.
  • Write user-facing docs.
  • Binary releases, supported OS matrix, upgrade safety across macOS / iOS versions.

None of that matters until the backend shim proves it can drive the pair flow honestly end to end.