release-ledger / references/system-model.md
Release ledger — system model
A reference shipped with the skill · 1,024 words
Release ledger — system model
The six stages, their contracts, and what each is allowed to do when the next one is unavailable. Names here are roles, not table names; map them onto the host's vocabulary in the implementation document.
Stage contracts
| Stage | Input | Output | Must not |
|---|---|---|---|
| Capture | One merge event | One queue row, or none if the key already exists | Call a model, enrich, filter on business rules, block the caller |
| Analysis | A batch of unanalysed queue rows | One ledger entry per row; the row marked analysed | Reorder, drop silently, run unbounded |
| Popup | A user identity | Entries newer than the user's watermark, within the window | Return entries the user's audience excludes |
| Dismiss | A user identity and a timestamp | The watermark advanced | Advance past the newest entry the user was shown |
| Digest | A time range and a channel | One post and one send row | Report success when it posted nothing |
| Announcements | A human author | A scheduled, audience-targeted entry | Enter the analysis queue |
Sequence: automatic capture through to the popup
forge endpoint queue analysis job entries user
│ │ │ │ │ │
├─ merge event ──►│ │ │ │ │
│ ├─ verify sig │ │ │ │
│ ├─ insert ──────►│ (on conflict │ │ │
│ │ │ do nothing) │ │ │
│◄── 200 ─────────┤ │ │ │ │
│ │ │ │ │ │
│ nightly ────────────┼────────────────►│ │ │
│ │ │ select where │ │ │
│ │ │ analysed_at is │ │ │
│ │ │ null, limit N │ │ │
│ │ │ ├─ describe ─────►│ │
│ │ │◄── mark analysed┤ │ │
│ │ │ │ │ │
│ │ │ │ │◄─ open ───┤
│ │ │ │ entries after watermark│
│ │ │ │ ──────────────────────►│
│ │ │ │ │◄─ dismiss ┤
│ │ │ │ watermark := now │
The 200 is returned before analysis exists. That is the point of the queue: the forge's retry policy and the model's availability are decoupled.
Sequence: the daily digest
scheduler ──► digest job
├─ read the previous send row for this channel
├─ select entries created after that send's cutoff
├─ select announcements whose start date is today
├─ if both sets are empty:
│ write send row {status: skipped, reason: "no entries"} ── stop
├─ render markdown to the channel's block format
├─ truncate to the channel's block limit with an "and N more" tail
├─ post
└─ write send row {status: sent | failed, external_id, error}
The cutoff comes from the previous send row, not from "now minus 24 hours". A job that failed yesterday must not create a hole; reading the last successful cutoff makes the next run cover both days.
Failure behaviour at each boundary
- Forge unreachable at capture. Nothing to do — the forge retries. Do not poll as a fallback unless capture has been missing events long enough to measure; a poller and a webhook both writing the same natural key is fine, and a poller replacing the webhook is a different design.
- Store unreachable at capture. Return a non-2xx so the forge retries. This is the one case where failing the request is correct.
- Model unavailable during analysis. The queue rows stay unanalysed. The next night picks them up. Cap the retry count per row and record the last error, so a permanently poisonous payload does not occupy the batch forever.
- A single row fails analysis. Record the failure on that row, continue with the batch. One bad payload must not stop the other twelve.
- The popup cannot reach entries. Render nothing. A what's-new popup that shows an error dialog on login is worse than a popup that does not appear.
- The channel rejects the digest post. Write the send row with
failedand the error, and leave the cutoff unadvanced so the next run retries the same range.
The watermark, precisely
The watermark is a per-user timestamp. The set a user sees is:
entries where published_at > max(user.watermark, now - window)
and published_at <= now
and audience matches the user
order by published_at desc
Two consequences worth stating explicitly:
- A new user sees the window, not the history. Their watermark is null, so
max(null, now - window)is the window floor. That is the desired behaviour: a first login is not a changelog archive. - Dismissal sets the watermark to the newest entry the user was shown, not to
now. If an entry is published while the popup is open, setting the watermark tonowwould swallow it unseen.
Announcements
An announcement is authored, not derived. It carries:
starts_on— the date it becomes visible. A pinned announcement posts to the digest channel on this date, once.ends_on— optional. After it, the announcement stops appearing in the popup even for users whose watermark predates it.audience— roles, groups, or "everyone". Resolved server-side.pinned— whether it sorts above analysed entries in the popup.
Announcements and analysed entries share a rendering path and nothing else. Keep them in separate tables: they have different lifecycles, different authorship, and different permissions, and merging them produces a table where half the columns are always null.
Backfill
Backfill runs through the same analysis path as the nightly job, with three additions:
- Count the input first and print an estimate of the model cost before anything is called. A month of merged work on a busy repository is a large number of tokens.
- Cap the batch size and run batches under an explicit total limit, so a miscounted estimate cannot become an unbounded bill.
- Set
published_atfrom the merge date, not from the backfill run time. Otherwise every historical change appears to have shipped today and every user's popup shows the entire history at once.