Phase 1, wave 3 — contracts for Plans 7 and 8 in parallel
Plans 7 (advances, spec §7) and 8 (petty cash, spec §8) are planned and built together.
Both consume Plans 3, 4, 5 and 6, which are merged to main. This document fixes what the
two plans share, and it fixes the seam changes the merged plans cannot yet express. These
changes land on the base branch phase1-wave3 before either plan branches, as the
wave-1 contract §1 requires. Every rule in the
wave-1 §3 and §5 and the wave-2 contract §3 still
binds.
Authority: the architecture spec
(2026-09-20-erp-architecture-design.md), §7
and §8, and §5.5, §5.7, §9.5, §10 and §11A where they touch them. Where this document
departs from the spec, it says so and gives the reason. Every departure is forced by an
engine rule that protects the ledger.
0. Why a base branch
Section titled “0. Why a base branch”Research on main (dd6f26f) found five things neither plan may change itself:
- A payout-first instance is never reversed before its accrual. An advance that is
rejected, cancelled or upheld in dispute after
paid_outbut beforeacquittedhasaccrual_open = false. SoPostsToLedger()is false, and the payout stays on the ledger forever. Plan 3 Ruling 17 flagged this and it was never fixed. - The spec’s single advance graph (§7.2) has a second cash leg,
topup_paidorrefund_confirmed. Validator rule 3(b) refuses it (payout_repeated), and so does the Poster’s once-only rule (R8). There is no semantic for cash coming in, and no guards exist to branch on the sign of the variance. float_topup(§8.3) has a payout and no accrual. Rule 3(c) refuses that under both posting orders.workflow.SeededGraphsis a hard-coded slice in Plan 3’s file. Both plans would edit the same line.- Document binding is expense-only. The one-record rule (
expenses.AttachDocument) and document read access (modules/expenses/document_handlers.go) see only claim lines. A receipt could back a claim and an advance and a spend, and an advance or spend approver could not open the receipt.
A sixth issue, integrity of the per-float cash account, is §1.5.
1. The seam changes (base branch phase1-wave3, built first)
Section titled “1. The seam changes (base branch phase1-wave3, built first)”The base branch owns these changes. It edits Plan 3, 4 and 5 packages, using their
reserved ranges (migration 00034, 00054). No plan branch edits these files.
1.1 Reversal once past payout (fixes finding 1, closes Plan 3 Ruling 17)
Section titled “1.1 Reversal once past payout (fixes finding 1, closes Plan 3 Ruling 17)”posting.EventgainsPastPayout bool, next toPastAccrual.PostsToLedger()becomes: Accrual or Payout, or (Rejected or Cancelled and (PastAccrual or PastPayout)).workflow_instancegainspayout_open BOOLEAN NOT NULL DEFAULT false(migration00034). Entering apayoutstate sets it, and enteringrejectedorcancelledclears it.accrual_openbehaves the same way.TransitionfillsEvent.PastPayoutfrom the value before the move.- The Poster’s reversal already mirrors every unreversed accrual and payout entry of the instance (Plan 4 R7). No rule changes.
- Validator rule 5 now also requires
reverseson a reject or cancel edge leaving any state at or past a payout, not only past an accrual. Existing seededexpense_claimgraphs still pass. The drift test proves it.
1.2 Posting order cash_only (fixes findings 2 and 3)
Section titled “1.2 Posting order cash_only (fixes findings 2 and 3)”workflow.PostingOrdergainsCashOnly = "cash_only". Acash_onlydefinition has noaccrualstate. Every path crosses at most onepayoutstate. It moves money between two balance-sheet accounts and recognises no expense.- Rule 3 under
cash_only:- (a) no payout on a cycle (as today);
- (b) no path crosses two payouts (as today);
- (c) any
accrualstate is refused, with the new codeaccrual_in_cash_only.
- The semantic
payoutmeans “the one cash leg of this document”. The kind’s payout rule decides direction. A refund’s rule postsDr Bank / Cr Employee Advances. No new semantic is added, andjournal_entry.semanticis unchanged. constants.yaml(the posting orders, if listed there), vocab and the OpenAPI enum gaincash_only.
1.3 Seeded-graph registry (fixes finding 4)
Section titled “1.3 Seeded-graph registry (fixes finding 4)”workflow.RegisterSeededGraph(kind string, build func(ownerSeat uuid.UUID) Graph)panics on a duplicate kind, or on a kind that failsKeyPattern.SeededGraphs(ownerSeat)returnsexpense_claimfirst, then the registered graphs in kind order.expense_claimstays built in. Each plan registers its graphs from its own platform package’sinit(). Step 60 (workflow_definitions) seeds any kind that has no version, so existing orgs gain the new kinds on the next provisioning run. This is the step’s current behaviour.- A registered graph must pass
ValidateGraph(g, "MYR"). Each plan tests its own graphs with the helperworkflowtest.RequireSeededGraphValid(t, kind), which the base adds.
1.4 Cross-consumer document binding (fixes finding 5)
Section titled “1.4 Cross-consumer document binding (fixes finding 5)”internal/platform/documents gains a binder registry:
// A Binder is one consumer's view of which of its records a document backs.type Binder interface { Kind() string // "expense_claim", "advance", "petty_cash_spend", "float" … // BoundTo returns the records of this consumer the document actively backs. BoundTo(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]Binding, error) // MayRead reports whether member may read a document this consumer binds. MayRead(ctx context.Context, tx pgx.Tx, documentID, memberID uuid.UUID) (bool, error)}type Binding struct{ Kind string; RecordID uuid.UUID; Reference string }
func RegisterBinder(b Binder) // panics on a duplicate Kindfunc BoundElsewhere(ctx context.Context, tx pgx.Tx, documentID uuid.UUID, except Binding) ([]Binding, error) // every consumer except the caller's own recordfunc MayRead(ctx context.Context, tx pgx.Tx, documentID, memberID uuid.UUID) (bool, error) // the uploader, or any binder says yes- The base moves Plan 5’s two expense-only checks onto this seam:
expenses.AttachDocumentrefuses a document thatBoundElsewherefinds in any consumer.ErrDocumentOnAnotherClaimbecomesErrDocumentBoundElsewhere, and the 409’s detail names the kind and reference.mayReadDocumentinmodules/expenses/document_handlers.gocallsdocuments.MayRead.
expensesregisters its binder. Plans 7 and 8 register theirs, and must callBoundElsewherebefore binding a document.- The one-record rule is enforced by these checks under the document’s row lock
(
documents.Lock), taken first in every binding path. A binding table’s own unique index covers its own consumer only.
1.5 Module-owned accounts (integrity of float accounts)
Section titled “1.5 Module-owned accounts (integrity of float accounts)”- Each float is its own postable cash account (Plan 9 D2), created at runtime as a leaf
under the
1530 Petty cash floatsheader. The accounts cannot carry a control kind:ResolveControlallows one account per kind (R23). So R24’s refusal of manual postings does not protect them, and a manual journal line could move a float’s ledger balance away from its storedcash_balance. accountgainsowner_module TEXT NULL(migration00054), with a CHECKaccount_owner_module_knownfor the closed set (pettycash). The set grows by migration.ledger.Postrefuses amanualline on an account with an owner, using the existingControlAccountManualErrorfamily.chart.CreateAccountacceptsOwnerModule.chart.UpdateAccountnever changes it.
- Plan 8 creates each float’s account with
OwnerModule: "pettycash".
1.6 What the base does not do
Section titled “1.6 What the base does not do”- No petty-cash settlement logic. That is Plan 8’s.
- No River, no notification platform, and no tenant enumeration. See §4 on reminders.
- No change to
internal/platform/payout. The wave-2 §1 shape stays frozen.
2. The two plans’ shapes, fixed here so they cannot collide
Section titled “2. The two plans’ shapes, fixed here so they cannot collide”2.1 Plan 7 — advances
Section titled “2.1 Plan 7 — advances”-
Departure from spec §7.2 (forced by §0.2): an advance is three workflow kinds, and every posting carries
source_id = <advance id>:Kind Order Graph (states → semantic) Posting advancepayout_firstdraft(draft) →pending_review(pending) →reviewed(pending) →approved(pending) →paid_out(payout) →received(acknowledged) →acquitted(accrual, controlemployee_advances, terminal);rejected,cancelled,disputedpayout: Dr Employee Advances / Cr bank-or-channel. Accrual: Dr expense (the acquittal’s category) / Cr Employee Advances, at actualadvance_topupcash_onlytopup_pending(pending: the approver signs off on the excess) →topup_approved(pending) →topup_paid(payout) →topup_received(acknowledged);rejected,disputedDr Employee Advances / Cr bank-or-channel, at +variance advance_refundcash_onlyrefund_pending(pending) →refund_confirmed(payout, terminal);disputedDr bank / Cr Employee Advances, at −variance - The acquittal transaction (entering
acquitted) evaluates the §7.3 decision table, then starts theadvance_topuporadvance_refundinstance when variance ≠ 0. The variance child’ssource_idis the advance id. There is one instance per(kind, source_id)(Plan 3 Ruling 22), so each advance has at most one top-up and one refund. - Disputes are reachable from every post-payout state of each kind (§7.2), with the seeded dismiss/uphold pattern. Upholding reverses through §1.1.
journal_entry.source_kindis the workflow kind, so an advance’s postings carrysource_kind ∈ {advance, advance_topup, advance_refund}. Plan 7 exportsadvances.SourceKinds = []string{"advance", "advance_topup", "advance_refund"}. Plan 9’s D1 sums over that set instead of= 'advance'. That is a note for Plan 9, which has not merged.- The Employee Advances control nets to zero on every closed advance, across the three kinds. Plan 7’s reconciliation check proves this.
- The acquittal transaction (entering
-
advance_offsetstays deferred (Plan 5 R11). Advances settle only through their own acquittal. Enabling the offset line would change Plan 5’s accrual and payout rules. It is not in §7, and it is a later plan’s. -
Payout channel: an advance pays through an
externalchannel only. Plan 7 refuses apetty_cashchannel with a typed 422, as Plan 5 did before Plan 8. Paying an advance from the tin is a follow-up once both plans have merged. Plan 7 storespayout.SnapshotJSON, so it must add its table tomigrate reseal-payout-details(Plan 5 R45). -
Names Plan 9 expects (D1): package
internal/platform/advances, andListForAging(ctx, tx, asOf) ([]AgingRef, error)with Plan 9’sAgingReffields. The reference shape isAD-YYYY-NNNN, and every advance has adue_ondate.
2.2 Plan 8 — petty cash
Section titled “2.2 Plan 8 — petty cash”| Kind | Order | Graph | Posting |
|---|---|---|---|
petty_cash_spend |
accrual_first |
draft → recorded(accrual, control petty_cash_ap) → spend_paid(payout, terminal); voided(cancelled), not_reimbursable(rejected), disputed |
accrual: Dr expense / Cr Petty Cash AP. Payout: Dr Petty Cash AP / Cr the float’s account |
float_topup |
cash_only |
requested → reviewed → approved → paid(payout, terminal); rejected, cancelled, disputed |
Dr the float’s account / Cr bank |
- Reviewer outcomes are the numeric constants
QUERIED=0,VERIFIED=1andNOT_REIMBURSABLE=2(§20.1.9 constants). The engine has no guards, so Plan 8’s transition route refuses the pay edge unlessoutcome == VERIFIED.AvailableTransitionsstill lists it, and the route is the gate. - Void semantics (§8.2) follow from §1.1:
- voiding from
recordedreverses the accrual only; - voiding from
spend_paidreverses both. Plan 8’s own code credits the float back only when a payout was reversed.
- voiding from
- The float ledger is Plan 8’s own module write. It sits beside the posting, after
workflow.Transitionreturns and before Plan 8’saudit.Write. Eachfloat_movementrow names thejournal_entry_id, found throughledgerby instance and semantic.cash_balanceandcommittedchange only in the same transaction as their movement. - Plan 8 owns the petty-cash hook in Plan 5’s code (Plan 5’s notes, Plan 6 P6). It is
the only wave-3 plan that edits
internal/platform/expenses, and it may edit only these:settle()’sErrChannelPettyCashbranch;PayoutRule.Build’sErrPettyCashDeferredbranch;- those two errors;
- the transition path’s post-payout and post-reversal side effects for a petty-cash settlement;
- their tests.
The credit goes to the paying holder’s float account, never the channel’s
CreditAccountID.expensesmay importpettycash.pettycashmust never importexpenses.
- Names Plan 9 expects (D2): package
internal/platform/pettycash, andListFloats/GetFloatwithFloatRef{ID, Code, HolderMemberID, HolderName, AccountID, OpenedOn, ClosedOn}. Every float movement posts to the float’s own account. The register never printscash_balance. - Float accounts are created with
OwnerModule: "pettycash"(§1.5), as leaves under the country pack’s float header. Plan 8 looks the header up by the chart’s1530code. It refuses to open a float when the org’s chart has no such header. - The placeholder permission
pettycash.float.disburseis Plan 8’s to adopt or retire.
3. Ownership
Section titled “3. Ownership”Base phase1-wave3 |
Plan 7 — advances | Plan 8 — petty cash | |
|---|---|---|---|
| Go packages | posting, workflow, accounting/{chart,ledger}, documents, expenses (§1.4 only), modules/expenses (§1.4 only) |
internal/platform/advances/…, internal/modules/advances/… |
internal/platform/pettycash/…, internal/modules/pettycash/…, plus the §2.2 edits in internal/platform/expenses |
| Migrations | 00034, 00054 |
00130–00149 |
00150–00169 |
| Provisioning step order | none | 110–119 |
120–129 |
| Workflow kinds | — | advance, advance_topup, advance_refund |
petty_cash_spend, float_topup |
| Permissions | — | advances.* |
pettycash.* |
| OpenAPI paths | — | /v1/advances/… |
/v1/pettycash/… |
| Reconcile keys | — | advance_control, advance_closed_nets_zero |
float_integrity, float_commitment, float_ledger |
| Document binder kind | expense_claim |
advance |
petty_cash_spend, float (close-out refund proof) |
| Idempotency | — | its own table | its own table |
- Plan 9 (
plan-9-reports, unmerged) holds00110–00129and orders100–109.00024–00029stay free for hotfixes. - The shared append-only list (wave-1 §3, as extended) applies unchanged. It also covers
cmd/migrate/reseal.go(Plan 7 only appends) and the four binaries’ module imports. - Neither plan edits the other’s packages,
internal/platform/payout, or any base-branch file.
4. Deliberate deferrals (both plans)
Section titled “4. Deliberate deferrals (both plans)”- Reminders (§7.6) — the computation ships, delivery is deferred.
- Plan 7 stores
due_onand exposes the overdue query and the escalation level (claimant, then manager) as data and API. - A River cron and a person-addressed notification need infrastructure that does not
exist on
main: River lands with Plan 9, there is no mailer, and the worker cannot enumerate tenants under RLS (Plan 4 R14). The delivery is a follow-up after Plan 9. - The ROADMAP sub-item “Acquittal reminders” stays unticked, with a note.
- Plan 7 stores
advance_offsetand paying an advance from the tin (§2.1).- Aged advances and the float register themselves are Plan 9’s D1 and D2. They follow once Plan 9 has merged.
5. Binding lessons (carried forward)
Section titled “5. Binding lessons (carried forward)”Everything in wave-2 §3 binds, and these are added:
- Lock order for petty cash is document → float → workflow instance.
- A claim settled from the tin locks claim, then float.
- A spend payout locks spend, then float.
- A top-up locks top-up, then float.
- A close-out locks the float.
All of these come before
workflow.Transition, and before anyaudit.Write.
- Start a workflow instance in an earlier transaction than its first
Transition(instance.go:283-298). Plan 7’s variance child is started in the acquittal transaction and first transitioned in a later one. - Every posting has a path back to a document and a decision (§11A.4, §11A.7):
- the acquittal’s receipt;
- the spend’s receipt;
- the top-up’s approval;
- the close-out’s refund proof. Each posting-rule test asserts it.
- Presence before absence. Every guard is shown failing by sabotage once.
6. Merge order
Section titled “6. Merge order”- Base
phase1-wave3merges first. - Plans 7 and 8 branch from it and may merge in either order. They share only append-only files.
- The later plan to merge takes
mainand runsmake generate. - Plan 9 renumbers nothing.
Migrations apply out of order, by design. Plans own number ranges and merge in any
order. A deployed database can therefore meet a migration numbered below one it has
already applied: the base’s 00034 and 00054 land after 00090, and Plan 9’s 00110
may land after Plan 7’s 00130. So cmd/migrate applies up with goose’s allow-missing
option. That makes one rule binding on every plan: a migration must commute with every
higher-numbered migration. It may redefine an object another range owns (a function, a
trigger, a CHECK) only after checking that no higher-numbered migration also redefines
it. The base branch carries a test that replays exactly this merge order.
7. Amendments from the base build
Section titled “7. Amendments from the base build”Binder.MayReadanddocuments.MayReadtake aclock.Clockaftertx. The expense read rule needs “today” for delegation windows, and every platform function takes its clock explicitly.owner_moduleis also frozen by theaccount_immutabletrigger (00054), not only left out ofUpdateAccount.- Lock order when binding a document: a consumer locks its own record first, then
the document (
documents.Lock), then callsBoundElsewhere. This matches Plan 5 Ruling R22, and it replaces §1.4’s “taken first”. The one-record rule holds because every binding path takes the document lock before checking. - A settlement proof counts as bound, but settling with a proof does not call
BoundElsewhere, because one bank slip may cover several claims.
8. Amendments from plan writing (accepted by the controller, 2026-09-22)
Section titled “8. Amendments from plan writing (accepted by the controller, 2026-09-22)”Writing the plans against the validator showed that the §2 graphs, as first drawn, do not publish. These amendments supersede §2 where they differ.
8.1 Plan 7
Section titled “8.1 Plan 7”- The variance legs get their own
draft(rule 1). Each leg is started in the acquittal transaction and opened by the claimant in a later transaction. - An acquittal of zero goes to a separate end state. It ends in
returned: cancelled semantic, target gate, reverses. It does not go toacquitted, because the ledger refuses a zero line.- The engine’s mirror of the payout puts the cash back on the payout’s own account.
- No refund leg is started.
- The branch is enforced three times: by the service, by which leg is started, and by a trigger.
- Dismissing a dispute needs acknowledged states. A dismissed dispute cannot re-enter
a posting state, because that makes a posting cycle. Plan 7 adds
acquittal_disputed/acquittal_confirmedtoadvance, andrefund_settledtoadvance_refund. - No uphold edge after acquittal. The engine would reverse the payout together with the accrual, and could not reach the variance legs’ entries. Correcting an acquitted figure is deferred to a later adjustment document kind.
advance_refundgainsrejected, so an upheld dispute can reverse.- A refused overspend leaves the excess owed and visible. The advance stays open, and a write-off kind is a later plan’s.
- The acquittal carries the §6.7 tax record: supplier name, supplier TIN, e-Invoice
UUID and document basis. It is validated by
internal/platform/taxrecord, which the base extracts fromexpenses. advancesmay importexpenses, for categories. It must never importpettycash.
8.2 Plan 8
Section titled “8.2 Plan 8”- A third kind,
float_closeout(cash_only), with its own table and grants.- Close-out cannot post as a manual entry, because a manual line on an owned account is refused (§1.5).
- It cannot post as a spend or a top-up, because those kinds’ single payout is spoken for.
- An empty float closes directly, with no close-out.
paid_confirmed(acknowledged, terminal) is added topetty_cash_spendandfloat_topup, so a dispute can be dismissed. Voiding a paid spend goes throughdisputed, because a terminal state may be left only into dispute.- Reviewer outcomes are strings:
queried,verified,not_reimbursable, stored as TEXT NULL (NULL = not reviewed). This replaces §2.2’s integers and follows AGENTS.md and §20.1.9. Every gate tests== "verified"exactly. - Spends carry the §6.7 tax record, validated by
taxrecord. A spend names a GL expense account, not a category, becausepettycashmay not importexpenses. - A claim settled from the tin records the account it actually credited. Its
settlement row stores the float’s account in
credit_account_id, not the 1510 placeholder. - Plan 8 may also edit the one petty-cash case in
internal/modules/expenses/errors.go, and its handler test. - A dispute raised after the float has closed cannot be upheld, and gets a typed refusal.
8.3 Both plans — document routes (base)
Section titled “8.3 Both plans — document routes (base)”POST /v1/documentsrequires the new permissiondocuments.document.upload, which is seeded on every filing role.GET /v1/documents/{id}and…/completeare authenticated-only, and the data rule decides. Forcomplete, only the uploader. ForGET, the uploader, or any binder’sMayRead, plus the expenseread_allshortcut.- Each plan’s binder honours that plan’s own
read_allpermission insideMayRead. - The 409 for a document bound elsewhere names only the kind.