AGENTS.md@assistant/src/notifications · git:20260903.4dc5da5 · 2026-09-03 · sha256 30c2f743b0f82787
AGENTS.md@assistant/src/notifications git:20260903.4dc5da5A
Immutable. This exact content is served forever at /api/v1/blob/30c2f743b0f82787.
# Notification Pipeline All notification producers **MUST** go through `emitNotificationSignal()` in `notifications/emit-signal.ts`. Do not bypass the pipeline by broadcasting events directly -- the pipeline handles event persistence, deduplication, decision routing, and delivery audit. Guardian-request cards (approvals, questions) ride this pipeline end to end -- the full lifecycle map is [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md). Card actions (`actions[]`) are built **once, centrally** in the broadcaster's context resolvers (`resolveApprovalContext` / `resolveQuestionContext`); channel adapters render only. Adding buttons for a new request kind = a broadcaster context branch, never adapter parsing. When a notification flow creates a server-side conversation (e.g. guardian question conversations, task run conversations), the conversation and initial message **MUST** be persisted before the conversation-created event is emitted. This ensures the macOS/iOS client can immediately fetch the conversation contents when it receives the event. Guardian-request producers (access requests, tool approvals, tool-grant escalations, voice questions, trusted-contact confirmations) **MUST** record approval-card deliveries through `recordApprovalCardDelivery` / `recordGuardianRequestDeliveries` in `guardian-delivery-recorder.ts` -- never by calling the gateway client's `createGuardianRequestDelivery` directly. That sink is the single place the card-to-request addressing convention lives (conversation id for the in-app card; chat + channel-native `ts` for channel cards), so the path that writes a delivery row cannot drift from the paths that read it back to withdraw a card -- the drift that previously fanned this logic into four divergent copies. (Tests may seed delivery rows directly for fixtures.) Approval-card **source references** (the link back to the channel message that triggered a request) resolve only through `resolveApprovalSourceReference()` in `runtime/approval-source-link.ts` -- producers spread the result into the `guardian.question` context payload and never hand-build links. Channel-format knowledge (id shapes, permalinks, mrkdwn) lives only in `messaging/providers/<channel>/` and `notifications/adapters/<channel>`; the four-layer ownership map is documented at the top of `approval-source-link.ts`. Exception: access-request cards predate the registry and still derive their Slack permalink from payload `messageTs` in `access-request-copy.ts` -- converge them onto the registry rather than adding a third resolution path. A notification delivered to an external channel (Slack, Telegram, Discord) becomes a conversation row **only after the adapter acknowledges the send**. `pairDeliveryWithConversation` resolves the chat's home conversation before the send for `continue_existing_conversation` channels (`resolveProactiveHomeConversation`: the chat's thread-less inbound conversation, else its `notification:`-namespace conversation) and writes nothing there; on adapter success the broadcaster calls `recordDeliveredChannelPost`, which writes the sent text with the neutral `providerMeta` envelope and `automated: true`, runs the shared post-send reconciliation (`runtime/outbound-post-reconciliation.ts`) so the acknowledged id lands on the envelope and in `channel_outbound_posts`, and marks a resident conversation stale. The delivery audit names that row in `canonical_message_id`; `message_id` keeps its meaning (provider id for a channel delivery, row id for a vellum delivery). A failed or pending delivery therefore has no conversation row, so nothing the channel never accepted can read as the assistant's words. Vellum and passive deliveries keep their pre-send pairing row, which the feed card's deep link and rewritable row depend on. Editing a notification rewrites the canonical row after the channel update succeeds and re-indexes it; deleting the channel post resolves to the row through the index. Guardian-request card rows are **not conversation history**. Only the vellum delivery persists a message row (`pairDeliveryWithConversation` pins it to the conversation the request is _about_, via `buildVellumCardAffinity`); channel guardian cards are delivery projections and pair no conversation at all, with the gateway delivery row (chat id + channel-native message id) as their only persisted envelope. `isGuardianCardRow` in `approval-card-data.ts` is the single definition of which rows are guardian cards (including rows channel deliveries paired before the projection-only policy), derived from the card's own `ui_surface` id rather than a stored marker so old rows need no backfill. **Both** history assemblers must consult it -- `Conversation.loadFromDb` and `loadSlackChronologicalContext`, which re-reads rows rather than using `this.messages` -- or the unfiltered one replays the card between a parked turn's `tool_use` and its `tool_result` and history repair destroys the real result. Surface state is exempt on purpose: the card's buttons must still route after a restart. Full rationale in [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md). Reply mechanics (`Reference code: X`, `Reply "X approve"`, `Reply "X trust"`, the invite-flow directive) live in **one** place: the `plainTextFallback` the broadcaster builds beside a card's actions. Composed copy never carries them, the decision engine strips a model's echo of them on every channel (`stripReplyMechanics`), and a transport appends them only when it sends text without buttons (`appendPlainTextFallback`). Never enforce them into copy for a channel, and never exempt a surface from a strip. Mechanics are instructions for a decision the card's buttons already offer; a directive beside the button for the same action is the noise this rule exists to prevent. An instruction for an action no surface has a button for is context, not mechanics: the access-request invite-flow directive stays in the context text, is ensured into model-composed copy (`ensureAccessRequestInviteDirectiveInCopy`), and sits in the Slack card's own block, until an invite action exists, at which point it moves to the fallback like the rest.