# Platform notifications

Registry owns one durable **News** projection. A source reports an immutable
Occurrence; Registry reduces it onto the current Subject a person can return to,
and the recipient owns the Subject's seen and dismissed state. The Desktop Shell
renders the account-complete Center. A Widget inside an Environment reads only
that Environment's projection.

An incoming call is not a News row, and external push is not a second inbox.
`ADR-0131` keeps live `CallAlert` state and per-device Delivery separate. The
current public notification APIs described here do not provide mobile/web push,
email, or SMS.

## Where this lives

- `packages/contracts/src/notifications.ts` — reviewed event policy, occurrence,
  acknowledgement, inbox, treatment, and legacy publish contracts.
- `services/registry/src/notifications/subject-routes.ts` — foreground and
  delegated App occurrence signals plus source acknowledgement.
- `services/registry/src/repo/sql/notification-subjects.ts` — the atomic
  Occurrence-to-Subject reducer, occurrence receipts, and acknowledgement
  watermarks.
- `services/registry/src/notifications/routes.ts` — Environment reads and the
  additive legacy publish paths.
- `services/registry/src/notifications/device-routes.ts` — the account-complete
  device-plane Center read, read, dismiss, and account control credential.
- `services/registry/src/notifications/treatment.ts` — the recipient's resolved
  `announce | file | mute` treatment.
- `services/registry/src/notifications/retention.ts` — bounded cleanup of expired
  working rows.

In Persian a **منبع** is the App or Environment source, while a **مبدأ** is an
HTTP origin. `docs/product/glossary.md` carries the term table.

## Four concepts with different lifetimes

| Concept    | Meaning                                                                                        | Lifetime                                                |
| ---------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Occurrence | One immutable source fact, identified by a stable source-scoped `eventId`                      | Receipt retained only to make retries idempotent        |
| Subject    | The current Center row for one conversation, document, job, invitation, or per-occurrence item | Working projection, normally up to 30 days              |
| Attention  | Durable recipient state for an unseen current Subject revision                                 | Until seen, dismissed, resolved, or expired             |
| Pulse      | A request to present one eligible new Attention episode now                                    | Live only; never reconstructed from a list or reconnect |

This is a small reducer in front of `platform_notifications`, not a general
event store. The source product remains authoritative for message history, job
logs, call history, and audit history.

## Review event policy with the immutable revision

New occurrence writers require a `policy` on the exact reviewed event
declaration. Policy cannot be changed by an individual signal:

```json
{
  "notifications": {
    "eventTypes": [
      {
        "type": "document.changed",
        "label": "Document changed",
        "policy": {
          "family": "generic",
          "subject": {
            "mode": "keyed",
            "revision": "source_monotonic"
          },
          "effects": ["raise", "refresh", "resolve"],
          "preferenceKey": "document.changed",
          "pulse": "none",
          "presentation": { "kind": "source" }
        }
      }
    ],
    "backgroundDelivery": "none"
  }
}
```

The reviewed fields mean:

- `family` is a closed platform presentation family. It replaces the legacy
  caller-supplied `category` as policy input; it is not a sound or urgency knob.
- `subject.mode` is `keyed` for a durable domain subject or `per_occurrence` for
  an independent row per event. A keyed revision is supplied by the source's
  authoritative domain, never by a wall clock.
- `effects` bounds the accepted `raise`, `refresh`, and `resolve` transitions.
  A per-occurrence policy may only allow `raise`.
- `preferenceKey` is the stable per-event preference identity.
- `pulse` is `new_attention` or `none`. Source-authored presentation must use
  `none`.
- `presentation` is either bounded source-authored content or one named,
  reviewed platform template.

`platform_template: message_waiting` belongs to the `message` family and is
admitted only for a platform-reviewed first-party revision. The source supplies
the event and an optional App-pairwise `actorSub`; Registry builds the title
from the recipient's own companion roster, uses a generic Persian fallback when
no permitted name resolves, and supplies the semantic `open_app` action. The
source cannot provide the title, body, preview, tone, or category for that
template.

A platform template is not a privilege inherited by every `message` event.
Source-authored words default to the `file` ceiling, including first-party App
words. A reviewed platform-authored template may default to `announce`; the
recipient may still lower it, and a source mute remains a floor.

