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 (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:
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:
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
RemoteDeviceStateUpdatedEventMercury 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¶
mercury-action-interceptor-design.md— GAMBIT's hook target, install mechanism, allow-list.mercury-envelope-empirical.md§"Pair action invocation envelope" — the captured INPUT envelope that GAMBIT's reply mirrors.pair-button-and-cfnetwork.md§"Two gates, not one" — the UI binding that PairAction success doesn't satisfy.- ADR-0009 §Consequences — the 15-action allow-list this schema applies to.
- ADR-0006 — the empirical discipline behind every type/value above.