Skip to content

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.

Research on main (dd6f26f) found five things neither plan may change itself:

  1. A payout-first instance is never reversed before its accrual. An advance that is rejected, cancelled or upheld in dispute after paid_out but before acquitted has accrual_open = false. So PostsToLedger() is false, and the payout stays on the ledger forever. Plan 3 Ruling 17 flagged this and it was never fixed.
  2. The spec’s single advance graph (§7.2) has a second cash leg, topup_paid or refund_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.
  3. float_topup (§8.3) has a payout and no accrual. Rule 3(c) refuses that under both posting orders.
  4. workflow.SeededGraphs is a hard-coded slice in Plan 3’s file. Both plans would edit the same line.
  5. 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.Event gains PastPayout bool, next to PastAccrual. PostsToLedger() becomes: Accrual or Payout, or (Rejected or Cancelled and (PastAccrual or PastPayout)).
  • workflow_instance gains payout_open BOOLEAN NOT NULL DEFAULT false (migration 00034). Entering a payout state sets it, and entering rejected or cancelled clears it. accrual_open behaves the same way. Transition fills Event.PastPayout from 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 reverses on a reject or cancel edge leaving any state at or past a payout, not only past an accrual. Existing seeded expense_claim graphs 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.PostingOrder gains CashOnly = "cash_only". A cash_only definition has no accrual state. Every path crosses at most one payout state. 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 accrual state is refused, with the new code accrual_in_cash_only.
  • The semantic payout means “the one cash leg of this document”. The kind’s payout rule decides direction. A refund’s rule posts Dr Bank / Cr Employee Advances. No new semantic is added, and journal_entry.semantic is unchanged.
  • constants.yaml (the posting orders, if listed there), vocab and the OpenAPI enum gain cash_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 fails KeyPattern. SeededGraphs(ownerSeat) returns expense_claim first, then the registered graphs in kind order.
  • expense_claim stays built in. Each plan registers its graphs from its own platform package’s init(). 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 helper workflowtest.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 Kind
func BoundElsewhere(ctx context.Context, tx pgx.Tx, documentID uuid.UUID,
except Binding) ([]Binding, error) // every consumer except the caller's own record
func 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.AttachDocument refuses a document that BoundElsewhere finds in any consumer. ErrDocumentOnAnotherClaim becomes ErrDocumentBoundElsewhere, and the 409’s detail names the kind and reference.
    • mayReadDocument in modules/expenses/document_handlers.go calls documents.MayRead.
  • expenses registers its binder. Plans 7 and 8 register theirs, and must call BoundElsewhere before 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 floats header. The accounts cannot carry a control kind: ResolveControl allows 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 stored cash_balance.
  • account gains owner_module TEXT NULL (migration 00054), with a CHECK account_owner_module_known for the closed set (pettycash). The set grows by migration.
    • ledger.Post refuses a manual line on an account with an owner, using the existing ControlAccountManualError family.
    • chart.CreateAccount accepts OwnerModule.
    • chart.UpdateAccount never changes it.
  • Plan 8 creates each float’s account with OwnerModule: "pettycash".
  • 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”
  • 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
    advance payout_first draft(draft) → pending_review(pending) → reviewed(pending) → approved(pending) → paid_out(payout) → received(acknowledged) → acquitted(accrual, control employee_advances, terminal); rejected, cancelled, disputed payout: Dr Employee Advances / Cr bank-or-channel. Accrual: Dr expense (the acquittal’s category) / Cr Employee Advances, at actual
    advance_topup cash_only topup_pending(pending: the approver signs off on the excess) → topup_approved(pending) → topup_paid(payout) → topup_received(acknowledged); rejected, disputed Dr Employee Advances / Cr bank-or-channel, at +variance
    advance_refund cash_only refund_pending(pending) → refund_confirmed(payout, terminal); disputed Dr bank / Cr Employee Advances, at −variance
    • The acquittal transaction (entering acquitted) evaluates the §7.3 decision table, then starts the advance_topup or advance_refund instance when variance ≠ 0. The variance child’s source_id is 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_kind is the workflow kind, so an advance’s postings carry source_kind ∈ {advance, advance_topup, advance_refund}. Plan 7 exports advances.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.
  • advance_offset stays 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 external channel only. Plan 7 refuses a petty_cash channel 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 stores payout.Snapshot JSON, so it must add its table to migrate reseal-payout-details (Plan 5 R45).

  • Names Plan 9 expects (D1): package internal/platform/advances, and ListForAging(ctx, tx, asOf) ([]AgingRef, error) with Plan 9’s AgingRef fields. The reference shape is AD-YYYY-NNNN, and every advance has a due_on date.

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=1 and NOT_REIMBURSABLE=2 (§20.1.9 constants). The engine has no guards, so Plan 8’s transition route refuses the pay edge unless outcome == VERIFIED. AvailableTransitions still lists it, and the route is the gate.
  • Void semantics (§8.2) follow from §1.1:
    • voiding from recorded reverses the accrual only;
    • voiding from spend_paid reverses both. Plan 8’s own code credits the float back only when a payout was reversed.
  • The float ledger is Plan 8’s own module write. It sits beside the posting, after workflow.Transition returns and before Plan 8’s audit.Write. Each float_movement row names the journal_entry_id, found through ledger by instance and semantic. cash_balance and committed change 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()’s ErrChannelPettyCash branch;
    • PayoutRule.Build’s ErrPettyCashDeferred branch;
    • 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. expenses may import pettycash. pettycash must never import expenses.
  • Names Plan 9 expects (D2): package internal/platform/pettycash, and ListFloats / GetFloat with FloatRef{ID, Code, HolderMemberID, HolderName, AccountID, OpenedOn, ClosedOn}. Every float movement posts to the float’s own account. The register never prints cash_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’s 1530 code. It refuses to open a float when the org’s chart has no such header.
  • The placeholder permission pettycash.float.disburse is Plan 8’s to adopt or retire.
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) holds 00110–00129 and orders 100–109. 00024–00029 stay 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.
  • Reminders (§7.6) — the computation ships, delivery is deferred.
    • Plan 7 stores due_on and 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.
  • advance_offset and 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.

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 any audit.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.
  1. Base phase1-wave3 merges first.
  2. Plans 7 and 8 branch from it and may merge in either order. They share only append-only files.
  3. The later plan to merge takes main and runs make generate.
  4. 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.

  • Binder.MayRead and documents.MayRead take a clock.Clock after tx. The expense read rule needs “today” for delegation windows, and every platform function takes its clock explicitly.
  • owner_module is also frozen by the account_immutable trigger (00054), not only left out of UpdateAccount.
  • Lock order when binding a document: a consumer locks its own record first, then the document (documents.Lock), then calls BoundElsewhere. 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.

  • 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 to acquitted, 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_confirmed to advance, and refund_settled to advance_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_refund gains rejected, 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 from expenses.
  • advances may import expenses, for categories. It must never import pettycash.
  • 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 to petty_cash_spend and float_topup, so a dispute can be dismissed. Voiding a paid spend goes through disputed, 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, because pettycash may not import expenses.
  • 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.
  • POST /v1/documents requires the new permission documents.document.upload, which is seeded on every filing role.
  • GET /v1/documents/{id} and …/complete are authenticated-only, and the data rule decides. For complete, only the uploader. For GET, the uploader, or any binder’s MayRead, plus the expense read_all shortcut.
  • Each plan’s binder honours that plan’s own read_all permission inside MayRead.
  • The 409 for a document bound elsewhere names only the kind.