# M4-D8 — subnet notification primitive (JIT plan)

**Status:** COMPLETE 2026-06-04 — D8-1 `9a6548f` (notif store + semilattice
merge) + D8-2 `f612b3f` (replication record + gated feed drivers + two-broker
E2E, FAULT-MATRIX rows 26–29) + D8-3 `4548410` (first-fire + resurface engine
+ consent/psyche producers + boundary wiring, **REQ-NOTIF-1 activated
`[impl, unit]`**) + D8-4 `126d843` (`spt notify` / `spt notif list|dismiss` +
`[session.notif]` manifest seam, **REQ-NOTIF-2 activated `[doc, impl,
unit]`**), all CI-green FINAL. Acceptance met: a notif first-fires to the
most-recently-active visible endpoint; resurfaces at `api boundary` /
new-session gated seen-per-endpoint + ~1h cross-endpoint suppression;
dismiss replicates subnet-wide over the wire (two-broker E2E); `spt notify`
produces + fires through the same engine. The D7.5 psyrelay notify leg and
the REQ-UPD-4 consent prompt are now notif producers (one primitive). Wake
edge, production feed-pump loop, and `int` legs ride D9.

## Goal

A **notif** becomes a first-class kind, distinct from agent→agent messages
(ADR-0007): user-directed, dismissable, resurfacing. The per-subnet spool
replicates across the subnet's nodes; **dismiss replicates subnet-wide**;
first-fire reaches the user's most-recently-active endpoint in-subnet;
undismissed notifs resurface at reported boundaries gated by seen-per-endpoint
plus a cross-endpoint suppression timeout. The self-update prompt, the consent
escalation, and the D7.5 Psyche `notify` leg collapse onto this one primitive
(producers, not three ad-hoc paths). `spt notify` lets any agent issue one;
the `notif_command` manifest seam renders it endpoint-native. Acceptance
(M4-PLAN §D8): a notif fires to the active endpoint, resurfaces at a boundary,
dismiss replicates subnet-wide; `spt notify` reaches the user.

Reqs: **REQ-NOTIF-1** (activate at D8-3, the slice completing the primitive),
**REQ-NOTIF-2** (activate at D8-4, the CLI + manifest seam).

## Shape (locked by what exists — no new design forks)

- **Merge model = join-semilattice, deliberately NOT the D6b version vector.**
  Every piece of replicated notif state is monotone: the row itself (insert
  idempotent on a unique `notif_id`), `dismissed` (a one-way `false→true`
  latch — merge = OR), `seen` (a set of endpoint ids — merge = union),
  `last_surfaced_ms` (merge = max). Concurrent writes commute; there is no
  conflict to surface and no causality to track. The D6b vector machinery
  exists for divergent *text* snapshots — notif state needs none of it, and
  pretending otherwise would re-import reconcile complexity ADR-0007 never
  asked for. Wall-clock appears only inside `last_surfaced_ms` (a suppression
  heuristic, not an ordering authority — a skewed clock can at worst
  over/under-suppress one resurface window, never lose state).
- **`notif_id = <node-hex>:<epoch>`** stamped from the node's existing
  monotonic `spt_store::epoch::EpochSource` — the same per-node epoch source
  D3b leases and D6b precedence ride (one source, three consumers). Unique
  across the subnet, replicable, and the idempotent insert key (a replayed
  feed re-presents the row; the merge no-ops).
- **Storage = node-level SQLite, subnet-tagged rows** —
  `<spt_home>/identity/notifs.db` via the shared `db::tune_connection`
  pragmas (WAL + busy_timeout, the spool.db posture). Node-level, not a perch
  property: a notif targets the *user in a subnet*, not a perch; perches come
  and go, the spool replicates across nodes. One db, `subnet` column; the
  replication feed filters by subnet membership.
- **Replication = the D4d registry posture (push-feed full-row records), not
  the D6c pull.** `NotifRecord` carries the full semilattice row state as one
  NDJSON tagged record (`net/ndjson.rs` decoder; unknown kind skipped);
  apply = merge (idempotent — replay/loss/reorder all safe by construction,
  same reasoning the epoch lease gives registry updates). Records carry **no
  origin field**: the apply gate's subject is the handshake-proven
  `remote_id_hex` from the broker's stream table (KNOWN-HAZARDS 7.5,
  REQ-HAZARD-WAN-ORIGIN-AUTH — the 7.5 mapping already names D8 notifs as a
  wire-inbound consumer). Inbound rows for a subnet this node is not a member
  of, or from an origin not trusted in that subnet's trust store, are
  **dropped fail-closed** (the `replicate.rs`/`SyncPolicy` posture).
- **Delivery = the existing msg substrate, daemon-authored typed envelope.**
  Surfacing a notif to an endpoint composes
  `<EVENT type="notify" from="<issuer>" notif_id="<id>" subnet="<s>">body</EVENT>`
  (`compose_typed_event`, 4.1 codec; typed envelopes pass through delivery
  verbatim) and delivers via `spt_msg::deliver::send` (TCP-first,
  spool-fallback). Nothing new on the wire below the record layer.
