diff --git a/docs/README.md b/docs/README.md
index f9378ee7..fa5c1b13 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -14,6 +14,7 @@ Module-specific instructions stay with the module that owns them.
- Mattermost mobile app upstream snapshot baseline: `../apps/mattermost/UPSTREAM.md`
- Mattermost push-proxy upstream snapshot baseline: `../services/push-proxy/UPSTREAM.md`
- Runtime Image Validation details: `runtime-image-validation.md`
+- iOS notification smoke prerequisites and runbook: `ios-notification-test-guide.md`
## Upstream Following Policy
diff --git a/docs/ios-notification-test-guide.md b/docs/ios-notification-test-guide.md
new file mode 100644
index 00000000..76854b55
--- /dev/null
+++ b/docs/ios-notification-test-guide.md
@@ -0,0 +1,520 @@
+# iOS Notification Test Guide
+
+Last verified: 2026-06-01
+
+This guide lists what is needed to run iOS notification smoke tests for
+`packages/messaging_flutter` through `apps/flutter-test`. It separates external
+Apple/Firebase setup, local host-app setup, current implementation gaps, and
+repeatable evidence to collect.
+
+Do not commit real Apple, APNs, Firebase, service-account, device-token, auth
+token, private endpoint, Team ID, or provisioning values into tracked docs.
+Use placeholders in examples and keep credential files in the approved local or
+secret-store location.
+
+## Current Repository State
+
+- The iOS plugin is scaffold-only. `packages/messaging_flutter/ios/Classes/NexoMessagingPlugin.swift`
+ currently registers a `nexo_messaging` method channel and implements only
+ `getPlatformVersion`.
+- The Dart plugin expects native notification and action channels:
+ `com.tokilabs.nexo.messaging/notifications` and
+ `com.tokilabs.nexo.messaging/notification_actions`.
+- `apps/flutter-test/lib/main.dart` already calls `Firebase.initializeApp()`
+ before starting the app.
+- `apps/flutter-test/ios/Runner/AppDelegate.swift` does not currently register
+ for remote notifications or install a `UNUserNotificationCenter` delegate.
+- `apps/flutter-test/ios/Runner/Info.plist` has no notification-specific keys.
+- `GoogleService-Info.plist` is intentionally ignored by `.gitignore`.
+
+The first meaningful iOS smoke target is therefore:
+
+1. Build and install the signed test app on a physical iOS device.
+2. Initialize Firebase successfully with a local `GoogleService-Info.plist`.
+3. Request notification permission.
+4. Obtain an APNs token and an FCM registration token.
+5. Deliver one visible FCM notification through APNs.
+6. Map a notification open into the Dart `NotificationOpenedEvent` contract.
+
+ACK, dismiss, inline reply, and native notification persistence are follow-up
+implementation work unless the iOS native bridge is expanded first.
+
+## Decisions Needed Before Testing
+
+Record the answers in a private run note or task evidence, not in tracked docs.
+
+| Decision | Why it matters |
+|---|---|
+| Apple Developer Team | Determines signing identity, App ID ownership, and provisioning profile. |
+| Bundle ID | Must match Xcode, Apple App ID, Firebase iOS app, and APNs topic. |
+| Provisioning flow | Automatic signing is easiest for smoke; manual profiles are better for controlled evidence. |
+| APNs auth style | Prefer token key `.p8` for Firebase/FCM; certificate `.p12` is a fallback if the project requires it. |
+| Firebase project/app | Existing project vs new smoke-only project affects data isolation and credential access. |
+| Firebase config storage | This repo ignores `GoogleService-Info.plist`; decide who provides it and where local copies live. |
+| Physical device | FCM via APNs requires a real iOS device for meaningful delivery testing. |
+| Evidence path | Decide where screenshots, logs, payloads, and run notes are stored. |
+| CI boundary | Keep real APNs/FCM delivery manual unless a signed device farm is explicitly available. |
+
+## Apple Setup Checklist
+
+### Account And App ID
+
+- Active Apple Developer Program membership.
+- Access to the target Apple Developer Team.
+- Bundle ID reserved for the iOS smoke app.
+- App ID with Push Notifications enabled.
+- Registered physical device if manual provisioning is used.
+
+The Bundle ID must be consistent everywhere:
+
+- Xcode `PRODUCT_BUNDLE_IDENTIFIER`.
+- Apple Developer App ID.
+- Firebase iOS app bundle ID.
+- APNs `apns-topic` when sending directly to APNs.
+
+### Xcode Capabilities
+
+Open `apps/flutter-test/ios/Runner.xcworkspace` in Xcode and configure the
+`Runner` target.
+
+- `Signing & Capabilities > Team`: selected smoke Team.
+- `Signing & Capabilities > Bundle Identifier`: selected smoke Bundle ID.
+- `Signing & Capabilities > Push Notifications`: enabled.
+- `Signing & Capabilities > Background Modes`: enable `Remote notifications`
+ if the smoke includes background/silent delivery or background FCM callbacks.
+
+Expected local file outcome:
+
+- A Runner entitlements file exists or Xcode build settings point to one.
+- The signed app includes `aps-environment` as `development` for debug smoke.
+- Provisioning profile includes the App ID and push entitlement.
+
+Useful verification commands after building:
+
+```sh
+codesign -d --entitlements :- build/ios/iphoneos/Runner.app
+```
+
+Look for:
+
+```xml
+aps-environment
+development
+```
+
+### APNs Auth Key Or Certificate
+
+Preferred path for Firebase/FCM:
+
+- Create or reuse an APNs Authentication Key in Apple Developer.
+- Download the `.p8` file once.
+- Record the Key ID privately.
+- Record the Team ID privately.
+- Upload the `.p8`, Key ID, and Team ID to Firebase Cloud Messaging settings
+ for the matching iOS app.
+
+Fallback path:
+
+- Create an APNs provider certificate for the App ID.
+- Export it as `.p12` if Firebase or a provider server requires certificate
+ auth.
+- Track expiry and renewal ownership.
+
+Do not confuse these with iOS code-signing certificates. Code signing lets the
+app install and carry the push entitlement. APNs provider credentials let a
+server, Firebase, or Apple Push Notification Console send pushes.
+
+## Firebase Setup Checklist
+
+### Project And iOS App
+
+- Firebase project exists.
+- iOS app is registered with the exact smoke Bundle ID.
+- `GoogleService-Info.plist` is downloaded for that iOS app.
+- The plist is copied locally to `apps/flutter-test/ios/Runner/GoogleService-Info.plist`.
+- The plist is added to the Xcode `Runner` target resources.
+- The plist remains untracked because `.gitignore` ignores
+ `**/GoogleService-Info.plist`.
+
+The test app already calls:
+
+```dart
+WidgetsFlutterBinding.ensureInitialized();
+await Firebase.initializeApp();
+```
+
+If Firebase initialization fails on iOS, check that the plist is present in the
+Runner target and that the bundle ID in the plist matches the signed app.
+
+### Cloud Messaging Settings
+
+In Firebase Console:
+
+- Open Project Settings.
+- Open Cloud Messaging.
+- Find the matching iOS app configuration.
+- Upload APNs auth key `.p8`, or configure APNs certificates if that is the
+ selected path.
+- Confirm Firebase Cloud Messaging API is enabled for HTTP v1 sends.
+
+For server-side sends:
+
+- Use Firebase Admin SDK, or
+- Use FCM HTTP v1 with a service account or Application Default Credentials.
+
+Do not place service-account JSON in the repo.
+
+## Native Bridge Work Needed For A Full Smoke
+
+The current iOS scaffold cannot yet complete the Dart notification contract.
+Minimum native bridge work:
+
+- Register the expected EventChannel:
+ `com.tokilabs.nexo.messaging/notifications`.
+- Register the expected MethodChannel:
+ `com.tokilabs.nexo.messaging/notification_actions`.
+- Implement `saveDeviceToken`, `getDeviceToken`, `setAuthToken`,
+ `clearAuthToken`, `setSigningKey`, and `debugSendNativeEvent` for iOS or
+ explicitly no-op them with clear smoke limitations.
+- Request user notification authorization or rely consistently on
+ FlutterFire's `FirebaseMessaging.requestPermission`.
+- Call `UIApplication.shared.registerForRemoteNotifications()`.
+- Handle `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`.
+- Handle `application(_:didFailToRegisterForRemoteNotificationsWithError:)`.
+- Decide whether Firebase Messaging method swizzling stays enabled.
+- If swizzling is disabled, set `Messaging.messaging().apnsToken` manually.
+- Subscribe to FCM token refresh and forward token changes to Dart.
+- Set `UNUserNotificationCenter.current().delegate`.
+- Forward foreground receipt, notification tap, and launch-from-notification
+ payloads into Dart.
+- Map APNs/FCM userInfo fields to `NotificationOpenedEvent`.
+
+Current Dart token formatting uses the Android-oriented prefix:
+
+```dart
+const String _kDeviceTokenPrefix = 'android_rn';
+```
+
+Before registering iOS tokens with a nexo server, define an iOS token prefix and
+server compatibility rule. A likely follow-up is to make the prefix
+platform-aware instead of hard-coded.
+
+## Payload Contract
+
+For nexo routing, keep custom fields outside the APNs `aps` dictionary. FCM
+`data` values must be strings.
+
+Required or high-value fields:
+
+| Field | Purpose |
+|---|---|
+| `type` | `message`, `opened`, `clear`, `session`, or `token_refresh` equivalent in the Dart contract. |
+| `server_url` | Preferred direct routing target when available. |
+| `server_id` | Fallback key if the app resolves server IDs locally. |
+| `channel_id` | Channel routing. |
+| `root_id` | Thread routing when CRT is enabled. |
+| `is_crt_enabled` | Determines channel vs thread navigation behavior. |
+| `ack_id` | ACK contract and signed push validation. |
+| `post_id` | Notification identity and ACK enrichment. |
+
+Visible FCM HTTP v1 smoke payload shape:
+
+```json
+{
+ "message": {
+ "token": "",
+ "notification": {
+ "title": "nexo smoke",
+ "body": "iOS notification smoke"
+ },
+ "data": {
+ "type": "message",
+ "server_url": "https://",
+ "channel_id": "",
+ "root_id": "",
+ "is_crt_enabled": "false",
+ "ack_id": "",
+ "post_id": ""
+ },
+ "apns": {
+ "headers": {
+ "apns-push-type": "alert",
+ "apns-priority": "10",
+ "apns-topic": ""
+ },
+ "payload": {
+ "aps": {
+ "sound": "default"
+ }
+ }
+ }
+ }
+}
+```
+
+Silent/background smoke payload shape:
+
+```json
+{
+ "message": {
+ "token": "",
+ "data": {
+ "type": "message",
+ "server_url": "https://",
+ "channel_id": ""
+ },
+ "apns": {
+ "headers": {
+ "apns-push-type": "background",
+ "apns-priority": "5",
+ "apns-topic": ""
+ },
+ "payload": {
+ "aps": {
+ "content-available": 1
+ }
+ }
+ }
+ }
+}
+```
+
+Use visible alert smoke first. Silent pushes are best-effort and easier to
+misread during early setup.
+
+## Test Levels
+
+### Level 0: Repo Contract Tests
+
+No Apple/Firebase credentials required.
+
+- Dart widget tests.
+- Dart event mapping tests using `handleNativeEvent`.
+- Integration tests using `debugSendNativeEvent` when native debug bridge exists.
+
+This level validates the Dart routing contract only. It does not prove APNs or
+FCM delivery.
+
+### Level 1: Simulator / Local Injection
+
+No real FCM via APNs.
+
+- Use simulator/local notification payload injection only to exercise
+ userInfo parsing and open routing.
+- Do not mark FCM delivery, APNs token registration, or background delivery as
+ passed from simulator evidence.
+
+Example local `.apns` fixture shape:
+
+```json
+{
+ "Simulator Target Bundle": "",
+ "aps": {
+ "alert": {
+ "title": "nexo smoke",
+ "body": "local routing fixture"
+ },
+ "sound": "default"
+ },
+ "type": "message",
+ "server_url": "https://",
+ "channel_id": "",
+ "root_id": "",
+ "is_crt_enabled": "false"
+}
+```
+
+### Level 2: Physical Device FCM Smoke
+
+Required for meaningful iOS delivery.
+
+- Signed debug or release app installed on a real iPhone/iPad.
+- Notification permission requested and accepted.
+- APNs registration succeeds.
+- FCM registration token is displayed or logged.
+- A visible FCM message arrives.
+- Tapping the notification opens or resumes the app.
+- Dart receives a notification-open event with routing fields.
+
+### Level 3: Nexo Server End-To-End Smoke
+
+Required before claiming server contract compatibility.
+
+- FCM token is registered with the nexo server using the agreed iOS token
+ prefix.
+- Server can send a nexo-compatible push payload.
+- Signed payload validation behavior is known.
+- ACK request behavior is observed.
+- Open routing reaches the host app callback.
+
+## Manual Smoke Runbook
+
+### 1. Preflight
+
+- Confirm Xcode version and iOS device OS version.
+- Confirm Bundle ID in Xcode, Apple Developer, and Firebase match.
+- Confirm `GoogleService-Info.plist` exists locally and is part of Runner
+ target resources.
+- Confirm APNs auth key or certificate is configured in Firebase.
+- Confirm the app target has Push Notifications capability.
+- Confirm `aps-environment` is present in the built app entitlements.
+- Confirm no real credential values are staged in git.
+
+### 2. Build And Install
+
+From the Flutter test app:
+
+```sh
+cd apps/flutter-test
+flutter pub get
+flutter run -d
+```
+
+If Xcode signing configuration is not already set, open:
+
+```sh
+open ios/Runner.xcworkspace
+```
+
+Build from Xcode once to resolve signing and provisioning issues.
+
+### 3. Permission And Token
+
+Expected evidence:
+
+- App launches without Firebase initialization error.
+- iOS notification permission prompt appears.
+- Permission state is logged.
+- APNs registration success is logged.
+- FCM token is logged or displayed in the app UI.
+- `Device token:` in the Flutter test app changes from `pending` to a formatted
+ token once the Dart/native bridge supports iOS token handoff.
+
+Important distinction:
+
+- FCM sends to the FCM registration token returned by Firebase Messaging.
+- Apple Push Notification Console or direct APNs sends require the APNs device
+ token from `didRegisterForRemoteNotificationsWithDeviceToken`.
+
+### 4. Send A Visible FCM Message
+
+Use Firebase Admin SDK or FCM HTTP v1. Keep payload, response ID, timestamp,
+device state, and app logs as evidence.
+
+Expected result:
+
+- Foreground: app receives callback or foreground presentation according to the
+ native delegate policy.
+- Background: system displays a notification.
+- Terminated: tapping the notification starts the app and exposes the launch
+ payload.
+
+### 5. Verify Routing
+
+For channel routing:
+
+- Payload has `is_crt_enabled=false`.
+- Payload has `server_url` and `channel_id`.
+- App UI updates `Last opened`.
+- App UI updates `Last navigation` with `channel:/`.
+
+For thread routing:
+
+- Payload has `is_crt_enabled=true`.
+- Payload has `server_url` and `root_id`.
+- App UI updates `Last opened`.
+- App UI updates `Last navigation` with `thread:/`.
+
+### 6. Verify ACK, Dismiss, Inline Reply Boundaries
+
+Current expected state:
+
+- ACK is not passed unless an iOS ACK client/native bridge exists.
+- Dismiss is not passed unless notification category/delete handling exists.
+- Inline reply is not passed unless `UNTextInputNotificationAction` and reply
+ forwarding exist.
+
+If these are not implemented, record them as known iOS gaps instead of smoke
+failures.
+
+## Evidence To Keep
+
+Do keep:
+
+- Date, operator, device model, iOS version, Xcode version.
+- Bundle ID shape, but not private Team ID if policy treats it as sensitive.
+- Build configuration: Debug/Profile/Release.
+- Entitlement proof with secrets redacted.
+- Firebase project/app alias, not raw private IDs if policy forbids them.
+- Permission status.
+- APNs registration success/failure log.
+- FCM token presence, redacted.
+- FCM message ID or API response ID.
+- Payload shape with private values replaced by placeholders.
+- Screenshot or screen recording of notification display and app routing UI.
+- App logs around receipt, tap, routing, ACK/dismiss/reply if implemented.
+
+Do not keep:
+
+- `.p8`, `.p12`, provisioning profile, private key, or service-account JSON.
+- Full APNs device token or FCM registration token.
+- Full auth token, signing key, private server URL, or user data.
+
+## Troubleshooting Matrix
+
+| Symptom | Likely cause | Check |
+|---|---|---|
+| Firebase initialization fails | Missing or wrong `GoogleService-Info.plist` | Confirm file is in Runner target and Bundle ID matches. |
+| Permission prompt never appears | Permission already decided, request not called, or initialization failed | Check iOS Settings and app logs. |
+| APNs registration fails | Missing entitlement or bad signing profile | Inspect built entitlements and provisioning profile. |
+| FCM token is null | APNs token not linked, Firebase app mismatch, network issue, or swizzling/delegate conflict | Check Firebase logs, APNs token callback, and Messaging setup. |
+| FCM send succeeds but no notification | Wrong token, wrong Firebase project, APNs key not uploaded, device offline, notification disabled | Confirm token source, Firebase app, APNs config, device notification settings. |
+| Direct APNs send fails | Using FCM token instead of APNs token, wrong topic, wrong environment | Use APNs device token and matching Bundle ID/environment. |
+| Foreground receives nothing | `UNUserNotificationCenter` delegate missing or not forwarding | Confirm delegate setup and callback implementation. |
+| Tap opens app but no routing | Launch/tap payload not forwarded to Dart | Check native open handler and EventChannel emission. |
+| Background/silent unreliable | iOS best-effort delivery, wrong headers, app force-quit, missing background mode | Start with visible alert smoke and test silent separately. |
+
+## Completion Criteria For This Milestone
+
+Minimum pass for `ios-notification-test` planning smoke:
+
+- Apple/Firebase ownership and credential flow are decided.
+- Bundle ID and provisioning approach are documented privately.
+- Firebase iOS app and local plist path are confirmed.
+- Physical-device smoke path is confirmed.
+- Current native bridge gaps are listed.
+- A visible FCM/APNs smoke checklist exists with evidence fields.
+- CI/headless boundary is explicit.
+
+Minimum pass for a future implementation smoke:
+
+- iOS native bridge emits Dart notification events.
+- FCM token is obtained and handed to Dart with an iOS-compatible prefix.
+- Visible FCM notification arrives on a physical device.
+- Tapping the notification triggers `NotificationOpenedEvent`.
+- Channel and thread routing callbacks are exercised.
+- ACK/dismiss/inline reply are either implemented and verified or explicitly
+ tracked as remaining gaps.
+
+## Official References
+
+- Apple: Registering your app with APNs
+ https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns
+- Apple: Generating a remote notification
+ https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification
+- Apple: Sending notification requests to APNs
+ https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns
+- Apple: Testing notifications using the Push Notification Console
+ https://developer.apple.com/documentation/usernotifications/testing-notifications-using-the-push-notification-console
+- Firebase: Add Firebase to your Apple project
+ https://firebase.google.com/docs/ios/setup
+- Firebase: Set up a Firebase Cloud Messaging client app on Apple platforms
+ https://firebase.google.com/docs/cloud-messaging/get-started?platform=ios
+- Firebase: Receive messages in Apple platform apps
+ https://firebase.google.com/docs/cloud-messaging/ios/receive-messages
+- Firebase: Build app server send requests
+ https://firebase.google.com/docs/cloud-messaging/send-message
+- FlutterFire: FCM via APNs Integration
+ https://firebase.flutter.dev/docs/messaging/apple-integration/
+- FlutterFire: Cloud Messaging usage
+ https://firebase.flutter.dev/docs/messaging/usage