Skip to content

ERP Architecture — Design Spec

Date: 2026-09-20 Status: Draft for review Scope: Phase 1 = expense claims, advances, petty cash, payout channels, double-entry GL Repo: mcomtask (greenfield)


A multi-tenant SaaS ERP. Phase 1 ships the expense-and-accounting core; later phases add payroll, assets, procurement, inventory and CRM. The architecture’s job is to make those later modules cheap rather than to anticipate their contents.

korre-pm-be and korre-project-management are reference only — no code is ported. They are consulted for two things:

  1. The expense claim flow, which is proven in production and is seeded here as the default workflow (Section 6).
  2. Design decisions worth keeping, cited inline where they are adopted.

The be-* / fe-* directories in repos/ are clones of those two repos (identical package.json names), not module splits. That pattern is not repeated here: this is one monorepo.

Decision Choice
Tenancy Multi-tenant SaaS, shared Postgres, org-scoped + RLS
Module shape Modular monolith with compiler-enforced boundaries
Backend Go + Gin + sqlc + pgx + goose + River
Frontend React 19 + Vite + TanStack Query + shadcn/Radix
API Spec-first OpenAPI (oapi-codegen), REST only, SSE for realtime
Jurisdiction Malaysia only, behind a CountryPack interface
Statutory compliance A design constraint, not a feature. Every design decision is checked against Section 11A before it is accepted
Claim flow korre flow seeded as the default, customizable per org
Step skipping by amount Out of Phase 1 — chain is fixed, as korre has it
Float top-up concepts Merged into one float_topup

Concern Choice Note
Language Go 1.26+ internal/ gives compiler-enforced module boundaries. go.mod is the source of truth for the version; CI reads it from there (amended 2026-09-22: pressly/goose/v3 needs 1.26)
HTTP Gin thin; handlers generated from OpenAPI
Data access sqlc plain SQL in, typed Go out; no runtime reflection
Driver / pool pgx v5 SET LOCAL per transaction for RLS
Migrations goose plain SQL, up/down, version table
Contracts oapi-codegen one openapi.yaml → Go server interfaces + TS client
Money shopspring/decimal + NUMERIC(19,4) a float never touches money
Validation go-playground/validator on generated request structs
Background work River Postgres-backed; enqueue in the same tx as the work
Cache / limiter Redis permission cache, rate limiting
Object storage S3-compatible (R2) presigned PUT; bytes never transit the API
Tests testify + testcontainers-go real Postgres, no mocked DB

korre-pm-be runs REST and GraphQL side by side, with a committed schema.gql to catch drift. An ERP needs versionable, auditable contracts more than it needs ad-hoc queries: every financial endpoint is a candidate for an audit question, and “which fields did this caller actually request” is a worse question than “which endpoint version did they call”. Realtime (claim status changes, notifications) is SSE over a single authenticated stream.


mcomtask-erp/
cmd/
api/main.go Gin server; wires modules, starts HTTP
worker/main.go River workers: FX sync, reminders, OCR, exports
migrate/main.go goose runner
internal/
kernel/ tenancy · audit · eventbus · money · db · config · errors
platform/
identity/ workflow/ documents/ notifications/
numbering/ tax/ reporting/ payouts/
modules/
expenses/ advances/ pettycash/ accounting/
api/
openapi.yaml the contract — single source of truth
db/
migrations/ goose .sql
queries/ .sql → sqlc
web/ React 19 + Vite
packages/
contracts/ TS types generated from openapi.yaml
mappings/ shared enum/lookup constants (generated to Go + TS)

Dependencies point downward only. This is the rule that keeps module 8 cheap.

Tier 3 BUSINESS expenses · advances · pettycash · accounting (Phase 1)
payroll · assets · procurement · inventory · crm (later)
↓ may depend on
Tier 2 PLATFORM identity · workflow · documents
notifications · numbering · tax · reporting · payouts
↓ may depend on
Tier 1 KERNEL tenancy · audit · eventbus · money · db · config · errors

Identical for every module, enforced by the Go compiler:

modules/expenses/
contract.go exported interface — the ONLY thing other modules may touch
events.go published event types
module.go constructor; registers workflow definition + posting rules
internal/ service, handlers, repo — UNIMPORTABLE from outside
queries/ sqlc .sql for this module's tables

A module communicates outward in exactly two ways: its contract.go interface, and the events in events.go. Cross-module reads that do not belong on a contract go through reporting/.


Every business table carries org_id UUID NOT NULL, including join tables — a table without org_id cannot carry an RLS policy, and becomes the hole.

Layer 1 — request context. Gin middleware reads the active org from a signed org claim in the access token and puts tenancy.Context on the request. There is no X-Org-Id header: a validated header is correct only while every code path remembers to validate it, and the failure mode is cross-tenant disclosure. Handlers never read org_id from a request body either.

Amended 2026-09-20 — this paragraph previously described a JWT-plus-header scheme, contradicting 4.4. See the session and authentication design for the resolution.

Layer 2 — Postgres RLS. Every request runs inside a transaction that opens with:

SET LOCAL app.current_org = '<uuid>';

and every table carries:

CREATE POLICY org_isolation ON expense_claims
USING (org_id = current_setting('app.current_org')::uuid);

SET LOCAL is transaction-scoped and therefore safe under pgbouncer transaction pooling: the setting dies with the transaction and cannot leak to the next borrower of the connection. This is why every request runs in a transaction, including reads.

The application role has no BYPASSRLS. Migrations run as a separate privileged role. A forgotten WHERE returns zero rows — a visible bug, not a silent breach.

Wrapped once in the kernel so no module author thinks about it:

func (d *DB) InTx(ctx context.Context, fn func(q *sqlc.Queries) error) error
Organization ──< OrganizationMember >── User
│
├── roles (many)
├── member_position ──> Position ──> OrgUnit (see 4.7)
├── cost_centre
├── employee_no
└── manager_override_id (exceptions only; the reporting
line is derived from the position tree)
Organization ──< Role >── RolePermission
Organization ──< CompanyProfile (1:1, see 4.6)
  • User is global (one login, many orgs). OrganizationMember is the tenant-scoped identity.
  • Roles are per-org and customizable. Seeded defaults: Employee, Approver, Finance, Admin, Owner. A hardcoded role enum is a Phase-2 emergency.
  • Permissions are string constants registered by the owning module — expenses.claim.approve, accounting.journal.post, pettycash.float.disburse. A new module’s permissions appear in the role editor automatically; there is no central enum.

4.3 Two authorization questions, kept separate

Section titled “4.3 Two authorization questions, kept separate”
  1. Can this role do this verb? — identity answers via permissions. Cached in Redis, invalidated by bumping perm_version on the org.
  2. On this specific record? — the owning module answers. “Is this approver in the chain for this claim, at this step, within their amount band” is claims/workflow logic.

Merging these two is how korre-pm-be arrived at a 981-LOC OrganizationAccessService.

Designed in full in 2026-09-20-session-and-auth-design.md. That document is authoritative for everything in this subsection; the summary below exists so this spec reads end to end.

Access JWT, 15 minutes, carrying sub, org, mem, pv and sid. Refresh token is an opaque random value — not a JWT — stored hashed, held in an httpOnly/Secure/SameSite=Lax cookie, rotated on use, with reuse detection outside a 10-second grace window revoking the whole family. A session family is the login, not the organization: switching orgs mints a new access token and never re-authenticates. Idle 14 days, absolute 90 days, never extended. Email and password with optional TOTP; no external identity provider, which also keeps authentication data inside Malaysia (11A.1).

Every authenticated request performs one liveness lookup on sid — the same Redis round trip 4.3 already needs — so a dismissed member loses authority on their next request rather than up to fifteen minutes later.

kernel/audit writes an append-only audit_log (actor, org, entity, action, before/after JSON, IP, request ID) in the same transaction as the change, so an audited action cannot commit without its trail. Built now, not when an auditor asks: retrofitting audit across 40 tables costs far more than wrapping it in the kernel once.

Append-only is not the same as tamper-evident. REVOKE UPDATE, DELETE stops the application; it does not stop anyone holding superuser. Each audit_log row therefore carries prev_hash and row_hash, chaining every row to its predecessor within an org, so an altered or excised row breaks the chain and a verification pass can say where. This is what lets the trail answer an auditor rather than merely exist (11A.5).

Not a settings screen — a hard dependency of the ledger, and the first thing created when an organization is provisioned. Each field below blocks something downstream:

company_profile (org_id PK, legal_name, trading_name, registration_no,
country_code, base_currency, fiscal_year_starts_on,
tin, sst_registration_no, msic_code,
registered_address, mailing_address, logo_file_id,
default_locale, timezone)
Field Blocks
country_code Which CountryPack seeds the chart of accounts, journals and taxes (Section 11)
base_currency Every base_amount and every threshold conversion (5.4, 6.5)
fiscal_year_starts_on Fiscal periods, year-end close, every report range (10.4)
tin, sst_registration_no, msic_code e-Invoice and SST. Must exist before any document is issued — they cannot be backfilled onto settled documents
registered_address, logo_file_id Document and print layouts

Provisioning order is therefore fixed: company profile → country pack seeds chart of accounts + journals + taxes → org structure → everything else. Changing base_currency or fiscal_year_starts_on after any entry is posted is refused; it is a new entity, not an edit.

Switching between organizations is already handled: X-Org-Id selects the tenant (12.2), membership is validated per request (4.1), and the switcher is the company dropdown at the top of the sidebar in the Figma design. Switching swaps roles, permissions, chart of accounts and workflow definitions together.

