> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# The exception system

> Seven check families, a nightly sweep, and a durable register where one exception is one row that ages. Acknowledge, waive with a reason and an expiry, and the rule that only a sweep may resolve anything.

# 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.

```mermaid theme={null}
flowchart TB
  SCHED["a scheduled job<br/>names its families"]
  SCHED --> RUN["a run<br/>reads · recomputes<br/>compares"]
  RUN --> REG[("the register<br/>ONE row per exception,<br/>not per sighting")]
  REG --> UI["the Audit<br/>workspace"]
  REG --> WL["the worklist<br/>the ACTIONABLE<br/>subset"]
  WL --> LANES{"which layer<br/>failed?"}
  LANES -->|matching| DATA["Actions ·<br/>bookkeeping lane"]
  LANES -->|analysis| ACCT["Actions ·<br/>accounting lane"]
  REG --> RESOLVE["resolved"]
  RUN -.->|"a later run does<br/>not find it"| RESOLVE
```

<Frame caption="The current demo register refuses an unreadable pagination envelope. It says nothing was checked rather than converting an unavailable response into zero exceptions.">
  <img src="https://mintcdn.com/vinmake/56mx-KkSKyEFY2E7/images/greatbook/features/trusted-response-audit-unavailable.png?fit=max&auto=format&n=56mx-KkSKyEFY2E7&q=85&s=2313e7d25b4eac277700f9c6938bf40d" alt="The current Audit exception register refusing an unreadable demo envelope" width="1440" height="1080" data-path="images/greatbook/features/trusted-response-audit-unavailable.png" />
</Frame>

<Note>
  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](/greatbook/guides/work-the-exception-register) is the same system from a user's chair.
</Note>

## Seven families, and what each one asks

| Family             | Question                                                                                                  | Grain          | When it runs                                           |
| ------------------ | --------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------ |
| `hash_chain`       | is every posted entry chained to the one before it, on a **fresh recompute**?                             | the entry      | nightly                                                |
| `invariant`        | do the [18 GL invariants](/greatbook/capabilities/gl-invariants) still hold over what is already posted?  | the book       | nightly, **and after any ledger write**                |
| `orphan`           | is any intake row resting in a status it should only pass through?                                        | the intake row | **hourly**                                             |
| `recon`            | does one transaction agree across every system it lives in?                                               | the graph node | nightly, **and when an intake row is acted on**        |
| `close_continuous` | do the [Close checksums](/greatbook/capabilities/close-list) that hold at every instant hold **tonight**? | the book       | nightly                                                |
| `close_gated`      | do the Close checksums that need the period to have ended hold?                                           | the period     | monthly, and when a question names one of its controls |
| `completeness`     | is *this named document* missing something, and how old is it?                                            | the document   | nightly                                                |

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?**

| Phase           | Shape                                                                                                                                                                    | Count     |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- |
| **continuous**  | a tie between two representations of one fact written in the same transaction, a referential-integrity or catalog check, an arithmetic identity inside a row, or a bound | 78 of 104 |
| **close-gated** | a drain-to-zero pool whose non-zero state is a normal in-flight condition, a tie against evidence that arrives with a lag, or a completeness assertion about a period    | 26 of 104 |

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.

<Note>
  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.
</Note>

**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

| # | Rule                           | Layer       | Severity ladder            | Restates                                 |
| - | ------------------------------ | ----------- | -------------------------- | ---------------------------------------- |
| 1 | `missing_counterparty_doc`     | bookkeeping | warning → error → critical | `GRNI`                                   |
| 2 | `missing_evidence`             | bookkeeping | warning (not aged)         | -                                        |
| 3 | `unsigned_document_of_record`  | bookkeeping | error (not aged)           | -                                        |
| 4 | `unallocated_settlement`       | accounting  | warning → error            | `PAYMENT-CROSSFOOT`, `RECEIPT-CROSSFOOT` |
| 5 | `obligation_overdue_unsettled` | bookkeeping | warning → error → critical | -                                        |
| 6 | `advance_unapplied_aging`      | accounting  | warning → error            | `PO-ADVANCE-POOL`, `SO-ADVANCE-POOL`     |

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.