## Signal an Occurrence from an open App

An account App holding `platform.notifications.publish.self@1` sends:

```http
POST /v1/apps/notifications/signal
Authorization: Bearer <app-session>
Content-Type: application/json
```

The public SDK wraps this route as
`appClient.signalNotificationOccurrence(occurrence)`.

For a keyed, source-authored Subject:

```json
{
  "eventId": "edit_01J9Y7K6",
  "eventType": "document.changed",
  "occurredAt": "2026-09-04T10:42:17.000Z",
  "effect": "raise",
  "subjectKey": "document:doc_42",
  "revision": 81,
  "content": {
    "kind": "source",
    "title": "Document updated",
    "body": "The latest changes are ready.",
    "action": { "kind": "open_app" }
  }
}
```

For the reviewed message template, `content` is instead:

```json
{ "kind": "platform_template", "actorSub": "aact_..." }
```

The App session selects the recipient, source revision, and account or current
Environment scope. A caller never supplies a raw account subject or mutable
source artwork. The response is `202 { "accepted": true }`; it does not reveal
the resolved treatment or recipient state.

`occurredAt` is the immutable source timestamp, while Registry records its own
receipt time separately. Registry accepts only bounded-live work: this
same-account foreground route refuses an occurrence more than 30 days old or
more than five minutes in the future. The delegated workload route preserves
its accepted-only envelope and applies the same case as a no-op. Retries keep
both `eventId` and `occurredAt` unchanged.

For `keyed` policy, `subjectKey` and `revision` are both required. For
`per_occurrence`, both are omitted and Registry derives the Subject identity
from `eventId`. `refresh` and `resolve` always require a keyed Subject.
When one source-domain identity is shared by several recipients, the source
must derive a stable recipient-scoped opaque Subject key; it must not expose the
shared raw conversation, document, or job identifier to Registry.

The reducer behaves as follows:

| Effect    | Newer accepted revision                                                       |
| --------- | ----------------------------------------------------------------------------- |
| `raise`   | Creates or reopens Attention and may emit one reviewed Pulse                  |
| `refresh` | Updates current presentation without reopening spent Attention                |
| `resolve` | Removes the active source-owned Subject without pretending the person read it |

The source must reuse the exact same `eventId` for retries. An exact retry, an
older Subject revision, or a revision already covered by an acknowledgement
watermark is an accepted no-op: it does not move seen state, extend expiry,
increment the projection, emit a Pulse, or raise an invalidation hint.

## Signal from an App backend

Delegated background delivery still begins with an explicit connection from an
authenticated account App session:

```ts
const delegation = await appClient.createNotificationDelegation();
await sendOnceToAppBackend({
  delegationToken: delegation.delegationToken,
  expiresAt: delegation.expiresAt,
});
```

The App revision must declare `backgroundDelivery: "delegated"` and request
both `platform.notifications.publish.self@1` and
`platform.notifications.connect.background.self@1`. Transfer the token over
the App's authenticated HTTPS channel, store it as a credential, never log it,
rotate it, and revoke it when the connection ends.

The backend combines that opaque per-user delegation with an expiring App
Project token scoped to `notifications:publish`:

```ts
import { createAppNotificationWorkloadClient } from "@al-amr/backend";

const notifications = createAppNotificationWorkloadClient({
  issuer: process.env.AL_AMR_REGISTRY_URL!,
  projectToken: process.env.AL_AMR_PROJECT_TOKEN!,
});

await notifications.signal(storedDelegationToken, occurrence);
```

This calls `POST /v1/workloads/apps/notifications/signal`. Registry can refuse
an invalid workload credential, missing scope, or malformed caller-owned
request. Once those outer facts are admitted, every recipient-dependent outcome
returns the same `202 { "accepted": true }`: missing/revoked delegation, stale
consent, changed revision, and a successful reduction are not distinguishable.
No count, treatment, read state, or recipient existence is returned.

Commit a stable outbox item beside the source-domain write and retry the same
event identity until Registry accepts it. Acceptance means the privacy-safe
handoff completed; it is not proof that a person saw a card or heard a sound.

## Acknowledge only content the App actually presented

When the App has displayed authoritative source content through a known domain
revision, it can advance one or more Subject watermarks:

