diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md
new file mode 100644
index 00000000000..096f4cb0670
--- /dev/null
+++ b/.changeset/29067-push-device-registry-spec.md
@@ -0,0 +1,16 @@
+---
+'@objectstack/spec': minor
+---
+
+feat(spec): the push channel's protocol face, declared ahead of its producer: the widened device registration request, the stored device shape, optional `INotificationService` device members, the push delivery data with its same-server `route`, and the APNs / FCM transport configuration by `credentialRef` (ADR-0012 §6)
+
+Clause-②: yes (widening)
+Metadata-protocol: added
+
+Nothing here is served or read yet. Every new or touched schema carries a `[PLANNED — not yet served]` or `[PLANNED — not yet read]` marker in its description, and no route row is added: the device routes land in `DEFAULT_NOTIFICATION_ROUTES` together with their handlers, so the catalog never advertises a route that answers 404. Nothing a client or an operator writes today is refused.
+
+- **`RegisterDeviceRequestSchema`** gains three optional members: `vendor` (`apns` | `fcm`; absent, the server derives it from `platform`, `ios` → `apns` and `android` → `fcm`, and a `web` registration names `apns` or `fcm`), `appId` and `locale` (a BCP 47 tag, the house `LocaleSchema`). `token`, `platform`, `deviceId` and `name` are unchanged, so every body a client sent before still parses as it did. The describes now say that re-registering a `deviceId` refreshes that device, and that a request without one gets a server-minted id the client keeps. A `vendor` outside `apns` / `fcm` is refused; since the member is new, no body sent before carries one. `RegisterDeviceResponseSchema` and `UnregisterDevice*` keep their members and gain only their description.
+- **`UserDeviceSchema`** (new, `system/push-device.zod.ts`) is the stored device: `deviceId`, `userId`, `vendor`, `token`, `platform`, `enabled`, and optional `appId`, `locale`, `name`, `invalidatedAt` and `lastSeenAt`. A device belongs to its user alone (no organization member), a token has one live row (the same user's registration without a `deviceId` refreshes the row that holds it, and a second user's registration takes it over), and a token the vendor reports dead keeps its row with `enabled: false` and `invalidatedAt`.
+- **`INotificationService`** gains four optional members: `registerDevice(userId, request)`, `unregisterDevice(userId, deviceId)`, `listDevices(userId)` and `invalidateDeviceToken(token)`. Their docs state the semantics an implementation owes. Absence means the capability is unavailable, so every existing implementation stays valid.
+- **`PushDeliveryDataSchema`** (new, `api/push-delivery.zod.ts`) is the data a push carries to the shell: `notificationId` and an optional `route`. `PushRouteSchema` admits only a same-server relative path (one leading `/`; no scheme, host, backslash, whitespace or control character; no dot segment and no percent-encoded `/`, `\`, `.` or `%` in its path). The route is derived, never authored: it is the notification's existing `actionUrl` (app-relative `/OBJECT/ID` or an `/apps/…` route) when that passes the schema, and absent otherwise. The shell resolves it on tap as the inbox does, never verbatim. FCM carries this data in `message.data`; APNs at the top level of the payload, beside `aps`. No `notify` key is added.
+- **`PushServiceConfigSchema`** (new, `system/push-config.zod.ts`, beside the email transport configuration): an `apns` block (token auth: `teamId`, `keyId`, `topic`, `environment: sandbox | production`, the `.p8` key by `credentialRef`) and an `fcm` block (HTTP v1: `projectId`, the service-account JSON by `credentialRef`). Secrets are references only (ADR-0143 D3). An inline secret is refused by name under the keys an operator would reach for, and refused as the content of a reference (a PEM block or a JSON document). `PushVendorSchema` names the two vendors. It is not a stack-definition key until its reader ships.
diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx
index ab2e5e47354..2a07a1bdd46 100644
--- a/content/docs/references/api/index.mdx
+++ b/content/docs/references/api/index.mdx
@@ -1,7 +1,7 @@
---
title: API Protocol — complete schema reference
navTitle: API Protocol
-description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
+description: "The ObjectStack API Protocol in 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -33,6 +33,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
+
diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json
index c9836a69072..3a723ef6c4d 100644
--- a/content/docs/references/api/meta.json
+++ b/content/docs/references/api/meta.json
@@ -37,6 +37,7 @@
"misc",
"package-api-assembled",
"package-lifecycle",
+ "push-delivery",
"record-page",
"sortability"
]
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index 7b34900ed57..498519f23de 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -2761,20 +2761,27 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
## RegisterDeviceRequest
+[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers
+
### Properties
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **token** | `string` | ✅ | Device push notification token |
| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform |
-| **deviceId** | `string` | optional | Unique device identifier |
+| **deviceId** | `string` | optional | Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; absent, the server mints one and answers it, and the client keeps it for later registrations and unregister |
| **name** | `string` | optional | Device friendly name |
+| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused |
+| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
+| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
---
## RegisterDeviceResponse
+[PLANNED — not yet served] Answer to a device registration: the device id the client keeps
+
### Properties
| Property | Type | Required | Description |
@@ -3019,6 +3026,8 @@ Uninstall package response
## UnregisterDeviceRequest
+[PLANNED — not yet served] Unregister one of the signed-in user's push devices
+
### Properties
| Property | Type | Required | Description |
@@ -3030,6 +3039,8 @@ Uninstall package response
## UnregisterDeviceResponse
+[PLANNED — not yet served] Answer to a device unregistration
+
### Properties
| Property | Type | Required | Description |
diff --git a/content/docs/references/api/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx
new file mode 100644
index 00000000000..edfb78e9492
--- /dev/null
+++ b/content/docs/references/api/push-delivery.mdx
@@ -0,0 +1,92 @@
+---
+title: Push Delivery schema — API Protocol reference
+navTitle: Push Delivery
+description: "Push delivery data — what a push carries to the shell. Reference for PushDeliveryData, PushRoute: every property with its type and default."
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Push delivery data — what a push carries to the shell.
+
+The shell reads it when the user taps the push: `notificationId` names the
+notification (the same id the inbox lists and `markRead` takes), and
+`route` is the notification's `actionUrl`, the link the inbox serves for it.
+
+**Where it sits in the vendor payload.** ADR-0012 §8 names it the `data`
+member of the push renderer's output; that is the renderer's shape, not the
+vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map
+of string values. On APNs they sit at the top level of the payload, beside
+`aps`, and never under a `data` key.
+
+**Declared ahead of its producer.** No push channel delivers yet, so
+nothing produces this data today; `PushDeliveryDataSchema` carries a
+`[PLANNED — not yet served]` marker in its `.describe()` until the push
+channel ships. The route rule below is the contract that channel owes.
+
+**What `route` is — the inbox's `actionUrl`, derived, never authored.**
+There is no `notify` key for it. It is the value the inbox already serves:
+an explicit `url` / `actionUrl` in the notification's payload, else
+`/OBJECT/ID` built from the notification's source record, else none. So it
+is either app-relative (`/opportunity/123`) or already a Console route
+(`/apps/crm/opportunity/123`). When that value passes
+`PushRouteSchema` the push carries it as `route`; when it does not
+(an absolute URL to another site, say) or there is none, the push carries
+no `route` and the tap opens the shell's default screen. The delivery is
+never failed for it.
+
+**The shell never navigates it verbatim.** On tap it resolves `route`
+exactly as the inbox bell and Home do for the same notification: through
+`resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`)
+with its host app segment, which hosts an app-relative path under
+`/apps/APP/` and passes an `/apps/…` route through. One resolver, so a
+push and the inbox row open the same screen.
+
+A shell with no app list of its own (a native shell that has not loaded
+the Console's catalog, say) does not guess either. It hands `route` to the
+Console to resolve, or hosts it under the segment the Console would answer
+(`resolveHostAppSegment` in the same file: the preferred app the user last
+had open, re-checked against the active apps, then the first active app,
+then `setup`). It never re-implements the resolver and never opens `route`
+verbatim.
+
+Not to be confused with the `PushNotificationSchema` that `system/` once
+declared and then removed (ADR-0049): that was an authoring template shape;
+this is the wire data of a delivery, produced by the platform.
+
+
+**Source:** `packages/spec/src/api/push-delivery.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { PushDeliveryDataSchema, PushRouteSchema } from '@objectstack/spec/api';
+import type { PushDeliveryData, PushRoute } from '@objectstack/spec/api';
+
+// Validate data
+const result = PushDeliveryDataSchema.parse(data);
+```
+
+---
+
+## PushDeliveryData
+
+[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route. No push channel delivers yet
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes |
+| **route** | `string` | optional | The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. The shell resolves it on tap as the inbox bell and Home do, never verbatim |
+
+
+---
+
+## PushRoute
+
+The notification actionUrl as a same-server relative path: app-relative (/opportunity/123) or a Console route (/apps/crm/opportunity/123). The shell resolves it on tap as the inbox does, never verbatim; never an absolute or off-server URL
+
+
+---
+
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 194eeb5d8af..4b1feb3488a 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
-description: Every schema published by @objectstack/spec — 1596 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1603 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
| Module | Pages | Schemas | Description |
| :--- | ---: | ---: | :--- |
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
-| [API Protocol](/docs/references/api) | 34 | 471 | REST contracts, endpoints, routing, realtime, batch, discovery. |
+| [API Protocol](/docs/references/api) | 35 | 473 | REST contracts, endpoints, routing, realtime, batch, discovery. |
| [Automation Protocol](/docs/references/automation) | 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
| [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
@@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with
| [Security Protocol](/docs/references/security) | 6 | 37 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
-| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
+| [System Protocol](/docs/references/system) | 39 | 292 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **205** | **1596** | 14 protocol modules |
+| **Total** | **208** | **1603** | 14 protocol modules |
---
@@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
## API Protocol
-**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **34 pages, 471 schemas**
+**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 473 schemas**
REST contracts, endpoints, routing, realtime, batch, discovery.
@@ -93,6 +93,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `EnsureCustomizationPackageRequest`, `EnsureCustomizationPackageResponse`, `GetPackageCustomizationsRequest`, `GetPackageCustomizationsResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` |
| [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` |
| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublicFormSubmitResponse`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` |
+| [`push-delivery.zod.ts`](/docs/references/api/push-delivery) | `PushDeliveryData`, `PushRoute` |
| [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` |
| [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` |
| [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` |
@@ -324,7 +325,7 @@ Studio designer metadata — the authoring surfaces for the protocols above.
## System Protocol
-**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 287 schemas**
+**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **39 pages, 292 schemas**
The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance.
@@ -355,6 +356,8 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
| [`migration.zod.ts`](/docs/references/system/migration) | `AddFieldOperation`, `ChangeSet`, `CreateObjectOperation`, `DataMigrationFlag`, `DeleteObjectOperation`, `ExecuteSqlOperation`, `MigrationDependency`, `MigrationJournalEvent`, `MigrationOperation`, `ModifyFieldOperation`, `OrganizationOwnershipBirthMarker`, `RemoveFieldOperation`, `RenameObjectOperation` |
| [`notification.zod.ts`](/docs/references/system/notification) | `NotificationChannel` |
| [`object-storage.zod.ts`](/docs/references/system/object-storage) | `AccessControlConfig`, `BucketConfig`, `FileMetadata`, `LifecycleAction`, `LifecyclePolicyConfig`, `LifecyclePolicyRule`, `MultipartUploadConfig`, `ObjectMetadata`, `ObjectStorageConfig`, `PresignedUrlConfig`, `StorageAcl`, `StorageClass`, `StorageConnection`, `StorageProvider`, `StorageScope` |
+| [`push-config.zod.ts`](/docs/references/system/push-config) | `ApnsTransportConfig`, `FcmTransportConfig`, `PushServiceConfig`, `PushVendor` |
+| [`push-device.zod.ts`](/docs/references/system/push-device) | `UserDevice` |
| [`registry-config.zod.ts`](/docs/references/system/registry-config) | `RegistryConfig`, `RegistrySyncPolicy`, `RegistryUpstream` |
| [`search-engine.zod.ts`](/docs/references/system/search-engine) | `AnalyzerConfig`, `FacetConfig`, `SearchConfig`, `SearchIndexConfig`, `SearchProvider` |
| [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` |
diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx
index a6d8e0451cd..70f0952a67e 100644
--- a/content/docs/references/system/index.mdx
+++ b/content/docs/references/system/index.mdx
@@ -1,7 +1,7 @@
---
title: System Protocol — complete schema reference
navTitle: System Protocol
-description: "The ObjectStack System Protocol in 37 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
+description: "The ObjectStack System Protocol in 39 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -34,6 +34,8 @@ This section contains all protocol schemas for the system layer of ObjectStack.
+
+
diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json
index ebc13f270fd..2aaf272deec 100644
--- a/content/docs/references/system/meta.json
+++ b/content/docs/references/system/meta.json
@@ -42,6 +42,8 @@
"demo-staff",
"dev-login",
"fate-supplement",
+ "push-config",
+ "push-device",
"sms-config",
"stack-server"
]
diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx
new file mode 100644
index 00000000000..20b31ca09c7
--- /dev/null
+++ b/content/docs/references/system/push-config.mdx
@@ -0,0 +1,135 @@
+---
+title: Push Config schema — System Protocol reference
+navTitle: Push Config
+description: "Push Service Configuration Protocol — the push channel's transports. Declared ahead of its reader."
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Push Service Configuration Protocol — the push channel's transports.
+
+**Declared ahead of its reader.** Every schema here carries a
+`[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this
+shape until the push channel plugin lands, so a value written to it today
+changes nothing. The marker comes off with the change that wires the reader.
+
+Operator-facing configuration for the `push` channel ADR-0012 §4 ships in
+core: Apple Push Notification service with token-based auth, and Firebase
+Cloud Messaging over its HTTP v1 API. It sits beside
+`EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of
+the email channel's transport selection.
+
+**Secrets are references, never values (ADR-0143 D3, ADR-0097 §3).** Each
+transport has one secret — the APNs `.p8` signing key and the FCM
+service-account JSON — and each is named by a `credentialRef`, the same key
+every other secret an author names is written under (ADR-0143 D3), which
+the push channel resolves through the secrets layer when it sends (in the
+open tier, the name of an environment variable). An inline secret is
+refused two ways: written under a key an operator would reach for (`key`,
+`privateKey`, `serviceAccount`, …), it is refused by name with
+`credentialRef` as the prescription; written into the reference itself, it
+is refused because a reference is one token — a PEM block carries
+whitespace and a JSON document carries braces, and a name carries neither.
+
+**What reads it, and why it is not a stack key yet.** The push channel
+plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it
+is a later change than this declaration. The email transport selection it
+sits beside is read from `config.email` of the stack definition
+(`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own
+configuration door in the change that ships the reader, not here, so a key
+is never authorable before something honours it.
+
+Out of scope here: a hosted (cloud-side) configuration surface, and the
+other vendors ADR-0012 §6 routes to alternate channel plugins (HMS,
+Xiaomi, OPPO, vivo).
+
+
+**Source:** `packages/spec/src/system/push-config.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { ApnsTransportConfigSchema, FcmTransportConfigSchema, PushServiceConfigSchema, PushVendorSchema } from '@objectstack/spec/system';
+import type { ApnsTransportConfig, FcmTransportConfig, PushServiceConfig, PushVendor } from '@objectstack/spec/system';
+
+// Validate data
+const result = ApnsTransportConfigSchema.parse(data);
+```
+
+---
+
+## ApnsTransportConfig
+
+[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference)
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **teamId** | `string` | ✅ | Apple Developer Team ID (10 characters) that issued the signing key |
+| **keyId** | `string` | ✅ | Key ID of the APNs auth key (10 characters) |
+| **topic** | `string` | ✅ | APNs topic: the app bundle ID the pushes are for |
+| **environment** | `Enum<'sandbox' \| 'production'>` | ✅ | APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds) |
+| **credentialRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself |
+
+
+---
+
+## FcmTransportConfig
+
+[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference)
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to |
+| **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself |
+
+
+---
+
+## PushServiceConfig
+
+[PLANNED — not yet read] Push channel transport configuration: one optional block per vendor (apns, fcm). Nothing reads it until the push channel plugin ships
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **apns** | `{ teamId: string; keyId: string; topic: string; environment: Enum<'sandbox' \| 'production'>; … }` | optional | APNs transport (token-based auth). Absent: no delivery to apns devices |
+| **fcm** | `{ projectId: string; credentialRef: string }` | optional | FCM HTTP v1 transport. Absent: no delivery to fcm devices |
+
+### Nested Shape: `PushServiceConfig.apns`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **teamId** | `string` | ✅ | Apple Developer Team ID (10 characters) that issued the signing key |
+| **keyId** | `string` | ✅ | Key ID of the APNs auth key (10 characters) |
+| **topic** | `string` | ✅ | APNs topic: the app bundle ID the pushes are for |
+| **environment** | `Enum<'sandbox' \| 'production'>` | ✅ | APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds) |
+| **credentialRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself |
+
+### Nested Shape: `PushServiceConfig.fcm`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to |
+| **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself |
+
+
+---
+
+## PushVendor
+
+Push vendor core delivers through: apns (Apple Push Notification service) or fcm (Firebase Cloud Messaging HTTP v1)
+
+### Allowed Values
+
+* `apns`
+* `fcm`
+
+
+---
+
diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx
new file mode 100644
index 00000000000..d88aa29e12a
--- /dev/null
+++ b/content/docs/references/system/push-device.mdx
@@ -0,0 +1,86 @@
+---
+title: Push Device schema — System Protocol reference
+navTitle: Push Device
+description: "Push Device Protocol — the stored device shape of the push device registry."
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+Push Device Protocol — the stored device shape of the push device registry
+(ADR-0012 §6, narrowed to what core ships).
+
+One row per (user, app install): what a device registration
+(`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the
+push channel reads to address a phone, and what the registry's listing
+answers (`INotificationService.listDevices`).
+
+**Declared ahead of its producer.** No table stores it and no route serves
+it yet; the schema carries a `[PLANNED — not yet served]` marker in its
+`.describe()` until the device registry lands (in the push channel plugin's
+`sys_user_device`, ADR-0012 §11).
+
+Three rules the registry owes, stated here because they decide the shape:
+
+- **(a) A device belongs to a user, not to an organization.** The row has no
+ organization member. A phone signed in to one person receives that
+ person's notifications whichever organization they come from; the
+ organization travels on the notification, never on the device. The
+ identity of a row is (`userId`, `deviceId`).
+- **(b) One live row per token.** A vendor token is delivered to one row
+ only. A token this user already holds under another `deviceId` (the app's
+ storage was cleared, so the registration carries no `deviceId`) refreshes
+ that row and answers its `deviceId`; it never makes a second live row.
+ When a second user registers a token another user's device holds, the
+ token moves to the second user and the first user's row for it is
+ removed, so a push meant for the phone's previous user never reaches its
+ new one.
+- **(c) A dead token keeps its row.** When the vendor reports a token dead
+ (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is
+ kept with `enabled: false` and `invalidatedAt` set, so "why did this phone
+ stop getting pushes" stays answerable. Nothing is delivered to a disabled
+ row, and a later registration of the same device re-enables it and clears
+ `invalidatedAt`.
+
+Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the
+domestic Android vendors plug in as alternate channel implementations) and
+`platform` is the registration's own three (`ios` | `android` | `web`).
+
+
+**Source:** `packages/spec/src/system/push-device.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { UserDeviceSchema } from '@objectstack/spec/system';
+import type { UserDevice } from '@objectstack/spec/system';
+
+// Validate data
+const result = UserDeviceSchema.parse(data);
+```
+
+---
+
+## UserDevice
+
+[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; the device registry lands with the push channel
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **deviceId** | `string` | ✅ | Client-held device id, stable per app install: the one the registration carried or the server minted. Unique per user |
+| **userId** | `string` | ✅ | The user the device belongs to: the session user who registered it. No organization member |
+| **vendor** | `Enum<'apns' \| 'fcm'>` | ✅ | Push vendor the token belongs to: the registration's vendor, or the one derived from platform |
+| **token** | `string` | ✅ | Vendor push token. Held by one live device at a time: a second user's registration moves it |
+| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform, as the registration named it |
+| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
+| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
+| **name** | `string` | optional | Device friendly name, as the registration named it |
+| **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device |
+| **invalidatedAt** | `string` | optional | When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, and cleared by the registration that re-enables it |
+| **lastSeenAt** | `string` | optional | When the device last registered or refreshed. Every registration and refresh sets it, so it is absent only on a row no registration wrote; listDevices answers such a device last |
+
+
+---
+
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
index fb80356f948..2124b03f462 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
| Dir | Sites |
|---|---|
-| `api/` | 487 |
+| `api/` | 488 |
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
index 3c1fa09d7cb..57a076a97fc 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
| Dir | Sites |
|---|---|
-| `system/` | 390 |
+| `system/` | 394 |
diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json
index 49e0ba93f57..376090a851d 100644
--- a/packages/spec/api-surface/api.json
+++ b/packages/spec/api-surface/api.json
@@ -836,6 +836,10 @@
"PublishPackageDraftsRequestSchema (const)",
"PublishPackageDraftsResponse (type)",
"PublishPackageDraftsResponseSchema (const)",
+ "PushDeliveryData (type)",
+ "PushDeliveryDataSchema (const)",
+ "PushRoute (type)",
+ "PushRouteSchema (const)",
"QueryAdapterConfig (type)",
"QueryAdapterConfigParsed (type)",
"QueryAdapterConfigSchema (const)",
diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json
index 438310b2db7..1ac5c7b2198 100644
--- a/packages/spec/api-surface/system.json
+++ b/packages/spec/api-surface/system.json
@@ -17,6 +17,8 @@
"AdvancedAuthConfigSchema (const)",
"AnalyzerConfig (type)",
"AnalyzerConfigSchema (const)",
+ "ApnsTransportConfig (type)",
+ "ApnsTransportConfigSchema (const)",
"AppCompatibilityCheck (type)",
"AppCompatibilityCheckParsed (type)",
"AppCompatibilityCheckSchema (const)",
@@ -254,6 +256,8 @@
"FailoverConfigSchema (const)",
"FailoverMode (type)",
"FailoverModeSchema (const)",
+ "FcmTransportConfig (type)",
+ "FcmTransportConfigSchema (const)",
"Feature (type)",
"FeatureParsed (type)",
"FeatureSchema (const)",
@@ -534,6 +538,10 @@
"PlatformTranslationDataSchema (const)",
"PresignedUrlConfig (type)",
"PresignedUrlConfigSchema (const)",
+ "PushServiceConfig (type)",
+ "PushServiceConfigSchema (const)",
+ "PushVendor (type)",
+ "PushVendorSchema (const)",
"QueueConfig (const)",
"QueueConfig (type)",
"QueueConfigParsed (type)",
@@ -803,6 +811,8 @@
"TranslationItemSchema (const)",
"UserActivityStatus (const)",
"UserActivityStatus (type)",
+ "UserDevice (type)",
+ "UserDeviceSchema (const)",
"VALIDATION_MESSAGE_FALLBACK_LOCALE (const)",
"VALIDATION_MESSAGE_KEY_PREFIX (const)",
"VALUE_SHAPES_MIGRATION_ID (const)",
diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json
index 9471c74aa49..85fb30b1dd3 100644
--- a/packages/spec/authorable-surface/api.json
+++ b/packages/spec/authorable-surface/api.json
@@ -1423,6 +1423,8 @@
"api/PublishPackageDraftsResponse:success",
"api/PublishPackageDraftsResponse:unhiddenApps",
"api/PublishPackageDraftsResponse:unhideError",
+ "api/PushDeliveryData:notificationId",
+ "api/PushDeliveryData:route",
"api/QueryAdapterConfig:odata",
"api/QueryAdapterConfig:operatorMappings",
"api/QueryAdapterConfig:rest",
@@ -1521,10 +1523,13 @@
"api/RecordPageTimelineRequest:discussion",
"api/RecordPageTimelineRequest:fieldChanges",
"api/RefreshTokenRequest:refreshToken",
+ "api/RegisterDeviceRequest:appId",
"api/RegisterDeviceRequest:deviceId",
+ "api/RegisterDeviceRequest:locale",
"api/RegisterDeviceRequest:name",
"api/RegisterDeviceRequest:platform",
"api/RegisterDeviceRequest:token",
+ "api/RegisterDeviceRequest:vendor",
"api/RegisterDeviceResponse:deviceId",
"api/RegisterDeviceResponse:success",
"api/RegisterRequest:email",
diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json
index 3d7d1b68865..71186982f34 100644
--- a/packages/spec/authorable-surface/system.json
+++ b/packages/spec/authorable-surface/system.json
@@ -29,6 +29,11 @@
"system/AnalyzerConfig:language",
"system/AnalyzerConfig:stopwords",
"system/AnalyzerConfig:type",
+ "system/ApnsTransportConfig:credentialRef",
+ "system/ApnsTransportConfig:environment",
+ "system/ApnsTransportConfig:keyId",
+ "system/ApnsTransportConfig:teamId",
+ "system/ApnsTransportConfig:topic",
"system/AppCompatibilityCheck:compatible",
"system/AppCompatibilityCheck:issues",
"system/AppInstallRequest:appId",
@@ -435,6 +440,8 @@
"system/FailoverConfig:healthCheckIntervalSeconds",
"system/FailoverConfig:mode",
"system/FailoverConfig:regions",
+ "system/FcmTransportConfig:credentialRef",
+ "system/FcmTransportConfig:projectId",
"system/Feature:code",
"system/Feature:description",
"system/Feature:label",
@@ -928,6 +935,8 @@
"system/PresignedUrlConfig:operation",
"system/PresignedUrlConfig:responseContentDisposition",
"system/PresignedUrlConfig:responseContentType",
+ "system/PushServiceConfig:apns",
+ "system/PushServiceConfig:fcm",
"system/QueueConfig:autoScale",
"system/QueueConfig:concurrency",
"system/QueueConfig:deadLetterQueue",
@@ -1389,6 +1398,17 @@
"system/TranslationItem:positions",
"system/TranslationItem:reports",
"system/TranslationItem:settingsCommon",
+ "system/UserDevice:appId",
+ "system/UserDevice:deviceId",
+ "system/UserDevice:enabled",
+ "system/UserDevice:invalidatedAt",
+ "system/UserDevice:lastSeenAt",
+ "system/UserDevice:locale",
+ "system/UserDevice:name",
+ "system/UserDevice:platform",
+ "system/UserDevice:token",
+ "system/UserDevice:userId",
+ "system/UserDevice:vendor",
"system/VectorClock:clock",
"system/WorkerStats:active",
"system/WorkerStats:avgExecutionMs",
diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json
index 8fe294f4f8d..c4243b537c9 100644
--- a/packages/spec/declaration-map/api.json
+++ b/packages/spec/declaration-map/api.json
@@ -613,6 +613,10 @@
"PublishPackageDraftsRequestSchema": "api/PublishPackageDraftsRequest",
"PublishPackageDraftsResponse": "api/PublishPackageDraftsResponse",
"PublishPackageDraftsResponseSchema": "api/PublishPackageDraftsResponse",
+ "PushDeliveryData": "api/PushDeliveryData",
+ "PushDeliveryDataSchema": "api/PushDeliveryData",
+ "PushRoute": "api/PushRoute",
+ "PushRouteSchema": "api/PushRoute",
"QueryAdapterConfig": "api/QueryAdapterConfig",
"QueryAdapterConfigSchema": "api/QueryAdapterConfig",
"QueryAdapterTarget": "api/QueryAdapterTarget",
diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json
index b68745bb36f..2667d05f1d1 100644
--- a/packages/spec/declaration-map/system.json
+++ b/packages/spec/declaration-map/system.json
@@ -11,6 +11,8 @@
"AdvancedAuthConfigSchema": "system/AdvancedAuthConfig",
"AnalyzerConfig": "system/AnalyzerConfig",
"AnalyzerConfigSchema": "system/AnalyzerConfig",
+ "ApnsTransportConfig": "system/ApnsTransportConfig",
+ "ApnsTransportConfigSchema": "system/ApnsTransportConfig",
"AppCompatibilityCheck": "system/AppCompatibilityCheck",
"AppCompatibilityCheckSchema": "system/AppCompatibilityCheck",
"AppInstallRequest": "system/AppInstallRequest",
@@ -166,6 +168,8 @@
"FailoverConfigSchema": "system/FailoverConfig",
"FailoverMode": "system/FailoverMode",
"FailoverModeSchema": "system/FailoverMode",
+ "FcmTransportConfig": "system/FcmTransportConfig",
+ "FcmTransportConfigSchema": "system/FcmTransportConfig",
"Feature": "system/Feature",
"FeatureSchema": "system/Feature",
"FieldEncryption": "system/FieldEncryption",
@@ -348,6 +352,10 @@
"PlatformTranslationDataSchema": "system/PlatformTranslationData",
"PresignedUrlConfig": "system/PresignedUrlConfig",
"PresignedUrlConfigSchema": "system/PresignedUrlConfig",
+ "PushServiceConfig": "system/PushServiceConfig",
+ "PushServiceConfigSchema": "system/PushServiceConfig",
+ "PushVendor": "system/PushVendor",
+ "PushVendorSchema": "system/PushVendor",
"QueueConfig": "system/QueueConfig",
"QueueConfigSchema": "system/QueueConfig",
"QuotaEnforcementResult": "system/QuotaEnforcementResult",
@@ -530,6 +538,8 @@
"TranslationItem": "system/TranslationItem",
"TranslationItemSchema": "system/TranslationItem",
"UserActivityStatus": "system/UserActivityStatus",
+ "UserDevice": "system/UserDevice",
+ "UserDeviceSchema": "system/UserDevice",
"VectorClock": "system/VectorClock",
"VectorClockSchema": "system/VectorClock",
"WorkerStats": "system/WorkerStats",
diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json
index d034a0295fe..99bb8369ee7 100644
--- a/packages/spec/export-origins/api.json
+++ b/packages/spec/export-origins/api.json
@@ -807,6 +807,10 @@
"PublishPackageDraftsRequestSchema": "src/api/protocol.zod.ts#PublishPackageDraftsRequestSchema (const)",
"PublishPackageDraftsResponse": "src/api/protocol.zod.ts#PublishPackageDraftsResponse (type)",
"PublishPackageDraftsResponseSchema": "src/api/protocol.zod.ts#PublishPackageDraftsResponseSchema (const)",
+ "PushDeliveryData": "src/api/push-delivery.zod.ts#PushDeliveryData (type)",
+ "PushDeliveryDataSchema": "src/api/push-delivery.zod.ts#PushDeliveryDataSchema (const)",
+ "PushRoute": "src/api/push-delivery.zod.ts#PushRoute (type)",
+ "PushRouteSchema": "src/api/push-delivery.zod.ts#PushRouteSchema (const)",
"QueryAdapterConfig": "src/api/query-adapter.zod.ts#QueryAdapterConfig (type)",
"QueryAdapterConfigParsed": "src/api/query-adapter.zod.ts#QueryAdapterConfigParsed (type)",
"QueryAdapterConfigSchema": "src/api/query-adapter.zod.ts#QueryAdapterConfigSchema (const)",
diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json
index 3739d2d59a3..84dea07f904 100644
--- a/packages/spec/export-origins/system.json
+++ b/packages/spec/export-origins/system.json
@@ -17,6 +17,8 @@
"AdvancedAuthConfigSchema": "src/system/auth-config.zod.ts#AdvancedAuthConfigSchema (const)",
"AnalyzerConfig": "src/system/search-engine.zod.ts#AnalyzerConfig (type)",
"AnalyzerConfigSchema": "src/system/search-engine.zod.ts#AnalyzerConfigSchema (const)",
+ "ApnsTransportConfig": "src/system/push-config.zod.ts#ApnsTransportConfig (type)",
+ "ApnsTransportConfigSchema": "src/system/push-config.zod.ts#ApnsTransportConfigSchema (const)",
"AppCompatibilityCheck": "src/system/app-install.zod.ts#AppCompatibilityCheck (type)",
"AppCompatibilityCheckParsed": "src/system/app-install.zod.ts#AppCompatibilityCheckParsed (type)",
"AppCompatibilityCheckSchema": "src/system/app-install.zod.ts#AppCompatibilityCheckSchema (const)",
@@ -246,6 +248,8 @@
"FailoverConfigSchema": "src/system/disaster-recovery.zod.ts#FailoverConfigSchema (const)",
"FailoverMode": "src/system/disaster-recovery.zod.ts#FailoverMode (type)",
"FailoverModeSchema": "src/system/disaster-recovery.zod.ts#FailoverModeSchema (const)",
+ "FcmTransportConfig": "src/system/push-config.zod.ts#FcmTransportConfig (type)",
+ "FcmTransportConfigSchema": "src/system/push-config.zod.ts#FcmTransportConfigSchema (const)",
"Feature": "src/system/license.zod.ts#Feature (type)",
"FeatureParsed": "src/system/license.zod.ts#FeatureParsed (type)",
"FeatureSchema": "src/system/license.zod.ts#FeatureSchema (const)",
@@ -513,6 +517,10 @@
"PlatformTranslationDataSchema": "src/system/translation.zod.ts#PlatformTranslationDataSchema (const)",
"PresignedUrlConfig": "src/system/object-storage.zod.ts#PresignedUrlConfig (type)",
"PresignedUrlConfigSchema": "src/system/object-storage.zod.ts#PresignedUrlConfigSchema (const)",
+ "PushServiceConfig": "src/system/push-config.zod.ts#PushServiceConfig (type)",
+ "PushServiceConfigSchema": "src/system/push-config.zod.ts#PushServiceConfigSchema (const)",
+ "PushVendor": "src/system/push-config.zod.ts#PushVendor (type)",
+ "PushVendorSchema": "src/system/push-config.zod.ts#PushVendorSchema (const)",
"QueueConfig": "src/system/worker.zod.ts#QueueConfig (type)",
"QueueConfigParsed": "src/system/worker.zod.ts#QueueConfigParsed (type)",
"QueueConfigSchema": "src/system/worker.zod.ts#QueueConfigSchema (const)",
@@ -765,6 +773,8 @@
"TranslationItem": "src/system/translation.zod.ts#TranslationItem (type)",
"TranslationItemSchema": "src/system/translation.zod.ts#TranslationItemSchema (const)",
"UserActivityStatus": "src/system/collaboration.zod.ts#UserActivityStatus (type)",
+ "UserDevice": "src/system/push-device.zod.ts#UserDevice (type)",
+ "UserDeviceSchema": "src/system/push-device.zod.ts#UserDeviceSchema (const)",
"VALIDATION_MESSAGE_FALLBACK_LOCALE": "src/system/validation-message.ts#VALIDATION_MESSAGE_FALLBACK_LOCALE (const)",
"VALIDATION_MESSAGE_KEY_PREFIX": "src/system/validation-message.ts#VALIDATION_MESSAGE_KEY_PREFIX (const)",
"VALUE_SHAPES_MIGRATION_ID": "src/system/migration.zod.ts#VALUE_SHAPES_MIGRATION_ID (const)",
diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json
index f39f4ac6218..9872fdfaa37 100644
--- a/packages/spec/json-schema.manifest/api.json
+++ b/packages/spec/json-schema.manifest/api.json
@@ -334,6 +334,8 @@
"api/PublishMetaItemResponse",
"api/PublishPackageDraftsRequest",
"api/PublishPackageDraftsResponse",
+ "api/PushDeliveryData",
+ "api/PushRoute",
"api/QueryAdapterConfig",
"api/QueryAdapterTarget",
"api/QueryOptimizationConfig",
diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json
index 1ab9724c7cd..44877f71954 100644
--- a/packages/spec/json-schema.manifest/system.json
+++ b/packages/spec/json-schema.manifest/system.json
@@ -7,6 +7,7 @@
"system/AddFieldOperation",
"system/AdvancedAuthConfig",
"system/AnalyzerConfig",
+ "system/ApnsTransportConfig",
"system/AppCompatibilityCheck",
"system/AppInstallRequest",
"system/AppInstallResult",
@@ -89,6 +90,7 @@
"system/FacetConfig",
"system/FailoverConfig",
"system/FailoverMode",
+ "system/FcmTransportConfig",
"system/Feature",
"system/FieldEncryption",
"system/FieldTranslation",
@@ -189,6 +191,8 @@
"system/PlatformTranslationBundle",
"system/PlatformTranslationData",
"system/PresignedUrlConfig",
+ "system/PushServiceConfig",
+ "system/PushVendor",
"system/QueueConfig",
"system/QuotaEnforcementResult",
"system/RPO",
@@ -287,6 +291,7 @@
"system/TranslationDiffStatus",
"system/TranslationItem",
"system/UserActivityStatus",
+ "system/UserDevice",
"system/VectorClock",
"system/WorkerStats"
]
diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt
index 5af8969ab70..d69713fcb3d 100644
--- a/packages/spec/llms.txt
+++ b/packages/spec/llms.txt
@@ -77,7 +77,7 @@ const query = {
---
-## 3. Schema Inventory by Domain (213 schemas)
+## 3. Schema Inventory by Domain (216 schemas)
Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources
that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number
@@ -85,10 +85,10 @@ here is verifiable from the installed package.
| Domain | Count | Key Schemas |
|--------|-------|-------------|
-| system | 37 | Auth, Cache, Demo Staff, Dev Login, Email / SMS Config, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics |
+| system | 39 | Auth, Cache, Demo Staff, Dev Login, Email / SMS / Push Config, Push Device, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics |
| kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry, Override Declaration |
| data | 32 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube, Seed |
-| api | 33 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle |
+| api | 34 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle, Push Delivery |
| ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation |
| automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization |
| shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) |
diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts
index e25be91e0e9..d6f5dcb3efd 100644
--- a/packages/spec/src/api/index.ts
+++ b/packages/spec/src/api/index.ts
@@ -61,6 +61,7 @@ export * from './errors.zod';
export { zodIssuesToFields } from './zod-issues-to-fields';
export * from './error-code-ledger.zod';
export * from './protocol.zod';
+export * from './push-delivery.zod';
// [#10235] Per-column sortability projection: the serve-time signal the object
// metadata read publishes so grids never re-derive "virtual ⇒ unsortable" from
// field type (2026-08-23 ruling, option A).
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index 59c1daeffb6..86476ea097b 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -28,7 +28,9 @@ import {
import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod';
import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod';
import { ActionDescriptorSchema } from '../automation/node-executor.zod';
-import { ServedTranslationDataSchema } from '../system/translation.zod';
+import { ServedTranslationDataSchema, LocaleSchema } from '../system/translation.zod';
+import { PushVendorSchema } from '../system/push-config.zod';
+import { PUSH_DEVICE_PLATFORMS } from '../system/push-device-platform';
// #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item
// responses publish. Same three vocabularies the resolver filters against, so a
// value this spec cannot name is a value the resolver would have dropped.
@@ -3507,25 +3509,61 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({
// Notification Operations
// ==========================================
+/**
+ * Register or refresh a push device for the signed-in user (ADR-0012 §6).
+ *
+ * **Declared ahead of its producer.** No route serves this body yet: the
+ * device routes land together with their handlers, and until then each of
+ * the four device schemas below carries a `[PLANNED — not yet served]` marker
+ * in its `.describe()`. What follows is the contract that route owes, not a
+ * description of something running.
+ *
+ * One request does both: registering a `deviceId` the user already has
+ * refreshes that device (its token, vendor, app and locale) instead of adding
+ * a second one. A request without `deviceId` gets one minted by the server
+ * and answered in {@link RegisterDeviceResponseSchema}; the client keeps it
+ * and sends it on every later registration and on unregister.
+ *
+ * The members after `name` (`vendor`, `appId`, `locale`) were added for the
+ * push channel, all optional, so the body a client sent before still parses
+ * unchanged. The device's owner is the session's user, never a body member.
+ * The stored device a registration produces is `UserDeviceSchema`
+ * (`system/push-device.zod.ts`).
+ */
+// The marker comes off when the server device-registry slice (#29068) mounts
+// POST /api/v1/notifications/devices and the unregister route, adds their rows
+// to DEFAULT_NOTIFICATION_ROUTES and flips that table's catalog pin.
export const RegisterDeviceRequestSchema = lazySchema(() => z.object({
token: z.string().describe('Device push notification token'),
- platform: z.enum(['ios', 'android', 'web']).describe('Device platform'),
- deviceId: z.string().optional().describe('Unique device identifier'),
+ platform: z.enum(PUSH_DEVICE_PLATFORMS).describe('Device platform'),
+ deviceId: z.string().optional().describe(
+ 'Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; '
+ + 'absent, the server mints one and answers it, and the client keeps it for later registrations and unregister',
+ ),
name: z.string().optional().describe('Device friendly name'),
-}));
+ vendor: PushVendorSchema.optional().describe(
+ 'Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, '
+ + 'android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused',
+ ),
+ appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
+ locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
+}).describe(
+ '[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; '
+ + 'the device routes land together with their handlers',
+));
export const RegisterDeviceResponseSchema = lazySchema(() => z.object({
deviceId: z.string().describe('Registered device ID'),
success: z.boolean().describe('Whether registration succeeded'),
-}));
+}).describe('[PLANNED — not yet served] Answer to a device registration: the device id the client keeps'));
export const UnregisterDeviceRequestSchema = lazySchema(() => z.object({
deviceId: z.string().describe('Device ID to unregister'),
-}));
+}).describe('[PLANNED — not yet served] Unregister one of the signed-in user\'s push devices'));
export const UnregisterDeviceResponseSchema = lazySchema(() => z.object({
success: z.boolean().describe('Whether unregistration succeeded'),
-}));
+}).describe('[PLANNED — not yet served] Answer to a device unregistration'));
export const NotificationPreferencesSchema = lazySchema(() => z.object({
email: z.boolean().default(true).describe('Receive email notifications'),
diff --git a/packages/spec/src/api/push-delivery.test.ts b/packages/spec/src/api/push-delivery.test.ts
new file mode 100644
index 00000000000..929b6a47b3c
--- /dev/null
+++ b/packages/spec/src/api/push-delivery.test.ts
@@ -0,0 +1,81 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The data a push delivery carries to the shell. Its `route` is the
+// notification's actionUrl, which the shell resolves on tap as the inbox does,
+// so the contract admits only a same-server relative path: a value that could
+// navigate anywhere else (another origin, a scheme, a protocol-relative
+// reference, a path a URL parser rewrites into one, or a path a router would
+// normalise or decode elsewhere) is refused here, and the server leaves
+// `route` out instead of sending it.
+
+import { describe, it, expect } from 'vitest';
+import { PushDeliveryDataSchema, PushRouteSchema } from './push-delivery.zod';
+
+describe('PushDeliveryDataSchema', () => {
+ // The two shapes the inbox serves as actionUrl: app-relative (the shell
+ // hosts it under its app) and a route already addressed to an app.
+ it.each(['/opportunity/123', '/apps/crm/opportunity/123'])('accepts the inbox actionUrl %j as route', (route) => {
+ const data = { notificationId: 'ntf_1', route };
+ expect(PushDeliveryDataSchema.parse(data)).toEqual(data);
+ });
+
+ it('accepts an absent route', () => {
+ expect(PushDeliveryDataSchema.parse({ notificationId: 'ntf_1' })).toEqual({ notificationId: 'ntf_1' });
+ });
+
+ it('accepts the derived record route `/OBJECT/ID` and a query or fragment', () => {
+ expect(PushRouteSchema.safeParse('/opportunity/123').success).toBe(true);
+ expect(PushRouteSchema.safeParse('/apps/crm/opportunity/123?tab=notes#activity').success).toBe(true);
+ expect(PushRouteSchema.safeParse('/').success).toBe(true);
+ });
+
+ it.each(['/apps/crm/.well-known', '/apps/crm/...', '/apps/crm/x?next=%2Fapps%2Fcrm', '/apps/crm/x#%2e%2e', '/apps/crm/x?q=100%25'])(
+ 'accepts %j: no dot segment, and a query or fragment keeps its own encoding',
+ (route) => {
+ expect(PushRouteSchema.safeParse(route).success).toBe(true);
+ },
+ );
+
+ it('requires a notification id', () => {
+ const parsed = PushDeliveryDataSchema.safeParse({ route: '/x' });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['notificationId']);
+ });
+
+ it.each([
+ 'https://evil.example/x',
+ '//evil.example',
+ 'javascript:alert(1)',
+ 'apps/crm/opportunity/123',
+ '',
+ // A URL parser reads a backslash as a slash and strips tab / newline, so
+ // each of these becomes a protocol-relative reference once parsed.
+ '/\\evil.example',
+ '/\t/evil.example',
+ '/\n/evil.example',
+ '/apps/crm\\..\\x',
+ // A router that normalises or decodes the path would open somewhere else.
+ '/../../api/v1/me',
+ '/%2e%2e/x',
+ '/./x',
+ '/a%2fb',
+ '/a%5cb',
+ '/apps/crm/..',
+ '/a%2Fb',
+ '/apps/crm/./x?tab=notes',
+ // Double-encoded: one decode leaves %2F / %2E / %5C for the next one.
+ '/a%252Fb',
+ '/%252e%252e/x',
+ '/a%255cb',
+ ])('refuses route %j', (route) => {
+ const parsed = PushDeliveryDataSchema.safeParse({ notificationId: 'ntf_1', route });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['route']);
+ });
+});
+
+describe('PushDeliveryDataSchema is declared, not yet served', () => {
+ it('carries the PLANNED marker until a push channel produces it', () => {
+ expect(PushDeliveryDataSchema.description).toMatch(/^\[PLANNED — /);
+ });
+});
diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts
new file mode 100644
index 00000000000..f69f8a90678
--- /dev/null
+++ b/packages/spec/src/api/push-delivery.zod.ts
@@ -0,0 +1,100 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+
+/**
+ * Push delivery data — what a push carries to the shell.
+ *
+ * The shell reads it when the user taps the push: `notificationId` names the
+ * notification (the same id the inbox lists and `markRead` takes), and
+ * `route` is the notification's `actionUrl`, the link the inbox serves for it.
+ *
+ * **Where it sits in the vendor payload.** ADR-0012 §8 names it the `data`
+ * member of the push renderer's output; that is the renderer's shape, not the
+ * vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map
+ * of string values. On APNs they sit at the top level of the payload, beside
+ * `aps`, and never under a `data` key.
+ *
+ * **Declared ahead of its producer.** No push channel delivers yet, so
+ * nothing produces this data today; {@link PushDeliveryDataSchema} carries a
+ * `[PLANNED — not yet served]` marker in its `.describe()` until the push
+ * channel ships. The route rule below is the contract that channel owes.
+ *
+ * **What `route` is — the inbox's `actionUrl`, derived, never authored.**
+ * There is no `notify` key for it. It is the value the inbox already serves:
+ * an explicit `url` / `actionUrl` in the notification's payload, else
+ * `/OBJECT/ID` built from the notification's source record, else none. So it
+ * is either app-relative (`/opportunity/123`) or already a Console route
+ * (`/apps/crm/opportunity/123`). When that value passes
+ * {@link PushRouteSchema} the push carries it as `route`; when it does not
+ * (an absolute URL to another site, say) or there is none, the push carries
+ * no `route` and the tap opens the shell's default screen. The delivery is
+ * never failed for it.
+ *
+ * **The shell never navigates it verbatim.** On tap it resolves `route`
+ * exactly as the inbox bell and Home do for the same notification: through
+ * `resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`)
+ * with its host app segment, which hosts an app-relative path under
+ * `/apps/APP/` and passes an `/apps/…` route through. One resolver, so a
+ * push and the inbox row open the same screen.
+ *
+ * A shell with no app list of its own (a native shell that has not loaded
+ * the Console's catalog, say) does not guess either. It hands `route` to the
+ * Console to resolve, or hosts it under the segment the Console would answer
+ * (`resolveHostAppSegment` in the same file: the preferred app the user last
+ * had open, re-checked against the active apps, then the first active app,
+ * then `setup`). It never re-implements the resolver and never opens `route`
+ * verbatim.
+ *
+ * Not to be confused with the `PushNotificationSchema` that `system/` once
+ * declared and then removed (ADR-0049): that was an authoring template shape;
+ * this is the wire data of a delivery, produced by the platform.
+ */
+
+/**
+ * A same-server relative path: it starts with exactly one `/`, so it carries
+ * no scheme and no host, and it contains no backslash, whitespace or control
+ * character — a URL parser reads `\` as `/` and strips tab and newline, which
+ * would turn `/\host` — or `/`, a tab, then `/host` — into a
+ * protocol-relative reference to another site.
+ *
+ * Its path (the part before any `?` or `#`) also carries no dot segment (`.`
+ * or `..`), no percent-encoded `/`, `\` or `.` (`%2F`, `%5C`, `%2E`, either
+ * case) and no encoded `%` (`%25`), which closes the double-encoded forms
+ * (`%252F`, `%252E`, `%255C`): a router that normalises or decodes the path,
+ * once or more, would move it somewhere the notification did not point. A
+ * path that needs a literal `%` is refused with them, so its push carries no
+ * `route` and the tap opens the shell's default screen. Both rules are
+ * patterns, so the published JSON Schema carries them too. A query or
+ * fragment keeps its own encoding.
+ */
+export const PushRouteSchema = z.string()
+ .regex(/^\/(?![/\\])[^\s\\\x00-\x1f\x7f]*$/, {
+ message: 'A push route is a same-server relative path: it starts with a single "/" and carries no scheme, host, '
+ + 'backslash, whitespace or control character.',
+ })
+ .regex(/^(?![^?#]*(?:\/\.\.?(?:[/?#]|$)|%(?:2[eEfF]|5[cC]|25)))/, {
+ message: 'A push route path carries no dot segment ("." or "..") and no percent-encoded "/", "\\", "." or "%" '
+ + '(%2F, %5C, %2E, %25): a router that normalises or decodes the path would open somewhere else.',
+ })
+ .describe(
+ 'The notification actionUrl as a same-server relative path: app-relative (/opportunity/123) or a Console route '
+ + '(/apps/crm/opportunity/123). The shell resolves it on tap as the inbox does, never verbatim; never an absolute or '
+ + 'off-server URL',
+ );
+export type PushRoute = z.input;
+
+/** The data a push delivery carries to the shell. */
+// The marker comes off when the push channel (#29041) produces this data.
+export const PushDeliveryDataSchema = lazySchema(() => z.object({
+ notificationId: z.string().min(1).describe('Notification id — the one the inbox lists and markRead takes'),
+ route: PushRouteSchema.optional().describe(
+ 'The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. '
+ + 'The shell resolves it on tap as the inbox bell and Home do, never verbatim',
+ ),
+}).describe(
+ '[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a '
+ + 'same-server route. No push channel delivers yet',
+));
+export type PushDeliveryData = z.input;
diff --git a/packages/spec/src/api/register-device-request.test.ts b/packages/spec/src/api/register-device-request.test.ts
new file mode 100644
index 00000000000..13fa98a8cee
--- /dev/null
+++ b/packages/spec/src/api/register-device-request.test.ts
@@ -0,0 +1,102 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The device registration request is WIDENED for the push channel (ADR-0012
+// §6): it gains an optional `vendor`, `appId` and `locale`. Pure widening, so
+// the body every client sends today must keep parsing unchanged, and the new
+// members are optional. The one value the widening refuses is a vendor
+// outside the pair core delivers through (`apns` / `fcm`); other vendors are
+// alternate channel plugins (ADR-0012 §6), not values of this field.
+
+import { describe, it, expect } from 'vitest';
+import {
+ RegisterDeviceRequestSchema,
+ RegisterDeviceResponseSchema,
+ UnregisterDeviceRequestSchema,
+ UnregisterDeviceResponseSchema,
+} from './protocol.zod';
+
+describe('RegisterDeviceRequestSchema (push device registry)', () => {
+ it('still parses the minimal body a client sends today, unchanged', () => {
+ const body = { token: 'fcm_token_xyz', platform: 'android' };
+ const parsed = RegisterDeviceRequestSchema.safeParse(body);
+ expect(parsed.success).toBe(true);
+ expect(parsed.data).toEqual(body);
+ });
+
+ it('still parses the full pre-widening body (deviceId and name stay optional members)', () => {
+ const body = { token: 't', platform: 'ios', deviceId: 'install-1', name: 'iPhone' };
+ expect(RegisterDeviceRequestSchema.parse(body)).toEqual(body);
+ });
+
+ it('round-trips the widened members: vendor, appId, locale', () => {
+ const body = {
+ token: 'apns-device-token',
+ platform: 'ios',
+ vendor: 'apns',
+ deviceId: 'install-1',
+ appId: 'com.example.shell',
+ locale: 'zh-CN',
+ name: 'iPhone',
+ } as const;
+ const parsed = RegisterDeviceRequestSchema.safeParse(body);
+ expect(parsed.success).toBe(true);
+ expect(parsed.data).toEqual(body);
+ expect(RegisterDeviceRequestSchema.parse({ token: 't', platform: 'android', vendor: 'fcm' }).vendor).toBe('fcm');
+ });
+
+ it('applies no default for an absent vendor: the server derives it from platform', () => {
+ // No `.default()` on purpose: the derivation (ios → apns, android → fcm)
+ // is the server's, stated in the describe, and the bare alias stays
+ // isomorphic (type-alias-convention.pin.test.ts).
+ const parsed = RegisterDeviceRequestSchema.parse({ token: 't', platform: 'ios' });
+ expect('vendor' in parsed).toBe(false);
+ });
+
+ // web has no default vendor, and the schema does not tie a vendor to a
+ // platform: a web registration names either one. Refusing a web body WITHOUT
+ // vendor is the route's (the server device-registry slice), not this schema's.
+ it.each(['apns', 'fcm'] as const)('admits a web registration naming vendor %s', (vendor) => {
+ const body = { token: 't', platform: 'web', vendor } as const;
+ expect(RegisterDeviceRequestSchema.parse(body)).toEqual(body);
+ });
+
+ it.each(['hms', 'xiaomi', 'web_push', 'APNS', ''])('refuses vendor %j (core delivers through apns / fcm only)', (vendor) => {
+ const parsed = RegisterDeviceRequestSchema.safeParse({ token: 't', platform: 'android', vendor });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['vendor']);
+ expect(parsed.error?.issues[0]?.code).toBe('invalid_value');
+ });
+
+ // The locale a push is rendered in must be a tag a translation bundle can
+ // answer: the house LocaleSchema, the shape every other locale key takes.
+ it.each(['zh_CN', 'Chinese', 'zh-CN ', ''])('refuses locale %j (not a BCP-47 language tag)', (locale) => {
+ const parsed = RegisterDeviceRequestSchema.safeParse({ token: 't', platform: 'ios', locale });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['locale']);
+ expect(parsed.error?.issues[0]?.code).toBe('invalid_format');
+ });
+
+ it.each(['en', 'zh-CN', 'zh-Hans-CN', 'es-419'])('accepts locale %j', (locale) => {
+ expect(RegisterDeviceRequestSchema.safeParse({ token: 't', platform: 'ios', locale }).success).toBe(true);
+ });
+
+ it('keeps the response and unregister shapes as they were', () => {
+ expect(RegisterDeviceResponseSchema.parse({ deviceId: 'install-1', success: true }))
+ .toEqual({ deviceId: 'install-1', success: true });
+ expect(UnregisterDeviceRequestSchema.parse({ deviceId: 'install-1' })).toEqual({ deviceId: 'install-1' });
+ expect(UnregisterDeviceResponseSchema.parse({ success: true })).toEqual({ success: true });
+ });
+});
+
+// Declared ahead of its producer (ADR-0049): no route serves the device
+// schemas until the server device-registry slice lands, and each says so.
+describe('device registration schemas are declared, not yet served', () => {
+ it.each([
+ ['RegisterDeviceRequestSchema', RegisterDeviceRequestSchema],
+ ['RegisterDeviceResponseSchema', RegisterDeviceResponseSchema],
+ ['UnregisterDeviceRequestSchema', UnregisterDeviceRequestSchema],
+ ['UnregisterDeviceResponseSchema', UnregisterDeviceResponseSchema],
+ ] as const)('%s carries the PLANNED marker', (_name, schema) => {
+ expect(schema.description).toMatch(/^\[PLANNED — /);
+ });
+});
diff --git a/packages/spec/src/contracts/notification-service.test.ts b/packages/spec/src/contracts/notification-service.test.ts
index 2455939c359..3c914a36f34 100644
--- a/packages/spec/src/contracts/notification-service.test.ts
+++ b/packages/spec/src/contracts/notification-service.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import type { INotificationService, NotificationMessage, NotificationResult } from './notification-service';
+import type { UserDevice } from '../system/push-device.zod';
describe('Notification Service Contract', () => {
it('should allow a minimal INotificationService implementation with required methods', () => {
@@ -207,4 +208,55 @@ describe('Notification Service Contract', () => {
expect((await service.listInbox!('u-1', { read: false })).notifications).toHaveLength(0);
});
});
+
+ describe('push device members (ADR-0012 §6)', () => {
+ it('are optional: a send-only provider is still a complete implementation', () => {
+ const service: INotificationService = { send: async () => ({ success: true }) };
+ expect(service.registerDevice).toBeUndefined();
+ expect(service.unregisterDevice).toBeUndefined();
+ expect(service.invalidateDeviceToken).toBeUndefined();
+ expect(service.listDevices).toBeUndefined();
+ });
+
+ it('type the registry an implementation provides: register, unregister, list, invalidate', async () => {
+ // A toy registry written to the documented semantics, so the member
+ // signatures are exercised by a real implementation, not just named.
+ const rows = new Map();
+ let minted = 0;
+ const service: INotificationService = {
+ send: async () => ({ success: true }),
+ registerDevice: async (userId, request) => {
+ const deviceId = request.deviceId ?? `dev-${++minted}`;
+ for (const [id, row] of rows) if (row.token === request.token && id !== deviceId) rows.delete(id);
+ rows.set(deviceId, {
+ deviceId, userId, token: request.token, platform: request.platform,
+ vendor: request.vendor ?? (request.platform === 'ios' ? 'apns' : 'fcm'), enabled: true,
+ });
+ return { deviceId, success: true };
+ },
+ listDevices: async (userId) => [...rows.values()].filter((row) => row.userId === userId),
+ unregisterDevice: async (userId, deviceId) => {
+ if (rows.get(deviceId)?.userId !== userId) throw Object.assign(new Error('Device not found'), { code: 'NOT_FOUND' });
+ rows.delete(deviceId);
+ return { success: true };
+ },
+ invalidateDeviceToken: async (token) => {
+ for (const row of rows.values()) {
+ if (row.token === token) Object.assign(row, { enabled: false, invalidatedAt: '2026-01-01T00:00:00.000Z' });
+ }
+ },
+ };
+
+ const minted1 = await service.registerDevice!('u-1', { token: 'tok', platform: 'ios', vendor: 'apns' });
+ expect(minted1).toEqual({ deviceId: 'dev-1', success: true });
+ // A second user registering the same token takes it over.
+ await service.registerDevice!('u-2', { token: 'tok', platform: 'ios', deviceId: 'install-2' });
+ expect([...rows.keys()]).toEqual(['install-2']);
+ expect(await service.listDevices!('u-1')).toEqual([]);
+ expect((await service.listDevices!('u-2')).map((d) => d.deviceId)).toEqual(['install-2']);
+ await expect(service.unregisterDevice!('u-1', 'install-2')).rejects.toMatchObject({ code: 'NOT_FOUND' });
+ await service.invalidateDeviceToken!('tok');
+ expect(rows.get('install-2')).toMatchObject({ enabled: false, invalidatedAt: '2026-01-01T00:00:00.000Z' });
+ });
+ });
});
diff --git a/packages/spec/src/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts
index 8515cd667bf..30a88309c93 100644
--- a/packages/spec/src/contracts/notification-service.ts
+++ b/packages/spec/src/contracts/notification-service.ts
@@ -26,6 +26,8 @@
* addressed to an unregistered channel are dead-lettered, not delivered.
*/
import type { NotificationChannel } from '../system/notification.zod';
+import type { RegisterDeviceRequest, RegisterDeviceResponse, UnregisterDeviceResponse } from '../api/protocol.zod';
+import type { UserDevice } from '../system/push-device.zod';
export type { NotificationChannel } from '../system/notification.zod';
/**
@@ -207,4 +209,93 @@ export interface INotificationService {
* @param userId - Owner of the inbox
*/
markAllRead?(userId: string): Promise;
+
+ /* ------------------------------------------------------------------ */
+ /* Push devices — the device registry (ADR-0012 §6) */
+ /* ------------------------------------------------------------------ */
+
+ /*
+ * All four are OPTIONAL, for the reason the inbox trio is: a provider
+ * with no push channel (SMTP, an SMS gateway, the dev stub) fills this
+ * slot legitimately with no device registry. Absence means the
+ * capability is unavailable — the device routes answer that, not a
+ * crash — so every implementation written before these members stays
+ * valid. Shapes are the wire contract the device routes serialize:
+ * `RegisterDeviceRequestSchema` / `RegisterDeviceResponseSchema` /
+ * `UnregisterDeviceResponseSchema` (`api/protocol.zod.ts`), and the stored
+ * device `UserDeviceSchema` (`system/push-device.zod.ts`).
+ *
+ * Declared ahead of their implementation: no provider implements them
+ * and no route calls them yet. They are the contract the device registry
+ * and its routes owe when they land, not a description of running code.
+ */
+ // First implementation and callers: the server device-registry slice
+ // (#29068); the push channel's invalid-recipient path calls
+ // invalidateDeviceToken (#29041).
+
+ /**
+ * Register or refresh a push device for this user.
+ *
+ * - **Refresh, not duplicate.** A `request.deviceId` this user already
+ * holds refreshes that device (token, vendor, app, locale, last seen)
+ * and re-enables it; an absent `deviceId` is minted here and answered
+ * in the response, which the client keeps.
+ * - **One live row per token.** A token this user already holds under
+ * another `deviceId` (the app's storage was cleared, so the request
+ * carries no `deviceId`) refreshes that row and answers its `deviceId`,
+ * never a second live row. Registering a token another user's device
+ * holds moves it to this user, and the other user's device stops
+ * receiving it. A push meant for the previous owner never reaches the
+ * phone's new user.
+ * - An absent `request.vendor` is derived from `request.platform`
+ * (`ios` → `apns`, `android` → `fcm`). `web` has no default vendor: a
+ * `web` registration names `apns` or `fcm`, and one without `vendor` is
+ * refused as a validation error, never stored with a guessed vendor.
+ *
+ * @param userId - The signed-in user, from the session; never a body member
+ * @param request - The registration body
+ */
+ registerDevice?(userId: string, request: RegisterDeviceRequest): Promise;
+
+ /**
+ * Unregister one of this user's devices (sign-out, account switch).
+ *
+ * Resolves `{ success: true }` once the device is removed. A `deviceId`
+ * this user does not hold — unknown, or another user's — never resolves:
+ * the call throws an error whose `code` is `NOT_FOUND`, which the device
+ * route answers as 404. It is never a distinct "forbidden", so a caller
+ * cannot learn that a device id exists for somebody else, and cannot
+ * remove it.
+ *
+ * @param userId - The signed-in user, from the session
+ * @param deviceId - The id {@link registerDevice} answered
+ */
+ unregisterDevice?(userId: string, deviceId: string): Promise;
+
+ /**
+ * List this user's devices, newest `lastSeenAt` first; a device with no
+ * `lastSeenAt` comes last.
+ *
+ * Disabled devices (a token the vendor reported dead) are listed too,
+ * with `enabled: false` and their `invalidatedAt`, so a settings screen
+ * can say why a phone stopped receiving pushes. Only this user's devices
+ * are ever answered; there is no listing across users.
+ *
+ * @param userId - The signed-in user, from the session
+ */
+ listDevices?(userId: string): Promise;
+
+ /**
+ * Retire a token the vendor reported dead (APNs `BadDeviceToken` /
+ * `Unregistered`, FCM `UNREGISTERED`), called by the push channel's
+ * invalid-recipient path (ADR-0012 §6, §12).
+ *
+ * The device row is KEPT, disabled, with the time of invalidation, so
+ * "why did this phone stop getting pushes" stays answerable and a later
+ * registration of the same device re-enables it. Nothing is delivered to
+ * a disabled device. A token no device holds is a no-op.
+ *
+ * @param token - The vendor token as the vendor reported it
+ */
+ invalidateDeviceToken?(token: string): Promise;
}
diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts
index cdacdd5a470..ddaae025180 100644
--- a/packages/spec/src/system/index.ts
+++ b/packages/spec/src/system/index.ts
@@ -77,6 +77,8 @@ export * from './email-config.zod';
export * from './email-template.zod';
export * from './email-template.form';
export * from './sms-config.zod';
+export * from './push-config.zod';
+export * from './push-device.zod';
export * from './metadata-form-registry';
// compliance.zod (GDPR/HIPAA/PCI configs) and masking.zod (role-based data
// masking) were REMOVED per ADR-0056 D8 "design+enforce or remove": both were
diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts
new file mode 100644
index 00000000000..327e6066ab0
--- /dev/null
+++ b/packages/spec/src/system/push-config.test.ts
@@ -0,0 +1,121 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// Push transport configuration (APNs token auth, FCM HTTP v1). Its two
+// secrets — the APNs `.p8` signing key and the FCM service-account JSON — are
+// named by reference only (ADR-0143 D3), so an inline secret is refused two
+// ways: under the key an operator would reach for (refused by name, with the
+// reference key as the prescription), and as the CONTENT of a reference (a
+// PEM block or a JSON document is a secret pasted where its name belongs).
+
+import { describe, it, expect } from 'vitest';
+import {
+ ApnsTransportConfigSchema,
+ FcmTransportConfigSchema,
+ PushServiceConfigSchema,
+ PushVendorSchema,
+} from './push-config.zod';
+
+const APNS = {
+ teamId: 'ABCDE12345',
+ keyId: 'KEY1234567',
+ topic: 'com.example.shell',
+ environment: 'production',
+ credentialRef: 'OS_PUSH_APNS_KEY',
+} as const;
+
+const FCM = { projectId: 'example-project', credentialRef: 'OS_PUSH_FCM_SERVICE_ACCOUNT' } as const;
+
+const PEM = '-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----';
+const SERVICE_ACCOUNT_JSON = '{"type":"service_account","project_id":"example-project","private_key":"x"}';
+
+describe('PushVendorSchema', () => {
+ it('names exactly the two vendors core delivers through', () => {
+ expect(PushVendorSchema.options).toEqual(['apns', 'fcm']);
+ });
+});
+
+describe('PushServiceConfigSchema', () => {
+ it('accepts both transports configured by reference', () => {
+ const config = { apns: APNS, fcm: FCM };
+ expect(PushServiceConfigSchema.parse(config)).toEqual(config);
+ });
+
+ it('accepts either transport alone, and the sandbox environment', () => {
+ expect(PushServiceConfigSchema.safeParse({ apns: { ...APNS, environment: 'sandbox' } }).success).toBe(true);
+ expect(PushServiceConfigSchema.safeParse({ fcm: FCM }).success).toBe(true);
+ });
+
+ it.each([
+ 'OS_PUSH_APNS_KEY',
+ 'push/apns-signing-key',
+ 'vault:push.apns.p8',
+ ])('accepts the reference form %j', (credentialRef) => {
+ expect(ApnsTransportConfigSchema.safeParse({ ...APNS, credentialRef }).success).toBe(true);
+ expect(FcmTransportConfigSchema.safeParse({ ...FCM, credentialRef }).success).toBe(true);
+ });
+
+ it.each(['key', 'privateKey', 'p8'])('refuses an inline APNs key under %j, by name', (key) => {
+ const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, [key]: PEM });
+ expect(parsed.success).toBe(false);
+ const issue = parsed.error?.issues[0];
+ expect(issue?.code).toBe('unrecognized_keys');
+ expect(issue?.message).toContain('credentialRef');
+ });
+
+ it.each(['serviceAccount', 'serviceAccountJson', 'privateKey'])('refuses an inline FCM credential under %j, by name', (key) => {
+ const parsed = FcmTransportConfigSchema.safeParse({ ...FCM, [key]: SERVICE_ACCOUNT_JSON });
+ expect(parsed.success).toBe(false);
+ const issue = parsed.error?.issues[0];
+ expect(issue?.code).toBe('unrecognized_keys');
+ expect(issue?.message).toContain('credentialRef');
+ });
+
+ it.each([
+ ['APNs', 'keyRef'],
+ ['FCM', 'serviceAccountRef'],
+ ] as const)('answers the %s near-miss %j with credentialRef, the house key for a secret reference', (transport, key) => {
+ const block = transport === 'APNs' ? APNS : FCM;
+ const schema = transport === 'APNs' ? ApnsTransportConfigSchema : FcmTransportConfigSchema;
+ const { credentialRef, ...rest } = block;
+ const parsed = schema.safeParse({ ...rest, [key]: credentialRef });
+ expect(parsed.success).toBe(false);
+ const issues = parsed.error?.issues ?? [];
+ expect(issues.some((i) => i.code === 'unrecognized_keys' && i.message.includes('credentialRef'))).toBe(true);
+ });
+
+ it('refuses a PEM block pasted into the APNs credentialRef', () => {
+ const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, credentialRef: PEM });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['credentialRef']);
+ });
+
+ it('refuses a service-account JSON document pasted into the FCM credentialRef', () => {
+ const parsed = FcmTransportConfigSchema.safeParse({ ...FCM, credentialRef: SERVICE_ACCOUNT_JSON });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['credentialRef']);
+ });
+
+ it('refuses an environment outside sandbox / production', () => {
+ const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, environment: 'development' });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['environment']);
+ });
+
+ it('refuses an unknown transport key on the block', () => {
+ const parsed = PushServiceConfigSchema.safeParse({ fcm: FCM, hms: {} });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.code).toBe('unrecognized_keys');
+ });
+});
+
+// Declared ahead of its reader (ADR-0049): nothing reads this shape until the
+// push channel plugin ships, and each block says so in its description.
+describe('push transport configuration is declared, not yet read', () => {
+ it.each([
+ ['PushServiceConfigSchema', PushServiceConfigSchema],
+ ['ApnsTransportConfigSchema', ApnsTransportConfigSchema],
+ ['FcmTransportConfigSchema', FcmTransportConfigSchema],
+ ] as const)('%s carries the PLANNED marker', (_name, schema) => {
+ expect(schema.description).toMatch(/^\[PLANNED — /);
+ });
+});
diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts
new file mode 100644
index 00000000000..6b45637ae9c
--- /dev/null
+++ b/packages/spec/src/system/push-config.zod.ts
@@ -0,0 +1,156 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+import { strictObject } from '../shared/strict-object';
+
+/**
+ * Push Service Configuration Protocol — the push channel's transports.
+ *
+ * **Declared ahead of its reader.** Every schema here carries a
+ * `[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this
+ * shape until the push channel plugin lands, so a value written to it today
+ * changes nothing. The marker comes off with the change that wires the reader.
+ *
+ * Operator-facing configuration for the `push` channel ADR-0012 §4 ships in
+ * core: Apple Push Notification service with token-based auth, and Firebase
+ * Cloud Messaging over its HTTP v1 API. It sits beside
+ * `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of
+ * the email channel's transport selection.
+ *
+ * **Secrets are references, never values (ADR-0143 D3, ADR-0097 §3).** Each
+ * transport has one secret — the APNs `.p8` signing key and the FCM
+ * service-account JSON — and each is named by a `credentialRef`, the same key
+ * every other secret an author names is written under (ADR-0143 D3), which
+ * the push channel resolves through the secrets layer when it sends (in the
+ * open tier, the name of an environment variable). An inline secret is
+ * refused two ways: written under a key an operator would reach for (`key`,
+ * `privateKey`, `serviceAccount`, …), it is refused by name with
+ * `credentialRef` as the prescription; written into the reference itself, it
+ * is refused because a reference is one token — a PEM block carries
+ * whitespace and a JSON document carries braces, and a name carries neither.
+ *
+ * **What reads it, and why it is not a stack key yet.** The push channel
+ * plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it
+ * is a later change than this declaration. The email transport selection it
+ * sits beside is read from `config.email` of the stack definition
+ * (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own
+ * configuration door in the change that ships the reader, not here, so a key
+ * is never authorable before something honours it.
+ *
+ * Out of scope here: a hosted (cloud-side) configuration surface, and the
+ * other vendors ADR-0012 §6 routes to alternate channel plugins (HMS,
+ * Xiaomi, OPPO, vivo).
+ */
+
+/**
+ * Push vendors core delivers through: `apns` (Apple) and `fcm` (Firebase).
+ *
+ * Closed on purpose. ADR-0012 §6 plugs the domestic Android vendors in as
+ * alternate channel implementations, not as transports of this channel, so a
+ * new value here is a spec change made together with its transport.
+ */
+export const PushVendorSchema = z.enum(['apns', 'fcm'])
+ .describe('Push vendor core delivers through: apns (Apple Push Notification service) or fcm (Firebase Cloud Messaging HTTP v1)');
+export type PushVendor = z.input;
+
+/**
+ * A secrets-layer reference: one token, never the secret it names.
+ * Whitespace and braces are refused because the secrets in question cannot
+ * be written without them (a PEM block, a JSON document) and a reference
+ * never needs them.
+ */
+// Stricter than the house `credentialRef` (`z.string().min(1)`,
+// shared/connector-auth.zod.ts) on purpose. A bearer token or an API key looks
+// like a name, so a connector's reference cannot tell a pasted secret from a
+// reference and checks only that one is there. The two push secrets can be
+// told apart: a .p8 key is a PEM block and a service-account file is a JSON
+// document, and every name the house writes (an environment-variable name in
+// the open tier) has neither whitespace nor braces. If a secrets layer ever
+// names a secret with either, this widens to the house shape.
+function secretReference(secret: string) {
+ return z.string()
+ .min(1)
+ .regex(/^[^\s{}]+$/, {
+ message: `This names the ${secret} in the secrets layer and never contains it: put the value in the secrets layer `
+ + '(an environment variable in the open tier) and write its name here.',
+ });
+}
+
+/** The guidance an inline secret meets on a push transport block. */
+function inlineSecretGuidance(transport: string, secret: string, refKey: string): string {
+ return `Not authorable on the ${transport} push transport. The ${secret} is a credential and is never written inline — `
+ + `put it in the secrets layer (an environment variable in the open tier) and name it with \`${refKey}\`.`;
+}
+
+const APNS_KEY_GUIDANCE = inlineSecretGuidance('APNs', '.p8 signing key', 'credentialRef');
+const FCM_ACCOUNT_GUIDANCE = inlineSecretGuidance('FCM', 'service-account JSON', 'credentialRef');
+
+/** APNs with token-based (`.p8` signing key) authentication. */
+export const ApnsTransportConfigSchema = lazySchema(() => strictObject(
+ {
+ surface: 'the APNs push transport',
+ history: 'This block is strict from birth — no APNs configuration existed before it.',
+ aliases: { bundleId: 'topic', keyRef: 'credentialRef' },
+ guidance: {
+ key: APNS_KEY_GUIDANCE,
+ privateKey: APNS_KEY_GUIDANCE,
+ p8: APNS_KEY_GUIDANCE,
+ authKey: APNS_KEY_GUIDANCE,
+ signingKey: APNS_KEY_GUIDANCE,
+ secret: APNS_KEY_GUIDANCE,
+ },
+ },
+ {
+ teamId: z.string().min(1).describe('Apple Developer Team ID (10 characters) that issued the signing key'),
+ keyId: z.string().min(1).describe('Key ID of the APNs auth key (10 characters)'),
+ topic: z.string().min(1).describe('APNs topic: the app bundle ID the pushes are for'),
+ environment: z.enum(['sandbox', 'production'])
+ .describe('APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds)'),
+ credentialRef: secretReference('APNs .p8 signing key')
+ .describe('Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself'),
+ },
+).describe('[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference)'));
+export type ApnsTransportConfig = z.input;
+
+/** FCM over the HTTP v1 API, authenticated by a service account. */
+export const FcmTransportConfigSchema = lazySchema(() => strictObject(
+ {
+ surface: 'the FCM push transport',
+ history: 'This block is strict from birth — no FCM configuration existed before it.',
+ aliases: { serviceAccountRef: 'credentialRef' },
+ guidance: {
+ serviceAccount: FCM_ACCOUNT_GUIDANCE,
+ serviceAccountJson: FCM_ACCOUNT_GUIDANCE,
+ credentials: FCM_ACCOUNT_GUIDANCE,
+ privateKey: FCM_ACCOUNT_GUIDANCE,
+ secret: FCM_ACCOUNT_GUIDANCE,
+ serverKey:
+ 'Not authorable. FCM legacy server keys belong to the retired legacy API; the HTTP v1 transport authenticates '
+ + 'with a service account — name its JSON with `credentialRef`.',
+ },
+ },
+ {
+ projectId: z.string().min(1).describe('Firebase project ID the HTTP v1 send endpoint is addressed to'),
+ credentialRef: secretReference('FCM service-account JSON')
+ .describe('Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself'),
+ },
+).describe('[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference)'));
+export type FcmTransportConfig = z.input;
+
+/**
+ * The push channel's transport configuration: one optional block per vendor.
+ * A vendor with no block is a vendor this deployment does not deliver
+ * through.
+ */
+export const PushServiceConfigSchema = lazySchema(() => strictObject(
+ {
+ surface: 'the push transport configuration',
+ history: 'This block is strict from birth — no push configuration existed before it.',
+ },
+ {
+ apns: ApnsTransportConfigSchema.optional().describe('APNs transport (token-based auth). Absent: no delivery to apns devices'),
+ fcm: FcmTransportConfigSchema.optional().describe('FCM HTTP v1 transport. Absent: no delivery to fcm devices'),
+ },
+).describe('[PLANNED — not yet read] Push channel transport configuration: one optional block per vendor (apns, fcm). Nothing reads it until the push channel plugin ships'));
+export type PushServiceConfig = z.input;
diff --git a/packages/spec/src/system/push-device-platform.ts b/packages/spec/src/system/push-device-platform.ts
new file mode 100644
index 00000000000..501479e51d5
--- /dev/null
+++ b/packages/spec/src/system/push-device-platform.ts
@@ -0,0 +1,15 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The device platforms a push device registration names: `ios`, `android`
+ * and `web`.
+ *
+ * One list for the two schemas that carry it, the registration body
+ * (`RegisterDeviceRequestSchema.platform`, `api/protocol.zod.ts`) and the
+ * stored device (`UserDeviceSchema.platform`, `system/push-device.zod.ts`), so
+ * a stored device never names a platform its registration would refuse.
+ *
+ * Internal to the package: no barrel re-exports this module, so it adds no
+ * public name. Each schema builds its own `z.enum` from it and describes it.
+ */
+export const PUSH_DEVICE_PLATFORMS = ['ios', 'android', 'web'] as const;
diff --git a/packages/spec/src/system/push-device.test.ts b/packages/spec/src/system/push-device.test.ts
new file mode 100644
index 00000000000..a3598c5cd96
--- /dev/null
+++ b/packages/spec/src/system/push-device.test.ts
@@ -0,0 +1,78 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// The stored device shape of the push device registry (ADR-0012 §6, narrowed
+// to core). It belongs to a user alone (no organization member), names one of
+// the two core vendors, and keeps a dead token's row disabled with the time
+// the vendor reported it.
+
+import { describe, it, expect } from 'vitest';
+import { UserDeviceSchema } from './push-device.zod';
+import { RegisterDeviceRequestSchema } from '../api/protocol.zod';
+
+const DEVICE = {
+ deviceId: 'install-1',
+ userId: 'usr_1',
+ vendor: 'apns',
+ token: 'apns-device-token',
+ platform: 'ios',
+ appId: 'com.example.shell',
+ locale: 'zh-CN',
+ name: 'iPhone',
+ enabled: true,
+ lastSeenAt: '2026-11-02T08:00:00.000Z',
+} as const;
+
+describe('UserDeviceSchema', () => {
+ it('round-trips an enabled device', () => {
+ expect(UserDeviceSchema.parse(DEVICE)).toEqual(DEVICE);
+ });
+
+ it('round-trips a device whose token the vendor reported dead', () => {
+ const dead = { ...DEVICE, enabled: false, invalidatedAt: '2026-11-02T09:00:00.000Z' };
+ expect(UserDeviceSchema.parse(dead)).toEqual(dead);
+ });
+
+ it('needs only the identity, the vendor, the token, the platform and enabled', () => {
+ const minimal = { deviceId: 'install-2', userId: 'usr_1', vendor: 'fcm', token: 't', platform: 'android', enabled: true };
+ expect(UserDeviceSchema.parse(minimal)).toEqual(minimal);
+ });
+
+ it.each(['deviceId', 'userId', 'vendor', 'token', 'platform', 'enabled'] as const)('requires %s', (key) => {
+ const { [key]: _dropped, ...rest } = DEVICE;
+ const parsed = UserDeviceSchema.safeParse(rest);
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual([key]);
+ });
+
+ it.each(['hms', 'web_push', 'APNS'])('refuses vendor %j (core delivers through apns / fcm only)', (vendor) => {
+ const parsed = UserDeviceSchema.safeParse({ ...DEVICE, vendor });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['vendor']);
+ });
+
+ it.each(['zh_CN', 'Chinese', 'zh-CN '])('refuses locale %j (not a BCP-47 language tag)', (locale) => {
+ const parsed = UserDeviceSchema.safeParse({ ...DEVICE, locale });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['locale']);
+ expect(parsed.error?.issues[0]?.code).toBe('invalid_format');
+ });
+
+ it('refuses an invalidation time that is not an ISO-8601 instant', () => {
+ const parsed = UserDeviceSchema.safeParse({ ...DEVICE, enabled: false, invalidatedAt: 'yesterday' });
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.path).toEqual(['invalidatedAt']);
+ });
+
+ it('names the same platforms its registration does', () => {
+ expect(UserDeviceSchema.shape.platform.options).toEqual(RegisterDeviceRequestSchema.shape.platform.options);
+ expect(UserDeviceSchema.shape.platform.options).toEqual(['ios', 'android', 'web']);
+ });
+
+ it('has no organization member: a device belongs to its user alone', () => {
+ expect(Object.keys(UserDeviceSchema.shape)).not.toContain('organizationId');
+ });
+
+ it('carries the PLANNED marker until the device registry stores it', () => {
+ expect(UserDeviceSchema.description).toMatch(/^\[PLANNED — /);
+ });
+});
diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts
new file mode 100644
index 00000000000..5329cce138b
--- /dev/null
+++ b/packages/spec/src/system/push-device.zod.ts
@@ -0,0 +1,75 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+import { PushVendorSchema } from './push-config.zod';
+import { LocaleSchema } from './translation.zod';
+import { PUSH_DEVICE_PLATFORMS } from './push-device-platform';
+
+/**
+ * Push Device Protocol — the stored device shape of the push device registry
+ * (ADR-0012 §6, narrowed to what core ships).
+ *
+ * One row per (user, app install): what a device registration
+ * (`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the
+ * push channel reads to address a phone, and what the registry's listing
+ * answers (`INotificationService.listDevices`).
+ *
+ * **Declared ahead of its producer.** No table stores it and no route serves
+ * it yet; the schema carries a `[PLANNED — not yet served]` marker in its
+ * `.describe()` until the device registry lands (in the push channel plugin's
+ * `sys_user_device`, ADR-0012 §11).
+ *
+ * Three rules the registry owes, stated here because they decide the shape:
+ *
+ * - **(a) A device belongs to a user, not to an organization.** The row has no
+ * organization member. A phone signed in to one person receives that
+ * person's notifications whichever organization they come from; the
+ * organization travels on the notification, never on the device. The
+ * identity of a row is (`userId`, `deviceId`).
+ * - **(b) One live row per token.** A vendor token is delivered to one row
+ * only. A token this user already holds under another `deviceId` (the app's
+ * storage was cleared, so the registration carries no `deviceId`) refreshes
+ * that row and answers its `deviceId`; it never makes a second live row.
+ * When a second user registers a token another user's device holds, the
+ * token moves to the second user and the first user's row for it is
+ * removed, so a push meant for the phone's previous user never reaches its
+ * new one.
+ * - **(c) A dead token keeps its row.** When the vendor reports a token dead
+ * (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is
+ * kept with `enabled: false` and `invalidatedAt` set, so "why did this phone
+ * stop getting pushes" stays answerable. Nothing is delivered to a disabled
+ * row, and a later registration of the same device re-enables it and clears
+ * `invalidatedAt`.
+ *
+ * Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the
+ * domestic Android vendors plug in as alternate channel implementations) and
+ * `platform` is the registration's own three (`ios` | `android` | `web`).
+ */
+// The marker comes off when the server device-registry slice (#29068) stores
+// and serves this shape.
+export const UserDeviceSchema = lazySchema(() => z.object({
+ deviceId: z.string().min(1).describe(
+ 'Client-held device id, stable per app install: the one the registration carried or the server minted. Unique per user',
+ ),
+ userId: z.string().min(1).describe('The user the device belongs to: the session user who registered it. No organization member'),
+ vendor: PushVendorSchema.describe('Push vendor the token belongs to: the registration\'s vendor, or the one derived from platform'),
+ token: z.string().min(1).describe('Vendor push token. Held by one live device at a time: a second user\'s registration moves it'),
+ platform: z.enum(PUSH_DEVICE_PLATFORMS).describe('Device platform, as the registration named it'),
+ appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
+ locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
+ name: z.string().optional().describe('Device friendly name, as the registration named it'),
+ enabled: z.boolean().describe('False once the vendor reported the token dead; nothing is delivered to a disabled device'),
+ invalidatedAt: z.string().datetime().optional().describe(
+ 'When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, '
+ + 'and cleared by the registration that re-enables it',
+ ),
+ lastSeenAt: z.string().datetime().optional().describe(
+ 'When the device last registered or refreshed. Every registration and refresh sets it, so it is absent only on a '
+ + 'row no registration wrote; listDevices answers such a device last',
+ ),
+}).describe(
+ '[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; '
+ + 'the device registry lands with the push channel',
+));
+export type UserDevice = z.input;
diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts
index 1de234178af..d23cfeab05b 100644
--- a/packages/spec/src/type-alias-convention.pin.test.ts
+++ b/packages/spec/src/type-alias-convention.pin.test.ts
@@ -283,9 +283,13 @@ import type * as M193 from './data/seed-file.zod.js';
// [ADR-0142] Ownership queues and assignment rules -- new modules, next free indices.
import type * as M194 from './automation/assignment-rule.zod.js';
import type * as M195 from './security/queue.zod.js';
+// The push channel's transport configuration and delivery data (ADR-0012 §6) -- new modules, next free indices.
+import type * as M196 from './system/push-config.zod.js';
+import type * as M197 from './api/push-delivery.zod.js';
+import type * as M198 from './system/push-device.zod.js';
// ---------------------------------------------------------------------------
-// 811 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
+// 818 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
//
// That number is machine-checked, not hand-kept. The runtime companion at the
// bottom of this file recomputes the pin count from the source and asserts that
@@ -652,6 +656,10 @@ export type Iso_api_protocol__ValidateDataIssueSchema = Assert, z.infer< typeof M28.ValidateDataRequestSchema > >>;
export type Iso_api_protocol__ValidateDataResponseSchema = Assert, z.infer< typeof M28.ValidateDataResponseSchema > >>;
+// api/push-delivery.zod.ts
+export type Iso_api_pushDelivery__PushDeliveryDataSchema = Assert, z.infer< typeof M197.PushDeliveryDataSchema > >>;
+export type Iso_api_pushDelivery__PushRouteSchema = Assert, z.infer< typeof M197.PushRouteSchema > >>;
+
// api/query-adapter.zod.ts
export type Iso_api_queryAdapter__OperatorMappingSchema = Assert, z.infer< typeof M29.OperatorMappingSchema > >>;
export type Iso_api_queryAdapter__QueryAdapterTargetSchema = Assert, z.infer< typeof M29.QueryAdapterTargetSchema > >>;
@@ -1408,6 +1416,15 @@ export type Iso_system_objectStorage__StorageClassSchema = Assert, z.infer< typeof M142.StorageProviderSchema > >>;
export type Iso_system_objectStorage__StorageScopeSchema = Assert, z.infer< typeof M142.StorageScopeSchema > >>;
+// system/push-config.zod.ts
+export type Iso_system_pushConfig__ApnsTransportConfigSchema = Assert, z.infer< typeof M196.ApnsTransportConfigSchema > >>;
+export type Iso_system_pushConfig__FcmTransportConfigSchema = Assert, z.infer< typeof M196.FcmTransportConfigSchema > >>;
+export type Iso_system_pushConfig__PushServiceConfigSchema = Assert, z.infer< typeof M196.PushServiceConfigSchema > >>;
+export type Iso_system_pushConfig__PushVendorSchema = Assert, z.infer< typeof M196.PushVendorSchema > >>;
+
+// system/push-device.zod.ts
+export type Iso_system_pushDevice__UserDeviceSchema = Assert, z.infer< typeof M198.UserDeviceSchema > >>;
+
// system/registry-config.zod.ts
export type Iso_system_registryConfig__RegistrySyncPolicySchema = Assert, z.infer< typeof M143.RegistrySyncPolicySchema > >>;
@@ -1745,7 +1762,7 @@ describe('ADR-0122 type-alias convention', () => {
// this title and the section header above the pin list — are now asserted
// against the recomputed count below, so neither can go stale without a red
// test naming it.
- it('still declares all 811 isomorphic pins', () => {
+ it('still declares all 818 isomorphic pins', () => {
// The truth of each pin is proved by tsc, not here — an `Assert>`
// that stops holding is a compile error with the alias named. What tsc
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -2723,7 +2740,19 @@ describe('ADR-0122 type-alias convention', () => {
// (`api/auth.zod.ts`), strict objects with no default and no transform —
// the anonymous branch, the authenticated branch and their discriminated
// union (Iso_api_auth__MePermissions*ResponseSchema). +3 added.
- expect(pins).toHaveLength(811);
+ // 811 -> 817 is the push channel's spec face (ADR-0012 §6): the push
+ // transport configuration (`system/push-config.zod.ts`: the vendor enum,
+ // the APNs and FCM blocks and the block holding them) and the push
+ // delivery data and its route (`api/push-delivery.zod.ts`), strict or plain objects of
+ // required and optional strings, enums and regex refinements, no default
+ // and no transform (Iso_system_pushConfig__*, Iso_api_pushDelivery__*).
+ // +6 added.
+ // 817 -> 818 is the push device registry's stored device shape
+ // (`system/push-device.zod.ts`, `UserDeviceSchema`): a plain object of
+ // required and optional strings, enums, a boolean and ISO instants, no
+ // default and no transform (Iso_system_pushDevice__UserDeviceSchema).
+ // +1 added.
+ expect(pins).toHaveLength(818);
// The count is stated in PROSE twice as well — this case's title and the
// section header above the pin list — and until commit c6b05c76a nothing read either