# Graph-native cash book + AR/AP ledger — design

Spec for [Task 1771](../../../../.tasks/1771-graph-native-cash-book-ledger.md). Written 2026-07-18.

## Why

On 2026-07-18 an admin asked the agent to record a £2,000 payment. The agent began inventing `Invoice`
properties (`total`, `amountPaid`, `balance`, `status:"part-paid"`), then reported that no ledger exists
and offered QuickBooks, a bespoke design, or a reminder task.

The report was partly wrong. `:Invoice` is in the base ontology (`schema-base.md:20`). But
`:InvoicePayment`, `:Credit`, `:InvoiceLine` and `:InboundInvoice` are declared only for the construction
vertical (`schema-construction.md:63-64`), so on the other nine `businessType` values an invoice can be
raised and a payment against it has nowhere to land. `paymentStatus` is free text. No arithmetic anywhere
relates an invoice total to what has been paid. The QuickBooks plugin performs zero graph writes. Write
Cypher is specialist-only (`platform/plugins/graph/PLUGIN.md:42`), so the admin agent has no deterministic
path even where the labels exist.

## Corrections to the task file

Three statements in Task 1771 were checked against the tree and do not hold. The implementation follows
the tree, not the task file.

1. **`ADMIN_CORE_TOOLS` does not exist.** `grep -rn "ADMIN_CORE_TOOLS\s*[=:]"` returns zero hits. It is
   named in `CLAUDE.md:35` but `.docs/superpowers/plans/2026-06-26-email-body-extraction.md:15` records it
   as stale. The live mechanism is `eagerTool` (`platform/lib/mcp-eager/src/index.ts:63`), which sets
   `_meta["anthropic/alwaysLoad"]`, plus the generated canonical-tool-names artifact.
2. **`packages/create-maxy-code/payload/` must not be hand-edited.** It is gitignored and
   `scripts/bundle.js:276-280` wipes it on every run.
3. **The ledger edges do not belong in `TYPED_EDGE_ALLOWLIST`.** See § Ontology below. The task file's
   list of eight triples is dropped; five patterns go to `schema-base.md` § Relationship Patterns instead,
   and `typed-edge-schema.ts` is not touched.
4. **A tool alone is not a standing check.** There is no plugin-level API for recurring jobs. The two real
   homes are a `setInterval` module wired into `platform/ui/server/index.ts`, or code called from
   `platform/plugins/scheduling/mcp/src/scripts/check-due-events.ts`. The SiteDesk censuses cited as
   precedent in the task file (`quote-generation.md:241-243`, `issue-flow.md:103-115`) have **no emitter in
   code**; they are documented log lines nothing produces. Shipping `ledger-reconcile` as only a tool would
   reproduce that defect.

## Architecture

Three units. The arithmetic invariant is needed by two processes, so it lives in neither of them.

### 1. `platform/lib/ledger-core/` — pure

No Neo4j, no filesystem, no environment reads. Exports:

- `computeOutstanding(totalPaymentDue, payments, credits)` — the invariant
  `outstanding = totalPaymentDue - Σpayments - Σcredits`.
- `applyPayment(invoice, existing, candidate)` — returns `{accepted}` or
  `{rejected, reason: 'over-application', remaining}`. Never clamps.
- `ageBuckets(openInvoices, now)` — 0-30 / 31-60 / 61-90 / 90+ days.
- `reconcileLedger(rows, now)` — returns the census finding, including `alarm`.

`platform/ui` already imports `platform/lib/*/dist` (`server/index.ts:45`,
`app/lib/graph-health.ts:25`), so one shared lib serves both consumers and the invariant is defined once.
ledger-core computes in integer minor units so the equality is exact and never subject to float drift.
Graph storage is unchanged: `amount` and `totalPaymentDue` stay decimal numbers, matching the existing
JobLogic-exact construction rows. The tools convert on the way in and on the way out, so no stored value
changes shape and existing construction data stays readable.

### 2. `platform/plugins/ledger/` — the MCP

Seven tools, registered with `eagerTool`, which wraps each handler in `wrapWithLifeline` and therefore
emits `op=request` and `op=result` with a correlation token and a handle census for free. This also
satisfies `platform/scripts/check-no-raw-mcp-registrations.mjs`.

| Tool | Purpose |
|---|---|
| `ledger-invoice-record` | Raise an AR `:Invoice` or record an AP `:InboundInvoice`, selected by a required `direction` |
| `ledger-payment-record` | Apply a payment to either invoice label |
| `ledger-credit-record` | Apply a credit to an `:Invoice` |
| `ledger-cash-record` | A `:CashEntry` not attached to any invoice |
| `ledger-balance` | One invoice: total, applied, outstanding |
| `ledger-statement` | Open receivables in ageing buckets, open payables, net cash position |
| `ledger-reconcile` | On-demand form of the census |

