Amended 2026-10-26 (STA-1235), decision 1: config
notifications.target names the built-in target
when a host passes none, messages (the default, unchanged) or delivery (the OS notifications
service’s delivery.message.send@1: the template key, a category code, the template values, the
rendered mail or null, and the Market). Notification({ category, messageCategory }) may name
the delivery service’s own category code beside the three-case category. Recipient.address(email)
addresses a person with no account yet (an invitation, a partner lead): a target takes it through
an optional addressPayload, and the driver refuses it before queueing anything when the target
has none. This is the one case where an address leaves the producer; a person with a principal is
always addressed by id. Scope recipients stay refused.Context
The OS notifications and audit services filed gaps: the notifier driver can only reachmessages.message.send@1 with a
mail-only input; an in-app entry has no product; ProviderResolver answers one Connection and reads the market only
from context; no generic market-pack ProviderDefaults driver ships; webhooks have no S3 target and no entitlement
gate. Payroll needs a connector contract (RV-50: no own payroll).
Decision
- Notifier target.
NotificationsNotifierDrivertakes aNotifierTarget: any action ref and a payload mapper that receives the template key, category, principal, market (from a boundMarketContext), data and each rendered channel, and answers the action’s input ornullto skip. The default staysmessages.message.send@1with the mail. - In-app product.
InAppMessage.productis required, andInAppStore.listfilters byproduct, for a per-product inbox and the ethical wall. - Failover list.
ProviderResolver#candidatesanswers every Connection to try: the tenant’s own (modeown), then the Market’s platform ones (modemanaged), each in pack order.resolveis its first entry, loaded lazily. Both take the Market as an argument and fall back toMarketContext. Whether an own Connection may fail over to a managed one is the caller’s policy.ProviderChoice.connectionIdis optional: a bring-your-own-only provider is offered with no platform Connection.MarketPackProviderDefaultsDriverreads the pack’sproviders.<capability>list (live entries, pack order) through the host’sProviderPackSource, as payments’ driver does. - Webhooks. An endpoint URL
s3://<bucket>/<prefix>?region=receives each delivery as an object keyed<prefix>/<type>/<yyyy>/<mm>/<dd>/<eventId>.json, the signed headers as metadata, written by thes3WebhookObjectSinkwith the platform role; a tenant’s own bucket grants that role by bucket policy, so no tenant credential is stored. A registration row may name an entitlement;registerrefuses a gated event without the grant and the fan-out rechecks it, throughWebhookEntitlementSource(fail closed when unbound). - Payroll.
IntegrationCategory.PayrollandPayrollConnector.pushPayInputs(batch, idempotencyKey)(hours, sessions, tips, commission per employee, with the provider’s employee id and pay code from the tenant’s mapping), checked byassertPayInputs;MemoryPayrollConnectorin./testing. The ZenHR, Kombo and Gusto connectors and the data-transfer export live with the capability’s owner and are not decided here.
Consequences
- A notifications service with another module or input needs no fork of the driver.
- Channel gateways stop repeating the ADR 0356 order rule.
- The audit SIEM stream is gated by plan in one place and can land in a bucket.
Enforcement
planned — the tests beside each file:notifications-notifier.driver.test.tsx, in-app-store.fake.test.ts,
provider-resolver.service.test.ts, market-pack-provider-defaults.driver.test.ts, deliver-webhook.action.test.ts,
webhook-endpoint.service.test.ts, memory-payroll.connector.test.ts.
Supersedes and sources
Supersedes: none. Sources: OSservices/notifications/SPEC.md gaps 1, 3, 5, 6 and 8; OS services/audit/SPEC.md
gap 7; RV-50; RV-26; R3-SO-05; STA-836; STA-837; STA-839; STA-826; STA-714.