ADR 040 System Events - steilerDev/cornerstone GitHub Wiki
Accepted (2026-10-08), planned — EPIC-21 (UX restructure, #2190). Partly implemented: §4 W1, W3 and W4 shipped in #2209 (story 1.3) on the existing diaryAutoEventService.ts writer (see the Deviation Log); the rest is not implemented yet. Plan stories 0.8
(part 1: no repeat milestone-delay events), 1.3 (A→B→A with Undo), 2.4 (this ADR's core) and 2.5 (the
History page) implement it. Contract 17 of
API Contract › EPIC-21 is its read API.
Story 0.8 part (W2 for milestone delays) implemented in #2199: completed and not-later milestones write
nothing; the same projected date as the latest event writes nothing (read from the legacy body until #2223
writes metadata).
Owner decisions: Q6 (automatic events leave the diary for History), Q20 (backfill, collapse exact repeats when shown, delete nothing), Q3 (no schema change).
Supersedes in part ADR-020 §3 ("Fire-and-Forget Automatic Events"): the fire-and-forget principle stays; what is written, and where it is shown, changes.
Automatic events are rows of diary_entries with is_automatic = 1, written by
server/src/services/diaryAutoEventService.ts through createAutomaticDiaryEntry
(server/src/services/diaryService.ts). Today:
| # | Observation (verified in code, 2026-10-08) |
|---|---|
| a |
metadata is always NULL and created_by is always NULL. The only content is an English title and body built from templates ("Status changed from In Progress to Completed"), so the German UI shows English history. |
| b |
autoReschedule (schedulingEngine.ts, step 7) calls onMilestoneDelayed on every run while the milestone's projected end is after its target — including for completed milestones, whose node is dated at completed_at. Every reschedule therefore appends another identical delay row, and keeps doing so after the milestone is reached. |
| c | Status events are written even when nothing changed (paid → paid). |
| d | Deposit events are written with source_entity_type = 'invoice_deposit' and the deposit id. That value is not in DIARY_SOURCE_ENTITY_TYPES (shared/src/types/diary.ts), so the event cannot link to anything (defect D-16). |
| e | Events are mixed into the site diary; in the owner's data the large majority of diary rows are system events, and a large share of those are exact repeats. |
| f |
onBudgetCategoryOverspend (budget_breach) has no caller; onAutoRescheduleCompleted (auto_reschedule) is a deliberate no-op. Both types stay in the CHECK constraint for legacy rows. |
System events stay in diary_entries (is_automatic = 1). No column is added or changed — Q3 holds.
The existing nullable metadata TEXT column carries the event's parameters as JSON (ADR-020 already
reserved it for "type-specific structured fields"). created_by (existing column) records the acting
user when there is one; scheduler-originated events keep NULL.
The site diary list stops showing them (the client passes automatic=false); they appear on the object's
History tab, on the History page (/history) and, when actionable, as one attention row.
// shared/src/types/systemEvent.ts (new)
type SystemEventMetadataV1 =
| { v: 1; event: 'status_changed'; from: string; to: string; subjectLabel: string;
depositId?: string; batchId?: string; revertOf?: string }
| { v: 1; event: 'milestone_delayed'; targetDate: string; projectedDate: string;
delayDays: number; subjectLabel: string }
| { v: 1; event: 'invoice_created'; subjectLabel: string; vendorId: string | null;
vendorName: string | null }
| { v: 1; event: 'legacy' }; // backfill could not parse the stored text-
from/toare stored values (in_progress,claimed), never labels; the client renders labels through the status key sets inI18N_UNION_KEYS(CLAUDE.md › Union-derived keys). -
subjectLabelis a snapshot of the object's name at write time (task title, invoice number, grant name). The read API returns the live name when the subject still exists and falls back to the snapshot when it was deleted (ADR-020: events survive their subject). -
entry_typekeeps its current values (work_item_status,invoice_status,subsidy_status,milestone_delay,invoice_created);eventis the render template,entry_typethe filter. - The English
titleandbodyare still written (thebodycolumn isNOT NULL, and exports and older clients read them) but are never the primary rendering.
Deposit (progress payment) status events are written with source_entity_type = 'invoice',
source_entity_id = <invoice id> and metadata.depositId = <deposit id>. The source-entity union stays
as documented; the History row opens the invoice page on its Payments tab.
The single writer (systemEventService, replacing the on* functions of diaryAutoEventService.ts)
applies, in order:
| Rule | Condition | Effect |
|---|---|---|
| W1 | from === to |
write nothing |
| W2 | milestone delay: the milestone is completed, or projected ≤ target, or the latest milestone_delay event of this milestone already has the same projectedDate
|
write nothing |
| W3 |
A→B→A inside the Undo window: the new event is B → A and the latest event of the same subject (entry_type, source_entity_type, source_entity_id) is A → B, by the same user, written less than UNDO_WINDOW_MS ago |
retract that latest event (delete the row) and write nothing |
| W4 | Undo by snapshot (contract 11): the snapshot lists the event ids its operation wrote | restoring the snapshot retracts exactly those rows |
| W5 | bank-report submission (POST /api/source-reports/mark-claimed) |
every event of the call carries the same batchId (UUID), used by the revert (contract 14) |
-
UNDO_WINDOW_MSis one constant inshared/(30 000 ms, owner decision); the client's Undo toast must close before it. - W3/W4 are the only deletions of event rows, and they can only reach a row younger than the Undo window that the same user's undone action created. Legacy rows are never deleted (Q20).
- Fire-and-forget stays (ADR-020 §3): the de-dup reads, the retraction and the insert run inside the
writer's own
try; a failure is logged and never fails the primary write. -
System events are always written (owner decision, EPIC-21 approval). The
DIARY_AUTO_EVENTSenvironment variable is removed in #2223 (story 2.4); after that, a stale value left in a deployment has no effect. Every bank-report batch therefore carries events and can be reverted through History. A batch submitted before #2223 while the variable wasfalsehas no events and stays unrevertable (404on contract 14).
A SQL migration (the runner, server/src/db/migrate.ts, applies .sql files only) fills metadata for
every row with is_automatic = 1 AND metadata IS NULL:
-
Parsing: the legacy texts are generated from a small, closed set of templates (current and former
versions of
diaryAutoEventService.tsin git history). The migration extracts the parameters with SQLite string functions (instr,substr) and maps English labels back to stored values withCASEexpressions built from the writer'sSTATUS_LABELStable, then writes the JSON withjson_object. -
Deposits: rows with
source_entity_type = 'invoice_deposit'are rewritten per §3 when the deposit still exists (invoice_deposits.invoice_id); when it does not,source_entity_typeandsource_entity_idbecomeNULLandmetadata.depositIdkeeps the old id. -
Unparseable rows get
{"v":1,"event":"legacy"}and render their stored English text. -
Idempotent (the
metadata IS NULLguard), non-destructive (noDELETE;title/bodyare untouched), and run inside the runner's transaction. - Tested with synthetic rows covering every known template. Checking the result against a real backup is done locally and never committed (Q18).
| Rule | Behaviour |
|---|---|
| R1 |
milestone_delay events dated after the milestone's completed_at are hidden (they describe nothing real). |
| R2 |
Exact repeats collapse: consecutive events of the same subject (no other event of that subject between them) with identical parameters (all metadata keys except subjectLabel) are returned as one row with repeat: { count, firstAt, lastAt }. |
| R3 | Nothing is deleted to achieve R1 or R2; includeHidden=true (admin diagnostics) returns the raw rows. |
R1 and R2 run in SQL (window functions, LAG over created_at per subject) so paging counts are correct.
No migration changes the schema. Schema › diary_entries documents the
metadata v1 shapes for automatic rows and the data-only backfill (migration number assigned when story
2.4 lands; the next free number at the time of writing is 0047).
- History reads in the user's language with locale dates, and every row links to its object.
- "≤ 1 event per real state change, none after a milestone is reached" becomes a testable invariant.
- The site diary shows only what people wrote.
- A bank-report submission is one recognisable batch that can be reverted.
- Two renderings exist for a while: v1 metadata and the legacy text fallback. The fallback is permanent
for
legacyrows and must stay tested. - W3 makes the event writer read before it writes; the reads use the existing
idx_diary_entries_source_entityindex. - Retracting rows (W3/W4) is a deliberate, narrow exception to "events are immutable"; it is the only reading of "A→B→A writes nothing" that does not need a pending-event buffer or a schema change.
-
New
system_eventstable: cleaner separation, but a schema change (Q3 forbids it) and a data copy of every legacy row. - Delete exact repeats: irreversible; rejected by the owner (Q20).
- Defer event writes until the Undo window closes: needs a durable pending buffer (schema) or loses events on restart.
- Backfill in a TypeScript data migration: the runner is SQL-only; adding a second migration kind for one backfill is not worth the mechanism. If the template set proves too irregular for SQL during story 2.4, raise it with the architect before adding a TS runner.
| Date | Section | Deviation | Resolution |
|---|---|---|---|
| 2026-10-09 | §4 Write rules | The ADR kept DIARY_AUTO_EVENTS=false meaning "nothing is written" and called the Undo window "initially" 30 s. |
Owner decisions at EPIC-21 approval: system events are always written and the variable is removed in #2223 (a stale value has no effect); the Undo window is 30 s. Code still reads the variable until #2223 lands. |
| 2026-10-10 | §4 Write rules (W1, W3, W4) | #2209 (story 1.3) implements W1, W3 and W4 before story 2.4 replaces the writer. W3 as written reads "the latest event of the same subject … by the same user", but automatic rows have created_by = NULL and metadata = NULL until story 2.4, so neither the user nor from/to can be read from the row, and no schema change is allowed (Q3). |
W3 uses an in-process ledger, not a read of the latest row: the writer records each status event it writes (subject → { eventId, from, to, userId, at }, subject = entry_type, source_entity_type, source_entity_id) and retracts only an event found there, by the same user, younger than UNDO_WINDOW_MS, and only with is_automatic = 1. A restart clears the ledger; the next reverse move then writes its event normally. Callers without a user (bank report, quotation conversion, invoice create) never retract. W1 (from === to writes nothing) is now applied by the writer itself. Deposits: every deposit status change, including → pending, now passes through the writer so W3 can retract a pending → paid event on paid → pending; a row is still written only for → paid and → claimed. W3 equally retracts paid → claimed on claimed → paid by the same user. W4: the Undo snapshot (API contract 11) collects the ids of every event written during the undoable call and the rows W3 retracted during it; Undo deletes the former (is_automatic = 1) and re-inserts the latter. When DIARY_AUTO_EVENTS=false (until #2223) nothing is written and nothing is recorded. |