```ts
await appClient.acknowledgeNotificationSubjects({
  acknowledgements: [
    {
      eventType: "document.changed",
      subjectKey: "document:doc_42",
      throughRevision: 81,
    },
  ],
});
```

The HTTP route is `POST /v1/apps/notifications/acknowledge`; the strict request
contains 1–100 unique acknowledgements. Source and recipient come from the App
session. Registry stores the maximum `throughRevision` even when the Subject or
its delayed Occurrence does not exist yet, so a late delivery cannot resurrect
already-presented work. An older acknowledgement never consumes a newer raise.

The answer is again only `202 { "accepted": true }`. Opening the App, clicking
Shell chrome, or accepting an AppHost launch is not acknowledgement; wait until
the product UI has actually presented content through the stated source
revision.

Activating a Center row asks the trusted Desktop host to execute its closed
semantic target. Exact execution is promised only when Desktop or AppHost owns
the authority for that target kind. An unsupported target produces an explicit
host outcome and leaves Attention intact; accepting a launch is never treated
as source-confirmed presentation.

## Shell Center and Environment Widget are different readers

The trusted Desktop Main process uses the device plane:

- `GET /v1/account/notifications?limit=…&cursor=…` — account-complete Center;
- `POST /v1/account/notifications/read` — mark listed rows seen;
- `POST /v1/account/notifications/dismiss` — remove listed rows from the
  working feed.

The Environment Runtime uses the parallel `/v1/runtime/notifications`,
`/read`, and `/dismiss` routes, but that list is narrowed to the current
Environment. Account-complete titles, bodies, sources, Subject keys, and
CallAlerts never enter an Environment merely because the same person is there.

List responses use opaque keyset pagination and include:

```json
{
  "notifications": [],
  "unseenCount": 0,
  "attentionCount": 0,
  "unreadCount": 0,
  "nextCursor": "opaque-if-more"
}
```

`unseenCount` includes every active Subject whose current revision has not been
seen, including `mute`. `attentionCount` excludes `mute` and drives the Shell
badge and the Environment attention projection. `unreadCount` remains the
legacy alias of `attentionCount` so an immutable Widget cannot turn a mute into
a badge. Counts are independent whole-feed queries and are never capped by the
current page.

Each V1 row may add `projectionVersion`, `attentionVersion`, and `subjectRevision` to the tolerant
Inbox schema. The strict legacy publish echo remains unchanged for immutable
App bundles.

V1 Center actions send the platform version that was actually rendered:

```json
{
  "notificationIds": ["ntf_..."],
  "projectionVersions": { "ntf_...": 4 }
}
```

Registry updates only the matching projection, so a stale read or dismiss
cannot consume a newer Subject revision. The optional `raisedAt` map remains the
legacy timestamp fence for immutable Environment readers.

Dismissal is the recipient's spent decision about the current Subject, not a
standing source preference. It also spends that Attention episode and removes
the row from normal Center reads. A newer `raise` may reopen it; `refresh` may
not. Dismissed and expired working rows are cleaned by bounded retention rather
than returned in the normal feed.

## Realtime is a content-free hint; Pulse is not reconciliation

`notification_created` and `account_inbox_changed` carry no title, source,
family, Subject, count, or action. Their only effect is to schedule the
authorized list read. Duplicate occurrences emit no hint. Clients also
reconcile after reconnect and on a slow fallback clock.

The first authoritative snapshot after boot, sign-in, or reconnect is a silent
baseline. Listing, pagination, polling, and replacing a stale cache never infer
a Pulse from row differences. Pulse is decided atomically with an eligible new
Attention episode and is a live delivery request only.

`attentionVersion` names the first platform projection of the current Attention
episode. Further unseen raises and refreshes advance `projectionVersion` but
preserve this identity. A live Pulse's `projectionVersion` matches that stable
`attentionVersion`, so a second message reaching storage before the first Pulse
is reconciled cannot erase the episode's only announcement. A reopened episode
gets a new identity; an old Pulse cannot announce it. Older inbox projections
without this additive field retain exact `projectionVersion` matching. Migration
backfills the current projection silently and never reconstructs past Pulses.

