Skip to content

GAMBIT — per-action Output schemas + AFM endpoint side-channel

Status: verified — 2026-04-29 (Phase D.6.6-impl Phase 1 deploy)

Empirical extraction of *ActionDeclaration.Output types for the 15 static-success actions per ADR-0009 §Consequences, plus the AFM endpoint side-channel mechanism used by acquireDeviceUsageAssertion. Phase 1 implementation lives at:

  • inject/iosmux_gambit.m per-action Output dispatch (commits b440a9249c8f1e)
  • inject/iosmux_gambit.m gambit_emit_afm_via_endpoint AFM emit (commits 1e8ef8128d7d89)

Sources: nm -arch x86_64 | xcrun swift-demangle on CoreDevice (24026 symbols), CoreDeviceService (2753 symbols), CoreDeviceUtilities (50391 symbols), DVTCoreDeviceCore (3481 symbols). All offsets are file offsets in the CoreDevice x86_64 slice unless prefixed with framework name.

TL;DR

GAMBIT's per-action dispatch (Phase 1 baseline) maps each of the 15 allow-listed action IDs to one of three Output container shapes:

  1. _VoidBox (Void) — the empty 0-key xpc_dictionary. Used for pair, unpair, acquireusageassertion, connect, disconnect, tags, darwinnotificationobserve. Public Swift API for these actions returns (); the OutputContainer carries Void on the wire.

  2. _CodableBox<T> — wraps a concrete Codable struct as OutputContainer.rawValue. Used for the rest:

Action ID Output struct
listusageassertions [UsageAssertionInformation]
gettrainname GetTrainNameActionDeclaration.Output { name: String }
darwinnotificationpost [DarwinNotificationPostActionResult]
enableddiservices EnableDeveloperDiskImageResult
disableddiservicesaction DeviceStateSnapshot
fetchddimetadata DDIMetadata
updatehostddis UpdateHostDDIsResult
removehostddis RemoveHostDDIsResult
  1. _CodableBox<DeviceConnectionChangeResult> — for actions whose semantic Output is "connection state changed" (pair, connect, disconnect, unpair). The PairAction iter 1-17 trail (gambit-pair-action-schema.md) spelled out the byte-level schema.

Public Swift API: RemoteDevice.pair() async throws -> () returns Void to callers but the wire OutputContainer.rawValue carries DeviceConnectionChangeResult.

In addition, acquireDeviceUsageAssertion requires a separate AssertionFulfilledMessage (AFM) emitted over an XPC endpoint side-channel after the action's Void reply.

1. ActionDeclaration / OutputContainer protocol layout

CoreDeviceUtilities.ActionDeclaration (protocol descriptor at CoreDeviceUtilities 0x265ee8) requires OutputContainer and InputContainer associated types. The container's RawValue: SY (RawRepresentable) is the semantic Output exposed to Swift callers.

Two concrete container types ship in CoreDeviceUtilities:

  • CoreDeviceUtilities._VoidBox (descriptor 0x266028) — Codable wrapper with rawValue: (). Wire form: empty 0-key xpc_dictionary.
  • CoreDeviceUtilities._CodableBox<A> (generic) — Codable wrapper with rawValue: A. Wire form: nested xpc_dictionary mirroring A.

The actual semantic binding for each action is determined by the *ActionImplementation struct in CoreDeviceService and the _Continuation<T> it awaits. CoreDeviceService continuations recovered (addresses are file offsets in CoreDeviceService binary):

Address _Continuation<T> Adjacent action implementation
0x...642c () (Void) AcquireDeviceUsageAssertionActionImplementation
0x...6566 [UsageAssertionInformation] ListUsageAssertionsActionImplementation
0x...6644 DeviceConnectionChangeResult ConnectActionImplementation
0x...66e4 DeviceStateSnapshot DisableDeveloperDiskImageServicesActionImplementation
0x...67e2 EnableDeveloperDiskImageResult EnableDeveloperDiskImageServicesActionImplementation
0x...6296 RemoveHostDDIsResult RemoveHostDDIsActionImplementation
0x...62d2 UpdateHostDDIsResult UpdateHostDDIsActionImplementation

Pair, Unpair, Tags, Disconnect, DarwinNotificationObserve, DarwinNotificationPost, FetchDDIMetadata implementations have no locally-attached _Continuation<T> symbol in CDS — their Output binding is determined by static metaclass + public-API evidence (see §2 per-type detail).

2. Per-action Output schemas (15-action allow-list)

