Trap Catalog

This is the load-bearing summary of every silent-failure trap, contract invariant, and platform constraint that Mobile Surfaces tracks. The same catalog is rendered into CLAUDE.md and AGENTS.md for AI coding assistants. Source of truth lives in data/traps.json.

41 rules total: 34 user-facing (22 iOS + 12 wire) + 7 catalog-maintenance. By severity: 33 error, 2 warning, 6 info.

How to use this catalog

  • When generating or editing code in a Mobile Surfaces project, treat every error rule as a hard invariant. If your change requires breaking the invariant, surface that and stop.
  • When auditing an existing project, walk the index from top to bottom. static rules can be checked by reading files; config rules by reading app.json, package.json, and expo-target.config.js; runtime rules by inspecting recent APNs response codes; advisory rules by reading the symptom and confirming runbook coverage.
  • When suggesting fixes, cite the rule id (e.g. MS013) so it can be traced back here.

Index

ID Category Severity Detection Title
MS001 iOS error static Live Activity adapter boundary
MS002 iOS error static ActivityKit attribute file byte-identity
MS003 iOS error static Swift ContentState fields and JSON keys match Zod liveSurfaceActivityContentState
MS004 iOS error static Swift Stage enum cases match Zod liveSurfaceStage
MS006 wire error static Generated JSON Schema must match Zod source
MS007 iOS error static All committed fixtures must parse as LiveSurfaceSnapshot
MS008 iOS error static Snapshot kind must match its projection slice
MS009 iOS error static Generated TypeScript fixtures must match JSON sources
MS011 wire error runtime ActivityKit payload size ceiling (4 KB / 5 KB broadcast)
MS012 iOS error config iOS deployment target must be 17.2 or higher
MS013 iOS error static App Group entitlement must match host app and widget extension
MS014 wire error runtime APNs token environment must match the build environment
MS017 maintenance error advisory apps/mobile/ios/ is generated; do not edit
MS018 wire error runtime APNS_BUNDLE_ID must not include the .push-type.liveactivity suffix
MS024 wire error config Project must depend on @mobile-surfaces/surface-contracts (and push, when sending)
MS025 iOS error config App Group declared in app.json
MS026 iOS error config Widget target managed by @bacons/apple-targets
MS028 wire error runtime APNs auth key environment variables must be set before sending
MS029 maintenance error config Generated apps/mobile/ios/ is gitignored
MS030 wire error runtime APNs provider token must be valid and current
MS031 wire error runtime Channel management failures (missing, malformed, or unregistered channel id)
MS032 wire error runtime Activity timestamp fields must be valid unix-seconds integers
MS035 wire error runtime apns-topic header missing or bundleId misconfigured
MS036 iOS error static Surface snapshot Swift structs match their Zod projection-output schemas
MS037 iOS error static Notification category outputs in sync with canonical registry
MS038 iOS error static Live Activity adapter inputs must be Zod-parsed before crossing the bridge
MS039 iOS error static Token store discipline: subscribe to ActivityKit token events through @mobile-surfaces/tokens
MS040 maintenance error static Swift trap-binding file byte-identity
MS041 iOS error static Projection-output envelopes must declare schemaVersion
MS042 maintenance error static Deprecation prose must not promise removal in the current or a past major
MS043 maintenance error static CHANGELOG entry required on package major
MS044 maintenance error static Catalog headline counts stay in sync with the trap catalog
MS045 iOS error static Widget Color asset references must resolve to a generated colorset
MS010 maintenance warning config Toolchain preflight (Node 24, pnpm, Xcode 26+)
MS015 wire warning runtime Push priority 5 vs 10 budget rules
MS016 iOS info advisory Subscribe to onPushToStartToken at mount, not on demand
MS019 iOS info advisory FB21158660: push-to-start tokens silent after force-quit
MS020 iOS info advisory Per-activity and push-to-start tokens may rotate at any time
MS021 iOS info advisory Discard per-activity tokens when the activity ends
MS023 iOS info advisory Per-activity tokens are bound to a single Activity instance
MS034 wire info advisory Broadcast capability must be enabled on the APNs auth key

Rules

MS001: Live Activity adapter boundary

category: iOS · severity: error · detection: static · tags: live-activity, contract · enforced by: scripts/check-adapter-boundary.mjs

