Skip to main content

The exception system

The Auditor is not only an agent you ask questions. It is a scheduled control that runs while nobody is watching, and a register that remembers what it found. That distinction is the whole page. A chat answer is read once and closed. An exception that nobody has dealt with is still there tomorrow, and it is 24 hours older.
The current Audit exception register refusing an unreadable demo envelope

The current demo register refuses an unreadable pagination envelope. It says nothing was checked rather than converting an unavailable response into zero exceptions.

Where you see it: the Audit workspace in the rail is the assurance desk, with Exceptions, Sweeps, Actions and Chat; Actions carries the subset of findings a person can actually do something about, split into two lanes. Working the exception register is the same system from a user’s chair.

Seven families, and what each one asks

All seven have a runner. close_gated is explicit-only: an invalid, open or unevaluable period is answered inconclusive by name rather than silently dropped. It stays out of the routine full sweep on purpose, because a permanent “could not verify” line under every sweep teaches a reviewer to skim past the lines that matter.

Why the Close list is two families and not one with a flag

The register resolves findings by family, never by job or by parameter. So a single close_list family running nightly with phase: continuous would clear every close-gated finding a period-close job had filed, every night. Making the phase a family costs one entry in the vocabulary and one database constraint, and the resolve semantics are then correct by construction rather than correct by everyone remembering.

The continuous Close sweep

The Close list splits cleanly along one question: is the expected value true at every instant, or only after a period-end procedure has run? Of the 78 continuous, 56 are must-be-zero checks that can produce a finding and 22 are report-only explainable balances that are carried in the summary and never filed.
The codebase already reached for this distinction and had no word for it. The payroll family gates five of its own checks behind “only meaningful once the period’s bills are raised”. That comment was the phase tag, written once, in one family, unnamed. Now it is a column on all 104.
Two checks are deliberately skipped, and the skip names the owner. AP-CONTROL and AR-CONTROL are the recon family’s. Two families reporting one fact would be two register rows for one exception, and the fingerprint cannot merge them because it includes the family. GROUP-TB is not skipped: it was removed from the inventory because it restated structural journal balance without proving consolidation. The fail-safe is the period question, and it is not optional. The Close oracle skips the period-scoped payroll, depreciation-coverage and Wave E kết chuyển controls when given no period, which is 26 of the 104. A nightly run passing no period would therefore cover 78 of 104 and report a clean pass, which is exactly the silent under-coverage the report’s unverified_scope field exists to refuse. So the open fiscal periods are resolved at run time from the book, the payroll subset runs once per open period, and if no period is open that subset is inconclusive with the reason stated. The executed count is data-dependent because the payroll subset expands per open period and other definitions expand per rail, item or facility. The run summary reports that count, its open-period scope, and the explainable balances separately; it also names AP-CONTROL and AR-CONTROL as the recon family’s rather than silently double-running them.

The completeness family

The row-level, early face of the Close list. GRNI asks “is the goods-received-not-invoiced pool zero?” once, organisation-wide, at close. This family asks “is this goods receipt still uninvoiced, and how old is it?” tonight, and names the document. Same money, two grains, with the Close code as the join, which is why every rule that has one restates it.

The six rules

Rules 1 to 3 are “find the missing document”, which is the matching layer. Rules 4 and 6 are “this money is attached to nothing”, which is the allocation layer. Rule 5 is the obligation lifecycle itself. Rules 2 and 3 have no clock at all, deliberately: a posted document with nothing behind it is wrong the moment it exists, not wrong for getting old. Only rule 5 can reach critical, because an unpaid obligation is the one that stops being housekeeping and becomes an integrity question.
The pairs are configuration, not code: eight required-counterparty pairs live in the generated reconciliation ontology, each carrying its own matching terms, its clock, its lane and the Close code it restates.Aging is a second, separate layer of configuration: the bands live on the job, default [30, 60, 90] days, overridable per organisation and per pair, so an organisation can age goods-receipt-to-invoice at 15/30/60 and leave everything else alone.The severity is a lookup, never a boolean: the highest threshold the age exceeds picks the rung.
A pair whose configuration cannot be resolved is inconclusive for that pair, never a pass. An unknown clock, or a matching term the graph does not carry, produces a coverage hole that reaches the register by name.And a rule that could evaluate no pair has compared nothing. That case is caught explicitly, because otherwise “it ran” and “it verified something” come apart: the family would claim it checked documents against the counterparty documents they require, report no exceptions, and read as a pass over a question nothing looked at.

The verdict is deterministic

Nothing in that path is asked, inferred or judged. An agent or an SOP may draft a pair and explain a finding in chat; neither is consulted when a check runs. There is no path by which a model’s opinion at 02:00 decides whether an exception exists. The numbers come from the ledger’s own derivations rather than a hand-rolled SQL twin of them, for the same reason the hash-chain family reuses the writer’s hash helpers: a verifier carrying its own copy of a rule verifies its copy, not the book.
The honest limit rides in the finding, not in a design document. Where a pair matches on party and month rather than on a purchase-order reference, the finding says so in its own text, so a reviewer reading the exception learns what it could and could not distinguish.

The register: one exception is one row that ages

