Regular, concise updates for the owner from recorded facts (the Factory bet's module M6, quick win H2). Exception batches carry each new branch-health episode once, in the next of at most three daily batches, never from 22:00 to 07:00. A Monday digest of at most 150 words gives one line per Product with its coverage, and cites every claim. It also records the owner's delight and frustration notes.
It sends nothing: no delivery channel exists until the owner picks one, so batches and digests are recorded and read by the Dashboard. It never says "Nothing needs you" under incomplete coverage.
Status: Accepted local implementation: independent Astra reviews and combined checks passed. No live polling, scheduling or delivery. Acceptance and evidence.
- Source:
src/owner-digest.ts - Tests:
test/owner-digest.test.ts,test/dashboard-digest.test.ts, digest cases intest/branch-health-adversarial.test.ts, andapps/dashboard/test/digest-render.test.ts. - Helpers: those of Branch health: the recorded capture, synthetic repositories and the in-memory reader
- Design:
<local evidence archive>§2 (the guardrails and "How we learn"), §3 (M6) and §4 (H2's acceptance) - Human page: guide
Intelligence: none — Every line is rendered from recorded facts and cites them; a model would put unsourced words into what the owner reads and trusts.
What it hides
- Local time. The commission's IANA zone gives wall-clock times through
Intl.DateTimeFormat; a local time that happens twice takes the earlier instant and one in a gap moves forward, as Temporal's "compatible" rule does. Quiet hours are 22:00 until 07:00 local. - Batch slots. A batch is due at the latest of the day's batch times that has passed, in local time. Nothing forms in quiet hours or before the day's first batch time; a slot missed in quiet hours waits for the next day's first. A batch takes the episodes branch health opened at or before its slot, after each Product's watermark, reading whole
branch-health.episodes-openedevents; with neither episodes nor coverage gaps, nothing is recorded (empty). A detection time is the poll's start, so a poll recorded after its slot's batch formed waits for the next batch. Each line names the episode's first failed step, when its jobs were read, and links its latest red run on GitHub. - The digest. Due from Monday at the digest time, formed once per ISO week and never in quiet hours. Its input holds a line's worth for each registered Product, in registry order, from its branch-health record. It adds the owner-required count over the Portfolio's current conditions (Required actions and blockers), the asks as given (unknown until an ask ledger records them) and the previous week's notes. A pure function renders it, red Products first, then those not known, then green ones.
- Batch coverage gaps. A batch names unreadable history or records, and a poll older than an hour before its slot. A poll reporting unread head, workflow or run evidence adds
poll-unreadable, even when recent. These gaps form a batch without new episodes; they never mean nothing changed. A Product with no Branch health record is named as unwatched in the Monday digest. - Current or not. A Product's last poll counts as current when it is at most an hour older than the digest (two of branch health's proposed 30-minute polls). An older one is shown as "not polled since" its time that day, or its date, with the state it had then; it is never shown as the current state and counts as unknown.
- The record. One journal aggregate,
owner-digest:main(sf-owner-digest/2): per-Product batch watermarks, the last slot, the 21 most recent batches, the 8 most recent digests (each with the input it was rendered from) and the notes of the last 8 weeks. What would be stored is re-validated first. A stored/1aggregate is refused asCORRUPT; there is no silent migration or reinterpretation of its text.
Public interface
| Name | What |
|---|---|
OwnerDigest | new OwnerDigest(journal, {commission, now, portfolio?}): formBatch(), formDigest({asks?}), recordNote({commandId, feeling, outsideAskMinutes, text?}) |
BatchOutcome | quiet, not-due, empty with its slot, or formed with replayed and the BatchRecord |
DigestOutcome | quiet, not-due with dueAt, or formed with replayed and the DigestRecord (week, dueAt, formedAt, input, rendered) |
NoteOutcome | recorded with replayed, the ISO week and that week's note count |
renderDigest(input) | the pure rendering: RenderedDigest {text, words, readingSeconds, lines, citations, coverageComplete, nothingNeedsYou, compact, asksCounted}; DigestError LIMIT when even the compact form with the asks counted passes 150 words |
readOwnerDigest(journal) | the recorded digests and batches, newest first, and the retained notes, re-validated; reads only |
proposedDigestCommission(), parseDigestCommission(value) | the proposal (Europe/London, batches at 09:00, 13:00 and 18:00, the digest at 07:00) and the check: 1–3 ascending batch times and a digest time, each from 07:00 to before 22:00, in a canonical zone |
wordsOf(text), isoWeekOf(date) | words as counted here (tokens holding a letter or digit; a dash alone is none, and a citation marker belongs to its word); the ISO week of a date |
OWNER_DIGEST_LIMITS | 150 words; 175 words a minute for reading time (the slow end of most adults' range); 3 batch times; quiet 22:00–07:00; a poll current for 1 hour; 50 episodes a batch; 2 episodes named a Product line; 5 asks listed; 21 batches, 8 digests and 8 weeks of notes kept, 400 notes at most; notes of 280 characters and at most 10,080 minutes; 100 Products; 786,432 bytes of state |
| Types | DigestCommission, DigestInput, DigestProduct, DigestHealth, DigestTally, DigestEpisode, AsksInput, DigestLine, FactRef, Citation, BatchItem, BatchRecord, NoteRecord, Feeling; DigestError with code INVALID, CONFLICT, CORRUPT or LIMIT |
Stored layout. Commands owner-digest/batch/<local slot> (such as 2026-09-30T09:00), owner-digest/digest/<ISO week> and owner-digest/note/<commandId>. Events owner-digest.batch-formed, owner-digest.digest-formed (with its words and reading time) and owner-digest.note-recorded.
The digest's lines.
- A header, such as "Factory digest, week of 5 October 2026."
- One line per Product, reds first:
<name>: red.with at most two open episodes (workflow, signature, onset date,runaway,shared step) and the check tally on its head;<name>: —, <reason>.when unknown or not current;<name>: green.with its tally. - A line on asks, a summary and last week's notes.
Each factual line carries a marker such as [3]; citations maps it to Branch health records, episode events, Portfolio conditions, registry entries, notes or the asks' source. Unknown asks have no source to cite.
DigestEpisode and BatchItem store blockerMirrored. Formation sets it only when Portfolio.blockerRecord(..., {asOf}) contains the exact source triple: collector branch-health, the expected record ID and the expected revision from blockerSource. A missing or mismatched mirror omits only that blocker citation; the Branch health record or episode event still supports the line. Stored inputs require the boolean and re-render their citations during validation. A poll-unreadable gap cites the registry; a stale-poll gap cites the last poll.
Invariants and guarantees
- Replay and batching. Watermarks advance only past events a batch took. An event's episodes stay together; one that would overfill a non-empty batch waits for the next. A formed slot replays its recorded batch. A no-signal episode's first failure is a separate event included once.
- Word bound. Over 150 words, Product lines lose detail and the summary counts unknown Products instead of repeating names. Next, asks are counted instead of listed. A still-oversized digest is refused, never truncated; reading seconds are rounded up at 175 words a minute.
- Complete coverage. "Nothing needs you" requires every Product green on a current poll, owner-required items read as zero, and complete ask coverage with none open. Incomplete recorded asks are "at least N"; unknown asks name their gap. Unknown health never becomes green or zero.
- Notes. Delight/frustration notes keep the owner's reported minutes and optional words. Reusing their command ID replays identical content or conflicts on different content. The digest counts the previous ISO week's notes.
- Stored evidence. Input, text, lines and citations are re-validated before writing and on reading. A malformed input, including an ask containing a line break, is refused before storage. Corrupt Product records/history produce gaps without hiding readable Products. Formation records text only: it never sends, applies a rule or schedules work.
Failure semantics
| Code | When |
|---|---|
INVALID | A bad commission (a fourth batch time, a time inside quiet hours, times out of order, a non-canonical zone), clock, note (feeling, minutes, text, command ID), journal, or an input, digest or batch that would not re-validate once stored |
CONFLICT | A note's command ID reused with other content; another writer moving the record while a batch, digest or note forms (asking again answers with the recorded one) |
CORRUPT | Unsupported stored protocol (including /1), malformed state, or recorded text/citations that do not follow from their input |
LIMIT | A digest over 150 words even compact with its asks counted; 400 notes; the state's byte bound |
Journal errors propagate unchanged.
Trust scope
Implementation tests cover recorded/synthetic fixtures, timing, replay, evidence, limits and corruption. Dashboard checks cover the read-only view; they do not establish live delivery or scheduling.
Not established:
- Delivery. Nothing reaches the owner but the dashboard page; the channel is the owner's decision.
- Scheduling. Nothing forms batches or digests on a schedule, and the times are a proposal. The extension spec's quarterly-only cadence has not been amended by the owner (bet-proposals.md §4). Branch health polls on no schedule either, so every Product line reads "not polled since" until something does.
- Asks and rule uses. The ask ledger is implemented in a separate lane but is not composed here, so default asks remain unknown and the digest never says "Nothing needs you"; M6's rule uses, verified movement and per-Product next step are not built.
- Notes' home. The ask ledger's weekly note also records a feeling and outside-ask minutes; which record the digest counts once both land is not decided, and no dashboard route records a note (
recordNotehas no caller). - Minutes. The minutes in a note are what the owner reports, not measured; reading time is an estimate at 175 words a minute.
- Zone changes. Slots compare as local date and time in one zone; changing the commission's zone between batches is not handled.
Composition
- Depends on: Branch health (
readBranchHealth,openedSince,HEALTH_REASONS,runUrl, types), Portfolio (the registry and current conditions), Required actions and blockers (attentionItems, for the owner-required count) and the Command journal. - Used by: the Dashboard:
GET /api/digestreadsreadOwnerDigeston a read-only journal, and the Digest page shows it, its sources folded away. Nothing insrc/forms a batch or digest.
Changing it safely
What must stay true
- The digest's text is a pure function of its stored input: a change to
renderDigestmakes every stored digest fail its re-validation, so a rendering change is a new protocol, never an edit in place. - Watermarks count branch-health event sequences: a change to branch health's event types or grouping must keep each episode in exactly one batch.
- The 150-word limit, quiet hours and three batches are the bet's acceptance; never relax them without the owner.
Tests and helpers
Focused tests: node --test test/owner-digest.test.ts test/dashboard-digest.test.ts test/branch-health-adversarial.test.ts, then test/branch-health.test.ts. Every change also needs the steps in AGENTS.md's documentation obligations.
| File | What it covers |
|---|---|
test/owner-digest.test.ts | Commission, batching, gaps, digest wording and limits, asks, notes, replay and stored corruption |
test/dashboard-digest.test.ts | Recorded DTOs and citations, read-only GET/HEAD, rejected commands and corrupt-state responses |
test/branch-health-adversarial.test.ts | Digest freshness, incomplete ask coverage, long asks and invalid stored input |
apps/dashboard/test/digest-render.test.ts | Loading/reload/error presentation, batch gaps, links and folded sources |
The continuation's independent probes check exact blocker source matching, omitted unavailable mirrors, unreadable poll gaps and refusal of /1 without writing. Their paths and source hashes are in the implementation review above. Browser evidence is separate from these rendered tests; none establishes scheduling or delivery.