Application code under apps/*/src/ must import the live-activity adapter through the boundary re-export, never directly from @mobile-surfaces/live-activity.

Symptom. Compile-time imports look fine, but call sites bypass the centralized re-export typed against LiveActivityAdapter. A future swap to expo-live-activity, expo-widgets, or a custom native module then has to update every importer instead of one shim, and the tsc-enforced adapter surface stops catching drift.

Fix. Import from apps/mobile/src/liveActivity (the boundary re-export) instead of @mobile-surfaces/live-activity directly. Add new methods to the adapter contract first, not at the call site.

See: https://mobile-surfaces.com/docs/concepts#adapter-contract

MS002: ActivityKit attribute file byte-identity

category: iOS · severity: error · detection: static · tags: live-activity, swift · enforced by: scripts/check-activity-attributes.mjs

MobileSurfacesActivityAttributes.swift in packages/live-activity/ios/ and apps/mobile/targets/widget/ must be byte-identical, and both must match the codegen output from packages/surface-contracts/src/schema.ts.

Symptom. Activity starts on the device but never appears on the Lock Screen. No log, no error. ActivityKit silently drops updates whose decoded ContentState shape does not match the widget extension's struct.

Fix. Both files are generated from the Zod source of truth. Edit liveSurfaceActivityContentState or liveSurfaceStage in packages/surface-contracts/src/schema.ts, then run pnpm surface:codegen to regenerate both files. CI gates codegen drift at stage 2 and byte-identity + Zod parity at stage 3. The follow-up plan to consolidate this duplication into a local Swift Package is upstream-blocked on @bacons/apple-targets local-SPM support and RN 0.84 local-path spm_dependency landing in Expo SDK 56. Codegen is the intermediate state until that unblocks.

See: https://mobile-surfaces.com/docs/concepts#native-constraints

MS003: Swift ContentState fields and JSON keys match Zod liveSurfaceActivityContentState

category: iOS · severity: error · detection: static · tags: live-activity, swift, contract · enforced by: scripts/check-activity-attributes.mjs

MobileSurfacesActivityAttributes.ContentState must declare the same fields, types, and JSON keys as liveSurfaceActivityContentState in packages/surface-contracts/src/schema.ts. Both Swift attribute files participate; the JSON-key shape is what ActivityKit decodes from the push payload.

Symptom. Push lands at APNs (200) but the Lock Screen view stays on its old state. The Codable decoder silently fails on a renamed key or a type mismatch and ActivityKit drops the update without surfacing an error.

Fix. Update the Zod source first, regenerate the JSON Schema (pnpm surface:check), then mirror the field change into both Swift attribute files. Project payloads through toLiveActivityContentState rather than hand-rolling JSON so the JS layer cannot diverge from the contract.

MS004: Swift Stage enum cases match Zod liveSurfaceStage

category: iOS · severity: error · detection: static · tags: live-activity, swift, contract · enforced by: scripts/check-activity-attributes.mjs

The Stage enum in MobileSurfacesActivityAttributes.swift must cover exactly the cases listed in liveSurfaceStage.

Symptom. ContentState decodes but the stage value falls back to a default. Your Lock Screen never shows 'completing' even after the job is done.

Fix. Add or remove cases in lockstep: Zod first, regenerate the schema, mirror into both Swift files.

MS006: Generated JSON Schema must match Zod source

category: wire · severity: error · detection: static · tags: contract, push · enforced by: scripts/build-schema.mjs

packages/surface-contracts/schema.json must be regenerated whenever the Zod source changes; CI fails if the committed file is stale.

Symptom. Backends validating with Ajv or jsonschema accept payloads the runtime then rejects (or vice versa). Consumers pinning the unpkg URL get out-of-date validation.

Fix. Run node --experimental-strip-types scripts/build-schema.mjs and commit the result.

MS007: All committed fixtures must parse as LiveSurfaceSnapshot

category: iOS · severity: error · detection: static · tags: contract · enforced by: scripts/validate-surface-fixtures.mjs

Every JSON file under data/surface-fixtures/ must parse via the current schemaVersion discriminated union (after $schema is stripped).

Symptom. Tests that exercise fixtures pass locally but fail in CI on a fixture nobody noticed was malformed; or fixture-driven previews silently render placeholder data.

Fix. Run pnpm surface:check and address any reported issues. Update data/surface-fixtures/index.json if the fixture was newly added.

MS008: Snapshot kind must match its projection slice

category: iOS · severity: error · detection: static · tags: contract · enforced by: packages/surface-contracts/test/surface-contracts.test.mjs

kind: 'widget' requires a widget slice; kind: 'control' requires a control slice; kind: 'notification' requires a notification slice.

Symptom. safeParse throws a discriminated-union error at the wire boundary; or worse, an old code path bypasses safeParse and a missing slice causes downstream surfaces to render placeholder data.

Fix. Pair every kind change with its slice. Use the projection helper for the kind (toWidgetTimelineEntry, toControlValueProvider, toNotificationContentPayload) rather than reaching into the snapshot directly.

Error classes: InvalidSnapshotError

MS009: Generated TypeScript fixtures must match JSON sources

category: iOS · severity: error · detection: static · tags: contract · enforced by: scripts/generate-surface-fixtures.mjs

packages/surface-contracts/src/fixtures.ts is generated from data/surface-fixtures/*.json and must not drift.

Symptom. Test imports reference the TS fixtures but get a stale shape; the harness shows different state than the fixture file on disk.

Fix. Run node scripts/generate-surface-fixtures.mjs and commit. CI runs the same with --check.

MS011: ActivityKit payload size ceiling (4 KB / 5 KB broadcast)

category: wire · severity: error · detection: runtime · tags: live-activity, push · ios min: 16.2

Per-activity Live Activity pushes are bounded at 4 KB; iOS 18 broadcast pushes at 5 KB. Standard notification alert pushes (push-type alert, kind notification) share the 4 KB ceiling; the 5 KB allowance is ActivityKit-broadcast-only and does not apply to sendNotification.

Symptom. APNs returns 413 PayloadTooLarge, or accepts the payload but iOS silently drops the update. Long localized strings or accumulated morePartsCount details are common offenders.

Fix. Trim the payload. Per-activity payloads are bounded at 4 KB; broadcast payloads at 5 KB. Shorten the liveActivity slice's body, lower morePartsCount, or split a state into two smaller pushes. Validate by sending the projection through toLiveActivityContentState and measuring.

See: https://mobile-surfaces.com/docs/push#error-responses

Apple docs: ref 1

Error classes: PayloadTooLargeError

MS012: iOS deployment target must be 17.2 or higher

category: iOS · severity: error · detection: config · tags: ios-version, config · ios min: 17.2 · enforced by: scripts/probe-app-config.mjs

Mobile Surfaces commits to push-to-start tokens (Activity<...>.pushToStartTokenUpdates) without if #available ceremony; deployment target below 17.2 breaks the live-activity adapter at compile time.

Symptom. Swift compile errors on pushToStartTokenUpdates references, or a build that succeeds on a lower target only because the symbol was guarded, and then push-to-start silently never works on iOS 16 devices.

Fix. Set ios.deploymentTarget to '17.2' in apps/mobile/app.json (or via expo-build-properties) and rerun prebuild.

See: https://mobile-surfaces.com/docs/compatibility

MS013: App Group entitlement must match host app and widget extension

category: iOS · severity: error · detection: static · tags: app-group, widget, control, config · enforced by: scripts/check-app-group-identity.mjs

apps/mobile/app.json is the single source of truth for the App Group identifier. The widget entitlements file at apps/mobile/targets/widget/generated.entitlements, the Swift constant at apps/mobile/targets/_shared/MobileSurfacesAppGroup.swift, and the TS constant at apps/mobile/src/generated/appGroup.ts are codegened from it and must not be hand-edited.

Symptom. Widget renders placeholder forever; control widget never reads the toggle state. No error: the entitlement mismatch makes both sides read separate App Group containers.

Fix. Edit app.json and run pnpm surface:codegen to regenerate the Swift constant, TS constant, and widget entitlements in lockstep. Rename across every site via pnpm surface:rename. Hand edits to the generated files revert on the next codegen and fail the stage-2 drift gate. The primary enforcer is the generate-app-group-constants --check gate at stage 2; check-app-group-identity is the defense-in-depth identity check across the four declaration sites.

See: https://mobile-surfaces.com/docs/ios-environment

MS014: APNs token environment must match the build environment

category: wire · severity: error · detection: runtime · tags: push, tokens

Tokens minted by a development build cannot authenticate against the production APNs endpoint, and vice versa. When a send call is given the token record's stored environment via the tokenEnvironment option, the push SDK rejects a mismatch with TokenEnvironmentMismatchError before the round-trip.

Symptom. Without the pre-flight, APNs responds 400 BadDeviceToken: the token is valid, it just belongs to the other environment, and the response gives no hint of that. With the tokenEnvironment option supplied, the SDK throws a precise pre-send error naming both environments.

Fix. Use environment: 'development' for dev-client and expo run:ios builds, environment: 'production' only for TestFlight and App Store builds. Track which environment minted each token (the @mobile-surfaces/tokens record carries it) and pass it as the tokenEnvironment send option so the mismatch is caught before the send.

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: BadDeviceTokenError , TokenEnvironmentMismatchError

MS017: apps/mobile/ios/ is generated; do not edit

category: maintenance · severity: error · detection: advisory · tags: cng, config

Continuous Native Generation rebuilds apps/mobile/ios/ from app.json, packages/live-activity/, and apps/mobile/targets/widget/. Manual edits are wiped on the next prebuild.

Symptom. An Xcode change you made to fix a build issue disappears after pnpm mobile:prebuild:ios and the original problem returns. Or the change persists locally but breaks every other contributor.

Fix. Edit the source files instead: app.json for plist/entitlements, packages/live-activity/ios/ for native module Swift, apps/mobile/targets/widget/ for the WidgetKit target. Then rerun prebuild.

See: https://mobile-surfaces.com/docs/ios-environment

MS018: APNS_BUNDLE_ID must not include the .push-type.liveactivity suffix

category: wire · severity: error · detection: runtime · tags: push, config

The push SDK auto-appends .push-type.liveactivity to the apns-topic; passing it pre-suffixed produces a malformed topic header. createPushClient rejects a pre-suffixed bundleId at construction with MalformedApnsConfigError, so the mistake fails fast instead of on every send.

Symptom. Without the construction-time guard the topic header carries a doubled suffix and APNs responds 400 TopicDisallowed on every send, even though the auth key is correctly enabled for the app. With the guard, createPushClient throws before the first send.

Fix. Set APNS_BUNDLE_ID to the bare bundle id (e.g. com.example.mobilesurfaces). The SDK and scripts/send-apns.mjs both handle the suffix internally.

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: TopicDisallowedError , MalformedApnsConfigError

MS024: Project must depend on @mobile-surfaces/surface-contracts (and push, when sending)

category: wire · severity: error · detection: config · tags: contract, push · enforced by: scripts/probe-app-config.mjs

Foreign Expo projects auditing as Mobile Surfaces consumers must list the contract package; backends sending pushes must additionally list @mobile-surfaces/push.

Symptom. Type errors fire on the snapshot helpers, or hand-rolled APNs code diverges from the validated contract. The failure mode is wire-level drift between client and server.

Fix. Add @mobile-surfaces/surface-contracts on every layer that emits or consumes a snapshot. Add @mobile-surfaces/push on the backend. Both packages release together (linked group).

See: https://mobile-surfaces.com/docs/backend

MS025: App Group declared in app.json

category: iOS · severity: error · detection: config · tags: app-group, config · enforced by: scripts/probe-app-config.mjs

app.json must declare a com.apple.security.application-groups entry for widgets and controls to share state with the host app.

Symptom. Widget extension reads its placeholder snapshot, never the live one. Control toggle does nothing visible. No log message; the App Group is just absent.

Fix. Add the entitlement to apps/mobile/app.json under expo.ios.entitlements. Match it on the widget target's expo-target.config.js.

See: https://mobile-surfaces.com/docs/ios-environment

MS026: Widget target managed by @bacons/apple-targets

category: iOS · severity: error · detection: config · tags: widget, control, toolchain · enforced by: scripts/probe-app-config.mjs

The Mobile Surfaces widget target lives outside the generated ios/ directory and is materialized by @bacons/apple-targets at prebuild time. The check fires when targets/widget/ exists but expo-target.config.js does not; a project with no widget target at all is skipped.

Symptom. Hand-managed Xcode target gets wiped on the next prebuild, or the widget extension never appears in the built app.

Fix. Keep the target source under apps/mobile/targets/widget/ with an expo-target.config.js. Pin @bacons/apple-targets at the supported exact version.

See: https://mobile-surfaces.com/docs/concepts

MS028: APNs auth key environment variables must be set before sending

category: wire · severity: error · detection: runtime · tags: push, tokens

Both the SDK and scripts/send-apns.mjs require APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_PATH, and APNS_BUNDLE_ID.

Symptom. The SDK throws at construction time (createPushClient) or at the first send. The failure is often hidden inside a deployment whose env vars never made it into the runtime.

Fix. Verify each env var on startup. The SDK's createPushClient validates presence; reject fast if any are missing.

See: https://mobile-surfaces.com/docs/push#sdk-reference

Error classes: MissingApnsConfigError

MS029: Generated apps/mobile/ios/ is gitignored

category: maintenance · severity: error · detection: config · tags: cng, config · enforced by: scripts/check-ios-gitignore.mjs

CNG-managed directories must not be checked into version control; commits will fight prebuild forever.

Symptom. Pull request diffs include hundreds of lines under apps/mobile/ios/ that nobody intended to change. Reviewers cannot tell what is real.

Fix. Add apps/mobile/ios/ to .gitignore. If files are already tracked, untrack with git rm -r --cached apps/mobile/ios then commit the .gitignore update.

See: https://mobile-surfaces.com/docs/ios-environment

MS030: APNs provider token must be valid and current

category: wire · severity: error · detection: runtime · tags: push, tokens

APNs returns 403 with reason Forbidden, InvalidProviderToken, or ExpiredProviderToken when the auth-key JWT cannot be verified; each reason has a distinct operator response.

Symptom. All sends fail with 403. ForbiddenError means the auth key was revoked in the Apple Developer portal. InvalidProviderTokenError means the JWT is malformed or signed with the wrong key id / team id. ExpiredProviderTokenError means the JWT is older than 60 minutes (typically clock skew, since the SDK refreshes at 50 minutes).

Fix. ForbiddenError: mint a new auth key in the Apple Developer portal and update APNS_KEY_ID / APNS_KEY_PATH. InvalidProviderTokenError: verify APNS_KEY_ID matches the key file and APNS_TEAM_ID matches the developer account. ExpiredProviderTokenError: check system clock alignment against NTP; if the SDK is long-lived, confirm createPushClient is not being held past process restarts without re-minting.

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: ExpiredProviderTokenError , ForbiddenError , InvalidProviderTokenError

MS031: Channel management failures (missing, malformed, or unregistered channel id)

category: wire · severity: error · detection: runtime · tags: push, channels, ios18

Broadcast and channel-admin calls reject when the channel id is missing from the request, malformed on the wire, or refers to a channel that was never created in the target environment.

Symptom. Broadcast or channel management fails with 400 (missing or bad channel id) or 410 (not registered). Common root causes: the channel was created against the opposite APNs environment, or the id was URL-decoded, truncated, or otherwise mutated before being sent back.

Fix. MissingChannelId: pass channelId to broadcast() or deleteChannel(). BadChannelId: use the id returned by createChannel() verbatim with no URL-decoding or truncation. ChannelNotRegistered: channels are environment-scoped, so re-create the channel in the target environment or call listChannels() to confirm the id exists there.

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: BadChannelIdError , ChannelNotRegisteredError , MissingChannelIdError

MS032: Activity timestamp fields must be valid unix-seconds integers

category: wire · severity: error · detection: runtime · tags: push, live-activity

APNs rejects Live Activity pushes whose date fields (staleDateSeconds, dismissalDateSeconds, apns-expiration) are not positive unix-seconds integers, and rejects broadcast sends to a no-storage channel that carry a nonzero apns-expiration.

Symptom. The SDK's pre-flight throws BadDateError on a malformed staleDateSeconds or dismissalDateSeconds (Apple's current reason table does not return a BadDate string; the class is preflight-only). The apns-expiration field still surfaces as a 400 BadExpirationDate from APNs when set on a no-storage broadcast channel, or when it is a millisecond timestamp, negative number, or non-integer.

Fix. Confirm every date field is a positive unix-seconds integer (not milliseconds, not Date.now()). For broadcast on a no-storage channel, apns-expiration must be 0; the SDK's broadcast() already enforces this.

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: BadDateError , BadExpirationDateError

MS035: apns-topic header missing or bundleId misconfigured

category: wire · severity: error · detection: runtime · tags: push, config

APNs requires an apns-topic header on every request; the SDK derives it from APNS_BUNDLE_ID and a missing or empty bundle id produces a malformed topic at send time.

Symptom. APNs returns 400 MissingTopic. The bundle id was unset or an empty string, so the SDK emitted only the .push-type.liveactivity suffix (or nothing) as the topic header.

Fix. Confirm APNS_BUNDLE_ID is set to the bare bundle identifier (e.g. com.example.app). Do not include the .push-type.liveactivity suffix; the SDK appends it internally. See MS018 for the inverse failure (suffix included by mistake).

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: MissingTopicError

MS036: Surface snapshot Swift structs match their Zod projection-output schemas

category: iOS · severity: error · detection: static · tags: widget, control, swift, contract · enforced by: scripts/generate-surface-swift.mjs

Every Codable struct that decodes a Zod projection-output schema is generated from that schema by scripts/generate-surface-swift.mjs: the four surface snapshots in apps/mobile/targets/_shared/MobileSurfacesSurfaceSnapshots.swift (MobileSurfacesWidgetSnapshot, MobileSurfacesControlSnapshot, MobileSurfacesLockAccessorySnapshot, MobileSurfacesStandbySnapshot) and the MobileSurfacesNotificationContentEntry sidecar in apps/mobile/targets/notification-content/, mapping to liveSurfaceWidgetTimelineEntry, liveSurfaceControlValueProvider, liveSurfaceLockAccessoryEntry, liveSurfaceStandbyEntry, and liveSurfaceNotificationContentEntry in packages/surface-contracts/src/schema.ts. The committed Swift files must match the generator output, so the structs always declare the same fields, types, JSON keys, and optionality as their schemas. This extends the MS003 guarantee from the Lock Screen to every non-Live-Activity surface.

Symptom. The widget, control, lock-accessory, or StandBy surface renders placeholder data forever. The host writes a snapshot into the App Group container, but JSONDecoder in the widget extension silently fails on a renamed key, a type mismatch, or an optionality mismatch and returns nil. No log, no error.

Fix. Update the Zod projection-output schema first (and the projection helper that feeds it), then run pnpm surface:codegen to regenerate the Swift structs. scripts/generate-surface-swift.mjs --check verifies the committed Swift files match the generator output and fails surface:check on drift.

MS037: Notification category outputs in sync with canonical registry

category: iOS · severity: error · detection: static · tags: notification, contract, config · enforced by: scripts/generate-notification-categories.mjs

packages/surface-contracts/src/notificationCategories.ts is the single source of truth for every UNNotificationCategory identifier Mobile Surfaces ships. The generated TS constant at apps/mobile/src/generated/notificationCategories.ts (host registration), the Swift constant at apps/mobile/targets/_shared/MobileSurfacesNotificationCategories.swift (extension routing), and (when the file exists) the UNNotificationExtensionCategory array in apps/mobile/targets/notification-content/Info.plist are all codegened from it and must not be hand-edited. The schema enforces parity at the wire boundary by constraining liveSurfaceNotificationSlice.category to z.enum over the registry's ids.

Symptom. Notification arrives at the device with aps.category set, but the UNNotificationContentExtension is never invoked: the user sees the default system chrome instead of the surface-aware custom view. No log, no error. iOS silently falls back when the payload category does not match any registered UNNotificationExtensionCategory in the extension Info.plist.

Fix. Edit packages/surface-contracts/src/notificationCategories.ts and run pnpm surface:codegen to regenerate every consumer in lockstep. The schema-level z.enum constraint rejects payloads that name a category outside the registry, so the wire stays load-bearing for parity.

MS038: Live Activity adapter inputs must be Zod-parsed before crossing the bridge

category: iOS · severity: error · detection: static · tags: live-activity, contract · enforced by: scripts/check-adapter-parses.mjs

Every adapter method that hands a LiveSurfaceActivityContentState to the native module (start, update) must safeParse the input against liveSurfaceActivityContentState before the call, and reject malformed inputs with InvalidContentStateError extends MobileSurfacesError. The adapter is the single chokepoint between hand-written JS and the ActivityKit Codable decoder; parse-on-entry is what turns a silent Lock Screen failure into a typed, trap-bound error at the call site.

Symptom. Push lands at APNs (200) but the Lock Screen view never updates; or start() resolves with an activity id but the Lock Screen renders placeholder data. ActivityKit's JSONDecoder silently rejects payloads whose shape diverges from the Swift ContentState struct, and without parse-on-entry the JS-side `state` argument can drift from the contract without anyone noticing until a user-facing surface stops moving.

Fix. Use the boundary re-export at apps/*/src/liveActivity, which routes through the wrapped adapter in packages/live-activity/src/index.ts. The wrapper calls liveSurfaceActivityContentState.safeParse(state) before each native call and throws InvalidContentStateError on failure. Do not call requireNativeModule("LiveActivity") or the bare NativeLiveActivity export directly. scripts/check-adapter-parses.mjs asserts the wrapper file references safeParse and InvalidContentStateError.

