MalloryEstate Briefing
M·029Approved
← All briefings

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.

Dated 2026-07-27Programme Notifications & Nudges

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.

src/cron/slaEnrich.ts
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.
updatePendingEnrichment
Writes back the computed clock and verdict; sla_due_at set directly (a NULL means *eod posture, no clock*), verdict columns COALESCE.
upsertPending contract
sla_due_at becomes COALESCE-preserving so an ingest push can't erase a computed deadline; verdict invalidation keys on arrived_at.
rowIsDm
Broadened to every DM label shape the producer actually writes, validated against all 52 distinct labels in prod.
listRecentlyHandled
Handled shows the newest, limit applied in SQL, and includes 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

The guided "caught up" pass — one tap, walk the court one item at a timeAny change to the composer's model tier or cadenceThe three-state amber tier on the board — still a live design decisionThe EOD digest's delivery route, now that `defer` is a dead end
spec · docs/superpowers/specs/2026-07-27-situation-board-availability-addendum-design.md
Mallory · Estate briefing