<Accordion title="The flagship case: a bill with no VAT invoice, aging" icon="receipt">
  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.

  | Subject                     | Requires                       | Matched on       | Why it matters                                                                                   |
  | --------------------------- | ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------ |
  | goods receipt note          | supplier VAT invoice           | party, order ref | goods received not invoiced: GRNI 2010 stays open and the input VAT behind it cannot be declared |
  | supplier VAT invoice        | goods receipt note             | party, order ref | the mirror: invoiced for goods no receipt records, so a phantom bill or an unfiled receipt       |
  | supplier VAT invoice        | purchase order                 | party, order ref | spend with no order behind it was never authorised by anybody                                    |
  | supplier VAT invoice        | e-invoice **or** register line | party, doc no    | input VAT on the books with no statutory evidence behind it                                      |
  | bank debit advice           | supplier VAT invoice           | party, doc no    | money left the bank against nothing the book can name                                            |
  | sales VAT invoice           | customer PO                    | party, order ref | revenue with no customer order behind it                                                         |
  | asset purchase invoice      | asset card                     | party, doc no    | capitalised with no asset record, so nothing will ever depreciate it                             |
  | float reimbursement voucher | expense receipt bundle         | party, doc no    | employee float spent with no receipts behind it                                                  |

  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.

  <Warning>
    **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.
  </Warning>
</Accordion>

### The verdict is deterministic

```
resolve config  (the ontology's pairs + the job's bands)  -> frozen structs
for each rule:  ONE parameterised SQL over the canonical tables
for each hit:   age  = today - subject[clock]
                band = the highest threshold the age exceeds  -> severity
```

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.

<Note>
  **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.
</Note>

## 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:

| In the identity                | Out of the identity | Why                                                                                |
| ------------------------------ | ------------------- | ---------------------------------------------------------------------------------- |
| the subject document           | its **age**         | the age grows nightly and a row that ages must stay one row                        |
| the required counterparty type | the **band**        | the band steps as the age crosses a threshold                                      |
| the check code                 | the **amount**      | an AP control off by a different amount each night would file a new row each night |

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.

| Verb            | Who may                            | What it claims                                     | Undone by                               |
| --------------- | ---------------------------------- | -------------------------------------------------- | --------------------------------------- |
| **acknowledge** | anyone                             | "seen, and being handled". Nothing about the book  | nothing; a re-sighting does not undo it |
| **waive**       | a **named human** only             | "I know, and it will not change until *this* date" | the expiry passing, which re-opens it   |
| **resolve**     | **nobody.** Only a sweep writes it | "a run re-read the book and did not find this"     | the exception coming back               |

```mermaid theme={null}
flowchart TB
  S(["a sweep<br/>finds it"]) --> OPEN["<b>open</b>"]
  OPEN -->|"seen again<br/>ONE row"| OPEN
  OPEN -->|"a human"| ACK["<b>acknowledged</b>"]
  OPEN -->|"reason<br/>+ expiry"| W["<b>waived</b>"]
  ACK -->|"reason<br/>+ expiry"| W
  W -->|"expiry<br/>passes"| OPEN
  ACK -->|"a sweep"| R["<b>resolved</b>"]
  OPEN -->|"a sweep"| R
  R -->|"it came back"| OPEN
```

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

| Bound                                                 | What happens                                                                    | Enforced by                                                   |
| ----------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| a reason is **required**                              | a waiver with no reason is refused                                              | a database CHECK, not a request model                         |
| an expiry is **mandatory**                            | a waiver with no expiry is refused                                              | the same CHECK                                                |
| the expiry may not run past a **server-computed cap** | 409, and the response **names the cap**, rather than silently clamping the date | the route, from the organisation's own fiscal periods         |
| the actor must be a **person**                        | 403 for an agent identity, a placeholder, or a service token                    | the same canonical identity comparison the posting gate makes |

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"*

<Warning>
  **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.
</Warning>

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.

<Note>
  **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.
</Note>