# Action ID Output container Output payload Side events
1 pair _CodableBox<DeviceConnectionChangeResult> DCCR { outcome: .success, updatedSnapshot: <DSS> } None on success path; PairActionUpdate streams via update channel during action
2 unpair _VoidBox Void None
3 acquireusageassertion _VoidBox Void AssertionFulfilledMessage over endpoint side-channel — see §3
4 listusageassertions _CodableBox<[UsageAssertionInformation]> array (empty [] for stub) None
5 connect _CodableBox<DeviceConnectionChangeResult> DCCR { outcome: .success, updatedSnapshot: <DSS connected> } None
6 disconnect _CodableBox<DeviceConnectionChangeResult> DCCR { outcome: .success, updatedSnapshot: <DSS disconnected> } None
7 tags undetermined — possibly _CodableBox<LabelsActionResult> gap None
8 gettrainname _CodableBox<GetTrainNameActionDeclaration.Output> { name: String } (e.g. "24A" for iOS 18) None
9 darwinnotificationobserve _VoidBox Void observations stream over XPCSideChannel (Input carries observationToken)
10 darwinnotificationpost _CodableBox<[DarwinNotificationPostActionResult]> one element per posted name None
11 enableddiservices _CodableBox<EnableDeveloperDiskImageResult> { deviceStateSnapshot, ddiServicesInfo, metrics: nil } None
12 disableddiservicesaction _CodableBox<DeviceStateSnapshot> full DSS None
13 fetchddimetadata _CodableBox<DDIMetadata> high-risk: init throws; needs proper plist keys None
14 updatehostddis _CodableBox<UpdateHostDDIsResult> { candidateDDIDirectories: [], currentDDIs: [], hostCoreDeviceVersion: 1.0, originalDDIs: [] } None
15 removehostddis _CodableBox<RemoveHostDDIsResult> { originalDDIs: [], currentDDIs: [] } None

lockstate is NOT in this allow-list (returns non-Void LockState; static success would lie about real device state).

2.1 DeviceConnectionChangeResult

Used by Pair, Connect, Disconnect, Unpair (via reuse of the same builder). Full byte-level schema in gambit-pair-action-schema.md.

2.2 DeviceStateSnapshot

Used by Disable DDIServices Output and embedded in DCCR.updatedSnapshot and EnableDeveloperDiskImageResult.deviceStateSnapshot and AssertionFulfilledMessage.updatedSnapshot.

Key Type Source
deviceInfo CoreDeviceProtocols.DeviceInfo getter 0x29d350
capabilityImplementations [CoreDevice.Capability : [Swift.String]] getter 0x2cc60
monotonicIdentifier Swift.Int64 getter 0x29d500

capabilities: Set<Capability> is computed from capabilityImplementations, NOT separately encoded.

Initializer: init(deviceInfo:, capabilityImplementations:, monotonicIdentifier:) at 0x29d520.

2.3 EnableDeveloperDiskImageResult

Key Type Source
deviceStateSnapshot DeviceStateSnapshot 0x55a40
ddiServicesInfo DeveloperDiskImageServiceInfo 0x55a60
metrics [Metric]? (optional) 0x55a90

Init: init(deviceStateSnapshot:, ddiServicesInfo:, metrics:) at 0x55ab0. DeveloperDiskImageServiceInfo() empty init at 0x553b0.

2.4 UpdateHostDDIsResult / RemoveHostDDIsResult

UpdateHostDDIsResult {
  candidateDDIDirectories: [URL],
  currentDDIs: [DeveloperDiskImage],
  hostCoreDeviceVersion: VersionNumber,
  originalDDIs: [DeveloperDiskImage]
}

RemoveHostDDIsResult {
  originalDDIs: [DeveloperDiskImage],
  currentDDIs: [DeveloperDiskImage]
}

2.5 UsageAssertionInformation (List Usage Assertions)

init(identifier:owningProcess:processName:creationReason:hostName:preparednessDescription:) at 0x33be0 — all six fields non-Optional:

Key Type Source
identifier Foundation.UUID 0x3c4f0
owningProcess Swift.Int32 0x33970
processName Swift.String 0x33990
creationReason Swift.String 0x339c0
hostName Swift.String 0x339f0
preparednessDescription Swift.String 0x33a20

2.6 DDIMetadata (FetchDDIMetadata Output) — high-risk

10-key Codable struct. init may throw if metadata dict is missing required DDIMetadata.PlistKeys keys (referenced at 0x87820). Empty shape may NOT validate — gap; not exercised in Phase 1.

2.7 PairActionUpdate (NOT Output)

Delivered through Pair's update channel (NOT the Output channel):

  • .started (0x350078)
  • .challenge(Bool, Double?) (0x350074)

PairActionChallengeAnswer { pin: String } is the client-supplied challenge response.

3. AFM endpoint side-channel mechanism

For acquireDeviceUsageAssertion Apple's design uses a second XPC connection between client (devicectl/Xcode) and server (CDS), separate from the action-XPC connection.

3.1 Wire layout

The AUA action's Input carries an xpc_endpoint_t:

AcquireDeviceUsageAssertionActionDeclaration.Input {
    reason: Swift.String,
    options: CoreDevice.UsageAssertionOptions,
    endpoint: __C.OS_xpc_object   // xpc_endpoint_t — mach send right
}
  • Client side (CD 0x30af0..0x30b0c): xpc_endpoint_create() once per AUA call; result stored at offset 0 of Input.
  • Server side (CDS 0x2120d): xpc_connection_create_from_endpoint() converts the inline endpoint into a live peer connection. CDS stores the result on InProgressServerAssertion.peerConnection ivar.
  • Server emit (CDS 0x1e66c): "Succesfully fulfilled assertion %s for device %s. Notifying client" — AFM emit point AFTER the action reply has been sent.

3.2 AssertionFulfilledMessage schema

AssertionFulfilledMessage {
    identifier:       Foundation.UUID,           // xpc_uuid (REQUIRED)
    updatedSnapshot:  DeviceStateSnapshot        // xpc_dict (REQUIRED)
}

Init at CD 0x2ef20. Both fields non-Optional. identifier MUST echo the assertion UUID the client allocated client-side (carried via the action's outer envelope, NOT via Input — Input has reason / options / endpoint, no UUID field).

Mangled type: _$s10CoreDevice25AssertionFulfilledMessageVmangledTypeName: "CoreDevice.AssertionFulfilledMessage" on the wire.

3.3 CDS-side preparedness pipeline (server-side AFM emit ordering)

Before AFM is emitted, CDS walks each preparedness stage in order (strings at the cited CDS file offsets):

CDS offset Log Stage
0x21114 "Received request to create usage assertion ..." entry
0x196b4 "Received success from device fulfilling tunnel assertion request" tunnel granted
0x19b00 "Acquiring power assertion if requested in preparedness" powerAssertionTaken
0x1a265 "Checking developer mode status" developerModeEnabledIfRequired
0x1bdcb "Not enabling DDI services ... Proceeding to extended info loading" developerDiskImageServicesEnabled
0x1cc6a / 0x1ce5e "Not loading extended info ... Proceeding to notify client that the assertion was fulfilled" extendedDeviceInfoLoaded
0x1e66c "Succesfully fulfilled assertion %s for device %s. Notifying client" AFM emit point

CDS imports zero progress-emission symbols (no setProgress, setUnitCountCompleted, fractionCompleted). ProgressUnit is solely client-side accounting on the NSProgress returned by the completion-handler API at CD 0x304a0. The server emits AFM once, after all preparedness stages are complete.

3.4 Client-side state machine (Result wrapping)

RemoteDevice.acquireDeviceUsageAssertion(withReason:options:) async throws -> DeviceUsageAssertion at CD 0x31750 is a Result-wrapping orchestrator. Sequence:

  1. Build IdentifiableAssertionDetails (with fresh client UUID).
  2. Call xpc_endpoint_create() (CD 0x30afd).
  3. Install unfair-locked listener consuming AFM, updating InProgressClientAssertion.state (CD ivar 0x34b9d0) to .fulfilled or .invalidated(error).
  4. Dispatch action via CoreDeviceUtilities.ActionDeclaration.forward(...).
  5. _swift_continuation_await on state-change.
  6. On .fulfilled: build DeviceUsageAssertion (ObjC class), log "Successfully acquired usage assertion ..." (CD 0x371930), resume .success(assertion).
  7. On .invalidated(err): log "Failed to acquire usage assertion on device %s due to error: %s" (CD 0x3718e0), resume .failure(err).

NO timeout in this code. _swift_continuation_await blocks indefinitely. The user-visible errorCode 3 ~10 ms after the success reply originates in the assertionq dispatcher chain in CoreDevice itself when the side-channel peer connection invalidates — see aua-side-channel-mechanism.md for the full failure path.

3.5 _shadowUseAssertion setter (Xcode side)

DVTCoreDeviceCore's DVTCoreDevice_Impl private class sets _shadowUseAssertion: DeviceUsageAssertion? (field-offset symbol Wvd at 0x6d7f8) when the AUA Result resolves to .success. This is the ObjC-class instance the hasConnection / Pair-button-rendering UI gate reads from — see pair-button-and-cfnetwork.md §"Pair button gating".

DeviceUsageAssertion ivars: identifier, options, reason, _lockedMutableState. State enum: .fulfilled, .invalidated(Error?).

The instance is built client-side from the values the client itself sent in Input.reason / Input.options plus a UUID it picked. The server only signals "assertion now fulfilled" via AFM; it does NOT send back the assertion fields.

4. RDSUE event chain (Q-D66-14 anchor)

Used by GAMBIT Phase 1 to flip kvoCache_isPaired and clear the Xcode spinner gate (commit 648f531).

Inject emits RDSUE
  → Mercury XPC delivers as ServiceEvent
  → CoreDevice EventManager.receiveEvent (file offset 0x306ff0)
  → typed handler for ServiceEventKind.remoteDeviceStateUpdate
  → DeviceManager.handle(deviceUpdateSnapshot:)  (thunk 0xcc4a0)
  → RemoteDevice.updateState(from:markServiceConnected:)  (thunk 0x183590)
  → _stateStorage setter
  → addDeviceInfoChanged callback fires
  → DVTCoreDeviceCore._updatePropertiesImpactingAvailability
  → kvoCache_isPaired := pairingState.toBool()

4.1 Critical filter: monotonicIdentifier

RemoteDevice.updateState silently drops snapshots whose DeviceStateSnapshot.monotonicIdentifier: Int64 is ≤ the previously cached value. NO decoder error, NO log, just a stale-event filter.

GAMBIT seeds its monotonic counter from mach_absolute_time() at install time (~10^11 baseline) so its broadcasts are guaranteed strictly newer than Apple's session-local counter.

4.2 RemoteDeviceStateUpdatedEvent Codable schema

RemoteDeviceStateUpdatedEvent {
    deviceIdentifier:  CoreDevice.DeviceIdentifier,
    state:             CoreDevice.DeviceStateSnapshot
}

init at CD 0x29bb50. static var event: ServiceEventKind { .remoteDeviceStateUpdate } getter at 0x57380. Codable keys at 0x393680.

4.3 ServiceEventKind raw values

(__cstring at CD 0x36ff80..0x3700a0):

  • remoteDeviceStateUpdateRemoteDeviceStateUpdatedEvent — pushes RDSUE stream
  • deviceManagerCheckInCompleteDeviceManagerCheckInCompleteEvent — initial bulk
  • deviceManagerFullyInitializedDeviceManagerFullyInitializedEvent — readiness gate, no payload
  • deviceManagerDevicesUpdate → device-set delta
  • operationStateUpdateOperationStateUpdatedEvent — operation manager (observational, not blocking)
  • pairingEvent, serviceOperationManagerCheckInComplete, deviceStateChanged, deviceRequestUpdate, operationStatusChanged — Codable types not in this binary's __text

GAMBIT only emits remoteDeviceStateUpdate (RDSUE) and AssertionFulfilledMessage (AFM). Other events are observational and empirically not required for action flow.

5. DevicePreparedness OptionSet bits

DevicePreparedness (Swift OptionSet, RawValue: Int)
  developerModeEnabledIfRequired   = 0x1   (CD 0x1a78a0)
  developerDiskImageServicesEnabled = 0x2  (CD 0x1a78b0)
  extendedDeviceInfoLoaded         = 0x4   (CD 0x1a78c0)
  powerAssertionTaken              = 0x8   (CD 0x1a0c00)
  all                              = 0xf   (CD 0x1a78d0; bitwise OR of all four)

Encoded via singleValueContainer as Swift.Int64 (NOT {rawValue: N} dict). Echoing requiredPreparedness from the request is sufficient — Xcode typically requests 0xf (all).

6. ProgressUnit raw values (AcquireDeviceUsageAssertion)

Decoder at CD 0x32fd0 uses lookup table 0x0000040302010005 (bytes at shift rawValue*8). Mapping:

rawValue Case
1 started
2 createdTunnelConnection
3 enablingDeveloperDiskImageServices
4 fetchingExtendedDeviceInfo
5 completed
≥6 or 0 none (Optional sentinel)

Server emits these only via NSProgress increments client-side; not required on the wire by GAMBIT.

7. Open questions / gaps

These remain after Phase 1 deploy. None block the cosmetic-accepted Q-D66-15 baseline.

  1. TagsAction Output — undetermined. Possibly LabelsActionResult (Tags ≈ Labels). No _Continuation<T> near TagsActionImplementation, no .Output nominal type descriptor.
  2. DisconnectAction Output — strongly assumed DCCR, no direct continuation evidence. DisconnectActionImplementation body at 0x...677a (CDS) would resolve.
  3. DDIMetadata empty Outputinit(metadata: [:]) may throw; minimum-viable shape needs DDIMetadata.PlistKeys enumerated.
  4. _shadowUseAssertion Mercury wire path — AFM event delivery channel is the endpoint connection (§3.1); whether it routes through the same RemoteXPC stream that carries action replies, or a completely separate XPC connection, is the open question. The current GAMBIT gambit_emit_afm_via_endpoint opens a fresh outbound xpc_connection_create_from_endpoint per action and emits over that. Empirically the Apple side expects an inbound persistent peer to live for the assertion's lifetime — the cosmetic gap in Q-D66-15 originates here.
  5. outcome.failure payloadMercury.NSErrorContainer (CD 0x34c718) is the failure carrier. GAMBIT only emits success; if a real failure path were ever needed, NSErrorContainer schema would need its own probe.

See also