The Shell owns account transient cards, badge, OS-notice policy, and platform
attention sound. The Widget owns neither account chrome nor account sound. It
reports only `attention: "none" | "waiting" | "urgent"` to its Environment,
without a count, family, source, title, or Subject.

One Main-owned audio arbiter spends each Pulse identity once. When the window
is backgrounded, the OS notice is explicitly silent because that same arbiter
already owns the tone; when focused, the same Pulse still gets one tone and the
Shell card replaces the OS notice. ADR-0133 activates the platform call producer: its incoming-call lease has priority over Pulse. Pre-empted Pulses are spent, never replayed. The Hub direct-call installation is disabled, so there is one attention-audio owner.

## Treatment and preferences

Every Subject carries one treatment resolved when a new revision is reduced:

| Treatment  | Durable result                                                      | Live Pulse eligibility                |
| ---------- | ------------------------------------------------------------------- | ------------------------------------- |
| `announce` | visible Subject and Attention                                       | Under reviewed `new_attention` policy |
| `file`     | visible Subject and Attention                                       | No                                    |
| `mute`     | visible, possibly unseen Subject; no badge or Environment attention | Never                                 |

Preferences are Registry-origin recipient decisions per source and optionally
per reviewed `preferenceKey`. `mutedUntil` is a timed silence. A source-level
mute is a floor that a newly declared event cannot escape. Changing a preference
does not replay, retract, or reclassify an Attention episode already reduced.

## Additive legacy paths

The original publish contract remains for immutable callers:

- active Environment: `POST /v1/runtime/notifications/publish`;
- open environment-hosted App: `POST /v1/apps/notifications/publish`;
- App workload: `POST /v1/workloads/apps/notifications/publish`;
- Environment workload: `POST /v1/workloads/environments/notifications/publish`.

Legacy payloads keep `category`, `title`, optional `body`/semantic `action`,
`dedupeKey`, and TTL. `category` gains no family, sound, or Pulse authority.
These routes write the existing projection directly and do not receive V1
Occurrence ordering or acknowledgement-watermark semantics. The source-wide
`POST /v1/apps/notifications/read` remains only for immutable App callers; new
first-party code uses exact Subject acknowledgement.

`platform.notify.companion@1` remains the bounded platform-authored companion
notice path, and consented Environment fan-out remains accepted-only with its
durable handoff. They do not let a caller select arbitrary words, raw account
subjects, or observable recipient-dependent results. See `ADR-0071`,
`ADR-0087`, and `ADR-0109` for those authority boundaries.

Environment occurrence signaling is not implied by the App V1 routes above.
Environment frontends and backends continue to use their existing Runtime and
short-lived exact-endpoint Grant publish paths until an explicit reviewed
occurrence API is added for that authority plane.

## Source presentation, live alerts, and external delivery

Center rows use Registry-managed revision artwork and never fetch a publisher
URL. The source media descriptor includes `shape: "icon" | "cover"` because the
reviewed compositions are not interchangeable.

A live `CallAlert` is deadline-bound backend truth, not a Notification Subject,
unread count, or Pulse. ADR-0133 activates the account/device plane with the
App-general platform call service and the independently delivered Call App.
`GET /v1/account/call-alerts` returns a bounded, device-asserted projection with
revision, reviewed owner, caller label, deadline and App-open action. Hints are
content-free; boot and reconnect are silent. Main owns expiry and the single
attention-audio lease. Hamsaz offers View Call and local Silence; only the App
answers or declines. Ended/accepted/declined/expired calls disappear immediately.
The backend records missed calls as quiet `call.missed` Occurrences through the
existing Subject reducer. Guest Runtimes and Environment Widgets get no account
call alerts, and no scoped `actorKey` is mapped to an account.

External push is deliberately not implemented by these contracts. A future
provider consumes eligible live projections per device; it cannot become the
source of truth or scrape arbitrary inbox rows.

See [App Host Services](/docs/architecture/app-host-services),
[API reference](/reference/api), [SDK reference](/reference/sdk),
[ADR-0130](../decisions/ADR-0130-a-notification-is-a-reviewed-occurrence-reduced-onto-a-subject.md),
and
[ADR-0131](../decisions/ADR-0131-news-live-alerts-and-external-delivery-are-different-planes.md).