A nightly job that files a fresh row every night is not a register, it is a firehose. So a finding has an identity, hashed from the organisation, the family, the rule, the subject and a small declared set of salient fields. Everything volatile is excluded from it on purpose: The row therefore carries first_seen_at, last_seen_at and seen_count, and the tenth sighting of one problem is one row that says “seen 10 times”, not ten rows. Re-running a sweep over the same unresolved subjects updates seen_count on the existing rows: one exception ages, and no duplicate row is filed.

Acknowledge, waive, resolve

Three verbs, and the difference between them is the most important thing on this page. Every arrow into resolved starts at a sweep, and that is the whole picture. acknowledged and waived both survive a re-sighting; only the waiver’s expiry undoes one.

Why there is no resolve button

The control is the status resolved, and it means one specific thing: a run that re-read the book did not find this. A button that wrote it would be the product asserting an outcome the database never established. That is the same rule the money path lives by, applied to assurance: an agent may report what it recomputed, never assert what it hopes.

Waive is bounded in four ways, and the database holds three of them

The database CHECK rather than a request model is the point. A rule stated only in application code is bypassed by one direct SQL write; stated as a constraint it is the database’s answer. The refusals are written to be read:
“a service-token caller cannot waive an audit finding: a waiver is a human silencing a control, and there is no human in a server-to-server request”
“‘auditor’ is an agent identity. A waiver is a judgement a person makes and answers for; an agent cannot make one”
A waiver must never silence a control silently, and this is the half that is easy to get wrong.waived is excluded from the unresolved set, so a waiver genuinely lets a “the books are reconciled” claim through. If it counted, a waiver would never let a clean claim through and the status would be pointless.So every surface that reads unresolved findings also ships waived_count beside its claim, on every response, whatever the filter. “Clean, with 1 waived exception” is the honest sentence; a bare “clean” over a waived control is the over-claim this system has closed three times.
When the expiry passes, the next sweep re-opens the finding by itself. Until then a waiver behaves exactly like an acknowledgement: a re-sighting does not undo it. The waiver’s author, reason and date are kept on the row after it re-opens, as the trail.

Actions: two lanes, and closing a task is not resolving a finding

Not every exception is something a person can act on. A coverage hole is an operations matter; an inconclusive finding may never become a task, and the code refuses to build one that is both. So the worklist route is a property of the rule, declared per rule, rather than a severity threshold. That is what lets a warning-severity completeness finding open a task at all, and it is guarded from the other direction too: a test enumerates every rule any family can emit at critical and requires each one to be either declared actionable or listed with a written reason why nothing can be done about it. Replacing a threshold with a declaration is a control that can silently narrow, so it is pinned. Each task carries the lane its finding carries. A break in the matching layer lands in the bookkeeping lane, which is worked in the Data workspace; a break in the analytical layer lands in the accounting lane. Neither person has to read the other’s list.
An untagged finding appears in BOTH lanes, not neither. A missing lane is over-routing, which is a mild annoyance. The opposite default is how an exception disappears.
The current Audit human worklist refusing a missing demo response

The current demo worklist cannot be read. Both lane counts and list membership remain unavailable.

Closing a task does not resolve its finding, and the response says so:
“the task was updated; the audit finding behind it is unchanged - only a sweep that re-reads the book may resolve an exception”
It is entirely legitimate for a person to mark the work done while the finding stays open until the next sweep confirms. A route that quietly resolved the finding would be the same lie as a resolve button, one screen over. The other direction is automatic: when a sweep resolves a finding, its task is closed in the same transaction, stamped with why it vanished, so no orphan task sits behind a resolved exception.

The API

Coverage and cleanliness are reported as two separate facts, never folded into one. A run states what it verified and what it could not, so “nothing complained” can never be read as “everything was checked”.

What is scheduled today

Nine jobs, seven on a clock and two on an event. monthly_close_gated runs with period: last_closed, resolving a bounded batch of finished periods that still need verification. An invalid, open or otherwise unevaluable period is reported inconclusive by name, never passed or treated as an ordinary exception.
nightly_full_sweep is not the full sweep. It is named for the three families that existed when it was seeded, and it has not been widened.Each family added since carries its own job rather than being folded in, which is why recon, close_continuous and completeness each have a line of their own. A job’s coverage is whatever its families column says, never what its name suggests, and the Sweeps tab prints that column beside every job for exactly this reason.
The two event jobs are narrower than they look, deliberately. on_intake_duplicate_check fires when an intake row is acted on - approved, rejected, resubmitted, posted - and not on arrival, because a duplicate merely sitting in the queue is covered by the nightly pass and approval is the moment one could reach the ledger. on_post_invariant_sweep re-checks the 18 GL invariants after any ledger write, coalesced so a burst of writes is one sweep, and the run records the event range it answered for. The two new nightly jobs sit after the three-hourly projector pass they depend on. Two guards run at boot and warn rather than fail: one that every enabled job’s cron expression actually parses, and one that two jobs touching a single family do not disagree about its scope. A cron expression the evaluator refuses is a review that quietly never happens, and a nightly verification absent for a fortnight with nobody looking is the failure both guards exist to make loud.

Where it is enforced