Notifications and device identity¶
This document specifies Milestone 12. The engineering plan states the requirement; this document states the mechanism. It is subordinate to engineering-plan.md, and it reuses rather than replaces the durable run loop, the event log, the scheduling control plane, the HTTP boundary, and the native Apple client. ADR-0062 records the architectural decisions; ADR-0061 records the authorization.
Section 29.8 of the plan deferred the Device concept and notifications "to
a milestone with concrete use cases". The use cases now exist: a scheduled
occurrence finishes and nobody is watching; an approval waits and the run is
parked; ask_user suspends a run and the question sits unread until the next
time the owner opens the app. multi-device-and-surfaces.md
audited the seam and found NotificationService to be "a port name with
nothing behind it" (multi-device-and-surfaces.md:221). This document puts a
mechanism behind it, lands the Device table the audit placed
(multi-device-and-surfaces.md:332), and stops there: no inbound channel, no
pairing of untrusted senders, no device-scoped tool, no presence-based routing.
Those are Milestone 14 and the roadmap.
Milestones 10 through 12 are complete. Milestone 13 is the next sequential authorized milestone and has not started.
Scope¶
Milestone 12 delivers the first half of Section 29: a device registry and a durable notification path to one transport. Payloads remain content-free except for the subsequently authorized schedule identity in ADR-0091. It includes:
- a
Deviceregistry: register, refresh, list, revoke, and delete a device for a principal, keyed by a client-minted installation identity; - a durable notification outbox written in the same transaction as the event that triggers it;
- a least-privilege dispatcher role that drains the outbox and holds the push credential and the database credential and nothing else;
- one push transport, Apple Push Notification service (APNs), behind a port a fake transport satisfies under the same contract;
- exactly six trigger transitions: an approval is requested, a run waits for user input, a run fails, a scheduled occurrence's run reaches a terminal state, a scheduled occurrence is missed or skipped, and a device invocation is pending (Milestone 24, ADR-0081);
- bounded payloads that deep-link the Apple client to the session, run, approval, or question;
- device and notification routes with exact scopes, an offline notification inbox, default-off activation, and the Apple client's registration, token upload, revocation, and deep-link handling.
The milestone does not include inbound Surfaces, the session-key resolver, or
pairing (Milestone 14); DeviceChannel, device-scoped tools, or the device.
tool domain; presence or presence-based tool exposure; per-device scope
narrowing; the hand-off suspension kind; actionable approve-or-deny buttons on
the lock screen; email or webhook transports; or notifications for an
interactive run.completed. Each of those is named in the plan's roadmap or
in Milestone 14, and the ports below are shaped so each is additive.
The boundary: the outbox is written with the event; the transport never is¶
A notification is a consequence of a state transition the platform already records. The only reliable place to decide that a notification is owed is the transaction that records the transition, and the only reliable way to deliver it is from a durable row, later, by a process that can retry. Nothing in the interactive request path talks to Apple:
terminal writer / scheduler -- one transaction --> event + outbox row
|
v
notify role claims the row
|
v
PushTransport.deliver(target, message)
|
v
delivery ledger + settled outbox row
This yields three load-bearing invariants:
- A committed triggering event either has its committed outbox row or the enqueue was refused inside a savepoint and the run's terminal state is unchanged; there is no third state.
- Delivery is at-least-once from durable rows and idempotent by a derivation-style deduplication key; the transport is never a correctness dependency and losing a push cannot lose the underlying approval, question, or result, all of which remain readable through the existing API.
- A payload carries identifiers, a closed kind, a closed status, and a templated title. ADR-0091 additionally permits the pinned schedule title and nominal time on schedule notifications. The client fetches instructions and results after the tap, authenticated, from the API.
The existing LiveEventBroadcaster (ports/live_events.py) remains the
answer for a client that holds an open connection. The seam audit drew the
distinction this document keeps: delivering to an open connection and
delivering to a device that has none "are different problems with different
durability requirements" (multi-device-and-surfaces.md:221-242). The
broadcaster stays best-effort and in-process; the outbox is durable and
cross-process.
Domain model¶
Device¶
Device is the registry record Section 29.6 names, reduced to what Milestone
12 consumes. The capabilities and granted_scopes members of 29.6's model
are not columns yet: nothing reads them until a device channel exists, and a
column nothing reads is speculative.
class DeviceKind(StrEnum):
MOBILE = "mobile"
LAPTOP = "laptop"
DESKTOP = "desktop"
WEB = "web"
CLI = "cli"
SURFACE = "surface" # a Section 29 Surface (Milestone 14): a messaging channel, no local capabilities
class PushProvider(StrEnum):
APNS = "apns"
TELEGRAM = "telegram" # Milestone 14: the token is the chat reference resolved at pairing
class PushEnvironment(StrEnum):
SANDBOX = "sandbox"
PRODUCTION = "production"
class DeviceStatus(StrEnum):
ACTIVE = "active"
REVOKED = "revoked"
class Device(BaseModel):
id: UUID
tenant_id: str
principal_id: str
client_device_id: str
name: str
kind: DeviceKind
platform: str
app_bundle_id: str | None
push_provider: PushProvider | None
push_token: SecretStr | None
push_environment: PushEnvironment | None
push_token_updated_at: datetime | None
push_token_invalidated_at: datetime | None
muted_kinds: frozenset[NotificationKind]
status: DeviceStatus
revoked_at: datetime | None
last_seen_at: datetime
created_at: datetime
updated_at: datetime
client_device_id is minted once by the client, persisted in the client's
keychain beside the bearer token, and is stable across token rotation and app
updates; it is unique per principal. A surface device is the exception
Milestone 14 defines: its identity is minted by the surface role, its
platform names the channel, and its push_provider is telegram, with the
paired chat reference as the routing token — declared here so that a Surface
row is representable by this contract and the dispatcher can partition by
provider. push_token and push_provider are present together or absent
together; an unpaired or revoked Surface may therefore have neither, while a
routed Surface uses Telegram and no other provider;
push_environment is required when push_provider is apns and must be null
for any other provider — a Telegram Surface has no sandbox or production host.
The token is a secret value: it is returned to no client, appears in no event or log, and
device views carry a six-character fingerprint of it. muted_kinds is the
per-device preference: a phone can be loud and a laptop quiet without a third
table. status = revoked requires revoked_at and a null token.
Notification and delivery¶
The outbox row says what the platform decided to tell a principal. The delivery ledger says what the transport did about it:
class NotificationKind(StrEnum):
APPROVAL_REQUESTED = "approval_requested"
QUESTION_ASKED = "question_asked"
RUN_FAILED = "run_failed"
SCHEDULE_RUN_FINISHED = "schedule_run_finished"
SCHEDULE_OCCURRENCE_SKIPPED = "schedule_occurrence_skipped"
OPS_ALERT = "ops_alert" # Milestone 15 health check
OPS_RECOVERED = "ops_recovered" # Milestone 15 health check
TEST = "test"
class NotificationStatus(StrEnum):
PENDING = "pending"
DISPATCHED = "dispatched"
SUPERSEDED = "superseded"
EXPIRED = "expired"
FAILED = "failed"
class Notification(BaseModel):
id: UUID
tenant_id: str
principal_id: str
kind: NotificationKind
dedupe_key: str
session_id: UUID | None
run_id: UUID | None
approval_id: UUID | None
question_id: UUID | None
schedule_id: UUID | None
occurrence_id: UUID | None
payload: NotificationPayload
priority: int
expires_at: datetime | None
status: NotificationStatus
attempts: int
next_attempt_at: datetime
claimed_by: str | None
claimed_until: datetime | None
created_at: datetime
settled_at: datetime | None
class DeliveryOutcome(StrEnum):
DELIVERED = "delivered"
RETRY = "retry"
UNREGISTERED = "unregistered"
REJECTED = "rejected"
SKIPPED = "skipped"
class NotificationDelivery(BaseModel):
id: UUID
notification_id: UUID
device_id: UUID
attempt: int
outcome: DeliveryOutcome
provider_reason: str | None
provider_id: str | None
attempted_at: datetime
dedupe_key is the idempotency boundary, in the same form as the
derivation_key the event log and the scheduling accountant already use:
approval.requested:{approval_id}
run.waiting_for_user:{run_id}:{question_id}
run.failed:{run_id}
schedule.run_accounted:{occurrence_id}
schedule.occurrence.skipped:{occurrence_id}
device.test:{device_id}:{idempotency_key}
ops.{tenant_id}.{signal}.{episode} # Milestone 15 alert
ops.{tenant_id}.{signal}.{episode}.recovered # Milestone 15 recovery
The two ops forms are scoped by tenant and by alert episode — the health check's per-signal counter that increments each time a signal fails after having recovered — so a recovered-then-failing signal is a new notification, and the recovery of one episode can never be deduplicated away by the alert of the next.
The database enforces UNIQUE(dedupe_key). A retry after an unknown commit,
a repeated hook invocation, or a second process recording the same transition
inserts nothing. The identifier columns carry no foreign keys: a notification
about a deleted session is settled superseded at dispatch time rather than
being torn out of the ledger, and the ledger is what the offline inbox reads.
The payload is closed¶
class NotificationPayload(BaseModel):
version: Literal[1]
kind: NotificationKind
title: str
status: str | None
tool_name: str | None
session_id: UUID | None
run_id: UUID | None
approval_id: UUID | None
question_id: UUID | None
schedule_id: UUID | None
occurrence_id: UUID | None
notification_id: UUID
signal: str | None # ops kinds only: a declared health signal
severity: Literal["info", "warn", "critical", "recovered"] | None # ops kinds only
reason_code: str | None # ops kinds only: from the checked-in table
release_id: str | None # ops kinds only
schedule_context: ScheduleNotificationContext | None # schedule kinds only, ADR-0091
title is chosen from a fixed template per kind — "Approval needed", "The
agent has a question", "Run failed", "Scheduled run finished", "Scheduled run skipped", "Production alert", "Production recovered", "Test
notification" — and status is a closed-enum disposition or run status. The
four ops fields are null for every kind but the two ops kinds, for which
signal is one of the declared health signals, severity is the literal set
info | warn | critical | recovered that the model enforces before enqueue,
and reason_code comes from the checked-in table — never free text. tool_name is registry vocabulary (domain.verb), never user
content. The model has no extra fields. What the payload never carries:
message text, tool arguments, an approval's action_summary, question text, a
failure message, a schedule's instruction, reasoning, or a
traceback. The reasons are the ones the corpus already states for the event
stream and the export (ADR-0006, ADR-0032, scheduling.md's
"never logged" rule for instructions), plus one more: this payload transits
Apple's servers and the lock screen of a phone that may be face-up on a table.
The client fetches details after the tap, when it is online and authenticated.
APNs presentation uses a templated alert title and body derived only from this
closed payload. The veetbot dictionary retains its version and fixed title so
installed clients can still validate and open the notification. The visible
alert distinguishes completed, failed, and cancelled scheduled runs, and missed,
overlapping, unauthorized, and misconfigured occurrences. Every kind explains
the outcome or next action. Approval production copies the persisted approval's
registry tool name; device-action production copies the invocation's tool name.
Absent tool names use a generic next-action template. Operations alerts name
the declared health signal and severity through fixed display labels, with a
generic health-check label for an unrecognized signal. No template uses
user text, arguments, question, failure message, or summary, apart from the
explicit schedule-title exception below.
Authorized schedule identity exception¶
ADR-0091 records the owner's
2026-09-11 request to identify the actual schedule in its outcome alert. It
supersedes the content-free rule only for a closed optional schedule_context
on schedule_run_finished and schedule_occurrence_skipped payloads:
class ScheduleNotificationContext(BaseModel):
title: str # single line, at most 160 characters, credential-filtered
scheduled_for: datetime # aware nominal instant, preserving its UTC offset
The producer receives the revision already loaded by accounting or
materialization and verifies it matches the occurrence's schedule and revision.
It snapshots that revision's title and nominal instant, rendered in the pinned
recurring timezone (UTC for one-time schedules). It never reads the current
title during dispatch. Normalization removes invisible formatting controls,
collapses whitespace, and truncates with an ellipsis after checking the complete
original and normalized title for credentials. A credential-bearing or empty
legacy title uses Scheduled task. The title is excluded from model reprs.
APNs places the title in aps.alert.subtitle and the scheduled time, with its
numeric UTC offset, in the body. The transport excludes schedule_context from
the version-1 veetbot dictionary to preserve installed-client deep links. The
authenticated inbox and persisted JSON retain the snapshot. Old rows without it
remain readable and use the generic display. No instruction, result, message,
or failure preview is allowed, and logs and delivery records gain no title.
Triggers¶
Exactly six transitions enqueue, and each is observed where the corpus already records it — the sixth, a pending device invocation, arriving with Milestone 24 (ADR-0081):
trigger observed in kind
---------------------------------------- ------------------------------------- ---------------------------
approval.requested terminal writer, same transaction as approval_requested
run.waiting_for_approval
run.waiting_for_user terminal writer question_asked
run.failed terminal writer run_failed
schedule.run_accounted scheduling accountant, same schedule_run_finished
transaction as the accounting event
schedule.occurrence.{missed, scheduling materializer, same schedule_occurrence_skipped
skipped_overlap, transaction as the occurrence
authorization_failed,
configuration_failed}
device_invocation.created device-invocation write path, same device_invocation
transaction as the device_invocations row
The single terminal writer in runtime/executor.py is the only code that
appends run.failed, run.waiting_for_user, run.waiting_for_approval, and
approval.requested, and it does so inside one unit of work. The enqueue is an
injected in-transaction callable on the executor, in the shape the checkpoint
seeder already takes, invoked after the event append and wrapped in a
savepoint: an enqueue failure rolls back to the savepoint, is recorded in
the same transaction as a content-free notification.enqueue_failed process
event carrying only identifiers and a reason code, and the terminal
transaction commits unchanged. This is the rule ADR-0051 set for the
memory-formation hook and the same reason — a notification must never be able
to fail a run. The only committed triggering event without an outbox row is
therefore one whose enqueue failure is itself audited; there is no silent
form. The scheduling accountant and materializer own their own
transactions and take the outbox through the schedule unit of work.
Interactive run.completed, run.cancelled, tool lifecycle events, and
message deltas enqueue nothing. A principal who wants to know that an
interactive run finished is, by construction, watching it. A surface reply
(Milestone 14) is a separate outbox class, not a notification. The one
producer that is not a run or schedule transition is Milestone 15's host
health check, which enqueues only the ops_alert and ops_recovered kinds
through the outbox port with its own tenant- and episode-scoped deduplication
keys and cool-down; it adds no trigger to the six above.
Dispatch¶
The notify role runs NotificationDispatcher.run_once() in bounded batches.
For each claimed row it:
- Claims pending rows whose
next_attempt_atis due and that have an undelivered target for a provider the calling role can deliver — thenotifyrole passes{apns}, the Milestone 14 surface role{telegram}— withFOR UPDATE SKIP LOCKED, stampingclaimed_byand a leaseclaimed_until, in the manner of the schedule worker'slock_due. Dispatch is partitioned by provider so that each role holds exactly one secret; a row is settled only when no provider has an undelivered target, and the per-attempt ledger keeps two roles' attempts from colliding. - Checks staleness against the row's identifiers: an approval already
resolved or expired, a question already answered, a run no longer waiting,
or a session that no longer exists settles the row
supersededwith no transport call. Arun_failedrow for a server-markedemail_operationalsession is also superseded: its failure belongs to Email refresh status, never a Chat conversation. The triggering event and transactional outbox remain audited. Delivered-notification reconciliation applies this same rule to remove alerts sent by earlier versions; approvals and questions retain their ordinary attention rules. A focused operational approval or question opens the authenticated shared attention cards without persisting its audit session in Chat history; focus-free operational email alerts keep their Email-list routing. - Checks
expires_at; an expired row settlesexpiredwith no transport call. - Resolves push targets: the principal's active devices with a token whose
muted_kindsexcludes the row's kind. - Calls
PushTransport.deliveronce per target and writes oneNotificationDeliveryrow per attempt. - Settles the row only when no provider has an undelivered target. After
delivering its own provider's targets it records each attempt in the
ledger; if another provider still has an undelivered target it releases the
claim without settling so that provider's role can claim the row; once
every target across every provider has a terminal delivery row (or no
target exists) the row is
dispatched, orfailedwhen any target ended at its attempt ceiling; a target that returnedRETRYleaves the rowpendingwith the next backoff instant for that provider.
The transport's outcome vocabulary is closed and mapped deliberately. A
transport 5xx, 429, network failure, or expired or invalid provider token is
RETRY — the provider token is re-minted and the device is not charged. A 410 Unregistered
or 400 BadDeviceToken / DeviceTokenNotForTopic is UNREGISTERED: the
device's token is nulled, push_token_invalidated_at is set, a
device.push_token_invalidated process event is appended, the delivery is
recorded, and the row is not retried to that device. Any other 4xx is
REJECTED: payloads are closed-shape, so a rejected payload is a defect to
surface, not a transient to retry.
Retry follows a closed schedule in the versioned limits file: the attempt
after a RETRY happens at +30 seconds, +2 minutes, +10 minutes, +1 hour, and
then the row is failed. expires_at is the approval's own expiry where one
exists and twenty-four hours for terminal notices, checked before every send.
Two dispatchers are safe under the claim lease, and the per-attempt delivery
ledger records every send; delivery is nonetheless at-least-once, not
exactly-once. If the transport accepts a push and the dispatcher stops before
it writes the delivery row, the next claimant cannot distinguish that accepted
send from no send and re-sends. The transport's collapse identifier (the
dedupe_key) makes that replay invisible on the device as a best-effort
reduction, not a guarantee, and the ledger shows the extra attempt.
Settlement is fenced to the claimed attempt and lease: every terminal or retry
update compares the row's current attempt and requires an active claim whose
claimed_until is later than the settlement instant. It returns False after
the lease expires or a newer worker reclaims or settles the row, leaving the
stored state untouched.
Wake-up is LISTEN/NOTIFY on a fixed channel after the enqueuing
transaction commits, over a bounded poll, exactly as the schedule worker does.
Correctness is the table scan.
Every dispatcher scan also reads a bounded oldest-first view of notifications
that have remained pending for more than five minutes and emits a
content-free warning carrying the notification identifier, kind, creation
instant, and claimant. A long-lived dispatcher emits that warning at most once
per notification in each five-minute cooldown, using bounded in-process state;
a process restart may emit it again. This makes a row whose only remaining
targets belong to an as-yet undeployed provider visible even though that
dispatcher correctly cannot claim or settle it.
Revocation is immediate in the only sense that matters here: the dispatcher resolves targets at claim time, so a device revoked before the next claim receives nothing. Section 29.7's "immediately removes its scopes and presence server-side" has no scopes or presence to remove in Milestone 12; the push target is what is removed.
The APNs transport¶
The first and only transport in Milestone 12 is Apple Push Notification service, because the only client that exists beyond the CLI is the native Apple app (apple-client.md, ADR-0049). The adapter:
- speaks HTTP/2 to
api.push.apple.com:443orapi.sandbox.push.apple.com:443, choosing the host per device frompush_environment, never globally — the common production defect is a sandbox token sent to the production host, and it surfaces asBadDeviceToken; - authenticates with a provider token: an ES256 JWT signed by the
.p8key read fromAPNS_KEY_FILE, carrying the key identifier and team identifier, re-minted inside Apple's twenty-to-sixty-minute window; the key is a private-file credential in the pattern the browser control plane established — a regular file, mode0600, never an environment value, never logged; - sends
apns-topicequal to the configured bundle identifier,apns-push-type: alert,apns-priority10 for approvals and questions and 5 for terminal notices,apns-collapse-idequal to thededupe_keywhen it is ASCII and fits Apple's 64-byte limit or its SHA-256 digest otherwise, andapns-expirationequal to the row'sexpires_atwhere present; - maps every response into the closed
DeliveryOutcomevocabulary above and records the provider's reason string and identifier on the delivery row; - holds one connection per environment and reuses it.
HTTP/2 needs the h2 extra of the HTTP client the repository already depends
on; it is the one new dependency, and ADR-0062 records it. The contract suite
runs the adapter against a fake server through the client's mock transport;
the in-memory PushTransport serves every application and gate test; real
delivery is verified through the test-notification route against a physical
device, because the simulator cannot receive a remote push.
Ports¶
The application layer owns four provider-neutral ports. They live in two new
modules, ports/devices.py and ports/notifications.py, because the seam
audit's placement rule is that a port lives in the module named for the
capability it abstracts and that a port with no neighbours "needs a new module
rather than a new Protocol in an existing one" (multi-device-and-surfaces.md:332-367):
class DeviceRegistry(Protocol):
async def upsert(self, device: Device, principal: Principal) -> Device: ...
async def get(self, device_id: UUID, principal: Principal) -> Device: ...
async def list(self, principal: Principal, page: Page) -> Page[Device]: ...
async def revoke(self, device_id: UUID, principal: Principal, at: datetime) -> Device: ...
async def delete(self, device_id: UUID, principal: Principal) -> None: ...
async def invalidate_push_token(self, device_id: UUID, reason: str, at: datetime) -> Device | None: ...
async def push_targets(self, tenant_id: str, principal_id: str, kind: NotificationKind) -> list[PushTarget]: ...
class DeviceRegistrationIdempotencyRepository(Protocol):
async def get(self, tenant_id: str, principal_id: str, key: str) -> DeviceRegistrationIdempotencyRecord | None: ...
async def create(self, record: DeviceRegistrationIdempotencyRecord) -> DeviceRegistrationIdempotencyRecord: ...
class NotificationOutbox(Protocol):
async def enqueue(self, notification: NewNotification) -> Notification | None: ...
async def claim_due(self, now: datetime, limit: int, claimant: str, lease_seconds: float, providers: frozenset[PushProvider]) -> list[Notification]: ...
async def record_delivery(self, delivery: NotificationDelivery) -> None: ...
async def list_deliveries_for(self, notification_ids: tuple[UUID, ...]) -> dict[UUID, list[NotificationDelivery]]: ...
async def settle(self, notification_id: UUID, attempt: int, status: NotificationStatus, next_attempt_at: datetime | None) -> bool: ...
async def list(self, principal: Principal, page: Page) -> Page[Notification]: ...
class PushTransport(Protocol):
async def deliver(self, target: PushTarget, message: PushMessage) -> PushOutcome: ...
enqueue returns None when the deduplication key already exists; it is an
INSERT ... ON CONFLICT DO NOTHING, not a read-then-write. Every port has an
in-memory adapter for application and property tests and a PostgreSQL adapter
for production, and every port has a named shared contract exercised by every
registered adapter — the contract-coverage gate the structural checks already
enforce for every Protocol under ports/. The outbox and registry
repositories join the existing units of work so the run and schedule
transactions can write them. The in-memory outbox claims no durability across
processes and production startup refuses notification dispatch without
PostgreSQL.
The name NotificationService is retired rather than given a body. The seam
audit's open question four asked whether it is one port or two
(multi-device-and-surfaces.md:441); the answer is two, and the broadcaster is
a third thing that already exists. The request-idempotency repository is the
fourth port and follows the schedule route's principal-scoped replay pattern.
Persistence¶
Milestone 12 adds four tables:
devices
id UUID PRIMARY KEY
tenant_id TEXT NOT NULL
principal_id TEXT NOT NULL
client_device_id TEXT NOT NULL
name TEXT NOT NULL
kind TEXT NOT NULL
platform TEXT NOT NULL
app_bundle_id TEXT NULL
push_provider TEXT NULL
push_token TEXT NULL
push_environment TEXT NULL
push_token_updated_at TIMESTAMPTZ NULL
push_token_invalidated_at TIMESTAMPTZ NULL
muted_kinds JSONB NOT NULL DEFAULT '[]'
status TEXT NOT NULL
revoked_at TIMESTAMPTZ NULL
last_seen_at TIMESTAMPTZ NOT NULL
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL
UNIQUE (tenant_id, principal_id, client_device_id)
device_registration_idempotency_keys
tenant_id TEXT NOT NULL
principal_id TEXT NOT NULL
key TEXT NOT NULL
request_hash TEXT NOT NULL
response JSONB NOT NULL
created_at TIMESTAMPTZ NOT NULL
PRIMARY KEY (tenant_id, principal_id, key)
notification_outbox
id UUID PRIMARY KEY
tenant_id TEXT NOT NULL
principal_id TEXT NOT NULL
kind TEXT NOT NULL
dedupe_key TEXT NOT NULL UNIQUE
session_id UUID NULL
run_id UUID NULL
approval_id UUID NULL
question_id UUID NULL
schedule_id UUID NULL
occurrence_id UUID NULL
payload JSONB NOT NULL
priority SMALLINT NOT NULL
expires_at TIMESTAMPTZ NULL
status TEXT NOT NULL
attempts INTEGER NOT NULL DEFAULT 0
next_attempt_at TIMESTAMPTZ NOT NULL
claimed_by TEXT NULL
claimed_until TIMESTAMPTZ NULL
created_at TIMESTAMPTZ NOT NULL
settled_at TIMESTAMPTZ NULL
notification_deliveries
id UUID PRIMARY KEY
notification_id UUID NOT NULL REFERENCES notification_outbox(id) ON DELETE RESTRICT
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE RESTRICT
attempt INTEGER NOT NULL
outcome TEXT NOT NULL
provider_reason TEXT NULL
provider_id TEXT NULL
attempted_at TIMESTAMPTZ NOT NULL
UNIQUE (notification_id, device_id, attempt)
The device-registration idempotency row is principal scoped and stores the
canonical request hash plus the exact public DeviceView response. It never
stores the push token: the request hash is computed before persistence and the
response snapshot contains only the existing six-character token fingerprint.
Check constraints enforce that push_provider and push_token are null
together or present together, that push_environment is present exactly when
push_provider is apns, that status = revoked implies revoked_at and a
null token, and that muted_kinds holds
only declared kinds. A partial unique index on (push_provider, push_token)
WHERE push_token IS NOT NULL AND status = 'active' makes one live token belong
to at most one active device; a token re-registered from a fresh installation
moves, and the old row's token is nulled. Indexes support (status,
next_attempt_at) for the dispatcher's due scan and (tenant_id, principal_id,
created_at, id) for the inbox and device listings. All four tables carry the
tenant row-level-security policy the Milestone 11 migration established, the
delivery table through its parent.
The migration follows the Milestone 11 schedule head in the linear chain, and
the startup revision assertion moves with it. The identifier columns on the
outbox are deliberately unconstrained by foreign keys: a notification about a
purged conversation must survive long enough to be settled superseded, and
the offline inbox must still show that it was enqueued. Session erasure, in the
same transaction and before deleting the session graph, deletes the session's
pending outbox rows so that a purged conversation cannot still ring a phone;
settled rows remain under the existing retention rule, including the bounded
schedule identity snapshot authorized by ADR-0091 when present. Process events
and delivery records remain content-free.
Device lifecycle is audited as process events — device.registered,
device.push_token_updated, device.revoked, device.push_token_invalidated,
device.deleted — through the existing process-event repository, because a
device exists outside any session. This is the precedent scheduling.md
set for schedule lifecycle and the second of the two ways out the seam audit
named for the events.session_id NOT NULL constraint
(multi-device-and-surfaces.md:147-173). The audit's counter-argument, that
audit is split across two logs, does not bite: a device has no session, so
nothing about it was ever going to be in the session log. Payloads carry
identifiers and a token fingerprint, never the token.
Public API¶
Milestone 12 adds these routes, mounted on a feature-flagged router exactly as the schedule routes are:
POST /v1/devices device.write
GET /v1/devices device.read
GET /v1/devices/{device_id} device.read
POST /v1/devices/{device_id}/revoke device.write
DELETE /v1/devices/{device_id} device.write
POST /v1/devices/{device_id}/test-notification device.write
GET /v1/notifications notification.read
POST /v1/devices registers or refreshes: the same client_device_id for the
same principal updates the row in place and returns it; Idempotency-Key is
accepted and scoped as the schedule routes scope it. Replaying the same key and
request returns the exact original public response; reusing the key with
different content is a conflict. Device views return a
token fingerprint, never the token. The test-notification route enqueues a
test kind addressed to one device; it is the setup-verification path and the
end-to-end evidence that a real phone received a real push. GET
/v1/notifications is the durable offline inbox: every enqueued notification
for the principal with its status and delivery outcomes, paginated with the
existing opaque cursors. Cross-tenant and cross-principal access returns the
existing not-found envelope.
Three scopes extend the closed vocabulary in the form http-api-and-streaming.md and policy-and-approvals.md state:
They are resource-action pairs governing application routes, in the same
coexistence the corpus already has between the browser.* tool domain and the
browser.profile.* scopes; ADR-0034's objection was to a device. scope
namespace for scopes granted to a device, which this is not. Validation
rejects unknown kinds, a token without an environment, an environment without
a token, an unknown provider, and muted kinds outside the closed set, with
stable reason codes under device.* and notification.*.
Events and audit¶
Device lifecycle events are process events:
device.registered
device.push_token_updated
device.revoked
device.push_token_invalidated
device.deleted
Notification dispatch is audited by the delivery ledger rather than by events; the ledger is queryable through the inbox and carries the provider's reason and identifier per attempt. The session and run event logs are unchanged: the outbox row is a sibling write in the triggering transaction, not a new event type.
Configuration and deployment¶
Notifications are default-off through AGENT_NOTIFICATION_API_ENABLED=0 and
AGENT_NOTIFICATION_DISPATCH_ENABLED=0. The first mounts the routes; the
second enables both the in-transaction enqueue and the dispatcher role.
Production release validation accepts only 0 or 1 and requires the two
flags to change together, as it does for the schedule flags. Enabling dispatch
requires:
- PostgreSQL storage;
PUSH_PROVIDER=apns(the selector formWEB_SEARCH_PROVIDERandBROWSER_PROVIDERuse;disabledis the default);APNS_KEY_FILE,APNS_KEY_ID,APNS_TEAM_ID, andAPNS_TOPIC, validated at startup — the key file must be a regular file readable only by its owner;- a positive claim batch, lease, poll interval, and the retry schedule and expiry defaults in the versioned limits file.
When the dedicated schedule role is enabled, its credential-minimized environment repeats both notification flags and release validation requires them to match the application environment. This lets the schedule materializer and accountant write their transactional outbox rows while the schedule process still receives no APNs key; transport credentials remain exclusive to the notify role.
The dispatcher is a new worker role, agent worker --role notify, built by a
least-privilege builder in the sole composition root modeled on the schedule
worker's, with its own systemd unit and its own environment file. That file
holds the database URL and the four APNs settings and must not hold the API
bearer token, a model-provider key, a tool or web credential, or a
browser-profile credential — the same argument the schedule role's environment
file makes, applied to a process that holds a different secret. The API,
interactive worker, async worker, maintenance worker, and schedule worker never
load the push key. Multiple dispatchers are safe.
The alternative — a sweep on the maintenance worker — was rejected because the maintenance role reads the shared environment file every role reads, and the push key should live only where it is used.
The Apple client¶
ADR-0049's decision that the native client adds no device, notification, or pairing concept is partly superseded, as ADR-0050 superseded its local-history constraint: the client registers a device and renders a push, and it holds no notification state of its own. Concretely:
- an application-delegate adaptor requests notification authorization after a
connection is configured, calls the system registration, and on token
receipt posts to
POST /v1/deviceswith aclient_device_idminted once and stored in the keychain beside the bearer token, the token, the environment derived from the build configuration, the platform, a device name, and the bundle identifier; it re-posts on launch and on token change and revokes on disconnect; - authorization is requested only while the system status is undetermined;
an existing grant proceeds to push registration and an existing denial is
respected without a recurring application error. The equivalent
UNError.notificationsNotAllowedrejection is also nonfatal; unrelated registration failures remain visible. Enabling notifications again is an operating-system setting, followed by client registration; - the
aps-environmententitlement is added beside the existing keychain entitlement, the push capability is enabled on the application identifier, and provisioning profiles are regenerated — owner actions outside the repository; - a push payload's
veetbotdictionary is the closed payload above; a tap selects the session, attaches to the run, and scrolls to the approval or question card through the transcript-restore path ADR-0053 established; - a server that returns not-found for
/v1/devicesis feature-detected the way the client already detects a server that needs upgrading, so an older server keeps working; - the CI Apple job builds for the simulator; the user-interface fixture must not prompt for notification permission, and the new entitlement must not break unsigned simulator builds.
Actionable buttons on the lock screen — approve or deny without opening the app — are out of scope: an approval without re-authentication is a new layer in ADR-0017's stack and needs its own decision.
Tracked metrics¶
Track:
- enqueue count by kind and trigger;
- outbox depth and oldest pending age;
- dispatch attempts, outcomes, and settled status counts;
- delivery latency from enqueue to first delivered attempt, p50, p95, p99;
- token invalidations and device revocations;
- superseded, expired, and failed counts by kind;
- provider-token re-mint count and transport connection failures.
Metrics contain tenant-safe identifiers or aggregates and never payload content, tokens, or the key.
Build sequence¶
- Add the device and notification domain values, the closed payload, and the deduplication-key rules with property tests. M12.
- Add the four-table migration, ORM models, in-memory and PostgreSQL repositories, constraints, RLS, and the four port contracts. M12.
- Add the in-transaction enqueue hook on the terminal writer and the scheduling accountant and materializer, beginning with the every-write crash and trigger-catalog regressions. M12.
- Add the dispatcher, claim lease, staleness checks, retry schedule, and the fake transport; then the APNs adapter against a fake server. M12.
- Add the
notifyworker role, its least-privilege builder, systemd unit, environment file, release validation, and wake-up. M12. - Add the seven HTTP routes, exact scopes, idempotency, cursor pagination, OpenAPI assertions, and the offline inbox. M12.
- Add Apple client registration, token upload, revocation, entitlement, and deep-link handling with reducer and coordinator tests. M12.
- Run the full non-live suite, the PostgreSQL integration and resilience lanes, the Apple lanes, hosted CI, and the required GitHub CodeRabbit loop on one final head. M12.
Hard gates¶
- Device registration is idempotent and principal-scoped. Re-registering
the same
client_device_idfor the same principal updates one row in place; the same identifier under another principal is a distinct row; a cross-principal read returns not found. Registered asgate.device.register_idempotent, case. M12. - One live token belongs to one active device. A token re-registered from
a new installation moves to the new row and the old row's token is nulled;
two active devices never hold the same token. Registered as
gate.device.token_unique, case. M12. - Revocation takes effect before the next claim. Revoking a device clears
its token, appends
device.revoked, and a row claimed by the next dispatch targets no revoked device. Registered asgate.device.revoke_immediate, case. M12. - Device lifecycle is audited once and content-free. Register, token
update, revoke, invalidation, and delete each append exactly one process
event with a stable derivation key and a token fingerprint, never the
token. Registered as
gate.device.lifecycle_audited, case. M12. - The device and notification schema encodes its trust boundaries.
Metadata inspection proves primary and foreign keys, the partial unique
token index, the provider-specific push-field constraints, the dispatch
and listing indexes, and row-level security on all four tables. Registered as
gate.device.persistence_schema, structural. M12. - Device and notification persistence is principal isolated. Row-level
security and repository predicates prevent cross-tenant and
cross-principal reads and mutations of devices, outbox rows, and
deliveries. Registered as
gate.device.persistence_isolated, case. M12. - Enqueue is atomic with its trigger. Inject a crash after every write
in the terminal writer's finalize, the scheduling accountant, and the
materializer. No committed state holds an outbox row without its event or
a partially written row; the only committed event without an outbox row is
one whose enqueue failed inside its savepoint, and that failure is audited
as a content-free process event in the same transaction and never changes
the run's terminal state. Registered as
gate.notify.enqueue_atomic, case. M12. - Exactly the five transitions enqueue, and one named producer besides.
Approval requested, waiting for user, run failed, scheduled run accounted,
and scheduled occurrence skipped each enqueue one row; interactive
run.completed,run.cancelled, tool lifecycle events, message deltas, and surface replies enqueue nothing; the Milestone 15 health check is the only non-transition producer and enqueues only the two ops kinds. Registered asgate.notify.trigger_catalog, case. M12.
Milestone 24 widens the catalog to six with the pending device invocation;
the sixth trigger's behavior is gated by that milestone (ADR-0081).
9. Repeated triggers deduplicate. Generated repeats — a retry after an
unknown commit, a repeated hook invocation, two processes recording one
transition — produce exactly one outbox row per deduplication key.
Registered as gate.notify.dedupe, property. M12.
10. Payloads are content-free except for authorized schedule identity. A corpus of approvals, questions, failures,
and schedule instructions carrying secrets, arguments, message text, and
reasoning yields payloads with only the closed key set and a templated
title; only the two schedule kinds may also carry ADR-0091's bounded,
credential-filtered pinned title and nominal time. Instruction and result
previews remain forbidden, and the APNs tap dictionary stays unchanged.
Registered as
gate.notify.content_free, corpus. M12.
11. Concurrent dispatch is safe and replay is bounded. Two dispatchers
racing on one pending row deliver it to each target once under the claim
lease and write one delivery row per attempt; a crash between a transport
accept and the ledger write re-sends on the next claim under the same
collapse identifier and records a further attempt — at-least-once, never
a lost or unrecorded send. Registered as gate.notify.dispatch_once,
case. M12.
12. Retry is bounded and expiry is honoured. Transient outcomes follow the
declared backoff schedule and stop at the ceiling as failed; a row past
expires_at is settled expired and never sent. Registered as
gate.notify.retry_bounded, case. M12.
13. Stale notifications are suppressed. An approval resolved or expired
before dispatch, a question already answered, a run no longer waiting, or
a deleted session settles superseded with no transport call, and session
erasure deletes pending rows in the same transaction. Registered as
gate.notify.stale_suppressed, case. M12.
14. An unregistered token is invalidated once. Unregistered and
BadDeviceToken invalidate the device's token, append
device.push_token_invalidated, and remove the device from the next
enqueue's targets; a 5xx or 429 does neither. Registered as
gate.notify.token_revoked_on_410, case. M12.
15. The APNs adapter authenticates and addresses correctly. Against a fake
server the adapter signs ES256 provider tokens from the configured key
file, re-mints inside the window, sends topic, push type, priority,
collapse identifier, and expiration, selects the host from the device's
environment, and never logs the key or a token. Registered as
gate.notify.apns_auth, case. M12.
16. Every notification port has executable adapter contracts. The device
registry, device-registration idempotency repository, outbox, and push
transport each have a named shared contract exercised by every registered
adapter, including the fake transport. Registered as
gate.notify.port_contracts, structural.
M12.
17. A client can read every notification while offline. With no transport
configured, enqueue every kind; the inbox later returns each row with its
status and delivery outcomes through stable pagination. Registered as
gate.notify.offline_inbox, case. M12.
18. Notifications are default-off and credential-confined. With both flags
off, no route mounts, no outbox row is written, and no dispatcher builds;
release validation requires the flags to change together; the dispatcher's
environment file carries no bearer, provider, tool, or browser credential,
and no other role loads the push key. Registered as
gate.notify.default_off, case. M12.
19. Notifications migrate cleanly from an empty database. The migration
chain reaches head and exactly matches the declared SQLAlchemy metadata.
Registered as gate.notify.migration_clean, case. M12.
20. The notification migration is reversible at its boundary. Upgrade,
downgrade, and re-upgrade from the immediate predecessor leave a valid
schema. Registered as gate.notify.migration_stepwise, case. M12.
Open questions¶
- Per-device
muted_kindsis the whole preference model. A per-principal preference table with a device override would be more expressive; nothing asks for it yet, and adding it later is additive. - Email and webhook transports are intentionally outside this milestone. The
PushTransportport is the seam; each needs its own destination verification and outcome vocabulary, and the roadmap holds them as B4. - Interactive
run.completedis not a notification kind. If a principal wants it, the kind is added to the closed set and defaults to muted. capabilitiesandgranted_scopesfrom Section 29.6 wait for the device channel. Landing them now as empty columns would be speculative. Milestone 24 lands thecapabilitieshalf (ADR-0081).- Actionable lock-screen approval is a new authorization layer, not a notification feature, and needs its own ADR.
Milestone 27 call results¶
The owner-approved bland-calling.md adds the closed
call_finished kind. Only the calling reconciliation service produces it,
in the transaction that accepts a terminal, number-verified call record.
The fixed title is New call result; the only subject identifier is call_id.
No caller number, name, transcript, summary, run identity, or status enters the
push payload. Its deterministic owner/account/call deduplication key yields one
outbox entry. The notification expires with the call's thirty-day retention.
If that deadline passes while the receipt is being committed, the call and
receipt still persist but no expired notification is enqueued. Creation and
next-attempt timestamps share one clock reading before the retention deadline.
AGENT_CALL_NOTIFICATIONS_ENABLED defaults to false and requires the notification
API. Ordinary device mute preferences and authenticated owner inbox reads apply.
The Apple client opens an authenticated GET /v1/calls/{call_id} detail sheet,
including erased-record tombstones. This is independent of run deep links and
does not impersonate a completed owner run. It stores call content only in
presentation memory and clears it on logout or connection replacement. Only the
latest call-result request may update the sheet or present an error; dismissal
also invalidates a pending read. The dedicated
notification carries no authority to place calls or to act on caller claims.
Attention and delivered-notification reconciliation¶
The owner's 2026-09-30 extension, ADR-0143,
adds a thirty-second initial dispatch grace period for approval_requested,
question_asked, schedule_run_finished and schedule_occurrence_skipped.
Enqueue remains atomic with its triggering event. Expiry and retry deadlines
retain their meaning; device wake-ups and operational alerts are not delayed.
Staleness is checked again before each target send.
POST /v1/notifications/sync, under the new exact notification.write scope
and the existing API flag, accepts delivered_notification_ids (at most 200),
seen_run_ids (at most 100) and query_run_ids (at most 100, see scheduled
report indicators below), all UUID arrays, with no extra fields.
It atomically validates ownership and terminal status for every seen run,
stores idempotent principal-scoped run receipts, and returns
obsolete_notification_ids: only requested, owned notification IDs whose
subject is stale, expired, or whose terminal result has been seen.
An invalid or foreign run fails the entire request without partial receipts;
foreign and unknown notification IDs are ignored. Repeat requests are safe.
Receipts use a separate notification_run_receipts table keyed by tenant,
principal and run, with tenant RLS and deletion cascading from the run.
Dispatch checks the receipt even if it predates the notification's enqueue.
Receipts suppress run_failed and schedule_run_finished, never unresolved
approvals, questions, device actions or unrelated alerts.
The Apple client suppresses banner, list and sound only for a valid push matching its active, visible and loaded Chat conversation. It acknowledges only terminal results actually loaded there, reconciles on foreground entry, relevant stream changes and selection, and polls every ten seconds while active. A covered transcript or another mode does not count as viewing it. Each sync maps obsolete notification IDs back to the operating system's request identifiers, removing only those delivered entries. Connection replacement invalidates in-flight cleanup and acknowledgements. An older server or a failed request leaves alerts untouched. Sleeping clients reconcile when they next become active; remote recall is not guaranteed.
Scheduled report indicators¶
The owner's 2026-10-01 amendment to ADR-0143 reuses these receipts for a quiet
new-report dot. Sync additionally accepts query_run_ids (at most 100 UUIDs)
and returns unread_run_ids: only requested, owned COMPLETED runs with no
receipt, evaluated after any acknowledgements in the same request. Queries
never acknowledge a result; unknown and foreign IDs are omitted identically.
No notification need have been enqueued or delivered for a report to be unread.
Apple clients query the latest runs of scheduled conversations in their history, in bounded batches, and refresh the session index during the active ten-second poll so new occurrences appear. A six-point accent dot marks an unread scheduled row and any group containing one, including a collapsed group. VoiceOver names it “New report.” Expanding a group does not acknowledge its reports. Only the existing visible, successfully loaded terminal-transcript path does that. A new result has its own run ID and therefore its own receipt; reading an older report cannot clear it. Running and failed attempts show no new-report dot.
The indicator is a presentation cache of server state, cleared on connection replacement and logout. Failed requests retain the last confirmed state; late responses from an old connection or visibility context cannot change it. An older sync server that rejects the query field is retried without it so existing acknowledgement and alert cleanup still work; a response without the new field does not invent unread state. Active devices clear read dots on their next sync, normally within ten seconds; sleeping devices do so on foregrounding.