# 0046 — Notif scope, supersession, and quiet delivery

Date: 2026-07-21
Status: accepted
Amends: ADR-0007 (subnet notification primitive)

<!-- REQ set minted at NOTIF-TRUTH triage 2026-07-21 (doyle): REQ-NOTIF-{SCOPE,COALESCE,SEAM-DISMISS,QUIET-DELIVERY,TTL,MIGRATE}; dispatch = docs/NOTIF-TRUTH-DISPATCH.md -->

## Context

ADR-0007 shipped the notif as a **per-subnet, replicated, persist-until-dismissed**
primitive, and made the self-update prompt its flagship producer. The field taught
us what that combination does at steady state:

- **Duplicate flood.** An update stage is a fact about ONE node's binary, but every
  node in a subnet stages the same release, mints its own row, and replicates it
  subnet-wide — N nodes ⇒ N "update available" copies at every endpoint.
- **Eternal staleness.** Rows are immutable and dismissal is manual. "Update
  v0.38.0 available" survives v0.39.0 being installed, forever. Successive releases
  stack rows; nobody runs `spt notif dismiss`.
- **PTY interruption.** Notif delivery rode the default message window (live
  TCP first), so notifs land mid-anything in a live adapter session's PTY — for
  content that is by definition non-urgent.

The operator ruled the redesign on 2026-07-21 (glossary updated the same day —
CONTEXT.md "Subnet notifications" carries the canonical terms; this ADR carries
the why and the rejected shapes).

## Decisions

### 1. Scope is a per-row, producer-chosen attribute: `node` | `subnet`

<!-- [doc->REQ-NOTIF-SCOPE] -->

A notif about one node's state (*update available*, *consent needed*, *rollback*)
is **node-scoped**: it lives and dies on that node and never enters a replication
feed. A notif about the subnet (*node paired*, *pairing request*, agent-issued
notifs) stays **subnet-scoped** with the full ADR-0007 machinery — replication,
cross-node dismiss, semilattice join.

**Rejected:** making the whole primitive per-node. It would orphan cross-node
dismiss — the one ADR-0007 property worth its replication cost — for the kinds
that are genuinely about the subnet.

### 2. Supersession: a required-namespaced coalesce key, latest-wins

<!-- [doc->REQ-NOTIF-COALESCE] -->

A producer may stamp a row with a **coalesce key**. Producing a new row with the
same (scope-target, kind, key) auto-dismisses the prior rows. Keys are
**required** to take the form `<owner>:<key>` (e.g. `spt-core:update-staged`);
the produce front door rejects un-namespaced keys. Convention-only namespacing
decays; the check is one split at one seam, and there are no existing keys to
grandfather.

### 3. Staleness is dismissed at the seam that knows, never evaluated at surface

<!-- [doc->REQ-NOTIF-SEAM-DISMISS] -->

The state transition that makes a notif irrelevant explicitly dismisses it by
coalesce key: a successful `update apply` dismisses `spt-core:update-staged`; the
update worker's next check, seeing running ≥ staged, dismisses it too (covers
out-of-band installs); a later successful update dismisses a rollback row. The
primitive stores rows and latches — **no relevance predicates, nothing evaluated
at surface time**.

**Rejected:** an `expires_at`/predicate field evaluated by the surfacing engine.
It drags every producer's domain logic into the one engine, and it cannot work
for subnet-scoped rows whose fact lives on another node.

### 4. Delivery: the notify kind rides `active_only`, unconditionally

<!-- [doc->REQ-NOTIF-QUIET-DELIVERY] -->

Notif envelopes use the `active_only` window — spool-only, never live TCP, never
a wake — for **every** producer, including rollback. A notif's contract
(dismissable, resurfacing, persists until dismissed) is what "loud" means:
loudness is resurface-until-dismissed **persistence**, not interruption. Boundary
resurface (clear/compact/new-session/wake) and the adapter's safe-point drain are
the surfacing paths.

**Rejected:** keeping rollback on the interrupting window as a "loud failure
alert" exception. A per-kind exception table is exactly how the delivery-path
duplication ADR-0007 killed grows back; the rollback row's persistence already
guarantees it is seen at the next boundary.

### 5. TTL: producer-optional, expiry ignores seen-state