Structure copies `platform/plugins/contacts/`: own `mcp/src/lib/neo4j.ts`, per-tool files under
`mcp/src/tools/`, writes through `writeNodeWithEdges` from `platform/lib/graph-write/dist/index.js`.
`accountId` is read once from `process.env.ACCOUNT_ID` at `index.ts` and passed in, never derived inside a
tool. `createdBy` is `{agent: AGENT_SLUG, session: SESSION_ID, tool: <name>}`. Every arithmetic decision
delegates to ledger-core; the plugin contains no money arithmetic of its own.

### 3. `platform/ui/app/lib/ledger-census.ts` — the standing check

Mirrors `platform/ui/app/lib/timeentry-census.ts`: pure reconcile imported from ledger-core, impure
`runLedgerCensusOnce(session, now)`, and `startLedgerCensus(openSession, opts)` returning a stop function.
The interval is `unref()`'d so it never holds the process open, defaults to 15 minutes matching its
sibling, and takes a boot tick. A Neo4j error logs and returns null rather than throwing into the timer.
Wired at `platform/ui/server/index.ts` alongside `startTimeEntryCensus` (`:2208-2215`).

`ledger-reconcile` and the interval both call the same `reconcileLedger`, so the on-demand and standing
forms cannot drift apart.

## Ontology

Promote to `schema-base.md` so all ten verticals are covered by one definition:

| Label | Natural key |
|---|---|
| `:InboundInvoice` | `(accountId, supplier, confirmationNumber)` |
| `:InvoiceLine` | `(accountId, invoiceId, lineId)` |
| `:InvoicePayment` | `(accountId, invoiceId, paymentId)` |
| `:Credit` | `(accountId, creditId)` |
| `:CashEntry` | `(accountId, entryId)` — new |

`:CashEntry` requires `accountId`, `entryId`, `direction` (`'in'|'out'`), `amount`, `date`, `description`.

Two derivations, not stored fields:

- **Direction of a payment comes from the parent label.** An `:InvoicePayment` under `:Invoice` is money
  received; under `:InboundInvoice` it is money paid. No `direction` property on `:InvoicePayment`.
- **Outstanding is computed on read.** `outstanding`, `balance` and `amountPaid` join § Forbidden
  Properties (`schema-base.md:119`) for `:Invoice` and `:InboundInvoice`.

`schema.cypher` additionally gains the `:Invoice` `(accountId, confirmationNumber)` uniqueness constraint
that base already claims as its natural key but never declared, the `:CashEntry` constraint and index, and
entries in the `entity_search_admin` fulltext union (`:465-481`).

`schema-construction.md` drops the rows now owned by base. Construction-only labels stay.

The line-item `amount` carve-out against the base `totalPaymentDue` synonym ban
(`schema-construction.md:66-70`) moves to `schema-base.md` § Property Naming Rules with the labels.

Five edge patterns join `schema-base.md` § Relationship Patterns (`:168`), beside the
`(:Invoice)-[:BILLED_TO]->` pattern already there:

```
(:Invoice|:InboundInvoice)-[:HAS_LINE]->(:InvoiceLine)
(:Invoice|:InboundInvoice)-[:HAS_PAYMENT]->(:InvoicePayment)
(:Invoice)-[:HAS_CREDIT]->(:Credit)
(:InboundInvoice)-[:FROM_SUPPLIER]->(:Organization)
(:CashEntry)-[:COUNTERPARTY]->(:Person|Organization)
```

**Not `TYPED_EDGE_ALLOWLIST`.** That constant is imported by exactly two consumers,
`plugins/memory/mcp/src/tools/memory-ingest.ts:25` and `scripts/generate-edge-docs.ts:20`. It governs what
the typed-edge classifier may propose from free-text document ingest. `lib/graph-write/src/index.ts` never
references it and `lib/schema-validator.ts` performs no edge-type validation, so the ledger tools' writes
are not checked against it. Adding ledger triples there would make the classifier extract ledger edges out
of ingested documents, which is not intended. The allowlist header states the rule directly: edge types
outside it "are governed by schema-base.md / vertical-schema rules".

## Data flow — one payment

1. Resolve the invoice by reference; capture its label and `totalPaymentDue`.
2. Fetch existing `:InvoicePayment` and `:Credit` children.
3. ledger-core validates the candidate.
4. Write through `writeNodeWithEdges`, or reject.
5. Re-read outstanding from the graph.
6. Emit `op=verify`.

Step 5 is the point of the design. `op=write` proves intention; only the re-read proves outcome.

## Errors

Over-application rejects, is logged, and is reported to the user verbatim. It is never clamped and never
silently recorded. An unresolvable invoice reference rejects. Neo4j failures surface as `isError` payloads,
which `wrapWithLifeline` logs as `outcome=error` (`mcp-lifeline/src/index.ts:20-26`). The census swallows
nothing but never throws into the interval.

## Observability

Failure modes and the signal for each. Four are no-event failures owned by the census, not by a log line.

