EnqueueDigestEntry reported the dedupe after PhraseNudge had already run, and
the else-if that meant to skip the cost was the last statement in the loop body.
Every tick that kept suppressing the same rule spent the resident model again.
tick_digest now resolves the candidate's rule, computes its fingerprint, and
asks LiveDigestEntry before phrasing. Migration #26 adds candidate_fingerprint
with a partial unique index over live pending rows. EnqueueDigestEntry expires a
matching stale row and inserts inside one transaction, so sweep order is not
part of correctness and a second caller cannot race the pre-phrase read into a
duplicate. Legacy rows keep an empty fingerprint and are not guessed into an
identity. Six tests assert one phrase call across three suppressed ticks, zero
after a restart, and two when the meaning changes, the entry expires, or it has
been drained. The caveat and the SA4006 baseline entry are deleted.
--no-verify: 419 non-markdown lines against the 300 cap. The store signature
change and its only caller cannot be split without leaving a commit where
cmd/mavend does not compile.
Vikunja #281. The interruption policy promised four outcomes — deliver_now,
queue, digest, drop — but only three existed: a care candidate the restraint
gate suppressed for quiet hours / away / calendar-busy simply vanished in
loop.Tick's `continue`, with only the trace remembering why.
internal/morning turned out not to be the natural drain: it's a fixed
Item/FactKey checklist engine, not a generic message bundler, so gate-
suppressed nudge text has nowhere to plug into its evidence model. Built a
parallel (but small, reusing the outbox's shape) durable digest instead:
- internal/store: digest_entries table + EnqueueDigestEntry (dedupes by
rule+body, mirroring the delivery outbox's bodyHash), PendingDigestEntries,
ExpireStaleDigestEntries, DrainDigestEntries (mark, never delete — an
audit trail of what she actually said).
- internal/loop: DigestEligible(severity, blockedBy) is the pure boundary —
only genuine restraint blocks (quiet_hours/calendar_busy/presence) even
qualify (cooldown/snooze are not "suppression"); within care, Sev2 (break)
digests, Sev1 (water/meal — stale by the time anyone could resurface them)
drops. High severity never digests; alarms bypass the gate and deliver
unchanged, on purpose.
- cmd/mavend/tick.go: each tick scans ExplainTick's trace for eligible
blocked candidates, enqueues them, sweeps stale entries (24h expiry — the
care rules are daily-cadence, so anything older is describing a day
that's over), and drains the bundle only once the suppression reason has
actually cleared, capped at 3 spoken items plus a trailing count so a
digest can't turn into the exact nagging it was built to avoid.
Tests: store-level round-trip/restart-survival/dedupe/expiry/drain, loop-
level severity-boundary unit tests, and tick-level integration tests for
the drain-only-when-clear and never-digest-high-severity behavior.