<Frame caption="The current demo worklist cannot be read. Both lane counts and list membership remain unavailable.">
  <img src="https://mintcdn.com/vinmake/56mx-KkSKyEFY2E7/images/greatbook/features/trusted-response-human-tasks-unavailable.png?fit=max&auto=format&n=56mx-KkSKyEFY2E7&q=85&s=f94ebc7e331747002d7b2dfa08ad7e3c" alt="The current Audit human worklist refusing a missing demo response" width="1440" height="1080" data-path="images/greatbook/features/trusted-response-human-tasks-unavailable.png" />
</Frame>

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

| Route                                   | What it does                                                                                                                        |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `GET /audit/findings`                   | the register. `status` is `open`, `acknowledged`, `waived`, `resolved`, `unresolved` or `all`. **`waived_count` on every response** |
| `GET /audit/findings/inbox`             | the same findings in the shape the review inbox already renders, so an exception needs no new UI concept                            |
| `POST /audit/findings/{id}/acknowledge` | "seen, being handled"                                                                                                               |
| `POST /audit/findings/{id}/waive`       | `{reason, until?}`. Named human only                                                                                                |
| `GET /audit/jobs`                       | what is scheduled, and which families each job covers                                                                               |
| `GET /audit/jobs/runs`, `/runs/{id}`    | what ran, what it covered, and its `unverified_scope`                                                                               |
| `POST /audit/jobs/{key}/run`            | queue a run now                                                                                                                     |
| `POST /audit/jobs/tick`                 | execute due runs. This is what the cron line calls                                                                                  |
| `GET /actions/tasks?domain=`            | the worklist, filterable by lane                                                                                                    |
| `PATCH /actions/tasks/{id}`             | `{status?, assignee?}`. Attribution, never separation of duties: a task authorises nothing                                          |

<Note>
  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".
</Note>

## What is scheduled today

Nine jobs, seven on a clock and two on an event.

| Job                          | Families it covers                  | Cadence (UTC)      |
| ---------------------------- | ----------------------------------- | ------------------ |
| `hourly_orphan_scan`         | `orphan`                            | `15 * * * *`       |
| `nightly_chain_verify`       | `hash_chain`                        | `0 18 * * *`       |
| `nightly_full_sweep`         | `hash_chain`, `invariant`, `orphan` | `30 18 * * *`      |
| `nightly_recon_sweep`        | `recon`                             | `45 18 * * *`      |
| `nightly_close_continuous`   | `close_continuous`                  | `0 19 * * *`       |
| `nightly_completeness_sweep` | `completeness`                      | `30 19 * * *`      |
| `monthly_close_gated`        | `close_gated`                       | `0 21 4 * *`       |
| `on_intake_duplicate_check`  | `recon`                             | on `intake_events` |
| `on_post_invariant_sweep`    | `invariant`                         | on `event_stream`  |

`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.

<Warning>
  **`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.
</Warning>

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

| Concern                                                           | Code                                                          |
| ----------------------------------------------------------------- | ------------------------------------------------------------- |
| the family vocabulary, and the full sweep                         | `langgraph_chat/agents/auditor/scope.py`                      |
| the family sweeps                                                 | `langgraph_chat/agents/auditor/checks.py`                     |
| the six completeness rules, their ladders and their lanes         | `langgraph_chat/agents/auditor/completeness.py`               |
| the required-counterparty pairs                                   | `langgraph_chat/agents/common/recon_ontology.json`, generated |
| the phase and layer on every Close checksum                       | `backend/app/logics/finance/close_inventory.py`               |
| the finding shape, the fingerprint and the actionable declaration | `langgraph_chat/agents/auditor/report.py`                     |
| the register, the resolve clause and the waiver                   | `langgraph_chat/devcenter/audit_service.py`                   |
| the routes                                                        | `langgraph_chat/devcenter/audit_api.py`, `actions_api.py`     |
| the waiver constraint, the lane columns and the job seeds         | migrations `0035`, `0036`                                     |

## Related

* [The Auditor](/greatbook/agents/auditor) - the agent, its graph, and the read-only channel
* [Two layers on the same documents](/greatbook/two-layers) - what the lane on a finding means
* [The Close checksums](/greatbook/capabilities/close-list) - all 104, by phase and by layer
* [The reconciliation ontology](/greatbook/capabilities/reconciliation-ontology) - the graph three of the rules read
* [Working the exception register](/greatbook/guides/work-the-exception-register) - the same system, from a user's chair