| # | Failure | Event? | Signal |
|---|---|---|---|
| 1 | Narrated as recorded, nothing written | No | `[ledger-census]` counts flat across a window containing `op=request` lines |
| 2 | Payment with no parent-invoice edge | No | `[ledger-census] orphanPayments=<N>` |
| 3 | A stored balance field reappears and drifts | No | `[ledger-census] storedBalanceProps=<N>` |
| 4 | Duplicate payment via non-deterministic `paymentId` | No | `[ledger-census] suspectDuplicates=<N>` |
| 5 | Over-application | Yes | `op=reject reason=over-application` |
| 6 | Write rejected, resolved vertical lacks the label | Yes | `op=reject reason=unknown-label vertical=<file>` |
| 7 | `schema.cypher` edited, `seed-neo4j.sh` not re-run | No | `[ledger-census] declaredNotLive=<labels>` from `live-schema-source.ts:180` |
| 8 | Tool in one registry, absent from the other | No | `[ledger-census] toolsResolved=7/7` |

Lifecycle for `ledger-payment-record`, correlated by `paymentId`. Lines 1 and 6 come free from
`wrapWithLifeline`; the rest are domain lines the tool emits.

```
[ledger] op=request  paymentId=… invoiceRef=… direction=… amount=… date=…
[ledger] op=resolve  paymentId=… found=<bool> label=<Invoice|InboundInvoice> vertical=… totalPaymentDue=…
[ledger] op=arithmetic paymentId=… priorApplied=… priorCredits=… amount=… outstandingBefore=… outstandingAfter=…
[ledger] op=write    paymentId=… nodesCreated=… edgesCreated=…
[ledger] op=reject   paymentId=… reason=<over-application|unknown-label|no-parent> detail=…
[ledger] op=verify   paymentId=… outstandingReRead=…
[ledger] op=exit     paymentId=… ranMs=… outcome=<applied|rejected|error>
[ledger-census] invoices=… payments=… orphanPayments=… storedBalanceProps=… suspectDuplicates=… declaredNotLive=… toolsResolved=…/7
```

**Success:** `op=verify` present, `outstandingReRead` equal to `op=arithmetic`'s `outstandingAfter`; census
with every counter zero and `toolsResolved=7/7`.

**Failure:** `op=request` with no matching `op=exit`; `op=write` with no `op=verify`; any non-zero census
counter; **absence of a census line** in the expected window; `op=resolve found=false` on a reference the
user just supplied.

**Diagnostic path:** the plugin MCP tee log, `grep -E '\[ledger\]|\[ledger-census\]'`, then filter by one
`paymentId` for that operation's full lifeline.

## Registration

- `platform/plugins/ledger/PLUGIN.md` frontmatter is the source of truth for tool names.
- `cd platform && npm run gen:canonical-tools` regenerates
  `services/claude-session-manager/src/canonical-tool-names.generated.ts`.
  `platform/scripts/check-canonical-tool-names.mjs` fails the build on drift.
- `plugins.core[]` in all four brand JSONs: `maxy-code`, `realagent-code`, `sitedesk-code`,
  `property-administrators`. A plugin absent from a brand's list does not ship to that brand.
- Specialist templates `database-operator.md` and `project-manager.md`.
- `platform/package.json` build chain gains `lib/ledger-core`. The `plugins/*/mcp` glob picks the plugin up
  automatically.

## Testing

- **ledger-core** — pure unit tests, no infrastructure. Part payment leaves the exact remainder; two
  payments summing to the total leave zero; a credit reduces outstanding identically to a payment; an
  over-application is rejected with the remaining figure; a zero-total invoice accepts no payment; the same
  `paymentId` twice does not double-count; ageing buckets at the 30/60/90 boundaries.
- **Tools** — stubbed session; asserts the reject branch writes nothing and that `op=verify` re-reads.
- **Per-vertical coverage** — iterates all ten `businessType` values resolved by
  `resolve-active-vertical.ts:38,67-81` including the `trades → schema-construction` alias, validating an
  `:Invoice` + `:InvoicePayment` + `:CashEntry` write against each. Fails before the promotion, passes
  after. This is the test that would have caught the reported defect.
- **Census** — pure `reconcileLedger` cases for each non-zero counter.
- **Drift gate** — `schema-cypher-drift.test.ts:60` green with no new `PROSE_ONLY_LABELS` entries.

Baseline in this worktree before any change: 508 vitest tests pass across the memory plugin, 56 node:test
tests pass. Three suites report as failed under vitest because they are `node:test` files vitest cannot
collect. That is pre-existing and unrelated.

## Regression boundary

Existing construction-vertical tests unchanged. `resolve-active-vertical.test.ts` cases at `:69,:82,:95,:108`
unchanged. `account-schema-owned-dirs.test.sh` confirms no new account file-schema bucket is minted, since
`schema-base.md` has no `## Top-level node types` section. The SiteDesk `:Job -> :Quote -> :QuoteLine`
reification is untouched.

## Out of scope

Double-entry. QuickBooks-to-graph mirroring. VAT, tax and multi-currency. Bank feed import. Any UI.
SiteDesk quoting figures. Backfill of existing construction accounts.
