Skip to content

GAMBIT — PairActionDeclaration Output Codable schema (iter 1-17 resolved)

Status: verified — 2026-04-28, last commit 49c8f1e (iter 17)

Complete byte-level Codable schema for PairActionDeclaration.Output = CoreDevice.DeviceConnectionChangeResult empirically derived through 17 Pair-button click iterations on havoc with a live iPhone. Every type, key name, value form, and wire encoding below is sourced from a specific Apple decoder error read out of the unified log (subsystem com.apple.dt.coredevice) — none guessed.

TL;DR

The Mercury action interceptor (GAMBIT) at inject/iosmux_gambit.m synthesises a DeviceConnectionChangeResult Codable reply that Apple's KeyedDecodingContainer accepts in full as of iter 17. Apple's Xcode: (CoreDevice) [com.apple.dt.coredevice:pairing] Pairing attempt completed with error nil confirms the decode is clean. Action layer ✓; UI transition layer ✗ — spinner state remains, see pair-button-and-cfnetwork.md §"Two gates, not one" for the reason (UI binds independently to _shadowUseAssertion.fulfilled, not to PairAction Output success).

Type tree (top-down)

CoreDevice.output = DeviceConnectionChangeResult {
  outcome:         Outcome,
  updatedSnapshot: DeviceStateSnapshot
}

Outcome (auto-Codable enum, no rawValue) {
  case success                                  // no associated values
  case failure(Mercury.NSErrorContainer)
}

DeviceStateSnapshot {
  state:                     DeviceState_inner_type,  // see below
  capabilityImplementations: [DeviceCapabilityImplementation],
  deviceInfo:                CoreDeviceProtocols.DeviceInfo,
  monotonicIdentifier:       Swift.Int64
}

DeviceInfo (CoreDeviceProtocols.DeviceInfo) — required fields {
  identifier:                            Foundation.UUID,
  serviceDeviceIdentifier:               CoreDevice.DeviceIdentifier,
  pairingState:                          PairingState (String rawValue),
  preparednessState:                     DevicePreparedness (OptionSet, Int),
  state:                                 CoreDeviceProtocols.DeviceState (String rawValue),
  visibilityClass:                       DeviceVisibilityClass (String rawValue),
  isMobileDeviceOnly:                    Swift.Bool,
  areDeveloperDiskImageServicesAvailable: Swift.Bool,
  potentialHostnames:                    [Swift.String],
  tags:                                  [Swift.String],
  defaultUserCredentials:                Set<DefaultUserCredential>,
  providerSpecificValues:                [Swift.String: Swift.String],
  additionalMetadata:                    [Swift.String: CoreDevice.CodableValue],
  // optional fields populated from real iPhone Handshake (ADR-0006):
  productType:    Swift.String?,    // handshake "ProductType"
  osVersion:      Swift.String?,    // handshake "OSVersion"
  osBuild:        Swift.String?,    // handshake "BuildVersion"
  udid:           Swift.String?,    // handshake "UniqueDeviceID"
  serialNumber:   Swift.String?,    // handshake "SerialNumber"
  hardwareModel:  Swift.String?,    // handshake "HardwarePlatform"
  ecid:           Swift.UInt64?,    // handshake "UniqueChipID"
  // synthetic-only (no handshake source):
  name:           Swift.String?,    // "iPhone (iosmux)"
  reality:        DeviceReality?    // "physical"
}

DeviceIdentifier (CUSTOM Codable with named-key associated values) {
  case ecid(Swift.UInt64)                       // EcidCodingKeys (private)
  case uuid(Foundation.UUID, Swift.String)      // UuidCodingKeys: identifier, domain
}

Wire encoding per type (xpc_dictionary)

Top-level reply envelope

Outer envelope keys are echoed from the request (per mercury-envelope-empirical.md §"Pair action invocation envelope"):

{
  "CoreDevice.actionIdentifier":     <xpc_string>,    // echoed
  "CoreDevice.invocationIdentifier": <xpc_string>,    // echoed (UUID-shaped string)
  "CoreDevice.deviceIdentifier":     <xpc_string>,    // echoed
  "CoreDevice.coreDeviceVersion":    <xpc_dict>,      // echoed verbatim
  "CoreDevice.output":               <DeviceConnectionChangeResult>
}

xpc_dictionary_create_reply(request) creates the reply with the right mach-port routing context; we set values on it and xpc_release after the original send chain delivers (libxpc retains internally).

CoreDevice.output

{
  "outcome": { "success": {} },
  "updatedSnapshot": <DeviceStateSnapshot>
}

Outcome (auto-Codable enum)

.success           → {"success": {}}                   // empty inner dict
.failure(error)    → {"failure": {<NSErrorContainer fields>}}

GAMBIT emits .success for all 15 allow-listed actions per ADR-0009 §Consequences.

DeviceStateSnapshot

{
  "state": { "connected": {} },                     // dict-shape, see note below
  "capabilityImplementations": [],
  "deviceInfo": <DeviceInfo>,
  "monotonicIdentifier": <xpc_int64: 1>             // Swift.Int64, NOT UInt64
}

