Notifications & Nudges
Situation Board — Availability Addendum
What the board should say on a day you were in back-to-back meetings — and the enrichment gap that stopped v1 judging anything at all.
Situation
A post-ship review against live production found the board rendering correctly and judging nothing: 0 of 219 court items triaged, 0 of 17 open items carrying a deadline, 0 breach pushes ever fired, and an empty board:editorial. The court fell back to a raw reverse-chronological list — the two items the v1 spec's own example note named sat at positions 16 and 17.
Separately, a real day exposed a gap v1 never covered. Alex was in scheduled meetings 09:15–16:30 and opened the board at 19:00. The spec optimises for the steady-state glance — open it, see the two real ones, close it — and every decision follows from assuming regular check-ins. The judgement is frozen on wall-clock rather than on the user, the board has no idea he was unavailable, and the court row offers no way to say *not today*.
Key decisions
Enrichment moves off the poll
The clock and the triage: 'llm' hook lived in responseSlaPoll, which skips every connection whose transport isn't edge-poll — and every live connection is ingest. A transport-agnostic sweep working off stored rows replaces it, covering both transports and back-filling the existing table.
Feed calendar_events from the VM
calendar-sync.sh already keeps a structured event store and renders it to markdown; POST /ingest/calendar was built and never called. Wiring them resolves two problems at once — the dead *Coming up* zone gets its data, and the composer gets structured availability without parsing a rendered artifact.
The composer becomes availability-aware
Two cheap reads — today's calendar_events and the board's last-read time — turn the prompt from *"state of your world"* into *"what changed since you could last look, and how much fits the gap you actually have"*. Same cadence, same tier.
Surface the deferral verbs
boardAction already implemented resolve | snooze | dismiss; only *Mark handled* was ever shown. With ten minutes and seventeen items, defer is the verb a catch-up sweep needs most.
Invalidate on arrived_at, not snippet
The producer sends its own regenerated LLM summary as snippet, so keying verdict invalidation on it wiped every summary on every push. arrived_at comes from the producer's .last_ts and is stable per message.
Architecture
One new cron plus two storage-contract corrections. The sweep is deliberately split into a pure planner and a thin shell so the decisions are unit-testable without a model call.
planEnrichment() — pure: recompute the clock each sweep, request triage only where the channel opted in and the verdict is absent, cap triage per sweep. runSlaEnrich() is the thin I/O shell.sla_due_at set directly (a NULL means *eod posture, no clock*), verdict columns COALESCE.sla_due_at becomes COALESCE-preserving so an ingest push can't erase a computed deadline; verdict invalidation keys on arrived_at.dismissed so *Not today* stays auditable.Risks & guardrails
The scheduled-meeting signal is partial
It captures scheduled meetings only — overruns and ad-hoc calls are invisible. A good approximation of availability, never truth. mode:device would be the natural cross-check but is currently stale.
Triage retry cost
The planner re-requests triage whenever the verdict is absent, so a systematically failing model call would retry every 15 minutes forever. A per-sweep cap bounds it; logging the failure rather than swallowing it is what makes it visible.
Turning it on has an emission, not just a state
The config edit is one-command revertible, but enabling active posture on stale items fires high-priority pushes that override quiet hours before any revert can land. Model what a change emits over time, not only how it rolls back.
Bot DMs on an unmeetable clock
Auto-promoting every DM to active posture also catches Asana, Sentry and Google Calendar — bots that can never meet a response SLA. They are pinned back to eod posture by explicit channel override.
Silent no-ops read as healthy
Several catch {} blocks and a config-absent no-op meant a fully inert judgement layer looked identical to a quiet day for nine days. Logging the triage and composer failures is the cheapest guard against a repeat.
Delivery
Correctness first
rowIsDm label shapes, Handled ordering, and the Snooze / Not today row actions — small, independent, no config or deploy coupling.
The unblocker
The enrichment sweep plus the two upsertPending contract fixes. Nothing downstream — clock, triage, ordering, breach push — works without it.
Then commission
Channel entries in config:response-slas (posture: 'active', triage: 'llm') for a small starting set. The only step that changes live behaviour without a deploy, and the only one that adds recurring cost.
Then the judgement
Log the composer failure before tuning it; wire calendar-sync.sh to POST /ingest/calendar; make the composer availability-aware.
Out of scope
docs/superpowers/specs/2026-07-27-situation-board-availability-addendum-design.md