# NOTIF-TRUTH — milestone dispatch (doyle triage 2026-07-21)

Operator GO 2026-07-21: plan + build + drive to release publish (**v0.40.0**, minor —
behavior change; counter from published metadata at release time, never pinned early).

Two waves, one release. Authority: **ADR-0046** (amends ADR-0007) — six ruled decisions
with rejected shapes; canonical terms in CONTEXT.md §Subnet notifications (scope /
coalesce key / dismissal-at-the-seam / TTL / delivery-window entries, ruled 2026-07-21).
Field evidence that motivated it: N-node duplicate update-notif flood, eternal stale
"update available" rows, notif delivery interrupting live PTYs.

All six REQs minted in `traceable-reqs.toml` with `required_stages = ["doc"]` (the ADR
doc tags landed with the mint). todlando GROWS each REQ's stage list at the wave that
delivers its evidence (rule 5, per-wave activation — never a commit activated-but-
unevidenced):

- W1 grows: REQ-NOTIF-SCOPE (+impl,unit), REQ-NOTIF-COALESCE (+impl,unit),
  REQ-NOTIF-TTL (+impl,unit), REQ-NOTIF-MIGRATE (+impl,unit).
- W2 grows: REQ-NOTIF-SEAM-DISMISS (+impl,unit,int), REQ-NOTIF-QUIET-DELIVERY
  (+impl,unit,int), REQ-NOTIF-SCOPE (+int — scope-aware first-fire),
  REQ-NOTIF-MIGRATE (+int if the twohost migration leg lands here rather than W1).

## W1 — store + wire (scope/key/TTL fields, coalesce+TTL semantics, migration)

Branch suggestion: `build/notif-truth-w1` (off main).

| Leg | Seam | Build to this (ADR-0046 decisions 1, 2, 5, 6 + Compatibility) |
|---|---|---|
| 1. Row fields | `crates/spt-store/src/notif.rs` (row struct + schema), `crates/spt-net/src/net/notif.rs` (replicated record) | Add scope (`node`\|`subnet`), coalesce key, TTL as ADDITIVE serde-defaulted fields — the `host_binaries` pattern: omitted-serialized, old peers parse clean and ignore. Schema regen drift-gated. Semilattice UNCHANGED — supersession + TTL express through the existing `dismissed` one-way latch. |
| 2. Feed exclusion | replication feed assembly in `spt-net/net/notif.rs` | Node-scoped rows NEVER enter a replication feed (old peers never see them at all). Subnet-scoped rows keep full ADR-0007 machinery: replication, cross-node dismiss, semilattice join. |
| 3. Coalesce front door | notif produce path (`NotifStore` insert front door — every producer + `spt notify`) | Key REQUIRED namespaced `<owner>:<key>`; the front door REJECTS bare keys (one split at one seam — no keys to grandfather). New row with same (scope-target, kind, key) auto-dismisses prior rows, latest-wins. |
| 4. TTL | `NotifStore` surfacing read path | Expired row auto-dismissed instead of surfaced — timestamp compare, NOT a predicate. Expiry IGNORES seen-state. No global default. Producer-optional, informational kinds only. |
| 5. Migration | daemon first-run pass (new binary) | One-shot auto-dismiss of existing rows `from_id = "spt-update"`, kinds `consent`/`rollback` ONLY. Idempotent. Agent/psyche rows untouched. Dismissals replicate (cleanup reaches not-yet-upgraded peers); worker re-produces anything current within one cadence, so aggressive cleanup costs nothing. |

W1 gate rulings (doyle, binding):

- Unit matrix must pin: bare-key rejection copy; latest-wins across scope-target (same
  key different kind does NOT supersede); TTL expiry with seen=true (seen-independence);
  migration idempotence (second run dismisses nothing new); node-scoped row absent from
  the assembled feed; N-1 wire leg — old-shape record parses clean, new-shape record with
  fields omitted round-trips.