DeviceInfo (required fields)

{
  "identifier":               <xpc_uuid: 16 raw bytes>,    // NOT xpc_string!
  "serviceDeviceIdentifier":  <DeviceIdentifier dict>,
  "pairingState":             <xpc_string: "paired">,
  "preparednessState":        <xpc_int64: 15>,             // OptionSet 0xF = all 4 flags
  "state":                    <xpc_string: "connected">,   // String rawValue, NOT dict!
  "visibilityClass":          <xpc_string: "default">,
  "isMobileDeviceOnly":       <xpc_bool: true>,
  "areDeveloperDiskImageServicesAvailable": <xpc_bool: true>,
  "potentialHostnames":       <xpc_array: []>,
  "tags":                     <xpc_array: []>,
  "defaultUserCredentials":   <xpc_array: []>,            // Set encoded as array
  "providerSpecificValues":   <xpc_dict: {}>,
  "additionalMetadata":       <xpc_dict: {}>
}

DeviceInfo (optional, real-iPhone-sourced from handshake)

{
  "productType":   <xpc_string: handshake["ProductType"]>,        // e.g. "iPhone14,6"
  "osVersion":     <xpc_string: handshake["OSVersion"]>,          // e.g. "26.4.2"
  "osBuild":       <xpc_string: handshake["BuildVersion"]>,       // e.g. "23E261"
  "udid":          <xpc_string: handshake["UniqueDeviceID"]>,
  "serialNumber":  <xpc_string: handshake["SerialNumber"]>,
  "hardwareModel": <xpc_string: handshake["HardwarePlatform"]>,   // e.g. "t8110"
  "ecid":          <xpc_uint64: handshake["UniqueChipID"]>
}

If iosmux_rsd_get_handshake_properties() returns NULL (handshake not yet completed), GAMBIT skips ALL these fields per ADR-0006. Required fields above are still populated unconditionally.

DeviceInfo (synthetic only)

{
  "name":    <xpc_string: "iPhone (iosmux)">,    // synthetic device label
  "reality": <xpc_string: "physical">            // we sit behind a real iPhone via tunnel
}

DeviceIdentifier (.uuid case)

{
  "uuid": {
    "identifier": <xpc_uuid: 16 raw bytes>,    // NOT xpc_string
    "domain":     <xpc_string: "">             // empty for synthetic device
  }
}

DeviceIdentifier has a CUSTOM Codable with two private nested CodingKeys enums (UuidCodingKeys, EcidCodingKeys) — NOT auto-synth positional _0/_1. The empty domain mirrors the byte-level synthesis in iosmux_inject.m S1.C where DeviceIdentifier.uuid(_, "") was the layout.

Type-form quirks

Apple xpc-Codable bridge enforces strict UUID type

Auto-synth Codable for fields typed Foundation.UUID would IDEALLY accept either xpc_string (and bridge via UUID(uuidString:)) or raw xpc_uuid (16 bytes). Empirical reality: every UUID slot MUST be xpc_uuid, both auto-synth (DeviceInfo.identifier) and custom-Codable (DeviceIdentifier.uuid.identifier).

Earlier hypothesis from iter 11 ("auto-synth is more lenient") was falsified at iter 14 when decoder returned typeMismatch(UUID, "found OS_xpc_string") for DeviceInfo.identifier — same error mode as the custom DeviceIdentifier path. The decoder traversal order matters: it descends sub-fields first (e.g. serviceDeviceIdentifier) and only catches sibling-field type errors on return.

Implementation: uuid_parse(str, uuid_t) from <uuid/uuid.h>, then xpc_dictionary_set_uuid(dict, key, uuid_t) (public xpc API).

xpc_int64 vs xpc_uint64 — distinct type tags

Apple's Codable bridge does NOT coerce between xpc_int64 and xpc_uint64. They have different type tags at the wire level. iter 16 hit this:

typeMismatch(Swift.Int64, "Expected to decode Int64 but found a OS_xpc_uint64 instead.")

Use xpc_dictionary_set_int64() (signed) for Swift.Int64 fields. Use xpc_dictionary_set_uint64() for Swift.UInt64. The error message names the exact Swift type expected.

Same case-name strings live in DIFFERENT enums

connected, disconnected literal strings appear in 3+ unrelated enum types in CoreDevice / CoreDeviceProtocols frameworks:

Type Wire form Where used
CoreDeviceProtocols.DeviceState (String rawValue) "connected" DeviceInfo.state
Inner enum at DeviceStateSnapshot.state (auto-Codable, dict-shape) {"connected": {}} snapshot's state
DeviceConnectionChangeResult.Outcome cases (success/failure) unrelated to "connected" output discriminator

The strings(1) dump of CoreDevice.framework returns multiple connected/disconnected literals — they belong to DIFFERENT types with different Codable conformances. Cross-checking the type via demangled symbols (xcrun swift-demangle on nm -a output) is the only reliable schema source.

String-rawValue enum vs auto-Codable enum

Demangler output exposes the distinction:

init(rawValue: Swift.String) -> Self?    → String-rawValue enum
                                            wire: just "<caseName>"