Three distinct trees are commonly conflated. They are modelled separately here because approval routing depends on telling them apart.

  1. Org unit tree — Company → Division → Department → Team. Structure.
  2. Position tree — CEO → CTO → FE Manager. Seats, which exist whether or not anyone fills them.
  3. Reporting line — who reports to whom. Derived from the position tree, with an override for dotted lines and acting arrangements.
org_unit (id, org_id, parent_id, code, name_i18n, path, unit_head_position_id)
position (id, org_id, org_unit_id, code, title_i18n,
reports_to_position_id, path, is_active)
member_position (member_id, position_id, is_primary, valid_from, valid_to)

Three properties carry the weight:

  • reports_to_position_id is the hierarchy — CEO at the root, CTO and COO beneath, FE/BE managers beneath the CTO. A materialized path makes “everyone under the CTO” one indexed prefix query instead of a recursive CTE per approval check.
  • Positions and people are separate. A position can be vacant, a person can hold two, and someone can act in a position for a fixed window (valid_from / valid_to). Storing the title on the person instead means a resignation silently breaks approval routing.
  • One source of truth for the reporting line. It derives from the position tree; manager_override_id exists only for exceptions. Two stored hierarchies drift.

Out of scope for Phase 1, and deliberately: job grades, salary bands, headcount planning, vacancy and requisition workflows. That is the HR layer (Phase 5). Phase 1 builds only what approval routing needs.



The single highest-leverage extraction in the design. Everything in Sections 6–9 is built on it, and every future module registers into it instead of growing its own state machine.

In korre-pm-be, the claim state machine, its approval routing, escalation, delegation, disputes and amount bands all live inside one 3.6k-LOC service. That logic is worth zero to payroll. Here it is a platform module, and claims are its first consumer.

One definition per claim type, per org, versioned:

workflow_definition (id, org_id, kind, version, status, published_at, posting_order)
workflow_state (definition_id, key, label_i18n, semantic, semantic_params,
display_order, is_terminal)
workflow_edge (definition_id, from_key, to_key, gate, action_label_i18n, guard)
workflow_gate_grant (definition_id, state_key, grantee_kind, grantee_ref,
min_amount, min_amount_input, min_amount_currency,
all_states, role_set_id)
workflow_instance (entity_kind, entity_id, definition_id, definition_version, state_key)
workflow_decision (instance_id, from_key, to_key, actor_id, decided_at, reason, ip)

Three properties do the work:

  1. States are stable string keys scoped to a definition version. No global number space, no retired-but-immortal values, no reserved ranges. Contrast with korre, where SETTLED: 8 is retired but permanent and SPEND_PAID is 18 because 15–17 were reserved for an unrelated flow.
  2. Every claim is pinned to the definition version it was submitted under. Editing publishes v(n+1) and touches nothing in flight. This is what makes customization offerable to a live customer.
  3. Each claim type has its own definition. korre’s LEGAL_EDGES is keyed on status alone and “cannot see claimType”, which is why assertAcquittable, assertRefundable, assertTopupPayable, assertRecordableType, assertVoidableType and assertPayableSpend exist. With per-type definitions those guards disappear: an advance’s graph has the acquittal edge; a receipt’s does not.

5.3 Gates — adopted from korre unchanged

Section titled “5.3 Gates — adopted from korre unchanged”

gate on an edge is one of:

Gate Authorized by
reporter The submitter, and nobody else
target Grants on the destination state
source Grants on the state being left (used by dispute exits, so the raiser cannot close their own)
advance Grants on the state this rejection replaces

Derived from the graph rather than restated, so the two readings cannot drift.

The threshold is a column on the grant row — on (person, step). There is no step-level threshold stored anywhere.

Consequences, all of them load-bearing:

  • The same person can carry different floors on different steps — sign off review at any amount, approve only up to RM5,000.
  • “The step’s ladder” is derived at read time from all grants on that step. This is why the winning floor must be computed over the whole step’s rows and membership against the actor’s rows, separately. Narrowing to the actor first makes their own floor the largest at every amount, and the RM0 approver authorizes the RM5,000 claims reserved for the CEO.
  • Floors only, never ceilings. A band’s ceiling is the next larger floor on the same step. Two admins cannot leave a gap between RM2,000 and RM2,500, and adding a CFO at RM10,000 shortens the director’s reach without editing the director’s row.
  • Half-open [floor, next) — RM2,000.00 exactly lands in the upper band and nowhere else.
  • min_amount = NULL means that grant applies at every amount. It is not “unconfigured”.
  • No fallback. If no band reaches down to the amount, nobody is authorized and the move is refused.
  • Converted once at save, never at approval. A missing FX rate must never make a claim unapprovable; an overnight rate move must never silently reassign an approver. min_amount_input + currency are retained because “USD 500” and “MYR 2,235” are different policies even when they are the same number today.
  • Role sets are generators, not holders. A role set writes person-rows and owns them (role_set_id set); hand-added rows have it null and recompute never touches them. The threshold always ends up on a person’s row.

grantee_kind is a closed set. The first is what korre has today; the rest are what the position tree (4.7) makes possible:

grantee_kind grantee_ref Resolves to
member member id That person. korre’s only mode.
superior — The claimant’s own superior, resolved per claim
position position id Whoever currently holds that position
unit_head — The head of the claimant’s org unit
levels_up integer n The position n levels above the claimant’s, up the reports_to tree

So “above RM50,000 → the claimant’s department head” and “level 2 → two positions up” become policy that survives a resignation or a reorg, rather than a list of names that has to be re-edited. Escalation and delegation gain the same property: escalation walks up the tree instead of to a hardcoded successor.

superior is the kind that makes a shared step usable at all. Everyone has a different superior, so a step meaning “the claimant’s superior” cannot be written as a list of named people — it has to resolve per claim. An admin configures the review step once, and every claimant’s claim goes to their own manager. levels_up: 1 states the same thing generally; superior exists as its own kind because it is the common case and because it carries the fallback chain below, which levels_up does not.

Each of these is still one grant row carrying its own amount floor, so “superior signs up to RM2,000, above that the unit head as well” is two rows — not two lists of people.

korre already does this for the default-group case (SuperiorService.superiorOf), in a chain that is one hop, never transitive:

  1. the member’s own superiorId, else
  2. their department’s head (a retired department still counts), else
  3. null — nobody is notified

The chain is adopted, re-expressed against the position tree (4.7), and extended where it is thin:

  1. member.manager_override_id — the explicit exception, if set
  2. the holder of the claimant’s primary position’s reports_to_position_id
  3. the holder of the claimant’s org unit’s unit_head_position_id
  4. nothing — and this is where the behaviour changes

Case 4 becomes a refusal, not silence. korre’s own guide is candid that a null superior “produces no warning” and that preventing it “is the case your admin UI has to prevent”. That is a claim submitted into a queue nobody is watching. Here a superior grant that resolves to nobody refuses the submission, naming the claimant and the missing link, so the gap surfaces to someone who can fix it instead of to a claim that silently rots.

Three edge cases the chain answers explicitly:

  • The seat is vacant. on_vacancy on the grant: escalate walks up the reports_to tree to the next filled seat, bounded by max_escalation_levels; refuse stops. Default is escalate, bound 2 — unbounded escalation routes an RM100 claim to the CEO.
  • The superior is the claimant. A department head filing their own claim hits the existing self-approval refusal; the chain escalates one level up rather than blocking.
  • The claimant holds several positions. Resolution uses the position flagged is_primary. A member with no primary position is a configuration error caught at assignment time, not at submission.

Non-goal: the claimant never picks their own approver. Grants are configured by an administrator on the step, and the system resolves them. There is no ad-hoc “send this to X” at submission. This is deliberate — letting a claimant choose their approver lets them choose a friendly one, and it turns the approval matrix from a control into a suggestion. If an org chart is too incomplete for superior to resolve, the answer is to fix the org chart, which the submission refusal above forces.

Resolution happens before the floor computation, and the order is load-bearing. Section 5.4’s rule is that the winning floor is computed over the whole step’s grants. With dynamic grantees the step’s grant list is not known until the claimant is known, so the sequence per claim is: resolve every grant on the step to a concrete member set → compute the winning floor over that resolved list → test the actor’s membership. Computing the floor over unresolved rows would let a vacant position’s band distort the ladder.

Two consequences to handle explicitly:

  • A grant resolving to nobody (vacant position, deleted org unit, missing superior) is treated as absent, and publish-time validation rule 7 — bands must reach down to zero — is re-checked at submission for dynamic grants, since a vacancy can open a hole after publish. The claim is refused with a message naming the vacant position, not a generic error.
  • A grant resolving to the claimant themselves hits the existing self-approval refusal; the chain escalates one level up rather than blocking.

all_states = true replaces korre’s CLAIM_APPROVER_ALL_STEPS = -1, carrying the same three properties: authority at every step, exemption from the self-approval refusal on the posting transitions, and standing on the claimant’s own edges except the final acknowledgement.

5.5 Semantic roles — the accounting contract

Section titled “5.5 Semantic roles — the accounting contract”

Every state declares a semantic from a closed, system-owned set. accounting/ subscribes to semantics and never to state names.

Semantic Meaning Ledger effect
draft Editable, not submitted none
pending Awaiting a decision none
accrual Expense account confirmed posts; semantic_params.control_account names the credit side
payout Money left the org posts cash leg + FX gain/loss
acknowledged Claimant confirmed receipt none
dispute Contested none; resolution may reverse
rejected / cancelled Terminal, unsuccessful reverses accrual if past one
custom Org-invented step none, by construction

This is what makes customization safe. Insert four approval steps, rename “Reviewed” to “HOD Sign-off”, translate the whole flow — the ledger is untouched, because it was never listening to names.