Error classes: InvalidContentStateError

MS039: Token store discipline: subscribe to ActivityKit token events through @mobile-surfaces/tokens

category: iOS · severity: error · detection: static · tags: live-activity, tokens, contract · enforced by: scripts/check-token-discipline.mjs

Application code under apps/*/src/ must subscribe to onPushToken, onPushToStartToken, and onActivityStateChange through @mobile-surfaces/tokens (or its /react sub-path), never via direct adapter.addListener calls. The token-store package owns MS020/MS021 semantics (latest-write-wins on rotation, terminal lifecycle on activity end), and hand-rolled subscriptions in app code reliably re-introduce the silent token-drift failure mode the package exists to prevent. The check recognises two binding shapes for the event-name argument: a quoted string literal in the addListener call, and an identifier the same file binds via const/let/var to a token-event literal. Destructured bindings (const { onPushToken: ev } = {...}) and cross-file imported event-name constants are documented out of scope; both are undecidable from a single-file pass and pinned by fixtures in scripts/check-token-discipline.test.mjs.

Symptom. Backend send to a stored token returns 410 Unregistered or 400 BadDeviceToken after a working session, or the host accumulates dead tokens for a since-ended activity and never marks them terminal. The failure mode is the app forgetting it has stale state; the diagnostic surface is the backend logs, not the device.

Fix. Replace local addListener subscriptions with the useTokenStore hook from @mobile-surfaces/tokens/react, or createTokenStore + the adapter event subscriptions from @mobile-surfaces/tokens directly. Both encode the MS020 upsert-on-rotation and MS021 markDead-on-terminal semantics. The check allows direct addListener inside packages/live-activity/src/ and packages/tokens/src/ (the implementations themselves). For the documented out-of-scope binding shapes (destructured, cross-file imported), keep the addListener-via-literal or addListener-via-local-const form so the gate continues to catch real violations.

MS040: Swift trap-binding file byte-identity

category: maintenance · severity: error · detection: static · tags: contract, swift · enforced by: scripts/check-traps-swift-byte-identity.mjs

MobileSurfacesTraps.swift must be byte-identical at three sites: packages/traps/swift/, packages/live-activity/ios/, apps/mobile/targets/_shared/. The canonical copy is generated from data/traps.json by scripts/generate-traps-package.mjs; the other two are byte-identity replicas so the native module pod and the widget/notification-content extensions all resolve the same MSTrapBinding table.

Symptom. Drift across the three copies means a native module rejection carries a different trapId than the host app expects, and the `[trap=MSXXX]` suffix protocol parses to inconsistent ids on the JS side. No crash; just diagnostic noise that points the operator at the wrong fix.

Fix. Run pnpm surface:codegen (which calls generate-traps-package) to regenerate all three from the canonical data/traps.json source.

MS041: Projection-output envelopes must declare schemaVersion

category: iOS · severity: error · detection: static · tags: contract, config · enforced by: scripts/check-projection-envelope-version.mjs

Every projection-output Zod schema (liveSurfaceWidgetTimelineEntry, liveSurfaceControlValueProvider, liveSurfaceLockAccessoryEntry, liveSurfaceStandbyEntry, liveSurfaceNotificationContentEntry, liveSurfaceNotificationContentPayload) must declare schemaVersion as a z.literal, and all projection-output envelopes must agree on the same literal value (the canonical schemaVersion of the snapshot schema). The on-device Codable mirror runs a {schemaVersion: String} probe decode before the full struct decode, so a version mismatch surfaces as a placeholder view rather than a silent decode against an incompatible shape. Property order in the Zod source is not constrained: Swift Codable decodes by key name, so the probe works regardless of where schemaVersion sits.

Symptom. Widget binary on schemaVersion N reads a host snapshot at schemaVersion N+1; full Codable decode succeeds because the snapshot's shape happens to be a superset (additive minor) or fails silently (real schema break). Either way, the widget renders stale data or a placeholder without telling the user the host has shipped a schemaVersion the widget doesn't recognize.

Fix. Add `schemaVersion: z.literal("<current>")` to every projection-output schema in packages/surface-contracts/src/schema.ts. The literal value must match the snapshot schema's `schemaVersion` literal (the canonical wire-format generation). The matching Swift Codable structs in apps/mobile/targets/_shared/MobileSurfacesSurfaceSnapshots.swift and apps/mobile/targets/notification-content/MobileSurfacesNotificationContentEntry.swift are generated from those schemas by scripts/generate-surface-swift.mjs; run pnpm surface:codegen after the schema edit. The widget's `readSnapshot` helper then attempts the `{ schemaVersion: String }` probe first, and a mismatch renders the version-mismatch placeholder view instead of failing silently.

MS042: Deprecation prose must not promise removal in the current or a past major

category: maintenance · severity: error · detection: static · tags: contract, config · enforced by: scripts/check-deprecation-prose.mjs

Source files and docs cannot ship a 'will be removed in X.0.0' (or 'removed in X.0') claim from a @mobile-surfaces/surface-contracts version at major X or higher. The deprecation promise is load-bearing for downstream consumers who plan migrations against it; shipping the major while the prose still says the removal is happening now silently breaks the contract.

Symptom. A source comment or doc says a codec will be gone at version X.0.0. The package then ships at X.0.0 with the codec still present. Consumers reading the prose believe the codec is gone and skip a migration window; consumers reading the runtime keep using it indefinitely. The promise and the shipped behavior disagree.

Fix. Update the deprecation prose to a future major (one major past the current is the charter minimum). If the codec really should be removed now, drop it in a coordinated release and remove the prose at the same time. To opt a specific prose line out of the check, prefix the preceding line with `// CHARTER: keep` (the allowlist marker is intentionally narrow).

MS043: CHANGELOG entry required on package major

category: maintenance · severity: error · detection: static · tags: contract, config · enforced by: scripts/check-changelog-on-major.mjs

Every package under packages/* whose package.json declares version X.0.0 (for X >= 1) must have a matching `## X.0.0` heading in its CHANGELOG.md. The check looks for the heading; the body is up to the maintainer. The release workflow normally writes the entry on `changeset version`; this gate catches the case where a major was bumped manually or the changeset entry was missed.

Symptom. Package silently bumps from 5.0.0 to 6.0.0 with no CHANGELOG entry. Downstream consumers reading the CHANGELOG to understand the bump find nothing; release notes lose the audit trail; the major version becomes meaningless.

Fix. Write the CHANGELOG entry. Use `pnpm changeset version` if it didn't run, or hand-write the `## X.0.0` heading with the major changes underneath. For packages bumped purely by the linked-release group with no API change of their own, the convention is: `Linked-group bump for the v<N> schema release in @mobile-surfaces/surface-contracts. No <package> API change.`

MS044: Catalog headline counts stay in sync with the trap catalog

category: maintenance · severity: error · detection: static · tags: contract, config · enforced by: scripts/generate-catalog-stats.mjs

Every public count of catalog rules is generated. data/catalog-stats.json holds the canonical breakdown (total, live, deprecated, the severity and detection splits, and the PR-gated count), and the `catalog-stats:` marker blocks in README.md and the doc site are rewritten from it. The check fails when the stats file or a marker block drifts from data/traps.json.

Symptom. A rule is added, retired, or reclassified and a prose number is not updated. README advertises a documented-rule count the catalog no longer matches; vs-expo-live-activity.md claims a PR-gated count that disagrees with the registry. The catalog silently contradicts itself across published surfaces.

Fix. Run `pnpm surface:codegen` (or `node --experimental-strip-types scripts/generate-catalog-stats.mjs`) to regenerate data/catalog-stats.json and rewrite the marker blocks. Never hand-edit a number inside a `catalog-stats:` marker; edit data/traps.json and regenerate.

MS045: Widget Color asset references must resolve to a generated colorset

category: iOS · severity: error · detection: static · tags: widget, config, swift · enforced by: scripts/check-widget-color-assets.mjs

Every named-asset color reference in the widget target's Swift must name a colorset that @bacons/apple-targets materializes on disk. Two reference shapes are covered: SwiftUI Color("literal") and the UIKit bridge UIColor(named: "literal") (reachable from SwiftUI via Color(uiColor: UIColor(named: "literal")!)). The generator names each colorset after the literal key in the colors map of apps/mobile/targets/widget/expo-target.config.js, so the on-disk asset names are the config keys themselves ($accent.colorset, $widgetBackground.colorset), not the human-facing palette names.

Symptom. The widget renders with default system colors instead of the brand palette, and nothing reports an error. SwiftUI Color("name") and UIKit UIColor(named: "name") for a name with no matching colorset do not crash; both silently fall back to a default color. A Swift file that says Color("AccentColor") or Color(uiColor: UIColor(named: "AccentColor")!) while the asset catalog only holds $accent.colorset compiles, ships, and renders the wrong color on the device with no log, no warning, and no build failure.

Fix. Reference colorsets by the exact key declared in the expo-target.config.js colors map. Two keys are magic: $accent is bound to ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME and $widgetBackground to ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME. Reference the global accent as Color.accentColor (the literal Color("$accent") is also valid since the colorset is genuinely named $accent); reference the background as the literal Color("$widgetBackground"). scripts/check-widget-color-assets.mjs lists the on-disk *.colorset directories and fails when any Color("literal") or UIColor(named: "literal") names no colorset. Dynamic-name references (Color(varName)) are out of scope because name resolution from a binding is undecidable from text; use a literal reference instead.

See: https://mobile-surfaces.com/docs/ios-environment

MS010: Toolchain preflight (Node 24, pnpm, Xcode 26+)

category: maintenance · severity: warning · detection: config · tags: toolchain · enforced by: scripts/doctor.mjs

pnpm dev:doctor verifies Node 24, pnpm 10, Xcode major 26+, and simulator availability before iOS work begins.

Symptom. Builds fail with confusing Swift compiler errors, simulator launches that hang, or pnpm refusing to install. None of these point at the actual cause: a stale toolchain row.

Fix. Run pnpm dev:doctor. Update Xcode and re-run if the major version is below 26. Use Node 24.

MS015: Push priority 5 vs 10 budget rules

category: wire · severity: warning · detection: runtime · tags: push, live-activity

Live Activity priority 10 is for immediate user-visible updates and is heavily budgeted by iOS; sustained priority 10 sends are silently throttled.

Symptom. Updates land for the first few pushes then mysteriously stop arriving on the device. APNs returns 200; iOS still drops them. Logs show TooManyRequests bursts.

Fix. Default to priority 5 for Live Activity content-state updates. Reserve priority 10 for state transitions the user must see immediately (queued→active, completed).

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: TooManyRequestsError

MS016: Subscribe to onPushToStartToken at mount, not on demand

category: iOS · severity: info · detection: advisory · tags: tokens, live-activity · ios min: 17.2

Advisory: no enforceable static or runtime gate exists for this rule; treat it as a discipline note. iOS only delivers push-to-start tokens through Activity<...>.pushToStartTokenUpdates as an async sequence; getPushToStartToken() is only a probe of the latest token the event stream has already cached (it resolves null until the first emission), not an acquisition path.

Symptom. Backend never receives a push-to-start token, or only receives one after a manual app re-launch. Remote Live Activity start never fires for users who have not opened the app since install.

Fix. Subscribe via liveActivityAdapter.addListener('onPushToStartToken', ...) inside a mount-time effect. Re-store the token on every emission, since Apple may rotate at cold launch or system rotation.

See: https://mobile-surfaces.com/docs/push#token-taxonomy

MS019: FB21158660: push-to-start tokens silent after force-quit

category: iOS · severity: info · detection: advisory · tags: tokens, live-activity · ios min: 17.2

Apple-reported bug. After the user force-quits the app, pushToStartTokenUpdates may stop emitting until the next OS push wakes the app, but the previously-issued token still authenticates against APNs (200 response, no actual activity start).

Symptom. Backend successfully sends a remote start, gets 200, but the Lock Screen never shows the activity. User has force-quit the app since the last token rotation.

Fix. No client workaround. Document in customer-support runbooks: 'If the Lock Screen activity does not appear after a remote-start push, ask the user to open the app once.'

See: https://mobile-surfaces.com/docs/push#fb21158660-push-to-start-after-force-quit

MS020: Per-activity and push-to-start tokens may rotate at any time

category: iOS · severity: info · detection: advisory · tags: tokens

Advisory: token rotation is encoded structurally in the @mobile-surfaces/tokens latestWriteWins Map; no separate static or runtime gate. Both pushTokenUpdates and pushToStartTokenUpdates may emit fresh values at any moment (cold launch, system rotation, foreground transition).

Symptom. Backend send to a stored token returns 410 Unregistered or 400 BadDeviceToken on a previously-working device. The user did nothing wrong; the OS rotated the token.

Fix. Treat the latest event as authoritative. Re-store on every emission keyed by user/device id, and update the active record rather than appending.

See: https://mobile-surfaces.com/docs/push#token-taxonomy

Error classes: UnregisteredError

MS021: Discard per-activity tokens when the activity ends

category: iOS · severity: info · detection: advisory · tags: tokens, live-activity

Advisory: terminal-state token discard is encoded structurally in the tokens-package markEnding -> markDead lifecycle; no separate gate. Once onActivityStateChange reports 'ended' or 'dismissed', the per-activity push token is dead; sending to it is accepted by APNs (200) but iOS will not surface anything.

Symptom. Backend keeps spending sends on a token that produces no user-visible effect. No error, just silent drops.

Fix. Wire onActivityStateChange to your token store; mark tokens for the activity as terminal and stop selecting them for sends.

See: https://mobile-surfaces.com/docs/push#token-taxonomy

MS023: Per-activity tokens are bound to a single Activity instance

category: iOS · severity: info · detection: advisory · tags: tokens

Advisory: per-activity binding is encoded structurally in the tokens-package kind: 'perActivity' discriminant; no separate gate. A new Activity.request mints a fresh per-activity token; tokens from a previous run cannot drive the new activity.

Symptom. Update lands at APNs (200) but appears not to do anything. Reading the harness shows a different per-activity token than what your backend stored.

Fix. Re-store on every onPushToken emission; treat tokens as activity-scoped, not user-scoped.

See: https://mobile-surfaces.com/docs/push#token-taxonomy

MS034: Broadcast capability must be enabled on the APNs auth key

category: wire · severity: info · detection: advisory · tags: push, channels, ios18, config

Advisory: the iOS 18 broadcast capability is an APNs auth-key property an operator toggles in the Apple developer console; no client-side gate is possible. iOS 18 broadcast pushes and channel-admin calls require the 'Broadcast to Live Activity' capability on the APNs auth key. The capability is per-key, not per-app, and is invisible until the first send fails.

Symptom. createChannel() or broadcast() fails with 403 FeatureNotEnabled. The auth key is otherwise valid and other push types succeed; only broadcast-related calls reject.

Fix. Enable broadcast in the Apple Developer portal under Certificates, Identifiers & Profiles > Keys > select the key > edit > tick 'Broadcast to Live Activity'. Save and retry; no client change is needed.

See: https://mobile-surfaces.com/docs/push#error-responses

Error classes: FeatureNotEnabledError