init(from: Swift.Decoder) throws -> Self  + enum case for X.case_a
                                          → auto-Codable, no rawValue
                                            wire: {"<caseName>": {...associated values...}}

Encountered in this schema: - String-rawValue: PairingState, DeviceVisibilityClass, DeviceReality, CoreDeviceProtocols.DeviceState (used at DeviceInfo.state) - Auto-Codable: Outcome, DeviceState (used at DeviceStateSnapshot.state — different type than DeviceInfo.state despite the same case names)

OptionSet — encoded as singleValue Int

DevicePreparedness is a struct with init(rawValue: Int) — an OptionSet. Its Codable conformance encodes via singleValueContainer as Swift.Int64 directly, NOT a {"rawValue": N} dict:

"preparednessState": <xpc_int64: 15>

15 = 0xF = bitwise OR of all 4 flag values: - powerAssertionTaken - extendedDeviceInfoLoaded - developerDiskImageServicesEnabled - developerModeEnabledIfRequired

Foundation.UUID encoding

In auto-synth contexts (DeviceInfo.identifier, etc.), Foundation.UUID expects xpc_uuid (16 raw bytes), not the canonical 36-char string form. Apple's xpc-Codable bridge calls decode(UUID.self, forKey:) which checks xpc_get_type(value) == XPC_TYPE_UUID strictly.

xpc_string envelope keys remain string

The OUTER envelope keys (CoreDevice.actionIdentifier, CoreDevice.invocationIdentifier, CoreDevice.deviceIdentifier) ARE xpc_string per the captured Mercury action invocation envelope (mercury-envelope-empirical.md §"Pair action invocation envelope"). These are not Codable-decoded by Mercury — they're framework-level routing metadata that libxpc and Mercury XPC handle as strings.

Iteration trail (compact)

Iter Change Decoder error → fix
1 output: {} dataCorrupted — output exists but malformed
2 output: absent valueNotFound("CoreDevice.output") — required
3 output: null typeMismatch(DeviceConnectionChangeResult, "dictionary required")
4 output type identified: DeviceConnectionChangeResult (Apple log named it) added outcome key
5 outcome: "connected" typeMismatch(Outcome.CodingKeys, "dictionary required")
6 outcome: {connected: {}} "Invalid number of keys, expected one" — wrong case names
7 demangler revealed Outcome cases .success/.failure outcome: {success: {}}
8 added updatedSnapshot (just state) keyNotFound("capabilityImplementations")
9 added capabilityImplementations: [] keyNotFound("deviceInfo")
10 full DeviceInfo with handshake data keyNotFound(UuidCodingKeys.identifier) at .uuid
11 DeviceIdentifier.uuid uses named key identifier not _0 typeMismatch(UUID, "OS_xpc_string")
12 xpc_uuid raw bytes for DeviceIdentifier.uuid.identifier keyNotFound("domain") second slot
13 domain: "" empty string typeMismatch(UUID) at deviceInfo.identifier
14 xpc_uuid for deviceInfo.identifier too typeMismatch(String) at deviceInfo.state
15 DeviceInfo.state as String, not dict keyNotFound("monotonicIdentifier") at snapshot
16 added monotonicIdentifier as uint64 typeMismatch(Int64) — wants signed
17 monotonicIdentifier as int64 Pairing attempt completed with error nil

After iter 17 Apple's decoder accepts the entire reply. Xcode UI shows a spinner ("Xcode has already started pairing… Follow the instructions on iPhone…") instead of completing — UI is bound to a separate signal, not to PairAction's Output.

What this enables

  • Pair, Unpair, Connect, Disconnect, Tags, GetTrainName, DarwinNotificationObserve/Post and the rest of the 15-action static-success allow-list (per ADR-0009 §Consequences) all use GAMBIT_OUTPUT_CONNECTED today and will pass Apple's Codable decoder for any action whose Output happens to be DeviceConnectionChangeResult.
  • AcquireDeviceUsageAssertion, ListUsageAssertions, EnableDDIServices, DisableDDIServices, FetchDDIMetadata, Update/RemoveHostDDIs almost certainly have DIFFERENT Output types — when their UI triggers fire, decoder errors will name the type and we'll add per-action helpers.

Open question

pair-button-and-cfnetwork.md §"Two gates, not one" already noted that _shadowUseAssertion.fulfilled (set by AcquireDeviceUsageAssertion success, not PairAction success) is what flips the Pair button rendering decision. Iter 17 confirmed empirically: PairAction returning success does NOT flip Xcode's UI out of the spinner. The next-iter direction (per session 14 close discussion):

  • Path B: emit a RemoteDeviceStateUpdatedEvent Mercury broadcast from the inject side after Pair reply, so Xcode's KVO observers see a state-changed broadcast (not just an action reply containing a snapshot).
  • Alternative path: dispatch AcquireDeviceUsageAssertion ourselves via the same GAMBIT route (already in allow-list), with a synthesised Output that flips _shadowUseAssertion.fulfilled.

Tracked as Q-D66-14 in docs/plans/d66-research-questions.md.

See also