semantic_params.control_account is required because the credit side differs by flow: AP control for a receipt claim, Employee Advances for an advance, Petty Cash AP for a spend.

Tier What an org can do Gate
1 — Presentation Rename states, translate, reorder display, change action labels, set colours Self-serve
2 — Structure Toggle optional steps off, insert custom states, set grants/thresholds/role sets, escalation SLAs, required fields per state Self-serve, must pass validation
3 — Semantics Move where accrual or payout sits; add a terminal branch Blocked in UI; support-assisted

A definition cannot be published unless:

  1. Exactly one state has semantic = draft; at least one state is terminal.
  2. Every state is reachable from draft, and every non-terminal state has an exit. (No repeat of SETTLED: 8.)
  3. On any path from draft to a terminal state: at most one accrual and at most one payout; and any path crossing payout must cross accrual somewhere — before or after. Ordering is declared per definition (accrual_first | payout_first), not assumed. (Advances pay out before they accrue — see 7.3.)
  4. Every target / source / advance-gated edge resolves to at least one grant.
  5. Every rejection edge from a state at or past accrual carries a reversal action.
  6. An advance-gated edge requires exactly one forward non-reporter edge from its source.
  7. The grants on each gated step collectively reach down to zero (korre’s assertBandsCoverZero, moved from runtime to publish time).

Validation runs live in the editor, so an admin sees “Reviewed has no exit” while editing, not when a claimant’s claim jams.

Multi-level chains · amount bands · role sets and quorum · delegation (out-of-office, original approver recorded) · escalation (auto-advance after N idle days) · return-for-correction · dispute with mandatory reason · immutable decision log.

Payroll registers a definition later and inherits all of it.


ExpenseClaim (header) ──< ExpenseClaimLine (the money) ──< Attachment (documents/)
│ │
│ └── category · tax_code · cost_centre · project · rate_id
├── ClaimGroup (a trip, an event, a batch)
├── WorkflowInstance (workflow/)
├── PayoutInstruction (payouts/)
└── JournalEntry (accounting/)

Claim is the header; Line is the money. korre models a claim as an item, which is why grouping, per-line tax and partial approval were bolted on afterwards. A line carries its own category, tax code, cost centre and currency, so partial rejection (“everything except this RM400 dinner”) and per-line GL coding need no schema change.

One table with a discriminator; each type validates differently.

Type Computed from
RECEIPT Entered amount + attached proof
MILEAGE distance x ExpenseRate effective at the expense date
PER_DIEM days x rate, by destination band
ADVANCE_OFFSET Negative line settling an outstanding advance

Rates are date-effective rows, never edited in place: (org_id, kind, effective_from, effective_to, amount). Recomputing a 2024 claim must use the 2024 mileage rate, or the ledger and the reports disagree.

Taken from korre-pm-be and seeded as kind = expense_claim, version = 1:

draft → pending_review → reviewed → approved → category_verified → completed → received
│ │ │ │
└────────────┴──────────┴─────────────┴──→ rejected → draft
completed ─→ disputed
received ─→ disputed

Semantic tags: category_verified → accrual (control account: AP control), completed → payout, received → acknowledged.

Two korre decisions preserved:

  • Approval precedes the category check, and the accrual was moved with it rather than the steps being moved back. The invariant “never accrue to an account no human has verified” is preserved by where the accrual posts, not by step order.
  • category_verified is the posted-but-unpaid state, so rejecting from it must reverse the accrual. Rejecting from any earlier state posts nothing.

completed → approved does not exist. A claimant reporting that money never arrived raises a dispute, and a second person decides whether the payment is reversed. One mechanism, and reversal takes two people.

accrual is the point of no return. Before it a claim is editable and nothing has hit the ledger. At it the journal entry posts. After it, corrections happen by reversal and re-post, never by mutation. This is what makes the ledger append-only — and it leaves the door open to event sourcing later without requiring it now.

  • NUMERIC(19,4) in Postgres, shopspring/decimal in Go. A float never touches money — not in a DTO, not in a JSON response, not in a chart.
  • Every monetary value travels as a triple: amount, currency, base_amount.
  • base_amount is computed once, at a defined event (claims: the expense date’s rate) and then frozen. Recomputing on read means the trial balance changes when the FX table updates.
  • exchange_rate (from, to, date, rate, source), synced nightly from Bank Negara by a River job; manual override allowed and recorded.
  • FX gain/loss on settlement (rate moved between accrual and payout) posts to a dedicated GL account.

platform/documents owns presigned-PUT upload (bytes never transit the API), thumbnailing in a River job, checksum dedup and virus scanning. On upload it emits DocumentUploaded; the OCR worker extracts merchant, date, total and tax and writes a suggestion, never a value. The user confirms. Auto-filling from OCR without confirmation produces a ledger full of confidently wrong numbers.

OCR is bound by residency (11A.1). A receipt is a statutory record, so the OCR step may not ship images to a processor outside Malaysia. This rules out the convenient hosted vision APIs and makes OCR an in-cluster workload.

6.7 What a claim line must carry for the tax record

Section titled “6.7 What a claim line must carry for the tax record”

An expense claim’s supporting document is, from 2026, normally a validated e-Invoice issued by the supplier. The line therefore stores the identifiers that make it findable later — supplier_tin, einvoice_uuid, einvoice_validated_at, and a document_basis discriminator recording why this line’s support is acceptable:

document_basis When it applies What must be present
einvoice_company Supplier issued an e-Invoice in the organization’s name supplier_tin, einvoice_uuid
einvoice_employee Supplier issued it in the claimant’s name The above, and the category must permit it (below)
foreign_supplier Supplier is outside Malaysia Ordinary receipt; no e-Invoice, no self-billed document
relaxation_receipt Supplier could not issue one during a relaxation period Receipt, plus the reason recorded

An e-Invoice in the employee’s name only supports a deduction where that expense is provided for in the contract of employment. That is a policy fact about a category, not about a line, so expense_category carries employee_named_document_allowed, and a line claiming einvoice_employee against a category that forbids it is rejected at validation rather than discovered at audit.

These fields are Phase 1 even though submission is not. Section 4.6 already states the rule they follow from: identifiers cannot be backfilled onto settled documents.


The receipt requirement is deferred to acquittal, not waived. An advance payout is Dr Employee Advances / Cr Bank — it claims no deduction. The deduction is taken at acquittal against the real receipt. If an advance could reach paid-out with no receipt ever demanded, company money would have left against no document, permanently.

draft → pending_review → reviewed → approved → paid_out → received → acquitted
[payout] [accrual]
┌──────────┴──────────┐
variance < 0 ──────────────────▶ refund_pending → refund_confirmed
variance = 0 ──────────────────▶ (terminal at acquitted)
variance > 0 ──────────────────▶ topup_pending → topup_paid → topup_received

received is the claimant signing for the money. acquitted is a separate move where they supply the real receipt and the real amount. Disputes are reachable from every post-payout state.

Kept database-free and heavily tested (korre’s validateAcquittal, adopted):

actual Required Result
> 0 receipt file and a date on it variance = actual − approved
= 0 no receipt (none exists); reason required whole advance comes back
< 0 — refused; DB constraint mirrors it

Variance is signed and fans out three ways. An overspend is repaid only after an approver signs off on the excess — the budget is real, enforced by a signature rather than by a 400 at acquittal that would strand a claimant who has already bought the thing.

7.4 Ledger — advance 1,000, actual 1,200

Section titled “7.4 Ledger — advance 1,000, actual 1,200”
paid_out Dr Employee Advances 1,000 Cr Bank 1,000 → advances 1,000
acquitted Dr Expense 1,200 Cr Employee Advances 1,200 → advances (200)
topup_paid Dr Employee Advances 200 Cr Bank 200 → advances 0

Underspend mirrors it, with refund_confirmed posting Dr Bank / Cr Employee Advances. The Employee Advances control account nets to zero on every closed advance. Any non-zero row on it is an open or broken advance — this is the report finance will run.

An advance pays out before it accrues. Its definition declares posting_order = payout_first, which is why validation rule 3 (Section 5.7) permits accrual on either side of payout.

Unacquitted advances are an unreconciled asset on the balance sheet. A River cron emits escalating reminders to the claimant and their manager.


8.1 A float is a cash account someone is personally accountable for

Section titled “8.1 A float is a cash account someone is personally accountable for”
petty_cash_float (id, org_id, holder_id, cash_balance, committed, status,
refund_proof_file_id, opened_at, closed_at, ...)
float_movement (float_id, occurred_at, amount signed, kind,
source_type, source_id, journal_entry_id)

Balance is stored, not summed. korre’s reasoning is adopted: the overdraw refusal needs a row to lock (SELECT … FOR UPDATE), and a derived figure has none; summing would also get monotonically slower for the float’s lifetime.

A stored balance needs an auditable source. korre gets this by forcing every float movement to be a claim row — which is why spends, top-ups and refunds are claim types wearing borrowed status numbers. Here the source is an explicit float_movement ledger, with the same invariant checked by reconciliation:

cash_balance = SUM(float_movement.amount)

A movement may originate from a spend, a top-up, a refund or a closure without all four having to pretend to be claims. This is what lets each get an honest workflow definition instead of being kept off the main graph by not provisioning a kanban column.

Two balances, not one (korre derives committedSpends; here it is stored):

  • cash_balance — notes physically in the tin
  • committed — recorded but not yet paid
  • available = cash_balance − committed — what the overdraw check tests

Float status: open → closing → closed. Refund proof is required on entry to closing — the same asymmetry as an advance’s refund.

8.2 Spending is two-phase, and that is the control