<!-- [doc->REQ-NOTIF-TTL] -->

For informational kinds with no dismissal seam (*node-paired*, agent-issued
notifs): an optional expiry after which the row is auto-dismissed instead of
surfaced — a timestamp compare, not a predicate. **Expiry does not care whether
the row was ever seen**: TTL means "stale after this," and a producer that cannot
accept silent expiry must not set one. No global default.

**Rejected:** max-resurface counts — they punish multi-endpoint users (the count
is consumed by endpoints the user ignores), and N is arbitrary where a duration
is not.

### 6. Field migration: one-shot targeted auto-dismiss

<!-- [doc->REQ-NOTIF-MIGRATE] -->

On first run, the new binary auto-dismisses existing rows with
`from_id = "spt-update"` (kinds `consent`/`rollback`) — the known-stale class and
the observed flood. Agent/psyche rows are untouched (their relevance is not ours
to judge). The dismissals replicate, so cleanup propagates even to
not-yet-upgraded peers; the update worker re-produces any genuinely-current
update notif within one check cadence, so aggressive cleanup costs nothing.

## Compatibility

New row fields (scope, coalesce key, TTL) are additive serde-defaulted fields on
the replicated record — the `host_binaries` pattern: omitted-serialized, old
peers parse clean and ignore them. Node-scoped rows never enter feeds, so old
peers never see them at all. The semilattice is unchanged: supersession and TTL
express themselves through the existing `dismissed` one-way latch.

## Consequences

- Node-scoped first-fire targets the most-recently-active endpoint **on that
  node** (scope determines the candidate set); subnet-scoped first-fire is
  unchanged.
- The seen-per-endpoint gate and the ~1h cross-endpoint suppression window are
  unchanged — the flood was produced upstream of them, and they were never the
  defect.
- A future producer wanting an interrupting alert must argue for a new kind with
  a new contract in its own ADR — it does not get an exception inside this one.

## Amendment 1 (2026-07-22) — delivery-time row validity: the spool is a surfacing hint, never a second source of truth

<!-- [doc->REQ-NOTIF-DRAIN-ROW-VALIDITY] -->

**Field regression (operator report, perri's node, 2026-07-22 — the day after
this ADR shipped):** a node already updated to v0.40.0 received the v0.40.0
"update available" notice — twice. The row machinery above worked exactly as
designed (hertz field-verified: post-apply rows dismissed, active=0). The lie
lived one layer down.

**Root:** decision 4 makes every surface an `active_only` SPOOL write — a
durable per-endpoint copy drained at the adapter's next safe point. Every
lifecycle mechanism in this ADR (apply-seam dismissal, coalesce supersession,
TTL, the one-shot migration) operates on notif-store ROWS only; none touches
endpoint spools. So any surface that lands while the endpoint is busy or
offline — the NORMAL case for a working agent — becomes a detached snapshot
that no later dismissal can recall. It delivers whenever the endpoint next
drains, including after the update it advertises has been applied. Each
qualifying surface event (first-fire, boundary resurface, migration re-show,
plus pre-0.40.0 legacy mints) adds another copy. "Notices self-clear once
resolved" was true at the row layer and false at the delivery layer the
operator actually sees.

**Decision:** a notify envelope is validated against the notif store AT
DELIVERY TIME. The safe-point drain (`api poll` deferred presentation) delivers
a notify-kind envelope ONLY if its `notif_id` still resolves to an ACTIVE
(undismissed) row; dismissed, superseded, TTL-expired, or unknown → the copy is
dropped silently. Multiple spooled copies of one `notif_id` in a drain dedupe
to one delivery. The spool copy becomes what it always should have been: a
surfacing hint, with the row as the single source of notification truth at the
moment of delivery rather than the moment of surfacing.

- Pre-0.40.0 legacy plain-message copies carry no `notif_id` and cannot be
  validated; they self-clear by their one drain and are not worth machinery.
- Non-notify spool content is untouched — ordinary messages are not revocable
  and MUST deliver.

**Rejected:** recall-on-dismiss (a dismissal-time sweep of every endpoint spool
— races the drain, spans every perch dir, and still misses copies in flight);
making notify delivery live-only (quiet delivery's whole mechanism IS the
spool; removing it removes the safe-point contract).

