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
errorrule 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.
staticrules can be checked by reading files;configrules by readingapp.json,package.json, andexpo-target.config.js;runtimerules by inspecting recent APNs response codes;advisoryrules 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
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
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
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
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
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
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
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
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)
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
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.
MS013: App Group entitlement must match host app and widget extension
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.
MS014: APNs token environment must match the build environment
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
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.
MS018: APNS_BUNDLE_ID must not include the .push-type.liveactivity suffix
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)
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).
MS025: App Group declared in app.json
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.
MS026: Widget target managed by @bacons/apple-targets
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.
MS028: APNs auth key environment variables must be set before sending
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
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.
MS030: APNs provider token must be valid and current
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)
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
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
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
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
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
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
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
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
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
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
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
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
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.
MS010: Toolchain preflight (Node 24, pnpm, Xcode 26+)
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
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
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.
MS019: FB21158660: push-to-start tokens silent after force-quit
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
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
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.
MS023: Per-activity tokens are bound to a single Activity instance
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.
MS034: Broadcast capability must be enabled on the APNs auth key
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