Section titled “8.2 Spending is two-phase, and that is the control”
draft → recorded → spend_paid recorded: Dr Expense / Cr Petty Cash AP
│ ▲ [payout] commits the float; NO cash moves
│ └── reviewer spend_paid: Dr Petty Cash AP / Cr Petty Cash Float
├→ voided cash leaves the tin; float debited
└→ not_reimbursable
[accrual]

Reviewer outcomes, and the values matter:

Outcome Meaning Effect
QUERIED (0) The record is wrong Holder voids and re-records. Zero deliberately, so a client rendering it as truthy fails towards “not verified”, never a green tick
VERIFIED (1) Checked against the receipt The only value that opens the pay edge
NOT_REIMBURSABLE (2) The record is right, the org still will not pay Personal item, out of policy. Not QUERIED — that demands a correction that never comes and queues the spend forever

Both gates test == VERIFIED, never truthiness.

Void semantics depend on where you void from:

  • From recorded — only the accrual reverses. The float is not touched: it was committed, never debited, and crediting it would invent cash.
  • From spend_paid — the cash leg reverses too and the float is credited back, because the money really did leave.

Change from korre: a NOT_REIMBURSABLE spend gets its own terminal state that reverses the accrual and closes the item. Today its only exit is void, which means “I recorded it wrong” — the wrong sentence for “the record is right and we are not paying”.

Cash into the tin: requested → reviewed → approved → paid. paid posts Dr Petty Cash Float / Cr Bank and is the only place the balance moves.

Its approvers are a separate grant list from the claim review/approval steps. korre’s reasoning is preserved: a claim approved wrongly is a payment against a receipt somebody can still be asked about; cash approved wrongly into someone’s drawer is untraced. An org that wants the same people doing both says so by listing the same people.

Because this is its own definition, it needs no -2 / -3 sentinels.

Merged concept. korre has two overlapping routes — CLAIM_TYPE.PETTY_CASH_TOPUP (a claim crediting a holder’s float on completion) and FLOAT_TOPUP_* (an off-board holder request). Both end in the same journal entry. They are merged into one float_topup definition. An org needing two routes with different approvers publishes two definitions; what it cannot change is the journal entry, which the semantic tag fixes.

8.4 Replenishment and settlement from the tin

Section titled “8.4 Replenishment and settlement from the tin”

Replenishment counts only VERIFIED spends. A claim settled via the petty_cash payout channel (Section 9) debits the paying holder’s float in the same transaction as the settlement and credits the petty cash account rather than bank — gated on the payer holding an open float with enough available.


korre has two payment concepts, and only the meaningless one is customizable:

PaymentMethod DISBURSEMENT_METHOD
Shape Org table, free-text name Hardcoded int enum + check constraint
Behaviour none — a label Drives six disbursement_* columns, masking, exports, petty-cash settlement
Customizable Yes, but inert No — new method = enum + migration + constraint + frontend

The columns behind the enum are a union type with implicit rules: E_WALLET stores the provider in bank_name and the wallet ID in account_no; DUITNOW puts a proxy in account_no with no bank at all; CRYPTO ignores both. The schema comment concedes the gap: “a conditional rule per method is the next step if these turn out to be filled inconsistently.”

payout_channel (id, org_id, key, label_i18n, is_active, sort_order,
settlement_behaviour, -- CLOSED SET: external | petty_cash
credit_account_id, -- which GL account the payout credits
field_schema JSONB,
allowed_currencies, allowed_claim_types, max_amount,
requires_settlement_proof)

credit_account_id is what makes this useful rather than cosmetic. An org adds “Maybank Current — MYR” and “Wise — USD” as two channels bound to two GL accounts, and payouts post to the right bank without code changes.

settlement_behaviour stays closed at two values, because behaviour touches money:

  • external — the transfer happens outside the system; the channel is a routing note plus a GL credit account. Six of korre’s seven methods are this.
  • petty_cash — the only method that moves money inside the system. Debits the paying holder’s float and credits the petty cash account in the same transaction; gated on an open float with enough available; auto-raises the payout request for the holders’ queue.

9.3 field_schema removes the overloaded columns

Section titled “9.3 field_schema removes the overloaded columns”
[
{ "key": "payee_name", "type": "text", "required": true, "max": 140 },
{ "key": "duitnow_id", "type": "text", "required": true, "sensitivity": "masked",
"label": { "en": "DuitNow ID", "zh": "DuitNow 号码" },
"help": "Mobile, NRIC, passport, army/police no. or business reg no." }
]

Types: text · select · registry_ref · phone · account_no · file · address. registry_ref points at an org-maintained list — a generalization of korre’s CryptoPayoutOption, so bank lists and e-wallet provider lists work the same way.

Two properties carried over:

  • sensitivity: masked drives approver-facing masking, so the channel decides what is hidden rather than hardcoded knowledge of which column is an account number.
  • Labels are snapshotted onto the claim on write — a later rename must never restate what a settled claim was paid to. korre does this for the crypto label; here it is the rule for every field.
payout_channel_id, payout_details JSONB -- validated against field_schema on write
settled_channel_snapshot JSONB, settled_details JSONB -- frozen at settlement

Replaces six disbursement_* columns plus six settled_disbursement_* copies. korre’s snapshot instinct is right and generalizes rather than disappearing.

Cost, stated honestly: JSONB loses column-level constraints and makes ad-hoc SQL reporting harder. Three mitigations, all of them applied:

  1. Validate against the schema in one place on write.
  2. A DB CHECK that required keys are present and non-empty.
  3. Generated columns for the two things reports and exports actually filter on — channel key and the masked account tail.

korre’s seven enum values become seven rows, and the ambiguity in three disappears:

Channel Behaviour Fields
Cash external payee name
Bank transfer external payee name, bank (registry), account no. (masked)
DuitNow external payee name, DuitNow ID (masked) — no bank field
E-wallet external payee name, provider (registry), wallet ID (masked) — not “bank name”
Cheque external payee name, cheque no.
Crypto external network/token (registry), address (masked)
Petty cash petty_cash payee name; float resolved at settlement
  • Member payout profiles — saved details per channel, prefilling the claim; the snapshot rule still applies at settlement. (korre’s UserPayoutProfile.)
  • Eligibility — allowed_claim_types, allowed_currencies, max_amount.
  • Bulk payment files — a channel may later declare an export profile (DuitNow bulk, bank CSV). Not Phase 1; the channel is the right place to hang it.
  1. Every channel binds a credit_account_id that exists and is a cash/bank-type account.
  2. A petty_cash channel cannot require bank fields.
  3. Every registry_ref field points at a registry with at least one active row.
  4. Deactivating is allowed; deleting a channel any claim references is not.

account (id, org_id, code, name_i18n, parent_id, path, type, normal_balance,
is_control, control_kind, is_postable, currency, is_active)
  • type ∈ asset | liability | equity | income | expense; normal_balance derives from it and is stored so reports need no case statement.
  • Tree with a materialized path, so “all expenses under Travel” is one indexed prefix query rather than a recursive CTE on every report.
  • Only leaf accounts are postable (is_postable). Posting to a parent makes the tree unusable for reporting.
  • Control accounts (is_control, control_kind ∈ ap | employee_advances | petty_cash_ap | ar | bank | fx_gain_loss) are what the workflow’s semantic_params.control_account resolves against. A module never hardcodes an account code; it names a control kind and the org’s chart answers.
  • Seeded from the Malaysia country pack; fully editable, with codes unique per org.
journal (id, org_id, code, name_i18n, type, default_account_id,
sequence_key, currency, is_active)
journal_entry (id, org_id, journal_id, entry_no, entry_date, fiscal_period_id,
source_kind, source_id, description, reversal_of_id, posted_at, posted_by)
journal_line (id, entry_id, account_id, debit, credit, base_debit, base_credit,
currency, fx_rate, party_id, tax_code_id, memo)
analytic_axis (id, org_id, key, label_i18n, is_required, applies_to)
analytic_account (id, org_id, axis_id, code, name, parent_id, is_active)
analytic_distribution (journal_line_id, analytic_account_id, percentage, base_amount)

Every entry belongs to a journal (sales · purchase · bank · cash · misc), which carries its own numbering sequence, default account and currency. Bank and cash accounts are reached through a journal rather than floating in the chart — the pattern both AutoCount and Odoo use, and the reason a payout channel’s credit_account_id (Section 9.2) resolves to a bank journal.

Dimensions are configurable axes with ratio distributions, not columns. Cost centre and project are two seeded analytic_axis rows; a customer adding Branch adds a row. A line can split 60/40 across two projects, which a project_id column cannot express. applies_to and is_required let an axis be mandatory on expense lines and absent on bank lines.

Invariants, enforced in the database, not only in Go:

  1. SUM(debit) = SUM(credit) and SUM(base_debit) = SUM(base_credit) per entry — a deferred constraint trigger, so a multi-line insert can be built up inside the transaction and is checked at commit.
  2. Every line hits exactly one of debit/credit; both non-negative.
  3. Append-only. A posted entry is never updated or deleted. Corrections post a reversal entry with reversal_of_id set, and the reversal carries its own date.
  4. entry_no comes from platform/numbering — gapless per org per fiscal year, allocated inside the posting transaction.
  5. Posting into a closed fiscal period is refused (10.4).
  6. Analytic distributions on a line sum to exactly 100% where the axis is required for that account, and to 0 or 100% otherwise. A partially-tagged line is a reporting hole.

Modules do not write journal lines. They emit events; accounting/ owns the translation.

type PostingRule interface {
Kind() string // "expense_claim", "petty_cash_spend", ...
Semantic() workflow.Semantic // accrual | payout | reversal
Build(ctx context.Context, tx Tx, src Source) ([]Line, error)
}

