Technitium read down on one poll and up on the next, sixty seconds apart, and
the sev4 arrived after the service was already back.
mavpoll writes a service_down fact only when the state changes, so the fact's
timestamp IS the moment the monitor went down and its age is how long it has
stayed there. The debounce is that age against MinDownAge, 90s — one poll
interval plus jitter. No history to keep and no counter to persist.
It bounds the alarm and not the truth: DownServices still reports a monitor the
instant it goes down, because /dash showing a fresh outage is right even when
phoning him about it is not. Existing fixtures that seeded a one-minute-old
down fact now seed five, which is what they always meant.
Stub and LLM paths both read loop.DownServices, so the message can never name
a service the predicate did not fire on. Two down at once are both named — he
needs the blast radius.
Seventeen markdown files at the repo root, twelve of them dated one-shot
reports sitting next to CLAUDE.md. That is why stale docs read as
current: nothing in the path said which was which.
Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/
and carry a Last verified line. Dated measurements move to docs/evals/
ISO-prefixed, and are never edited after the day, so a newer number is
a new file. The senior review moves to docs/archive/.
Every reference was rewritten across markdown, Go comments, the Makefile
and the recall fixture. The touched Go packages still build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
The Gatherer now fills State.SnoozeUntil from store.SnoozedUntil instead
of nil, so a snooze finally reaches the gate. RemindDecisions gains the
one restraint check that applies to a reminder — quiet hours, presence
and cooldown are still bypassed, so "wake me 7" is unchanged. Reviewer:
the two tests in internal/loop/gate_test.go are the contract.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
Pins the conservative side of Gate(): quiet hours, away, calendar-busy,
cooldown and snooze, plus one nudge per tick at max severity. Reviewers: the
two skipped tests at the bottom are real gaps, not flakes. Reminders ignore
snooze (loop.go:120) and the Gatherer never fills SnoozeUntil (gather.go:153),
so snooze does nothing at runtime. No behaviour was changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