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.

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

Rules

MS001: Live Activity adapter boundary

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/architecture#adapter-contract

MS002: ActivityKit attribute file byte-identity

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/architecture#native-constraints

MS003: Swift ContentState fields and JSON keys match Zod liveSurfaceActivityContentState

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

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

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

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

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

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)

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

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

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

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

Tokens minted by a development build cannot authenticate against the production APNs endpoint, and vice versa.

Symptom. APNs responds 400 BadDeviceToken. The token is valid; it just belongs to the other environment.

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.

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

Error classes: BadDeviceTokenError

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

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

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.

Symptom. APNs responds 400 TopicDisallowed even though your auth key is correctly enabled for the app. The topic header has the suffix doubled or in the wrong position.

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

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

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-integration

MS025: App Group declared in app.json

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

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/architecture

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

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

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

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)

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

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. APNs returns 400 BadDate or 400 BadExpirationDate. The push payload looked valid locally but a date field was a millisecond timestamp, a negative number, or a non-integer; or apns-expiration was set on a no-storage broadcast channel.

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

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

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

Every hand-maintained Codable struct in apps/mobile/targets/_shared/MobileSurfacesSharedState.swift that decodes a Zod projection-output schema (today: MobileSurfacesWidgetSnapshot, MobileSurfacesControlSnapshot, MobileSurfacesLockAccessorySnapshot, MobileSurfacesStandbySnapshot, mapping to liveSurfaceWidgetTimelineEntry, liveSurfaceControlValueProvider, liveSurfaceLockAccessoryEntry, liveSurfaceStandbyEntry in packages/surface-contracts/src/schema.ts) must declare the same fields, types, JSON keys, and optionality as its schema. New surfaces register their struct + schema pair in scripts/check-surface-snapshots.mjs's SURFACES list. 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 mirror the field, type, JSON key, and optionality change into the matching struct in MobileSurfacesSharedState.swift. Run pnpm surface:check; check-surface-snapshots.mjs verifies all four structs against their schemas.

MS037: Notification category outputs in sync with canonical registry

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

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

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.

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).

MS040: Swift trap-binding file byte-identity

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

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 as its first property. All projection-output envelopes must agree on the same literal value (the canonical schemaVersion of the snapshot schema). The first-property ordering is load-bearing: the on-device Codable mirror reads {schemaVersion: String} first, so the JSONDecoder can detect a version mismatch before it attempts to decode the rest of the struct against an incompatible shape.

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>")` as the first property of 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). Mirror the field in the matching Swift Codable struct in apps/mobile/targets/_shared/MobileSurfacesSharedState.swift (and apps/mobile/targets/notification-content/ for the notification mirror). 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

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. Source comment in schema-v4.ts says the codec is gone at 9.0.0. Package ships at 9.0.0 with the codec still present in safeParseAnyVersion. Consumers reading the source comment believe v4 is gone and skip a migration window; consumers reading the runtime keep using v4 indefinitely.

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

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.`

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

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

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

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() always resolves null.

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

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

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

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

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

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