A module calls accounting.RegisterPostingRule(...) at boot. When a workflow instance enters a state tagged accrual or payout, the engine resolves the rule for (kind, semantic) and posts inside the same database transaction as the state change. A state change that cannot post does not commit.

This is the reason for the modular monolith. Distributed, this is a saga with compensating transactions over a ledger; in-process, it is one BEGIN.

Reversal is symmetric: entering a terminal rejected/cancelled/voided state from at or past accrual builds the mirrored entry from the original, never a recomputation.

fiscal_year (id, org_id, name, starts_on, ends_on, status)
fiscal_period (id, fiscal_year_id, seq, starts_on, ends_on, status) -- open | closed | locked
  • Posting resolves entry_date → fiscal_period and refuses anything not open.
  • Closing a period is a checked operation, not a flag flip: it runs the reconciliation set (10.6) and refuses on failure.
  • Year-end close posts the P&L-to-retained-earnings entry and writes a close_log with the balances as they stood — so a restatement question has an answer.
  • base_amount frozen at the defining event (Section 6.5).
  • FX gain/loss where the rate moved between accrual and payout: the difference posts to the fx_gain_loss control account as a third line on the payout entry. It is never absorbed into the expense.
  • Nightly Bank Negara sync via River; manual override recorded with actor and reason.

Run on demand, nightly, and as a precondition of period close:

Check Assertion
Float integrity cash_balance = SUM(float_movement.amount) for every open float
Float commitment committed = SUM(recorded-but-unpaid spends)
Advances control balance = SUM(open advances at their outstanding amount)
AP control balance = SUM(accrued-but-unpaid claims)
Trial balance SUM(base_debit) = SUM(base_credit) across all posted entries
Orphans no claim past accrual without a journal entry, and none before with one

Each failure names the offending rows. A reconciliation that only says “out of balance” is not a control.

Trial balance · P&L · balance sheet · general ledger detail · aged advances · float register · claim register. All built on one query layer in platform/reporting that takes a period, an account subtree (path prefix) and dimension filters (cost centre, project, department), so a new report is a projection rather than a new query engine. Export to CSV / XLSX / PDF runs in a River job for anything non-trivial.


platform/tax exposes a single interface:

type CountryPack interface {
Code() string // "MY"
SeedChartOfAccounts(ctx, org) ([]Account, error)
SeedJournals(ctx, org) ([]Journal, error)
SeedTaxes(ctx, org) ([]TaxCode, error)
StatutoryReports() []ReportDef
ResolveTaxCodes(ctx, org) ([]TaxCode, error)
ComputeTax(ctx, line TaxableLine) (TaxResult, error)
EInvoicePayload(ctx, doc Document) ([]byte, error)
FilingPeriods(ctx, year int) ([]Period, error)
}

A country pack seeds the chart of accounts, the journals, the taxes and the statutory reports together — Odoo’s l10n_XX scope, adopted because splitting them means every new country needs code in three modules.

Phase 1 ships MalaysiaPack only, with SST codes and the MyInvois payload shape stubbed behind the interface. Nothing outside platform/tax imports anything named SST, LHDN or MyInvois. That is what makes “Malaysia only, for now” cheap to widen rather than a promise that will not survive the first Singapore customer.

E-invoice submission itself (MyInvois API calls, retries, status polling) is Phase 2, but the document model carries the fields it will need — supplier TIN, buyer TIN, classification code — from Phase 1, because backfilling identifiers onto settled documents is not possible.


11A. Statutory compliance — the constraint the design is checked against

Section titled “11A. Statutory compliance — the constraint the design is checked against”

Not reviewed by a tax agent. Every requirement below cites the provision it comes from so a Malaysian tax agent or licensed auditor can check it against the source rather than against Go code. Nothing in the build waits on that review, but it must happen before a paying customer’s data lands. Deadlines in this area have moved repeatedly; re-verify before relying on any date. Last verified 2026-09-20.

Compliance here means one thing: when LHDN or an external auditor asks a question about a number, the system can answer it from its own records, seven years later. Each requirement below is expressed as something the design must make true, not as a feature.

ITA 1967 s.82(8) — records relating to a business in Malaysia shall be kept and retained in Malaysia.

Binding on deployment, not on code: the Postgres volume, the object store, every backup, and the disaster-recovery copy all live in a Malaysian datacenter. Because the target deployment is a VPS running the Docker image (§15), this is satisfied by choosing a Malaysian provider and is cheap to hold — there is no managed service quietly replicating elsewhere. It binds two further choices:

  • No offshore processor may touch record data. OCR (6.6) is the live example.
  • Backups are records. An offshore backup bucket breaches this as surely as an offshore primary.

11A.2 Seven years, legible, and retrievable

Section titled “11A.2 Seven years, legible, and retrievable”

ITA 1967 s.82A — records retained at least seven years from the end of the year of assessment. PR 5/2000 — electronic records must be readily convertible to readable form.

Two consequences:

  1. Retention outranks erasure. A tenant’s deletion request cannot anonymize a row whose retention clock is still running (17.3, as amended).
  2. Readability is a property of the export, not the database. “We still have the bytes” is not retrievability. The statutory export produces documents a human can read without this application: CSV or PDF, with the attachments they reference.

Records must be kept in the national language or English.

The UI may be localized freely. Record-bearing free text — supplier names, line descriptions, narrations that reach the ledger or a statutory export — must be English or Malay. ms is therefore a required locale, not an optional one (§13), and zh is a UI locale that does not extend to record fields.

11A.4 Every number traceable to a document and a decision

Section titled “11A.4 Every number traceable to a document and a decision”

Not one provision but what a tax audit actually does: pick a figure, walk it back.

The design already satisfies this and must keep doing so — gap-free document numbering (20.1.3), append-only journals, the immutable workflow decision log (5.8), attachments bound to the line they support (6.6), and the tax identifiers of 6.7. The rule for every later plan: no posted figure may exist without a path back to the document that evidenced it and the person who approved it.

An audit log that could have been edited proves nothing about a disputed figure. Hence the hash chain of 4.5, and a verification pass that can be run on demand and reports the first broken link.

Phase 5 of the LHDN mandate took effect July 2026; businesses below RM1 million annual turnover are exempt. Above it, essentially every prospective customer is in scope.

Phase 1 builds no MyInvois API integration, and that remains correct: the obligation to issue e-Invoices arises on the sales side, which Phase 1 does not build. What Phase 1 owes is the inbound side — capturing the identifiers on every claim line (6.7) so that when submission, validation or a tax audit arrives, the data is already there. Issuing, self-billed documents, status polling and retries stay Phase 2 (§11, §19).

Prose in a spec does not survive twenty plans. Where a requirement above can be checked by a machine, it becomes a drift test alongside the four Phase 0 already has:

Requirement Enforcement
11A.2 retention clock No anonymization path may run against a row inside its retention window — tested, not trusted
11A.3 record language Record-bearing columns reject text outside the permitted locales
11A.5 audit chain Chain verification runs in CI against a seeded tenant, and a deliberately altered row must fail it
11A.4 traceability Every posting rule under test asserts a resolvable path to its source document

11A.1 has no test — it is a deployment fact — so it is recorded in the runbook and checked at provisioning instead.


api/openapi.yaml is the source of truth. oapi-codegen generates Go server interfaces (so an unimplemented endpoint is a compile error) and the TypeScript client in packages/contracts. Schema drift shows up in review diffs.

  • Resource-oriented, /v1/..., plural nouns. Workflow moves are explicit sub-resources: POST /v1/claims/{id}/transitions with { to: "reviewed", reason?: "..." }. Never PATCH {status} — a transition is an authorized act, not a field write.
  • Cursor pagination everywhere. Offset pagination on a claims list is a timeout waiting for a customer with history.
  • Idempotency-Key required on every POST that moves money.
  • Errors are RFC 7807 application/problem+json with a stable type URI, so the frontend branches on a code rather than a message.
  • X-Org-Id selects the tenant; If-Match/ETag on claim edits prevents lost updates when two approvers open the same claim.
  • Available transitions are computed server-side and returned with the claim ({ to, label, gate, allowed, reason_if_not }). The frontend renders buttons from that list and never reimplements the gate logic. This is the single most important API decision for keeping a customizable workflow usable.

One authenticated SSE stream per session carrying claim state changes, approval queue deltas and notifications. No websocket, no GraphQL subscriptions.


Locales: en, ms, zh. ms is statutory (11A.3), not a nice-to-have, and record-bearing free text is restricted to en and ms regardless of the UI locale in use.

Figma: Uz1j7iSQYOYdCLKTm6bRsT — Mcom (3). Pages: 77:4027 Final Design · 83:5479 In Progress · 0:1 Components. Sections: 81:4038 Submit claim · 407:17220 备用金 (petty cash).

  • Shell: dark navy sidebar, grouped nav — 工作区 (Home, Projects, Activity) · 申请 (Expense claims, Petty cash, Asset booking) · 管理 (Team, Accounting, Announcements). Top bar with breadcrumb, global search, AI chat, notifications, user menu. The nav already matches the module tiers in Section 3.1.
  • Claims list (123:7183): four KPI stat cards tinted by status (pending amber, approved green, paid green, submitted blue) showing count + amount; tabs for My claims / My approvals / All claims; status filter chips with counts; search, project filter, month filter, filter and export actions; table with category pill, status pill, amount showing original currency above converted, submitted timestamp, claim reference (CLM-2026-0034), row overflow menu.
  • Petty cash (407:17220): 申请备用金 (request), 归还备用金 (return).
  • Components page (0:1): nav items with Default/Active states, card, textField, Dropdown, radio-button, checkbox-label, tag, Toast, Table header, payment-method-card, status chips (draft, Pending, Approved, …), and a full icon set (wallet, coins, file-submit, approval, reject, filter, export, on-hold, …).
  • Bilingual from the start — the design is in Chinese with English data. i18next with en / zh as first-class, not retrofitted.

