Checklist auto-verification: scalability plan (phase 2)
Status: first tranche built on branch feat/checklist-auto-tracking (see section 15, read it first). Sections 13 and 14 are current; where §2, §3 or §12 differ, they win. Follows phase 1 (2026-10-08-accounting-checklist-spec.md, PR #33).
A checklist row can be tied to real work in the ERP. The accountant opens the row, lands on the
page where the work is done, does it, and the row proves itself complete from the real data. Rows
that cannot be seen by the system stay manual and are shown as manual. A user-written description
never decides whether a row is verified; the attached signal does.
1.1 Phase 1 baseline this plan builds on (as merged in PR #33)
“Done” today means the row has a Performed Date; N/A counts as complete in the progress bar and section
counters. Nothing is verified. Phase 2 adds the verified/manual split to these counts.
Managers edit text (category, description, deadline, headings, section titles) in place; structure changes
use “Edit layout”. Cells grow to their text. Rows, history (checklist_event), activity_log and the
hash-chained audit_log already exist and are reused unchanged.
2. Three kinds of row (kinds of outcome; there is one row type, see §13)
On the row (and on the template row, so it copies into every run, as everything else does):
link_route (text, nullable): app route with placeholders, e.g. /banking/reconciliation?to={period_end}.
signal_key (text, nullable): key into the signal registry. Null = manual.
signal_params (jsonb, default {}): per-row options, e.g. {"account_group": "bank"}.
verification (enum: none, satisfied, unsatisfied, error) and verified_at, verified_basis (jsonb:
the numbers or ids the check used). Cached result, never the source of truth.
New table checklist_signal_state(org_id, run_id, item_id, state, checked_at, basis) is the
alternative if the cache should not touch the item row (avoids version bumps and stale-write
conflicts for users editing the same row). Recommended: use this table; leave item.version for
human edits only.
Nothing per-signal lives in the checklist schema. Adding a signal adds no migration.
Checkfunc(ctx, tx, org, run Run, p Params) (Result, error) // pure read, no writes
Triggers []string// eventbus kinds that make a re-check worthwhile
Routestring// default link_route
}
Each module registers its own signals in init(), the way permissions are registered today.
The checklist module imports none of them. Dependency direction stays modules to platform to kernel.
Check is a read-only function of the data and the run’s period. It is idempotent and safe to
call any number of times. This is the single most important scalability property: correctness
never depends on delivery of an event.
The UI lists registered signals in a dropdown. A key that is no longer registered shows as
“unavailable” and the row falls back to manual instead of breaking.
5. Evaluation: pull first, events as an accelerator
Only some modules publish eventbus events today (documents, banking statement import, workflow,
payables batch and a few others). Period close, reconciliation lock and posting runs may not. So
the design cannot rest on events.
Pull (always correct): a worker job evaluates open signal rows. Cadence: every 5 minutes
for runs that are open and were viewed or changed in the last 24 h; hourly for other open runs;
never for locked runs or templates.
Push (fast): each signal lists Triggers. When one of those events fires, the checklist
enqueues “re-check signal X for org O” into the same job queue. De-duplicated by (org, signal)
within a short window, so a burst of 500 postings causes one check, not 500.
On demand: opening a run page checks its visible signal rows, and a “Re-check” button
exists per row. Both go through the same code path as the job.
Adding event triggers to a module later is an optimisation only; nothing breaks without them.
Numbers to design for: 1,000 orgs, about 85 rows per run, about 40 linked, 1 to 3 open runs per org.
That is about 40,000 to 120,000 signal checks per pass.
Batch by signal, not by row. Many rows and runs in one org share a signal and period. Evaluate
once per (org, signal_key, params, period_end) and fan the result out to every matching row.
Typical cost drops from 40 checks per run to the number of distinct signals.
Cheap checks. Each Check must be one indexed query, budgeted at under 50 ms, with a per-check
statement timeout. A check that exceeds it records error and backs off (exponential, capped at 1 h).
Per-org fairness. Jobs are keyed by org and rate-limited per org, so one large tenant cannot
starve the rest. Use the existing kernel/jobs queue and RLS context per job.
No locks on business tables. Checks read at normal isolation and never hold anything.
Write amplification. State is written only when it changes (satisfied to unsatisfied etc.),
so a steady-state pass is read-only.
Growth in rows or signals costs one registry entry per signal; run size growth costs linear
work in distinct signals, not rows.
A signal row is satisfied only if Check says so. The system then sets Performed Date to the
date the condition first became true, not the date of the check.
No early manual tick on a signal row. A manager may override with a reason. The override is
logged and the row is badged manual override.
Un-satisfied later: if the work is reversed (period reopened, statement voided), the row keeps its
history but shows no longer satisfied and counts as open in progress. It is not silently unticked
and not silently kept green; a person decides, with a reason.
N/A still works with a reason, as in phase 1.
Every automatic change writes a checklist_event (auto_satisfied, auto_unsatisfied) with the
basis (the ids and counts the check used) and the actor who caused it when known, else system.
Sign-offs still go to the hash-chained audit log.
Locked runs are never re-evaluated; their state is frozen at lock time.
A signal row’s check uses the run’s period (period_end). A run without one cannot use
period-based signals; the row shows “set the period end”.
Evaluation runs as a system actor inside the org’s RLS context, never as a user, and reads only
what that org owns.
A user can only attach signals whose own module permission they hold. Seeing “verified” does not
reveal data from a module the viewer cannot read: only state and a short, non-sensitive basis are shown.
Setting or changing signal_key on a row needs checklist.manage and is logged.
First wave (the module already holds the facts): fiscal period closed, bank reconciliation locked
through period end, payroll run posted, depreciation run posted, FX revaluation posted, journals
all posted (no drafts in the period), sub-ledger control accounts agree with GL, intercompany
balances agree, inventory count posted, trial balance balances. These cover most of sections B, C, D,
F, I, J, L, N and parts of A, E, H, P.
Stays manual: confirmations, document collection, filings made outside the ERP, management review,
audit preparation and board approval. Phase 2 adds optional evidence (attachment or note) for them.
Done before phase 2: description and heading boxes grow to their text; columns have minimum widths.
Migration: row columns (or checklist_signal_state), event kinds. Registry package in platform,
no signals yet. Tests: registry, de-dup, state transitions, locked runs skipped.
Worker job (pull) plus on-demand evaluation on run open and per-row Re-check.
First three signals end to end (fiscal period closed, bank reconciliation locked, payroll posted).
Link button, badge, activity entries, i18n en/zh/ms.
Event triggers for the modules that already publish events.
Signal picker in the row dialog, evidence on manual rows, remaining first-wave signals.
Load test: seed 1,000 orgs, assert one pass stays within budget and per-org fairness holds.
There is a single kind of row. Tracking is an optional attachment, chosen in the same dialog that
sets the row up, and changeable at any time.
Add or edit row:
Description (free text, always editable).
Tracking (one choice): Manual (default) or Track automatically.
If Track automatically: pick a source, add conditions, pick done when
(exists / none left / all done / totals agree), optional link page.
Picking a source from the catalog pre-fills the description (still editable) and the link page.
Save. The row shows a badge: manual, verified by system, or ready to review.
The same dialog works for a column: type Auto value plus a source gives a live value per row
(status, count such as “2 of 3 reconciled”, or an amount) with a link; any other type is manual input.
Switching mode later never loses data:
Manual to tracked: the description and any ticked date stay; the rule starts checking.
Tracked to manual: the rule is detached, the last verified date stays as an ordinary entry, and
the change is logged with a reason.
NULL means manual. No separate table per mode, no per-signal columns, no migration when a source is
added. The cached result stays in checklist_signal_state (state, checked_at, basis), never on the item.
Custom columns of type auto store the same track object on the column.
13.3 Scalability points specific to this combined design
One code path for manual and tracked rows. The row is the same object; only track differs. Counters,
filters, history and locking need no tracked/manual branches.
Rules are data, evaluated by one engine. Cost grows with the number of distinct
(org, source, conditions, period) combinations, not with rows or runs (see §6). Hash the normalised
track object to dedupe checks across rows, runs and templates.
Catalog is declarative. Each module registers its sources (entity, filterable fields, sum fields,
default link, required permission). The dialog, validation and engine all read the same declaration, so a
new source needs only its registration.
Validation at save time. A rule is rejected if the source is unknown, a field or operator is not on
the source’s allow-list, there are more than 5 conditions, or the user lacks the source’s read permission.
Versioned rules.track carries v; the engine keeps old versions readable, so catalog changes do not
break saved rules. A source that disappears makes the row fall back to manual with a visible note.
Templates and runs. Starting a run copies track with the row. Placeholders ({run.period_end}) resolve
per run, so one template serves every month and company.
Bulk setup. Selecting several rows and choosing “Track as…” applies one rule to all of them in a single
commit; large templates (hundreds of rows) are set up in one pass.
Observability. Per-org counters for checks run, errors, slow checks and rows verified, so a bad rule or
source is found before users report it.
Grounded in what the repo already has: River job queues (kernel/jobs), the in-process eventbus, RLS per
org, checklist_event (insert-only, indexed by run and by item), activity_log, and the hash-chained
audit_log, whose writes take a per-org advisory lock (pg_advisory_xact_lock(audit-chain, hashtext(org))).
Service levels to hold: run page open p95 < 800 ms at 2,000 rows; a cell commit round trip p95 < 250 ms;
a tracked row reflects completed work within 10 s via events and within 5 min worst case via the pull job;
one tenant never delays another tenant’s checks by more than one scheduling cycle.
A. Audit chain serialization (the real contention point).
Auto-ticks that write audited sign-offs would queue behind each other per org on the advisory lock.
A month-end that completes 40 rows within seconds would serialize 40 audit writes.
Auto-ticks write checklist_event + activity_log always; the hash-chained audit entry is written once
per evaluation batch per run (one entry summarising N rows, with the row ids and bases in its payload),
not once per row. One lock acquisition per batch.
Manual sign-offs stay one audit entry each (human speed, no contention).
The batch audit entry is written last in the transaction to keep the lock held for the shortest time.
B. Evaluation fan-out.
Key every check by rule_hash = H(source, normalised where, done_when, over, period_end, org).
Evaluate once per hash; write the result to checklist_signal_state keyed by (org, rule_hash); rows
reference it. 1,000 rows sharing 12 rules cost 12 queries.
Rows resolve their state by joining to checklist_signal_state at read time, so a result change is one
upsert, not N row updates (no write amplification, no item.version bumps, no edit conflicts).
Only the transition unsatisfied to satisfied (and back) fans out: it then updates the affected items and
writes their events in one transaction per run, chunked at 200 rows.
C. Job queue and fairness.
A dedicated River queue checklist_check with its own concurrency cap, separate from posting and payroll
queues, so checks can never starve business jobs.
Job args are (org_id, rule_hash); unique-by-args with a short window collapses event bursts.
Per-org token bucket (e.g. 20 checks per minute, burst 60) and per-org max in-flight of 2, so a tenant with
thousands of rules is throttled to its own share.
The periodic sweep does not scan all rows: it selects due (org, rule_hash) by next_check_at, indexed,
with FOR UPDATE SKIP LOCKED. Cadence is adaptive: 5 min if the run was viewed or edited in the last 24 h,
1 h if open and idle, none if locked, a template, or the rule’s source reports no change (see D).
Backoff on errors is per rule hash, exponential to 1 h, with a circuit breaker after 10 consecutive
errors that marks the rule “unavailable” and alerts instead of retrying forever.
D. Cheap change detection.
Each source may declare a ChangeToken(ctx, tx, org, period) (for example max(updated_at) or a row count
on its indexed table). The engine stores the last token with the state; if the token is unchanged the check is
skipped. Sources without a token are always re-checked on the slower cadence. This removes most steady-state
reads for idle orgs.
E. Source queries.
Each source ships its query as a fixed, parameterised function over indexed columns; the rule only fills
parameters. A source must pass an EXPLAIN guard test (index used, no seq scan on tables above a size
threshold in the test fixture) and a statement timeout (SET LOCAL statement_timeout = '2s').
The engine runs checks on a read replica when one is configured; on the primary otherwise, at
READ COMMITTED, no locks held.
“Over a set” rules (all active bank accounts) cap the set size (default 500) and report truncation as an
error state, never as a false “done”.
F. Run page payload and rendering (large runs).
Keep the run header, columns and sections in one call; load rows per section on expand, and paginate a
section above 200 rows (keyset by position). Collapsed sections load only counts.
Row JSON stays lean: signal state is joined as {state, label, checked_at}; the basis blob is fetched
on demand when the user opens the row’s detail.
Virtualise the table body client-side above 300 rows; the editable cell stays uncontrolled until focus
(it already keeps its draft locally), so typing never re-renders the table.
The description box grows to its text and column widths have minimums, so the table does not reflow on edit.
G. Edit path.
Commits already batch at about 500 ms and apply each change under a savepoint with optimistic version.
Cap a batch at 100 changes and 256 KB; a larger paste is chunked client-side.
Bulk “Track as…” and bulk “Mark N/A” are single commits with one history entry per row but one audit
entry per batch (same rule as A).
Concurrent editors on the same row get the existing 409 with the other person’s value merged by the client;
tracked-state changes never touch item.version, so system updates cannot cause user conflicts.
H. History growth.
checklist_event is append-only and grows linearly. Partition by month on occurred_at (declarative
partitioning), keep the two existing indexes per partition, and add (org_id, kind, occurred_at) for
“show auto-ticks only”.
Drawer reads use keyset pagination (already cursor-based) and never count.
Locked, old runs move their per-row detail to cold partitions after the retention window set in
retention_test.go; the audit chain is never pruned.
Auto events are compact: basis is a bounded JSON (at most 4 KB; larger sets store counts and the first
50 ids plus a “see report” link).
I. Catalog and rule evolution.
Rules carry v. The engine registers a migrator per source version; a catalog change that cannot migrate a
rule marks it unavailable (row falls back to manual with a note), never silently changes meaning.
A contract test per source: every rule the default template ships must validate and evaluate on a seeded
fixture, so a module refactor that breaks a checklist rule fails CI in that module’s own test run.
J. Multi-region and tenancy.
Every query and job runs inside the org’s RLS context; no cross-org batching. Rule-hash dedup is per org.
A group of companies (group register) can share a template; runs and rules are still per org entity, so
group-wide rollups read checklist_signal_state counts only, never re-run checks.
Metrics per org and per source: checks run, skipped by change token, p50/p95 duration, errors, timeouts,
rows transitioned, queue age, audit batch size and lock wait. Alerts on: queue age > 10 min, any source
p95 > 1 s, circuit breaker open, audit lock wait p95 > 500 ms. A support view lists an org’s rules with last
check, last error and cost.
14.5 Load and correctness testing (part of phase 2 done criteria)
Branch feat/checklist-auto-tracking: migration 01145, internal/platform/checklist/{track,tracking,sweep}.go,
the module’s three endpoints, OpenAPI, and web/src/features/checklist/{tracking.tsx,...}.
Built
One row type, optional tracking (13.1).checklist_item.track (JSONB rule, NULL = manual) and
performed_auto. The row dialog has the Manual / Track automatically choice, source, up to 5 conditions,
“done when” and the Go-to-page link; the same fields are in the Add row dialog. The sources list and each
source’s default rule come from the catalog, so the dialog needs no change when a source is added.
Catalog and engine (13.3, 14.2 B/E). Three sources: fiscal_period (closed or locked), bank_reconciliation
(every active bank account has a live lock through the period end; an unlocked reconciliation does not count),
payroll_run (approved for the month). Rule: source, conditions (ops eq, ne, gte, lte, in; value or placeholder
{run.period_end} / {run.period_month}), done when exists / none / all_done. Validated at save (unknown
source or field, operator by kind, value shape, at most 5 conditions, link must stay inside the app). SQL is built
from the registry’s declarations; every value is a bound parameter; the source’s permission is required to attach it.
Shared checks and state (14.2 B). Rows with the same resolved rule share one check per pass. Results are cached
in checklist_signal_state (keyed by run and rule hash), never on the item, so a system update never bumps a row’s
edit version. Date a row is ticked = the source’s own timestamp (reconciliation lock time, period close time,
approval time), not the time of the check.
Evaluation paths. On run open and the Re-check button (POST /checklist/runs/{id}/evaluate, checklist.work), and a
background sweep (checklist_sweep, every 5 minutes, per run in its own transaction; a run nobody touched for
24 hours is checked at most hourly; locked runs and templates never). The sweep is the safety net the plan calls for.
Behaviour rules (section 7). Auto-tick marks done with no person recorded; “no longer satisfied” is flagged once
(the date stays, a person decides); a hand-entered date on a tracked row needs checklist.manage and a reason and
then counts as manual; removing a rule needs a reason and keeps the date as an ordinary entry; an N/A row is never
ticked. History records auto_satisfied, auto_unsatisfied, track_set/changed/cleared with source system.
One activity line and one audit entry per run per pass (14.2 A), not one per row.
Page. A tracked row shows a badge (verified by system, “2 of 3 done”, waiting, no longer satisfied, could not
verify), its source and a Go-to-page button; the header adds “n verified by the system”; the performed cell says
“Verified by the system”. Manual rows show nothing extra. Staff without checklist.manage get no tracking button.
Default template. Row 7 (Prepare bank reconciliation for all bank accounts) ships tracked. Others stay manual
until a source honestly fits them; users add rules from the dialog.
Tests. Platform (validation, tick once and log, shared check, hand override, remove with reason, period
placeholder, locked/template skipped, copy to a new run, catalog by permission, default rules valid, sweep);
a real-data test on the bank source (lock, tick, unlock, flag once); HTTP (catalog, set, permissions, evaluate,
remove); web (badges, states, dialog, no button for staff).
Not built yet (deferred, in this order)
Event triggers (plan 5.2): no module publishes an event the three sources need (reconciliation lock, period close and
payroll approval publish none today), so the 5-minute sweep is the only background path. Add Triggers to a source
when its module publishes.
Auto-value columns (13.1 column dialog).
Dedicated checklist_check queue with per-org rate limits, change tokens, circuit breaker, adaptive cadence beyond
the active/idle rule, statement_timeout on checks (14.2 C/D/E). The sweep uses the maintenance queue.
checklist_event monthly partitioning (14.2 H) and the metrics/support view (14.4). Converting a populated table
to partitions is a rewrite; do it as its own migration.
Bulk “Track as…”, evidence on manual rows, the “suggest a rule from wording” idea, load tests (14.5 items 2, 3,
6 to 8), per-org feature flag rollout (14.6).
More sources (journals, receivables, payables, fixed assets, intercompany) and default rules for more of the 85 rows,
which need each module’s own “done” definition agreed first.
Setup is the weak point of any tracking feature, so the common case needs no setup at all.
Pre-wired defaults. The default template ships three rows already tracked: bank statements imported (row 6), bank reconciliation locked (row 7) and depreciation posted (row 27). Only rows with real data behind them are wired; judgment rows stay manual.
Suggestion on Add row. When a new row’s wording names work the system can check (“reconcile bank accounts”, “post depreciation”), the dialog offers “We can tick this row for you. [Use this]”. The phrases live in i18n (tracking.hints.<source>, per language), so a new source needs only its server entry plus its name, sentence and hints.
Plain wording; the rule editor is folded. The dialog shows one dropdown and one sentence. Conditions and “done when” sit under “Advanced”. The go-to-page link is no longer typed: it comes from the source’s catalog entry.
New sources:bank_statement (imported, not voided, through the period end, for every active account) and depreciation_run (a run posted for the fiscal period that contains the period end; its dates come from the period through a field written as a sub-query in the registry).
15.2 Section-level design (built; supersedes the per-row tracking dialog)
Matching the wording of rows is not a safe foundation (typos, other languages, rewording), so nothing the system acts on is inferred from text.
Section link. A section may be linked, by a person, to one area of the ERP: accounting periods, fixed assets, banking or payroll (checklist_section.module). The link is stored on the section, so renaming it or its rows cannot break it. The header then shows what the system sees there (“Every bank account has its statement imported: 3 of 3”, “Not yet”, “Go to page”). It informs; it never ticks a row. The default checklist links General Closing, Cash & Bank, Fixed Assets and Payroll.
Header disagreement. When every row is ticked but a check is not satisfied, the header says so.
Row auto-check. A row’s only setting is a choice from the short list of named checks, or None. Rows we know ship pre-wired (6, 7, 27). No suggestion from the description, no condition editor, no typed link: the destination comes from the source.
Sign-off (optional, per section). A manager switches “Needs sign-off” on. A second person (anyone with checklist.manage who did not perform a row of the section) signs once every row is done or not applicable. Changing a row’s done state, adding, removing or moving a row clears it. A run cannot be locked while a required sign-off is missing. Withdrawing needs a reason. All of it is in the history.
Not built: a separate checklist.review permission (uses checklist.manage), required-evidence attachments per row, and a settings page for custom rules (the engine still accepts them over the API).