- `notifsync.rs` is the sibling-seam suite for anything touching the notif record — it
  runs at gate whole ([[shared-seam-change-run-all-seam-tests]]).

## W2 — producers + delivery (seam dismissal, active_only, scope-aware first-fire)

Branch suggestion: `build/notif-truth-w2` (off main after W1 merges).

| Leg | Seam | Build to this (ADR-0046 decisions 3, 4 + Consequences) |
|---|---|---|
| 1. Producer scoping | `crates/spt-daemon/src/pump/update.rs` + `pump/notif.rs` (`produce_consent_notif`) | update/consent/rollback rows produced NODE-scoped with coalesce keys (e.g. `spt-core:update-staged`). Pairing/agent kinds stay subnet-scoped. |
| 2. Seam dismissal | `spt update apply` success path; update worker check cadence; rollback producer | Apply success dismisses `spt-core:update-staged`. Worker check seeing running ≥ staged dismisses too (out-of-band installs). A later successful update dismisses the rollback row. The primitive stores NO relevance predicates — nothing evaluated at surface time. |
| 3. Quiet delivery | notif envelope send path (delivery window selection — `spt-msg/src/deliver.rs` / `spt-store/src/spool.rs` seam) | Notify kind rides `active_only` UNCONDITIONALLY, rollback included: spool-only, never live TCP, never a wake, EVERY producer. No per-kind exception table (the rejected shape — that is how ADR-0007's delivery duplication grows back). Boundary resurface + adapter safe-point drain are the surfacing paths. |
| 4. Scope-aware first-fire | first-fire target resolution | Node-scoped first-fire targets the most-recently-active endpoint ON that node (scope determines the candidate set); subnet-scoped first-fire unchanged. Seen-per-endpoint gate + ~1h cross-endpoint suppression window UNCHANGED — they were never the defect. |

W2 gate rulings (doyle, binding):

- The quiet-delivery int leg mirrors `crates/spt/tests/active_only_never_relay_e2e.rs`
  — a live attached PTY session receives NO notif mid-stream; the row surfaces at the
  next boundary. Rollback kind explicitly in the matrix (the rejected exception).
- Seam-dismissal int rides the real update pipeline shape (staged → apply → row gone;
  staged → out-of-band install → worker check → row gone), not a synthetic dismiss call.
- Twohost legs: node-scoped row minted on A never appears at B; subnet-scoped dismiss on
  B still replicates to A (ADR-0007 machinery intact); migration pass on upgraded A
  clears the stale row at not-yet-upgraded B via replicated dismissal.
- Behavior-change grep: any test asserting the OLD delivery window or OLD produce
  signature gets found by grep BEFORE the rename, not discovered red at gate
  ([[behavior-change-grep-tests-not-comments]]).
- KH sweep: honor every KNOWN-HAZARDS invariant touching notif/spool/delivery; if a fix
  shape collides with one, stop and come back for a ruling — don't build around it.

## Sequence

1. todlando: W1 (activate/grow W1 REQ stages at build start; branch; build; preflight
   clippy --workspace + notifsync + targeted suites + traceable check).
2. doyle: gate W1 (isolated worktree under `.worktrees/`, fresh CARGO_TARGET_DIR,
   nextest for int, cross-platform leg if path semantics move — not expected).
3. todlando: W2 — same discipline. 4. doyle: gate W2.
5. deployah: release **v0.40.0** (bump-in-PR, CHANGELOG end-user voice, counter from
   published metadata; changelog must name the migration visibly — users' stale rows
   disappear on upgrade, that is a Changed entry, not Internal).
6. hertz: field-verify. The observables: no duplicate update notifs across the subnet;
   stale `spt-update` rows gone post-migration on BOTH upgraded and peer nodes; zero
   PTY interruptions from notifs on a live session; rollback surfaces at the next
   boundary (not mid-stream, not never).

Evidence index: ADR-0046 (decisions + rejected shapes), CONTEXT.md §Subnet
notifications (canonical terms), operator grill session 2026-07-21 (doyle transcript).