React 19 + Vite + TanStack Query + TanStack Table + shadcn/Radix + Tailwind v4 + zustand for local UI state only (server state belongs to TanStack Query). Design tokens are pulled from Figma variables into Tailwind theme tokens — no hardcoded hex.

Route structure mirrors the modules, so a feature lives in one folder:

web/src/
app/ router, providers, shell (sidebar, topbar, org switcher)
features/
claims/ advances/ pettycash/ approvals/ accounting/ settings/
components/ design-system primitives mapped from the Figma Components page
lib/ api client (generated), i18n, formatting, money

13.4 Workflow-driven UI — the rule that makes customization real

Section titled “13.4 Workflow-driven UI — the rule that makes customization real”

Because the flow is per-org data (Section 5), the UI must be too:

  • Status chips are rendered from the definition, not from a hardcoded enum. The chip set, labels, colours and order come from workflow_state.
  • Filter chips with counts are generated from the same list.
  • Action buttons come from the API’s available_transitions (12.2), including the reason a disabled action is disabled. The frontend never evaluates a gate or a threshold.

A customer who renames a step or inserts one sees it everywhere without a release.

13.5 Screens the design does not yet cover

Section titled “13.5 Screens the design does not yet cover”

These need designing, in roughly this order of need:

  1. Workflow definition editor — the graph, per-step approver grants and amount thresholds, publish-time validation errors inline. The highest-value missing screen.
  2. Payout channel editor — channel list, field_schema builder, GL account binding.
  3. Chart of accounts — tree, account editor, control-account mapping.
  4. Journal entries & general ledger — entry list, entry detail with lines, manual journal, reversal.
  5. Fiscal periods & close — period list, close checklist showing reconciliation results.
  6. Reports — trial balance, P&L, balance sheet, aged advances.
  7. Advance acquittal — variance display and the three-way outcome.
  8. Float register & reconciliation — float list, movement ledger, close-out.

Where a screen is missing, it will be built from the existing Components page so it lands inside the same design language rather than beside it.


Layer Approach
Pure logic Table-driven unit tests: acquittal decision table, band tiling, gate resolution, workflow validation rules. No database.
Repository / SQL testcontainers-go against real Postgres. RLS is tested explicitly: a query without tenant context must return zero rows.
Posting Golden-file tests — given a claim and a rule, assert the exact journal lines.
Workflow Property test: for every seeded definition, every state is reachable and every path satisfies the Section 5.7 rules.
API Generated-contract tests from openapi.yaml; request/response shapes cannot drift.
E2E Playwright on the critical money paths: submit → approve → verify → settle; advance → acquit → refund; spend → review → pay.

Two invariants get dedicated, always-on tests, because they are the ones that lose money quietly:

  1. Every posted entry balances, in both transaction and base currency.
  2. cash_balance = SUM(float_movement.amount) for every float, after every operation.

  • One Docker image, three entrypoints (api, worker, migrate). Migrations run as a separate job with a privileged role, never by the API on boot.
  • Postgres with pgbouncer in transaction pooling mode — safe because tenant context is SET LOCAL (4.1).
  • Structured JSON logging with request ID, org ID and actor on every line; OpenTelemetry traces across HTTP → DB → River.
  • Backups with PITR, and a restore that is actually rehearsed. An ERP whose backups have never been restored does not have backups.
  • Rate limiting per user and per IP, Redis-backed.
  • CREATE INDEX CONCURRENTLY applied outside migration transactions.
  • The VPS, its volumes and every backup target are in a Malaysian datacenter — ITA s.82(8) (11A.1). This is a provisioning check, not a code path: it is verified when an environment is created and recorded in the runbook.

Sections 18 and 19 benchmark this against AutoCount and Odoo and extend the roadmap to Phase 6. Five items move into Phase 1 as a result — journals, analytic axes, the party model, a widened CountryPack, and the multi-entity decision — and are reflected below.

Phase 1 — this spec

  1. Kernel: tenancy, RLS, audit, money, db, events
  2. Identity: users, orgs, company profile, members, roles, permissions, sessions, party (employee role) 2b. Org structure: org units, positions, reports_to tree, member assignments
  3. Workflow engine + definition editor + validation
  4. Accounting: journals, chart of accounts, analytic axes, journal entries, posting engine, fiscal periods
  5. Expense claims on the seeded default flow
  6. Payout channels
  7. Advances
  8. Petty cash
  9. Reports: trial balance, P&L, balance sheet, aged advances
  10. Frontend for all of the above

Order matters, in two places. 2 and 2b before 3, because grants can bind to positions (5.4), so the position model must exist before the grant model is finalized — and the company profile must exist before the country pack can seed a chart of accounts. 3 and 4 before 5, because claims are the first consumer of both, and building claims first is how the logic ends up inside claims again.

Phase 2 — MyInvois e-invoice submission, SST returns, WHT, bank statement import and reconciliation, AR/revenue, bulk payment file export.

Phase 3+ — payroll, assets, procurement, inventory, CRM. Each registers a workflow definition and posting rules; none touches the ledger directly.


Resolved 2026-09-20, after Phase 0 merged and before Plan 2 was written. These were flagged as “cheap now, a migration of every posted row later”; they are now closed.

  1. Multi-entity / consolidation — RESOLVED: one org = one legal entity. org_id is the posting dimension. There is no entity_id. A group with three subsidiaries onboards as three organizations, and consolidation across them is a Phase 2+ reporting concern solved above the ledger, not inside it. Every ledger table therefore carries exactly one tenancy column, the one RLS already enforces.

  2. Base currency — RESOLVED: per organization. It lives on the company profile (§4.6), alongside country and fiscal year start. This follows from decision 1: with one entity per org there is no second scope for it to attach to.

  3. Retention and deletion — RESOLVED: export, then anonymize in place. Never hard delete — and never before the retention clock expires.

    Amended 2026-09-20, the same day it was taken, on the audit-compliance review: the original wording would have anonymized on request. ITA s.82A requires records retained seven years from the end of the year of assessment, so a row inside its window cannot be scrubbed — a deletion request against it is accepted, recorded, and suspended until the clock expires, then executed. Every record-bearing row therefore carries enough date information to compute its own retention expiry. On a tenant’s request the system produces a complete export of their rows, then scrubs personal data — names, emails, bank and payout details, uploaded documents — while ledger amounts, dates and account codes survive as anonymized rows. This is the defensible reading of erasure where statutory accounting retention overrides it, and it keeps the append-only invariant genuinely absolute: no sanctioned deletion path is ever cut through the ledger. The cost is accepted knowingly — the product cannot promise a customer that every trace of their data is gone, only that nothing identifying them remains.

  4. Approval delegation scope — RESOLVED: the delegate acts on the delegator’s thresholds, and the decision log records both. Authority is lent, not replaced. A delegate approving for a director approves to the director’s ceiling; the immutable decision log (§5.8) names the delegate as actor and the delegator as the authority relied on, so an auditor can always see whose ceiling was used.

Two reference points, chosen because they bracket the market this product sits in: AutoCount is the Malaysian SME incumbent (GL/AR/AP/Stock/Billing plus ~30 modules, multi-location stock, MyInvois API validation, SST), and Odoo is the modular open-source ERP whose extension architecture is the one worth learning from.

18.1 What each does that this design should adopt

Section titled “18.1 What each does that this design should adopt”
Pattern Where from Why it matters here
Journals as a first-class entity Both Every entry belongs to a journal (sales, purchase, bank, cash, misc); bank and cash accounts are linked to a journal, not free-floating. Journals carry their own numbering sequence, default accounts and posting rules.
Multi-axis analytic accounting with distributions Odoo Costs are distributed across analytic accounts by ratio, on multiple independent axes, with applicability rules — not a single foreign key per dimension.
Fiscal localization pack Odoo (l10n_XX) A country pack ships the chart of accounts, taxes, fiscal positions and legal reports together, auto-selected by the company’s country.
Partner master (customer / supplier / employee) Both AR needs a customer master with statements and aging; AP needs a supplier master with aging and payment batches. One party model, many roles.
Document chains AutoCount Quotation → SO → DO → Invoice → Payment; PO → GRN → Purchase Invoice → Payment. Each step copies forward and back-references.
Payment batches (GIRO / bulk) AutoCount Paying 200 claims is one batch producing one bank file, not 200 settlements.
Self-billed e-Invoice AutoCount MyInvois requires self-billed documents on the payables side — foreign suppliers, agents, commissions. Sales-side e-Invoice alone is half the requirement.
Report / layout designer AutoCount MY SME customers expect to edit their own invoice layouts and print formats. Not a nice-to-have in this market.
User-defined fields Both Every ERP customer eventually needs one more field on a document.
Per-tenant module install Odoo (apps) Modules are installed per company with declared dependencies, not all-on for everyone.

