Motivation / problem
The thin “low activity this week” email is easy to ignore. A digest earns its place in the inbox only when it tells you something you’d otherwise miss and why it matters: which decisions were promoted, which runbooks are decaying, which questions keep failing to find an answer, and what specifically needs your attention. And it has to arrive where the team already is — not only email, but Discord, Slack, Teams and inside the app.Theory & background
A digest is a composition, not a query.DigestComposer pulls from the
already-computed signal services and assembles a typed DigestPayload of
sections; it never recomputes a metric. Each section is independently toggleable
per user (digest_preferences.sections), and the whole digest has a frequency
(weekly | monthly | off). The monthly variant is an executive roll-up (KB ROI,
coverage/decision-debt trends, contributor activity).
The optional narrative is produced by AiDigestNarrator using a dedicated
model so it never competes with the primary chat model — and it always degrades
to deterministic copy when disabled or unreachable (R14/R43), so a digest never
fails because an LLM did.
Design
DigestRendererRegistry is an R23 strategy registry: every renderer FQCN is
validated at boot and each renderer’s supports() predicate is mutually
exclusive, so a channel resolves to exactly one renderer. Transport reuses the
existing notification channel adapters (DiscordChannel / SlackChannel /
TeamsChannel / EmailChannel).
Data model / contract
Surfaces (tri-surface, R44):
- Command:
digest:send {--frequency=weekly|monthly} {--tenant=} {--channel=email|discord|slack|teams} {--dry-run} {--preview}anddigest:prune-feed. - HTTP:
GET /api/admin/digest/preview(admin, RBAC-gated);GET /api/me/digest/latestandGET /api/me/digest-preferences/PUT /api/me/digest-preferences(per-user). - MCP:
KbDigestPreviewTool(read).
Decision rationale (ADR-style)
- Compose, don’t recompute. The composer reads the snapshot + health + insights + gaps services. This keeps a single source for each metric and makes the digest cheap to render on demand.
- A dedicated free model for the narrative. Locking the digest to its own provider/model means a team can run an expensive primary chat model and still pay ≈$0 for digest prose, and the digest can never starve the chat model of rate budget.
- Narrative is additive and fail-safe.
ai_narrative_enabled=false(or an unreachable provider) yields a fully-formed digest with deterministic copy — the narrative is never load-bearing (R14). - Renderers behind an R23 mutex registry. Adding a channel is adding a
renderer with a non-overlapping
supports(); first-match-wins ambiguity is a boot-time error, not a silent mis-route.
Worked example
Preview the composed payload + every rendered card as JSON, without sending:metrics, new_docs, stale_docs, top_gaps, leaderboard):
Gotchas & operations
--preview implies --dry-run: it composes and renders but never sends. Use it
to inspect exactly what each channel will receive before scheduling.- Weekly and monthly sends are scheduled after the daily engagement snapshot so they read fresh metrics — see Scheduler & Maintenance.
- A never-configured preference (omitted or
nullsections) resolves to all sections (default-in); an empty array[]means none (explicit opt-out). Don’t conflate the two — an unchecked “all” box that sends[]honestly means no sections, not silently all. - The in-app feed grows unbounded without
digest:prune-feed; the daily prune honoursfeed_retention_days.