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.