Five of these are cheap now and expensive later, so they move into Phase 1:

  1. No journal concept. Section 10.2 posts entries with no journal. Adding journal (id, org_id, code, name, type, default_account_id, sequence_id, currency) and journal_entry.journal_id later is a migration across every posted row. Add to Phase 1.
  2. Dimensions are hardcoded. journal_line.cost_centre_id and project_id cannot express “60% project A, 40% project B”, and a customer wanting a Branch axis needs a schema change. Replace with configurable axes plus distributions:
    analytic_axis (id, org_id, key, label_i18n, is_required, applies_to)
    analytic_account (id, org_id, axis_id, code, name, parent_id, is_active)
    analytic_distribution (journal_line_id, analytic_account_id, percentage, base_amount)
    Add to Phase 1. Cost centre and project become two seeded axes.
  3. No party model. Claims pay employees, so Phase 1 does not feel the absence — but AP and AR both need it, and retrofitting a party onto settled documents is not possible. Add party (id, org_id, kind_flags, name, tin, addresses, payment_terms, …) with employee/customer/supplier as roles on one party, not three tables. Add to Phase 1 (employee role only; the other roles stay unused until Phase 2).
  4. CountryPack is too narrow. Section 11 covers tax only. Odoo’s localization packs also seed the chart of accounts and the statutory reports, which is the right scope — otherwise every new country needs code in three modules. Extend the interface to SeedChartOfAccounts, SeedTaxes, StatutoryReports. Add to Phase 1.
  5. Multi-entity is still open (Section 17.1). Both products treat company as a posting dimension. Deciding entity_id now costs a column; deciding it after the ledger has data costs a migration of every entry. Decide in Phase 1.

The remainder are genuinely later, and are scheduled in Section 19: document chains, payment batches, self-billed e-Invoice, report designer, user-defined fields, per-tenant module install, stock costing and UOM.

18.3 What this design does that neither does — and should keep

Section titled “18.3 What this design does that neither does — and should keep”

Stated so it does not get refactored away by someone comparing feature lists:

  • The workflow is customer-editable data with a validated graph. AutoCount’s approval flow is largely fixed; Odoo’s is server actions and automation rules, which is powerful but is programming, not configuration. A finance admin editing an approval chain in a UI that refuses to publish an unreachable state is a genuine differentiator.
  • Semantic tags as the contract between workflow and ledger. This is what makes the above safe. Neither product has an equivalent, because neither lets customers rearrange a posting flow in the first place.
  • Amount thresholds that tile by construction. Floors only, no ceilings, no gaps possible. Most approval matrices let an administrator create a hole.
  • Expense claims as a first-class domain, with advances and petty cash modelled properly rather than as journal shortcuts. In both reference products this is the weak spot — which is exactly why it is Phase 1 here.
  • Odoo’s dynamic ORM and view inheritance. It is what makes Odoo infinitely customizable and also what makes an Odoo upgrade a project. Customization here is bounded: workflow definitions, payout channels, analytic axes, user-defined fields, report layouts — a closed set of extension points, each validated.
  • AutoCount’s desktop-first, single-tenant deployment. Multi-tenant SaaS from day one (Section 4).
  • Feature-list parity as a goal. Matching 30 modules is not the plan; Section 19 is ordered by what a Malaysian SME actually needs to leave AutoCount.

Each phase is shippable and sellable on its own. The rule throughout: a new module registers a workflow definition and posting rules; it never writes journal lines directly and never touches another module’s tables.

Phase 1 — Expense & accounting core (this spec)

Section titled “Phase 1 — Expense & accounting core (this spec)”

Kernel · identity · workflow engine · journals · chart of accounts · analytic axes · party (employee role) · posting engine · fiscal periods · expense claims · advances · petty cash · payout channels · trial balance / P&L / balance sheet / aged advances · MY country pack seeding CoA + SST codes.

Sellable as: claims and reimbursement with real books behind it.

Phase 2 — Payables, receivables and MY compliance

Section titled “Phase 2 — Payables, receivables and MY compliance”

The phase that makes it an accounting system rather than a claims system.

Area Contents Reference
AP Supplier role on party, purchase invoices, credit notes, supplier aging, payment allocation AutoCount AP
AR Customer role, sales invoices, credit notes, receipts + allocation, aging, statements of account AutoCount AR
Payment batches Select N payables → one batch → one bank file (DuitNow bulk / GIRO) → one settlement AutoCount GIRO batches
e-Invoice MyInvois submission with API validation, status polling, retry; self-billed on the AP side AutoCount MyInvois
SST SST-02 return generation from tax codes MY statutory
WHT Withholding on applicable payments, CP37/CP38 support MY statutory
Bank Statement import (MT940/CSV), auto-match, reconciliation Both
Budgets Budget per account per period per analytic axis, budget-vs-actual reports AutoCount GL

Architectural hooks needed from Phase 1: party roles, journals, analytic distributions, CountryPack.StatutoryReports.

What turns a product into one a reseller can deploy without engineering.

  • User-defined fields — typed, per document type, with validation, searchable and exportable. One mechanism, reusing the field_schema pattern already built for payout channels (Section 9.3).
  • Report & document layout designer — editable print layouts for invoices, claim vouchers, payment advices and statements. Templating with a preview, not a code deploy.
  • Per-tenant module install — module_install (org_id, module_key, version, enabled) with declared dependencies, so a customer buys Claims without Inventory.
  • Custom report builder on platform/reporting — pick period, account subtree, analytic filters, columns; save and share.
  • Import/migration tooling — mapped CSV/Excel import for chart of accounts, parties, opening balances and historical documents. This is a go-to-market requirement, not a convenience: nobody leaves AutoCount if their history cannot come with them.
  • Procurement: PR → PO → GRN → purchase invoice, with the document chain generalized into platform/documents (copy-forward, back-reference, partial fulfilment) so sales reuses it unchanged.
  • Sales: quotation → SO → DO → invoice, on the same chain.
  • Inventory: items, multi-UOM, multi-location, stock transfers, costing (FIFO and weighted average), stock adjustments, serial/batch numbers. Costing posts through the same posting engine — inventory is the module most often wired straight into the ledger, and that is the mistake to avoid.
  • Fixed assets: register, depreciation schedules, disposal. Depreciation is a River cron emitting a posting event.
  • Payroll: pay items, statutory MY deductions (EPF, SOCSO, EIS, PCB), payslips, bank files, EA forms. Registers a payroll_run workflow definition and inherits approval, delegation, escalation and the decision log for free — the payoff for Section 5.
  • HR adjacency: leave and claims interplay, employee lifecycle affecting approval routing (manager_id already exists in Section 4.2).
  • Multi-entity consolidation: inter-company eliminations, consolidated reporting, entity-level and group-level charts. Only cheap if Section 18.2 item 5 is decided in Phase 1.
  • Module extraction: any Tier 3 module can leave the monolith behind its existing contract when load justifies it. Reporting and document rendering are the first candidates; the ledger never leaves.
  • Public API + webhooks for third-party integrations, on the same OpenAPI contract.
  • AI assistance — the Figma design already reserves a place for it: receipt OCR suggestions (Phase 1), then anomaly detection on claims, spend forecasting and natural-language report queries over platform/reporting.
  1. Phase 2 before Phase 3 — a customer will not buy customization for a product that cannot yet issue an invoice.
  2. Phase 3 before Phase 4 — import tooling and layout design are what let resellers deploy without engineering, which is the constraint on growth long before feature count is.
  3. Inventory before payroll — inventory is the deeper accounting integration and more commonly the reason an SME needs an ERP at all; payroll is frequently bought separately.
  4. Consolidation last, decided first. The decision lands in Phase 1; the feature lands when there are customers with groups.

20. Initialization — what the foundation must include

Section titled “20. Initialization — what the foundation must include”

Two different initializations, both of which korre-pm-be got partly wrong in ways it has since documented. Phase 0 (codebase foundation) is what must exist before any feature code. Organization provisioning is what must happen when a tenant is created.

Each item below is here because retrofitting it costs more than building it, and in most cases because korre has a comment explaining what it cost.

korre’s most transferable idea: a convention that isn’t tested is a convention that drifts. It has three of these, each written after something broke:

korre test What it caught
soft-delete.extension.spec.ts Parses schema.prisma; fails if a model carrying deletedAt is classified in neither the managed nor unmanaged set. Without it, a model looks protected but delete() is a physical DELETE and reads are unfiltered.
env-example.spec.ts Scans source for env reads and fails if .env.example omits one. It found 15 undocumented variables, including the entire mail block — so a new environment booted with password reset silently writing reset links to the log. “The example is documentation, and documentation does not fail a build.”
Committed schema.gql Schema drift shows up in review diffs instead of at runtime.

Phase 0 ships the Go equivalents, and two more that this design specifically needs:

  1. Every table has org_id and an RLS policy. A test queries pg_policies against information_schema.tables and fails on any business table without one. This is the single most important test in the system — Section 4.1’s second isolation layer is worthless on the one table someone forgot.
  2. Every env var read appears in .env.example.
  3. Every soft-deletable table is classified, and the ledger tables are explicitly not soft-deletable.
  4. Generated artifacts are committed — openapi.yaml derivatives, sqlc output — so drift is a diff, not a surprise.
  5. Every seeded workflow definition passes the Section 5.7 validator, as a test.

korre ended up with three — today() in accounting reports, todayUtc() beside claim amounts, startOfToday() inside the issues service — the same function under three names, where “a report defaulting its asOf had no reason to disagree with a claim rejecting a future expense date about where the day boundary falls”.

kernel/clock provides one TodayUTC(), injectable so tests can pin it. Every date-only column is midnight UTC, because a local-midnight answer sits hours off the values it is compared against and the direction of the error depends on where the process is deployed.

20.1.3 Document numbering — a table, not a sequence

Section titled “20.1.3 Document numbering — a table, not a sequence”

korre’s ClaimReferenceService gets this right and the reasoning is worth restating: allocation is an upsert on a sequences (scope, year, next_number) row, inside the transaction that creates the document. The row lock serializes concurrent creates, and a transaction that aborts gives the number back.