## Amendment 2 (2026-08-17) — an addressed notif: the recipient is a field, not the issuer's slot

<!-- [doc->REQ-KNOCK-NOTIFY-INSTIGATOR] -->

**Field regression (releases#149 → #169):** lia minted an invite code, someone
redeemed it, and lia was never told. The notification was produced, on the right
node, and could not surface. The three consented knock courtesies — an approval
notifies the knocker, a redemption notifies the minter, a counter-knock's
arrival notifies the original knocker — were each written the same way at every
producing site (local approve, cross-node approve, local redeem, cross-node
redeem), and that shape had three independent faults:

- **the recipient rode `from_id`**, because the row carries no addressee. The
  envelope then says the recipient issued the notice;
- **the subnet was `""`**, which is not a member subnet. BOTH surfacing paths
  walk member subnets only — `resurface_at_boundary` iterates `policy.subnets`
  (built from real memberships) and first-fire admits candidates through
  `policy.visible(id, subnet)`, false for every endpoint at `""` — so the row
  was unreachable by construction, not by timing;
- **they called `NotifStore::produce_scoped` directly**, bypassing the daemon's
  produce-and-fire front door, so nothing was delivered live either.

**Root:** the primitive as ratified has no way to express *an addressed
courtesy*. Delivery is by presence-MRA — the most recently active endpoint that
is visible in the subnet — which is right for "a fact the user should see" and
wrong for "the answer YOU asked for". On a node hosting fourteen endpoints, a
correctly-subnet'd row still lands on a bystander.

**Decision:** `NotifRow` grows an optional `to_id` — the endpoint the notif is
*for*. Additive serde-defaulted (the `SubscribeReq.code` precedent): an N-1 peer
parses an addressed row clean and reads it as unaddressed. Both surfacing paths
honor it:

- **first-fire targets the addressee**, and the address BEATS most-recently-
  active. There is deliberately no fallback to the MRA winner — an addressee who
  is not a live visible surface yields `NoTarget`, and the row waits for that
  endpoint's own next boundary. Falling back would deliver the courtesy to
  precisely the bystander the address exists to exclude;
- **a boundary resurface at any other endpoint skips the row**, with its own
  outcome rather than one of the suppressions: "not you" and "not now" are
  different facts, and reporting the first as the second would say the row could
  have surfaced there.

All producers move onto the produce-and-fire front door with a REAL member
subnet — the correlation subnet the exchange rode (the member subnet whose
registry has seen the peer's node) where one is derivable, otherwise the first
member subnet **the addressee is visible in**. Visibility is the condition both
surfacing paths impose, so choosing a subnet the recipient is hidden in would
reproduce the unreachable row under a nicer name.

The approval courtesy is also **lifted ahead of the mutual arm**: it sat after
the `ReceiptOutcome::Consumed` arm, which only a `--mutual` knocker reaches, so a
plain knock's receipt returned `Ignored` and the ordinary knocker was
structurally un-notifiable. It is now produced on every approval, proven by the
outbound knock row this node itself sent (correlation id, answering endpoint,
handshake-proven origin node) rather than by the pre-authorization — so a
receipt matching none of our knocks notifies nobody, and a guessed correlation
id cannot become a spray channel. A denial still notifies no one; the ratified
set does not extend to it.

- Scope, supersession, TTL and the semilattice are untouched: an addressee
  changes who surfaces a row, never what it is. The knock courtesies stay
  node-scoped, and where their arrival is REPLAYABLE they carry a key naming the
  exchange (`spt-core:knock-answered:<correlation-id>`) rather than the kind: two
  approvals are two facts and both must stand, but two receipts for one knock are
  one fact said twice, and decision 2's supersession is what makes the replay
  dismiss its own earlier row instead of nagging.
- The `seen` set stays per-endpoint and subnet-wide; an addressed row simply
  never gets seen by anyone but its addressee.

**Rejected:** giving the three rows a real subnet and stopping there — the
smallest diff, and not a fix: delivery stays presence-MRA, so the notice lands
on a bystander on any multi-endpoint node. It is a louder miss. **Rejected:**
re-homing the three onto the message spool (addressed and cross-node by
construction) — those three were deliberately put on the notif spool AGAINST
inbox delivery, and this would silently retire that decision while splitting
knock courtesies from every other notice.
