Skip to content

ADR-0073: Unmatched captures carry a durable notification identity

  • Status: Accepted
  • Date: 2026-10-18

Context

The review queue deduped only inside one process. The capture pipeline guards against a re-processed notification with a bounded in-memory session-key set (MAX_SESSION_KEYS = 512) that is dropped on process death, and it computes the NotificationKey before the exclusion/signal gates so a repeated masked or signal-less alert cannot re-queue (ADR-0005). Unmatched inserts, however, stored no identity at all: two rows in unmatched were indistinguishable from the same bank notification arriving twice. So a 48 h replay after a listener rebind or a process restart — the set starts empty again — queued a second row for a notification already reviewed or dismissed. Transactions were already protected by a unique notificationKey; the queue was not.

The original postTime is available on the capture path (onNotification(pkg, text, postTimeMillis, …)), so a durable key can be derived from it. Rows queued before this decision have no stored post time, and inventing one would make a replay of an old notification collide with an unrelated row.

Decision

UnmatchedEntity gains a nullable notificationKey: String? = null with a unique index on it. The value is the same NotificationKey used by transactions — package:originalPostTime:sha256(normalizedText) — never capturedAt. UnmatchedDao.insert becomes OnConflictStrategy.IGNORE, so a duplicate key returns -1; the pipeline treats -1 as “already present, consumed”, never as an error. The review UI is unaffected: dismissing still sets isDismissed = 1 on the existing row, and the replay collides instead of re-inserting.

Nullable is load-bearing: SQLite permits repeated NULLs in a unique index, so every pre-v9 row keeps a NULL key and coexists. Migration MIGRATION_8_9 adds the column and creates the unique index without back-filling — no released migration is rewritten. The database bumps @Database(version = 8) → 9 and the generated v9 Room schema JSON is committed under the schema directory.

Consequences

Replay is idempotent across sessions for both masked and unmatchable notifications: at most one queue row per originating notification, dismissed rows do not reappear, and different notifications (distinct postTime) stay distinct. Legacy rows survive unmodified. The session set remains a cheap in-process optimization; the unique index is now the durable guarantee for the queue as it already was for transactions.

Verification

UnmatchedReplayInstrumentedTest drives two fresh CapturePipeline instances over one real Room database: masked and unmatchable replays yield one row, a dismissed row stays dismissed on replay, distinct postTimes stay distinct, and null-key legacy rows coexist with keyed ones. Migration8To9Test migrates a populated v8 database, validates against 9.json, and checks the unique index, the surviving null-key rows and NULL coexistence.