A Postgres sequence would do neither: it does not roll back, so a failed create leaves a permanent gap — and the first question anybody asks of a gap in a reference series is what was deleted.

Two scoping rules:

  • Human-facing references use the calendar year (TRVA-2026-0033). A claimant should not have to know when their employer’s fiscal year ends.
  • Statutory document numbers use the fiscal year and the journal’s sequence (10.2).

And one initialization constraint that falls out of it: a claim must be creatable before a fiscal year exists, which is true of most organizations on their first day. Only posting requires an open fiscal period.

20.1.4 The org-configurable registry primitive

Section titled “20.1.4 The org-configurable registry primitive”

This design has many org-editable lookup tables — payout channels, analytic axes, expense categories, crypto/bank/e-wallet registries, role sets, rates. korre extracted the shared rules into org-registry.ts precisely so “adding a fourth registry cannot quietly get one of them wrong”. Build the primitive once, in Phase 0:

  • Global seeded rows plus the organization’s own, with the org’s shadowing
  • Case-insensitive uniqueness per org (korre uses partial expression indexes on lower(name), because NULL organization_id values are distinct in Postgres and a plain unique constraint lets two globals both be named “Cash”)
  • Retire, never delete (is_active), distinct from deleted_at for a row filed in error and refused once anything references it
  • Seeded rows are not editable by the org; copies are
  • Sort order, and labels as translation rows

kernel/errors maps Postgres error codes (unique violation, FK violation, check constraint, serialization failure) to RFC 7807 problems with stable type URIs, in one place. Handlers never inspect driver errors. korre has prisma-error.ts for this; the mistake to avoid is each module translating its own.

20.1.6 i18n is a foundation, not a feature

Section titled “20.1.6 i18n is a foundation, not a feature”

The frontend AGENTS.md opens with a single line — “Make sure support multi languages” — and the Figma design is in Chinese with English data. This has to be structural from the first table:

  • Every user-facing name is a translation row, never a VARCHAR on the entity. korre’s BoardColumnTranslation and category translations are this; the ones that aren’t are the ones that caused the board-provisioning comment about zh labels disagreeing.
  • Accept-Language resolved once in middleware, applied at the serialization edge.
  • Seeded data ships en and zh together. Adding a language later to seeded rows means backfilling every tenant.

20.1.7 Audit and activity are two different logs

Section titled “20.1.7 Audit and activity are two different logs”
  • audit_log (4.5) — compliance. Append-only, before/after, written in the same transaction as the change.
  • activity_log — the user-facing “who did what” feed rendered in the UI.
  • request_log — operational telemetry.

korre has all three; the distinction is worth stating because merging them produces a table that is too noisy to audit and too slow to render.

20.1.9 Shared vocabulary — what belongs in a constants package and what does not

Section titled “20.1.9 Shared vocabulary — what belongs in a constants package and what does not”

Both sides need to agree on a vocabulary, and korre solves this with @korre/mappings (pm-helper). The idea is right; the way it is scoped there is what to fix.

What went wrong in korre, in its own words:

  • Version skew. expenses-claim-items/claim-type.ts re-declares CLAIM_TYPE locally by spreading the package and adding three petty-cash types, with a comment that reads “DELETE THIS FILE when the package ships PETTY_CASH_TOPUP: 4 …”. A spec test asserts the two agree. That is a workaround for a package that cannot ship as fast as the repo that needs it.
  • The package shipped wrong data, twice. board-provisioning.service.ts deliberately refuses to use the package’s EXPENSES_CLAIM_COLUMN_POSITION, because it “has now been wrong twice in opposite directions” — following it would render the board in an order the workflow refuses to move through.
  • Labels diverged. The package’s zh labels for three columns disagree with the ones the backend has always seeded, so adopting them would silently rename columns on newly provisioned boards while existing ones kept the old names.

Every one of these comes from the same root cause: the package carried things that have a source of truth elsewhere — ordering that belongs to the workflow, labels that belong to the translation tables.

The rule here:

The shared package carries the closed vocabulary both sides must agree on to communicate. It never carries data that has a source of truth in the database, and it never carries labels.

In the package Not in the package
Semantic roles (accrual, payout, …) Workflow state keys — per-org data, fetched from the API
Gate kinds (reporter, target, source, advance) State labels and translations — translation rows (20.1.6)
Grantee kinds (member, superior, position, unit_head, levels_up) Display order — workflow_state.display_order
Settlement behaviours (external, petty_cash) Payout channel keys — org registry rows
Account types, normal balances, control kinds Chart of accounts codes — seeded, then edited by the org
Line types, reviewer outcomes, float statuses Expense categories, rates, banks — registry rows
Permission keys, error type URIs, currency and country codes Anything an admin can rename

The distinction is testable: if an org can change it, it is data, not a constant. korre’s claim statuses fail that test today, which is exactly why this design moves them into workflow_state (5.2).

One source, generated to both languages. No hand-maintained parallel files:

  • Enums that appear in API payloads are declared in api/openapi.yaml as components.schemas enums. oapi-codegen emits named Go types; openapi-typescript emits the TS union. The pipeline already exists (12.1) — adding a second mechanism for the same job is how the two drift.
  • Vocabulary that never crosses the wire (permission keys, error type URIs, job names) lives in one constants.yaml with a small generator producing internal/kernel/vocab and packages/contracts/vocab.ts.
  • Generated files are committed (20.1.1 rule 4), so a change to the vocabulary shows as a diff on both sides in the same commit.

Values are strings, never integers. korre’s entire numbering debt — SETTLED: 8 retired but immortal, SPEND_PAID: 18 because 15–17 were reserved, -1/-2/-3 approver sentinels because the number space had no room — follows from storing integers and never being able to reuse one. String keys remove that class of problem outright, and the storage cost is irrelevant next to it.

Labels are keyed by the constant, never shipped with it. semantic.accrual is a translation key resolved from i18n files on the frontend and translation rows in the database for seeded data. This is what stops korre’s third failure: a label change is a translation change, not a package release that silently renames things for new tenants only.

Money on the wire is a string. "1234.56", never a JSON number — a JSON number is a double on the frontend, and shopspring/decimal on one side with a float on the other is not a shared vocabulary, it is a rounding bug waiting for a large invoice. The generated TS type is string, and packages/money wraps parsing and formatting so no component ever calls parseFloat.

Utilities are not shared. korre also has @yangsaiyam/helper for dates, files, routes and locale helpers. That pattern does not cross a language boundary — Go and TS each get their own. Only the vocabulary is shared.

Both korre repos carry an AGENTS.md with the same rules, and they are good ones — read before changing, identify the source of truth, no fake certainty, minimal diffs. Phase 0 ships one, with two additions specific to this design:

  • Module boundaries: nothing outside a module may import its internal/; cross-module access is contract.go or an event. (The compiler enforces it; the doc explains why.)
  • OpenAPI is the contract. korre requires manually updating docs/api-inventory.md in the same change as any endpoint change. Spec-first generation removes that discipline requirement — the generated client won’t compile if the spec is stale — which is the better version of the same rule.

korre’s AccountingSetupService states the failure mode exactly: “Seeding at creation is what stops the first claim approval from hitting an empty mapping table — the same failure mode an empty organization_roles table causes on create endpoints.”

1. company_profile country, base currency, fiscal year start (4.6)
2. country pack seeds chart of accounts · journals · tax codes (11)
3. analytic axes cost centre, project (10.2)
4. roles + permissions Employee, Approver, Finance, Admin, Owner (4.2)
5. org unit + position a root unit and a root position for the owner (4.7)
6. registries payout channels, expense categories, rates (9.5)
7. workflow definitions expense_claim, advance, petty_cash_spend,
float_topup — seeded AND published at v1 (5)
8. fiscal year + periods for the current year (10.4)
9. number sequences claim, journal per journal (20.1.3)

Step 1 gates everything: the country code selects the pack, and the pack decides steps 2 and 6.

  1. Idempotent. An organization that already has a chart keeps it, including any edits made to adopt the accountant’s own codes. Re-running repairs a partial setup — a missing fiscal year, an unmapped category — without risking what is there.
  2. Re-runnable by an admin over the API, not only by a CLI. korre’s reasoning is blunt: before it had an endpoint, the only remedy for a failed setup was running the seed on the host, “which is an instruction nobody holding this permission can act on”. An “accounting is not set up” screen that cannot offer a button is a screen that tells the reader to go find someone else.
  3. One code path for both. The seeding logic lives in a shared package called by both the CLI backfill and the runtime creation, because “a chart of accounts that differs between the two would be a reconciliation problem rather than a convenience”.

GET /v1/org/readiness returns what is provisioned and what is missing, per step above. The frontend renders the gaps with a repair action. This is also what surfaces the Section 5.4 failure modes that appear after provisioning — a vacant position leaving a threshold ladder with a hole, a superior grant that cannot resolve — so they are visible in a settings screen rather than discovered by a claimant whose submission is refused.

An organization belonging to a group inherits the group’s published chart template, with DEFAULT_CHART_OF_ACCOUNTS from the country pack as the fallback — never an org with no chart at all. Same pattern for workflow definitions: a group template, else the seeded korre default.

Phase 0 is new and precedes everything in Section 16:

Phase 0 repo scaffolding · kernel (tenancy, RLS, audit, clock, money, errors,
eventbus, numbering) · registry primitive · i18n spine · drift tests ·
CI · AGENTS.md · OpenAPI + sqlc + goose pipelines
Phase 1 identity + company profile → org structure → workflow engine → ledger →
claims → payout channels → advances → petty cash → reports → UI

Organization provisioning (20.2) is built with Phase 1 step 2 and extended by every later step — each module that needs seeded data registers its own provisioning step rather than editing a central function.