- **First-fire target = the consent resolver, generalized.**
  `consent::most_recently_active` already resolves the most-recently-active
  live Self perch over `last_active_ms`. D8b extracts the subnet-scoped form:
  filter to endpoints **visible in the notif's subnet**
  (`VisibilityStore::hidden` — hidden ⟹ not a surface, ADR-0006 §6) before
  the recency pick. `consent.rs` keeps its public surface; the resolver core
  moves to the notif engine and consent's becomes the degenerate
  (unscoped) call.
- **Resurface = engine + boundary wiring, wake edge deferred.** One engine
  function `resurface_at_boundary(endpoint, boundary)` filters: undismissed ∧
  endpoint ∉ `seen` ∧ `now − last_surfaced_ms > suppression` (default
  3600 000 ms, a `notif.rs` const with a config seam) ∧ endpoint visible in
  the notif's subnet → deliver + mark seen + bump `last_surfaced_ms`. Wired
  now at the two boundaries that already execute on-node code:
  `api boundary clear|compact` (`reporting::cmd_boundary`) and
  new-session-start (the `api startup` path). The **wake edge**
  (state→active) has no edge-detection hook today (`touch_active` stamps
  every pulse tick, it doesn't observe transitions) — it rides the D9
  production trigger loops with the D5/D6/D7/D7.5 loops. Seen-per-endpoint
  and `last_surfaced_ms` replicate (union/max), so the ~1h suppression holds
  **cross-endpoint and cross-node**, not just locally — the "notif can't
  bounce between endpoints" guarantee is subnet-wide state, not node luck.
- **Producers are thin (D8c).** A producer = one call:
  `produce(kind, from, subnet, body) → first_fire(...)`. The update consent
  gate's `NeedsConsent` output becomes a `consent-needed` /
  `update-available` notif (consent.rs's module docs already name ADR-0007 as
  the prompt's delivery vehicle — this is the planned landing, not a
  refactor of working delivery; no prompt UX exists today). The D7.5
  `psyrelay` notify leg swaps its interim own-Self-perch `send` for
  `produce + first_fire` — `RelayOutcome::Notified` keeps its shape (the
  sanitize boundary is untouched; only the delivery surface behind it moves).
- **When feeds pump is NOT this task.** The production replication trigger
  loop (when a node emits/receives notif feeds) rides D9 with every other
  loop. D8 ships the drivers: emit-side record authoring, apply-side
  merge, E2E over loopback broker streams — same deferral as D5/D6/D7.

## Slices (each CI-green before the next)

1. **D8-1 — notif store + semilattice merge (D8a core).**
   - `spt-store/src/notif.rs`: `NotifRow { notif_id, subnet, kind, from,
     body, created_ms, dismissed, seen: BTreeSet<String>,
     last_surfaced_ms }` + `NotifStore` over `identity/notifs.db`:
     - `produce(...) -> NotifRow` (mints `notif_id` from `EpochSource`),
     - `list(subnet)` / `undismissed(subnet)` / `get(notif_id)`,
     - `dismiss(notif_id)` (latch), `mark_seen(notif_id, endpoint)` (union),
       `touch_surfaced(notif_id, now_ms)` (max),
     - `merge_row(incoming) -> MergeOutcome` — the semilattice join:
       insert-if-new, OR/union/max on the state fields, `Unchanged` when the
       join is a no-op (the feed's idempotency signal).
   - Unit tests: produce/list/dismiss round-trip; merge is idempotent +
     commutative (apply A∘B == B∘A, replay no-ops); dismiss latch never
     un-dismisses (a stale row with `dismissed=false` cannot clobber `true`);
     seen union + last_surfaced max; reopen durability; `notif_id`
     uniqueness across produce calls. Tag `[impl/unit->REQ-NOTIF-1]` on the
     real evidence (req stays dormant until D8-3 activation — tags land with
     the code they describe).
2. **D8-2 — subnet replication record + drivers + E2E (D8a wire).**
   - `spt-net/src/net/notif.rs`: `NotifRecord` (the full-row NDJSON record,
     `kind`-tagged; `pub type NotifDecoder = NdjsonDecoder<NotifRecord>`).
     Unit: round-trip, chunk-split reassembly, unknown-kind skip, forged
     `origin`/`node` field inert (the 7.5 negative, same as sync.rs).
   - `spt-daemon/src/notifsync.rs`: `emit_notif_feed` (author records from
     local rows for one subnet) + `apply_notif_feed(origin_hex, records,
     policy)` — gate per record: origin trusted in the record's subnet
     (TrustStore) ∧ this node a member of that subnet, else **drop
     fail-closed**; admitted records → `NotifStore::merge_row`.
   - E2E (loopback two-broker, the D7-2 shape): produce on A → feed → B holds
     the row; dismiss on B → feed back → A's row dismissed (the acceptance
     "dismiss replicates subnet-wide"); replayed feed no-ops; untrusted
     origin's records dropped with zero rows written.
   - FAULT-MATRIX rows: replayed notif feed (idempotent join); concurrent
     dismiss + surface on two nodes (commutes — both survive the join);
     untrusted-origin notif record (dropped, fail-closed); non-member subnet
     record (dropped, never materialized).
3. **D8-3 — first-fire + resurface engine + producers (D8b + D8c).**
   - `spt-daemon/src/notif.rs` (engine):
     - `most_recently_active_in(subnet, owlery, vis)` — the consent resolver
       generalized: live Self perches, **visible in `subnet`** only, greatest
       `last_active_ms`; `consent::most_recently_active` delegates to the
       unscoped form (public surface unchanged).
     - `first_fire(store, row, owlery)` — resolve target → deliver the typed
       notify envelope (`compose_typed_event` + `spt_msg::deliver::send`) →
       `mark_seen` + `touch_surfaced`. No live endpoint in-subnet ⇒ typed
       `NoTarget` outcome, row stays unseen (the next boundary picks it up) —
       never an error, never a guess.
     - `resurface_at_boundary(store, endpoint, owlery, now_ms)` — the gated
       filter from Shape; returns typed per-row outcomes (Surfaced /
       SuppressedSeen / SuppressedWindow / HiddenInSubnet).
   - Boundary wiring: `reporting::cmd_boundary` (after the rebind) and the
     new-session-start path call `resurface_at_boundary`. Wake edge → D9
     (documented in NOT-in-D8).
   - Producers (D8c):
     - consent: a `NeedsConsent` decision produces a `consent-needed` (or
       `update-available`) notif via the engine — the ADR-0007 producer the
       consent.rs docs promised; `decide` stays pure, the producer wraps it.
     - psyrelay: the `Notify` arm swaps `send(self_id, …)` for
       `produce + first_fire` (kind `psyche`); `RelayOutcome::Notified` keeps
       its shape; the D7.5 unit asserting the own-Self-perch interim updates
       to assert the notif-primitive surface (the swap D7.5 documented).
   - Tests: first-fire lands at the most-recently-active **visible** endpoint
     (a hidden-in-subnet endpoint is never the target even when most recent);
     resurface at `api boundary` delivers undismissed to a new endpoint and
     suppresses inside the window / when seen; dismissed never resurfaces;
     producer round-trips for consent + psyche paths.
   - **Activate `REQ-NOTIF-1` → `[impl, unit]`** in `traceable-reqs.toml`
     (store + wire + engine + producers all carry evidence by here).
4. **D8-4 — `spt notify` CLI + `notif_command` manifest seam (D8d).**
   - CLI (`crates/spt/src/cli.rs`): `spt notify [--subnet <s>] -- <body>`
     (issuer = the session's resolved endpoint id; envelope `from` = issuer
     endpoint + node + subnet per ADR-0007) → `produce + first_fire`. Plus
     the ack surface: `spt notif list [--subnet <s>]` / `spt notif dismiss
     <notif_id>`. `[impl->REQ-NOTIF-2]`.
   - Manifest seam: `[session.notif]` `SessionRole` (the existing role-table
     shape — one opaque `command` template, keys spt-core fills:
     `{notif_id}`, `{notif_from}`, `{notif_subnet}`, `{notif_body}`).
     Declared on harness-adapter **and** shell-adapter manifests (ADR-0007);
     surfacing renders + spawns it (detached) when declared, combinable with
     the agent-surface delivery. v1 ships the seam + a mock-manifest test
     (echo-command captures the substituted render); concrete OS
     toast / GameRobot adapters are M5 consumers.
   - Docs: MANIFEST.md `[session.<role>]` table gains `notif`;
     CONTEXT.md glossary entry for **notif** (first-class kind vs message);
     `<!-- [doc->REQ-NOTIF-2] -->` on the real sections.
   - **Activate `REQ-NOTIF-2` → `[impl, unit]`**.
   - Tests: `spt notify` from a seeded session produces a row + delivers to
     the active endpoint (acceptance leg); dismiss via CLI latches +
     replicates (against the D8-2 feed); manifest render substitutes all
     four keys; undeclared `notif_command` ⇒ agent-surface only (no spawn,
     no error).

## NOT in D8

- **Production trigger loops** — when notif feeds pump between nodes, and the
  wake-edge (state→active) resurface hook: D9, with the D5/D6/D7/D7.5 loops.
- **Cross-user generalization** — per-(subnet, user) spool keys, targeted
  `spt notify`: the ADR-0007 forward seam, M5; the `from` field ships ready.
- **Concrete `notif_command` adapters** (OS toast, GameRobot `alert-symbol`):
  M5 — D8 ships the manifest seam + render only.
- **PresenceChannel** — the deferred generalization of the delivery step;
  the notif engine is its registry-resolution precursor, unchanged scope.
- **GUI surfaces** (notif badges etc.) — out of spt-core.

## Conventions (carried)

- NO `cargo fmt`. Tag `[impl->REQ-…]` / `[unit->REQ-…]` on real evidence.
  `traceable-reqs check` from repo root before done. Linux CI clippy
  `-D warnings` is the real gate. Push each slice → watch FINAL green before
  next (`gh run list --json conclusion`).
  Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
