Plan 5 — Expense Claims Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A member files an expense claim — a header with receipt, mileage and per-diem lines, each carrying its category, tax code, analytic shares, frozen base amount and the Malaysian tax record (§6.7) — attaches receipts uploaded straight to an in-Malaysia object store and verified by content hash, and moves it through the seeded korre flow (Plan 3); at category_verified the claim accrues to AP and at completed it pays out against a payout channel (Plan 6) whose settlement snapshot — sealed by Plan 6, never opened by Plan 5 — is frozen onto the claim, both through posting rules registered with Plan 4’s engine, in the transition’s own transaction; petty-cash payouts are refused until Plan 8, and an operator command reseals every stored snapshot when the payout key rotates.
Architecture: Three platform packages and one module. internal/platform/documents owns objects: presigned upload tickets, verification by content hash, thumbnails, download URLs, and the OCR seam (documents/ocr, suggestion only, no-op by default); documents/s3store is the one S3-compatible adapter (MinIO on the VPS). internal/platform/expenses owns everything with an expense rule in it: categories (the existing expense_category registry, extended), date-effective rates, groups, claims, lines, attachments, submission checks, the claim-side transition wrapper around workflow.Transition, settlement snapshots, the expense_claim accrual and payout posting rules, and two reconciliation checks. internal/modules/expenses is the HTTP shell for /v1/expenses/… and /v1/documents/… and registers the posting rules at boot. The database enforces what must never drift: draft-only line edits (a trigger reading the workflow instance’s semantic), append-only rates and settlements (a settlement’s only permitted change is a key-rotation reseal of its ciphertext), no plaintext masked payout value in a settlement, named constraints on every tax-record rule. cmd/migrate reseal-payout-details is the key-rotation command Plan 6 hands to this plan.
Tech Stack: Go 1.26 · Gin · pgx v5 (raw SQL, the house pattern) · goose · sqlc (models only) · oapi-codegen · testify · testcontainers (Postgres 17, MinIO) · shopspring/decimal behind kernel/money · github.com/minio/minio-go/v7 · golang.org/x/image/draw
Specs (read before any task):
docs/superpowers/specs/2026-09-21-phase1-wave2-contracts.md— BINDING. The payout seam (§1), ownership (§2), Plan 4’s review lessons (§3), residency and OCR (§4), merge order (§5).docs/superpowers/specs/2026-09-21-phase1-wave1-contracts.md— §1 (the posting seam), §3 (shared files are append-only), §5 (standing constraints).docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §6 in full, §5.3–5.5, §9.2–9.4, §10.2–10.3, §10.6, §11, §11A, §12.2, §20.1.3, §20.1.4, §20.2.docs/superpowers/plans/2026-09-21-plan-3-workflow-engine.md— the workflow API this plan calls (Task 7’s contract, Task 8’s seeded flow). Planned, not yet merged here: Task 1 reconciles every symbol against the merged code before any task that consumes it runs.docs/superpowers/plans/2026-09-21-plan-6-payout-channels.md— as revised (Rulings P6, P15, P29–P33): the sealed snapshot,OpenSnapshot/ResealSnapshot, the payout keyring, purge on deactivation, the petty-cash hand-off, and its Notes for later plans. Task 1 Part B reconciles against its merged code.docs/superpowers/plans/2026-09-21-plan-4-accounting-core.mdand its merged code —accounting.RegisterPostingRule,Source,Draft,ledger.*,chart.*,fx.Resolve,analytic.Plan,reconcile.Register.
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Go floor is 1.26.0. CI derives its version from
go.mod; never restate one. - Records stay in Malaysia (wave-2 §4, ITA 1967 s.82(8)) — user decision, binding. Documents live in an S3-compatible store (MinIO by default) on the Malaysian VPS; its bucket, its volumes and every backup stay in Malaysia; nothing is sent offshore. Presigned URLs are short-lived, uploads are verified by content hash, and documents are never deleted while the retention clock runs.
- OCR is an interface only (wave-2 §4, spec §6.6) — user decision, binding. Pluggable, suggestion-only, disabled by default with a no-op implementation; OCR output is never written into a record field without the user confirming it; no provider is chosen — the user chooses later, and any provider must process data in Malaysia.
- Ownership (wave-2 contract §2). Go code only under
internal/platform/expenses/…,internal/platform/documents/…,internal/modules/expenses/…, plus the named exceptions in Ruling R2. Never editinternal/platform/posting,internal/platform/workflow,internal/platform/accounting,internal/platform/payout, or any other plan’s package. From Plan 6, import only the revised wave-2 §1 surface:payout.Channel,payout.GetChannel,payout.Snapshot,payout.SettlementSnapshot(unmarshalled for masked display, no key),payout.ResealSnapshotandpayout.ResealProfiles(Task 21’s reseal command only),payout.BehaviourPettyCash, the typed refusals Task 1 records (includingErrNoKeyring,ErrUnknownKey,ErrSealBroken), and, in tests only,payouttestandpayout.PurgeMemberProfiles. Plan 5 never callspayout.OpenSnapshot(Ruling R44; a guard test enforces it). - Payout details are sealed (Plan 6 Rulings P15, P31). The settlement snapshot is stored exactly as
payout.Snapshotreturned it: masked members are AES-256-GCM ciphertext. No API response carries avalueorsealedmember of a masked field — onlydisplay. No audit row, activity row, log line or error message carries a snapshot, a raw payout value or ciphertext; audit rows name ids and key ids only. - Migrations
00070–00089only; this plan uses00070–00073. Provisioning step orders80–89only; this plan uses80. Permissionsexpenses.*only. OpenAPI paths/expenses/…and/documents/…only (served under/v1). - Shared files are append-only (wave-1 contract §3):
constants.yamlpermissions list,api/openapi.yaml,internal/app/app.go,internal/app/routes_test.go(composedModules), the blank imports incmd/api,cmd/migrate,cmd/orgadmin,cmd/workerand theirwiring_test.go,internal/kernel/db/retention_test.golists,.env.example,docs/DEPLOY_NOTES.md(a new section on top),docs/ROADMAP.md(tick only Plan 5’s lines and the statutory “Claim lines carry the tax record” line). Generated files are regenerated withmake generate, never hand-merged. - Tenant isolation, both layers. Every new table:
org_id UUID NOT NULL,ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid. Every SQL statement namesorg_id = $nfor each org-scoped table it touches (join equalities count) and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Every FK between two org-scoped tables is composite(org_id, x) REFERENCES t (org_id, id)(TestEveryTenantForeignKeyIsCompositeOnOrgID). - Named constraints (wave-2 §3). Every
CHECK,UNIQUEandEXCLUDEis writtenCONSTRAINT <name> …, and no name ends in_check,_keyor_excl(the suffixes Postgres generates). Error mapping is keyed on these names. Task 3’s guard fails the build otherwise. - Pinned functions (wave-2 §3). Every function or trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table (public.expense_claim).TestEveryFunctionPinsItsSearchPathenforces the first half. - No
SET CONSTRAINTSin non-test Go (TestNothingIssuesSetConstraints). - Every table is in exactly one list in
internal/kernel/db/retention_test.go. Claims, lines, attachments, groups and documents areamendable(DELETE revoked). Rates, settlements and OCR suggestions areappendOnly. The idempotency store isoperational. - Every state change writes
audit.Writeon the samepgx.Tx, after every row lock the transaction will take (wave-2 §3 lock order). The order everywhere in this plan: idempotency key row →expense_claimrow (FOR UPDATE) → document row (FOR UPDATE, attach only) →workflow_instancerow (taken insideworkflow.SetAmount/workflow.Transition) →audit.Write. This plan adds no advisory-lock class. - Commit-time errors (wave-2 §3). Every handler whose transaction can post to the ledger passes
db.InTx’s error throughledger.MapErrorbefore mapping it to HTTP. - One org per transaction. Every platform function reads the org from
tenancy.OrgFromContext; nothing takes an org argument. - No nested
db.InTx. Platform functions take(ctx, tx pgx.Tx, …). Only handlers and the OCR subscriber open transactions. Object-store I/O happens between transactions, never inside one. - Money is
money.Amount(NUMERIC(19,4)); exchange rates, tax rates, percentages and quantities (km, days) aremoney.Rate(NUMERIC(19,10)). Neverfloat64. On the wire both are strings; the OpenAPIMoneyandRateschemas already map to them. - The business date is
clock.TodayUTC(c); everyDATEhandled in Go is midnight UTC. Nevertime.Now()in code under test. - Record-bearing free text (claim title, line description, supplier name, relaxation reason, group name) is English or Bahasa Malaysia (§11A.3): Ruling R15’s script check, in Go and in a named
CHECK. - Errors are RFC 7807. Handlers
c.Error(apperr.New(kind, detail))and return. Platform code returns sentinel or typed errors, never*apperr.Problem. - Every route goes through
httpx.Protectedwith anexpenses.*permission registered in the module’sinit(), and is documented inapi/openapi.yamlin the same commit (internal/app/routes_test.go). postingandaccounting.DefaultRules()are process-global. Tests that need a Poster callposting.ResetForTest()first and int.Cleanup, registeraccounting.NewPoster(clock, rs)over a freshaccounting.NewRuleSet()holdingexpenses.Rules(), and never uset.Parallel().- Presence before absence (
AGENTS.md). Every refusal test shows the accepted case first; every guard is watched failing once (sabotage step) and its red output is quoted in the task report. - Concurrency tests bound every wait with
context.WithTimeoutand clean up int.Cleanup, so a regression fails instead of hanging. - Tests: during development run only the packages you changed (
go test ./internal/platform/expenses/ -count=1).make generateafter any migration, OpenAPI orconstants.yamlchange. One fullgo test -p 2 -count=1 ./...in Task 20 only. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”Decisions taken where the specs are silent or ambiguous. Format: Ruling: <decision> — <why> — <cost if wrong>.
- R1 Ruling: Packages are
internal/platform/documents(+/s3store,/documentstest,/ocr),internal/platform/expenses(+/expensestest), and one moduleinternal/modules/expensesthat serves both/v1/expenses/…and/v1/documents/…— the ownership table names onlymodules/expenses, and documents have no second consumer yet — a latermodules/documentsis a file move plus oneapp.goline. - R2 Ruling: Named exceptions outside the ownership table:
internal/kernel/config/objectstore.goandocr.go(+ tests),internal/kernel/db/constraintnames_test.go(new guard),.env.example,docs/DEPLOY_NOTES.md,go.mod/go.sum(minio-go, x/image, testcontainers MinIO module),sqlc.yaml(appendmoney.Ratecolumn overrides) — config must be read inkernel/configforTestEveryEnvVarIsInExample, and Plan 4 set the precedent withconfig/bnm.go— another plan editing the same files appends, it does not conflict. - R3 Ruling: The constraint-name guard grandfathers every
(table, constraint)pair that exists at goose version 69 (the end of Plans 3–4’s ranges) and fails on any laterCHECK/UNIQUE/EXCLUDEwhose name ends_check,_keyor_excl(optionally followed by digits); Plan 6’s own guard (TestPayoutConstraintsAreNamed, its Ruling P24) covers only its three tables, so both guards stand and this one also checks Plan 6’s00090— Plans 3–4’s migrations predate the rule and renumbering their constraints now is churn — a deliberate name that happens to end_keymust be renamed. - R4 Ruling: All SQL is hand-written pgx in the platform packages, no
db/queries/expenses*.sql;make generatestill refreshesinternal/gen/sqlc/models.go, andsqlc.yamlgainsmoney.Rateoverrides for the new rate-shaped columns — the house pattern (Plans 2b, 3, 4) and the tenant-predicate guard reads Go literals — none. - R5 Ruling: A claim is reimbursed in the organization’s base currency only. A receipt line may be in any currency; its
fx_rate,fx_rate_sourceandbase_amountcome fromfx.Resolve(line currency → base, expense date)at save and are frozen on the line (recomputed only when a draft edit changes amount, currency or date). The claim total, the instance amount, the accrual and the payout are all in base, so a payout has no FX difference — §6.5 says the base amount is computed once, “claims: the expense date’s rate”, and Plan 3’s instance amount is base-only — a foreign-currency reimbursement needs a claim-currency column and Plan 4’s FX-difference path later. - R6 Ruling: A line may reference only an organization-owned category: the FK is composite
(org_id, category_id) REFERENCES expense_category (org_id, id)and a line’sorg_idisNOT NULL, so a global row (org_id NULL) can never match; provisioning step 80 seeds Malaysian categories as the org’s own rows —tenantfk_test.gonames “a reference to a table ondb/org_id_nullable.txt” as a decision for the first such FK, and a composite key is tenant-exact without a trigger — a future global seed must be copied into the org (registry rule 3) before use. - R7 Ruling: A category’s GL account is an
account_idcolumn onexpense_category(composite FK toaccount;CHECK (org_id IS NOT NULL OR account_id IS NULL)), required to be an active, postableexpenseaccount at submission and at accrual — the accrual needs one debit account per category and the org’s own row can hold it — a category that must split across accounts needs a mapping table. - R8 Ruling: Every seeded category has
employee_named_document_allowed = false; an administrator enables it per category — an employee-named e-Invoice supports a deduction only where the contract of employment provides for the expense (§6.7), which is a per-org fact — until enabled,einvoice_employeelines are refused. - R9 Ruling:
expense_rateis org-only (not a registry) and append-only (UPDATE and DELETE revoked). The rate for(kind, band, date)is, among rows witheffective_from ≤ dateand (effective_toNULL ordate < effective_to), the greatesteffective_from, ties broken by newestcreated_at(a correction is a new row). A line freezesrate_idand its computed amount. No rates are seeded — §6.2 “never edited in place”; mileage and per-diem policies are org-specific, not statutory — a correction does not re-price a draft line already saved; the claimant re-saves it. - R10 Ruling: Quantities (km for mileage, days for per diem) are
money.Ratedecimals storedNUMERIC(19,10),> 0; amount =rate.Amount.MulRate(quantity)— a float must never touch money andRateis the codebase’s non-money decimal — none. - R11 Ruling: Line types are the lowercase strings
receipt,mileage,per_diem,advance_offset(a wire enum in OpenAPI, a namedCHECKin the table);advance_offsetis accepted by the schema but creation is refused (422) until Plan 7 supplies advances — §20.1.9 “vocabulary values are strings”, and an offset without an advance to settle is meaningless — none until Plan 7. - R12 Ruling: Mileage and per-diem lines are in base currency (the rate’s currency),
fx_rate = 1,fx_rate_source = 'identity', and carry no tax code or tax record — they are computed allowances, not supplier documents — an org paying per diem in a foreign currency enters it as a receipt line. - R13 Ruling: A receipt line’s amount is the tax-inclusive gross; an optional
tax_code_id(an active tax code valid on the expense date) records the SST in it;tax_amount = gross × r / (100 + r)in the line currency, stored for the record; the accrual debits the full gross to the category account withTaxCodeIDon the journal line — Plan 4 R27 leaves tax computation to the claim line, and SST borne on an expense is not recoverable input tax — a recoverable-tax regime needs the rule to split a tax line. - R14 Ruling: The tax record is checked in two places. At save (shape, also as named
CHECKs):einvoice_company/einvoice_employeeneedsupplier_tinandeinvoice_uuid;relaxation_receiptneedsrelaxation_reason;supplier_tinmatches^[A-Z]{1,2}[0-9]{8,13}$after upper-casing;einvoice_uuidmatches^[A-Z0-9]{10,64}$after upper-casing; mileage/per-diem lines carry no basis. At submission (completeness): every receipt line has adocument_basis, at least one verified attached document, and — foreinvoice_employee— a category that permits it, re-read then.einvoice_validated_atis optional and entered by the claimant — §6.7 plus §11A.6 (capture now, validate in Phase 2) — the identifier patterns are loose on purpose; Phase 2’s MyInvois validation tightens them, and a pattern that is too strict would block a real e-Invoice today. - R15 Ruling: Record-bearing free text refuses Han, Kana and Hangul characters — in Go (
CheckRecordText) and as a namedCHECKusing!~ '[-ヿ㐀-䶿一-鿿가-]'— §11A.3 requires English or Malay records andzhis a UI locale only — a claimant cannot record a Chinese-script supplier name; they romanize it (Malaysian registered names are romanized). - R16 Ruling: Draft-only editing is enforced in the database: a
BEFORE INSERT OR UPDATEtrigger onexpense_claim_lineand aBEFORE UPDATE OF total_amount, title, group_id, payout_channel_idtrigger onexpense_claimrefuse the write unless the claim’s workflow instance is in adraft-semantic state (read from Plan 3’sworkflow_instanceandworkflow_state); lines are soft-withdrawn (withdrawn_at), never deleted — §6.4 “after accrual corrections happen by reversal, never by mutation”, and a Go-only check is one forgotten call away from a mutated accrued claim — the trigger couples to Plan 3’s table and column names, which Task 1 verifies. - R17 Ruling: A line’s analytic shares are a
JSONBarray[{analytic_account_id, percentage}]validated withanalytic.Plan(ctx, tx, "expense", base, shares, true)at save, at submission and again by the accrual rule (throughledger.Post); the postedanalytic_distributionis the FK-checked form — a child table would need DELETE on every draft edit — an analytic account retired after submission aborts the accrual, and the claim is rejected back to draft to fix. - R18 Ruling: No partial rejection: an approver rejects the whole claim with a reason naming the line, the claimant withdraws it and resubmits — Plan 3 R15 fixes the instance amount after draft — one extra round trip per partial rejection.
- R19 Ruling: References are
EC-YYYY-NNNNfor claims andEG-YYYY-NNNNfor groups, fromnumbering.Allocatescopesexpense_claim/expense_claim_group, period = calendar year ofclock.TodayUTCat creation, inside the creating transaction — §20.1.3 (calendar year, row-locked, gapless, creatable before a fiscal year exists) — none. - R20 Ruling: A member files claims only for themselves (claimant = the workflow reporter = the caller). Groups are org-wide: any filer may put their own draft claim into any active group; only the group’s owner edits it — §5.4’s “a claimant never picks their own approver” extends naturally to “nobody files as someone else”; a trip usually spans several people — a secretary filing for a director needs delegation of filing later.
- R21 Ruling: A claim is visible to its claimant, to holders of
expenses.claim.read_all, to members with an allowed transition on it now (workflow.AvailableTransitions), and to members who appear in its decision log; anyone else gets 404.GET /expenses/claims?scope=to_actreturns at most 200 claims, oldest-in-state first, without a cursor — approvers must see the receipts they decide on, and hiding existence stops probing — a large org’s queue needs an index-backed query from Plan 3 later. - R22 Ruling: A transition runs in one transaction in this order: idempotency key → claim
FOR UPDATE→ submission checks (leaving draft) → settlement (entering payout:payout.GetChannel, Plan 5’s own eligibility checks,payout.Snapshot, insert settlement) →workflow.Transition(instance lock,posting.Dispatch, its audit) → the claim’s own audit and activity rows.workflow.Transitionis the only caller ofposting.Dispatch, and it holdslockInstancefor the whole call — wave-2 §3’s lock order and the Poster’s once-only rule (Plan 4 R8) — none. - R23 Ruling: The claimant may store a preferred
payout_channel_idwhile in draft; the actor moving the claim into the payout-semantic state may name another in the transition body. Plan 5 checks the exportedChannelfields itself (active;settlement_behaviour = external; base currency allowed whenAllowedCurrenciesis non-empty;"expense_claim"allowed whenAllowedClaimTypesis non-empty; total ≤MaxAmount; a proof document whenRequiresSettlementProof) with its own errors, then callspayout.Snapshot(ctx, tx, channelID, claimantID, "expense_claim", total, base). Plan 6’s typed refusals are mapped to 422 by the list Task 1 records; anything unmapped stays 500 — the revised wave-2 contract fixes the struct and four functions and names only the three seal refusals; Task 1 Part B’s table lists the rest — a Plan 6 refusal missed in Task 1 shows as 500 until mapped. - R24 Ruling (user decision, Plan 6 P6 amended): Plan 5’s payout posting rule defers to Plan 8 whenever the settlement snapshot’s
channel.settlement_behaviourispetty_cash, and it never creditsChannel.CreditAccountID(the seeded cash-in-hand placeholder) for such a claim. Until Plan 8 lands, deferring means refusing with a typed error, twice:settlerefuses apetty_cashchannel beforepayout.Snapshotis called (ErrChannelPettyCash, 422 namingpayout_channel_id), andPayoutRule.Buildindependently refuses a settlement whose snapshot sayspetty_cash(ErrPettyCashDeferred, which aborts the transition) — so no code path, including a directBuildcall, posts petty cash to cash in hand. Skipping the posting instead was rejected: the claim would reachcompletedwith AP still open and no cash leg, a ledger that says the claimant is unpaid while the workflow says paid. Plan 8 replaces both refusals: it removes thesettlecheck and turnsPayoutRule.Build’spetty_cashbranch into a credit to the paying holder’s float account (see Notes for later plans) — Plan 6 P6/P29 bind Plan 5 to defer, and a refusal is the only deferral that keeps the ledger and the workflow in agreement — no petty-cash reimbursement until Plan 8. - R25 Ruling: Settlements are append-only rows (
expense_claim_settlement), one per payout, holdingSnapshot’s sealed JSON verbatim, the channel’sCreditAccountIDread at that moment, amount, currency, proof document, actor andtxid xid8 DEFAULT pg_current_xact_id(); the payout rule reads only the settlement written in the current transaction and refuses otherwise. §9.4’s mitigations sit on this table: two stored generated columns,channel_key(snapshot->'channel'->>'key') andaccount_tail(snapshot->>'account_tail'), both plaintext in the sealed JSON (Plan 6 P31); named CHECKs that the snapshot is version 1 with a channel key and afieldsarray, that no masked field carries avalue, and that every masked field carriessealed— so a plaintext account number cannot be stored even by a bug. The only permitted UPDATE is a key-rotation reseal ofsnapshot(Ruling R45) — §9.4 “frozen at settlement”, and a dispute-upheld-then-repaid claim must keep both snapshots — none. - R26 Ruling: The accrual rule posts one entry in base currency on the default
purchasejournal: one debit per active line (category account, line base,TaxCodeID,PartyID= claimant’s party, memo"<ref> L<n> <description>", the line’s shares) and one credit onSource.Controlfor the claim total (withPartyID); it refuses when the lines’ base sum differs from the claim total or the instance amount. The payout rule finds the control account as the credit line of the instance’s unreversed accrual entry, readsledger.SourceBalanceon it, and posts Dr control (−balance, frozen base) / Cr the settlement’s credit account, on the active journal whose default account is that credit account, else the defaultbankjournal — §10.3’s rule contract; reading the accrual’s actual control account survives an org re-pointing its AP control between accrual and payout — none. - R27 Ruling: Plan 5 registers two reconciliation checks (Plan 4 R15):
expense_claim_accrual(instanceaccrual_open⇔ an unreversedaccrualentry exists for it) andexpense_claim_ap(per claim with an unreversed accrual: balance on that accrual’s control account = −total when unpaid, 0 when an unreversed payout exists) — §10.6 “AP control” and “orphans”, each finding naming the claim — none. - R28 Ruling:
Idempotency-Keyis required onPOST /v1/expenses/claims/{id}/transitions(every transition, not only payouts). Keys are scoped(org, member, key); the same key with a different request hash is 422; only a successful response is stored and replayed (a failed request rolls its key back); the table isoperationalwith no pruner yet — §12.2 and Plan 3 R14 hand this to the consumer — the table grows until a pruner lands (Plan 9’s River work). - R29 Ruling: Claim mutations (header, lines, attachments) require
If-Match: "<revision>"; a missing header is 400, a stale one 409 (the kernel has no 412 kind, and adding one is outside this plan’s ownership);GETreturnsETag: "<revision>"— §12.2 “If-Match/ETag on claim edits” — clients branch on 409’s detail rather than on 412. - R30 Ruling: Uploads are presigned PUT only; the client declares filename, content type (
image/jpeg,image/png,application/pdf), size ≤ 20 MiB and SHA-256; the PUT URL signsContent-Typeandx-amz-checksum-sha256and livesOBJECT_STORE_PRESIGN_TTL(default 5 min, max 15 min). Completion streams the object once from the store, checks size, SHA-256 and magic bytes; a mismatch marks the documentrejectedwith its reason (object kept); a match marks itverifiedand, for JPEG/PNG under 40 megapixels, writes a 256-px JPEG thumbnail. Re-declaring a hash the same uploader already has verified returns that document (dedup) — §6.6 “bytes never transit the API”, wave-2 §4 “verified by content hash” — PDF and HEIC get no thumbnail until a renderer is chosen. - R31 Ruling: No code path deletes a document row or an object; bucket versioning/object-lock and in-Malaysia backups are deployment steps written into
DEPLOY_NOTES.md— wave-2 §4 “never deleted while the retention clock runs” — none. - R32 Ruling: Virus scanning (§6.6) is deferred; downloads are presigned GETs with
Content-Disposition: attachment— it is not on this plan’s roadmap lines and needs an in-cluster scanner decision — a malicious PDF is stored and served as a download only. - R33 Ruling: The S3 client is
github.com/minio/minio-go/v7(S3-compatible,PresignHeadersigns extra headers); its adapter is tested against the testcontainers MinIO module; every other test usesdocumentstest.MemStore— one small dependency that speaks to any S3-compatible store on the VPS — swapping to aws-sdk-go-v2 touches one package. - R34 Ruling:
cmd/apirefuses to boot withoutOBJECT_STORE_ENDPOINT,OBJECT_STORE_BUCKET,OBJECT_STORE_ACCESS_KEY,OBJECT_STORE_SECRET_KEY(region defaultsus-east-1, MinIO’s default); there is no default endpoint (an SDK default would be AWS global);.env.examplecarries a refused placeholder secret; residency is a deployment fact recorded inDEPLOY_NOTES.mdand the runbook (§11A.7: 11A.1 has no test) — nothing in code can prove where a server stands — none. - R35 Ruling: OCR is
ocr.Extractor{Name; Extract}withocr.Noopas the only implementation;OCR_PROVIDERaccepts only""/nonein Phase 1 and refuses anything else at boot; extraction runs post-commit ondocuments.uploaded, logs and swallows its own failures, and stores results only in append-onlydocument_ocr_suggestion, exposed read-only onGET /v1/documents/{id}; a guard fails ifinternal/platform/expensesimportsdocuments/ocror names the suggestion table — §6.6 “a suggestion, never a value”, wave-2 §4 — the user picks a provider later; adding it is oneExtractorand oneFromConfigcase. - R36 Ruling: A document may be actively attached to lines of one claim only (several lines of that claim may share it — a hotel folio); attaching locks the document row and is allowed while the claim is
draftorpending-semantic (a reviewer may ask for more evidence); detaching only in draft; a document must beverifiedand uploaded by the claimant — the same receipt on two claims is the classic double claim — two claimants splitting one bill attach copies with different hashes, which is honest. - R37 Ruling: Permissions: reuse
expenses.claim.create(already inconstants.yaml); appendexpenses.claim.read,expenses.claim.read_all,expenses.claim.transition,expenses.setup.manage. The Phase 0 placeholdersexpenses.claim.approveandexpenses.claim.verifyare left inconstants.yamluntouched and never registered — authority to decide comes from workflow grants (§5.4), the role permission only says “may use the transition endpoint at all” — none. - R38 Ruling: Plan 5 records
activity.Recordrows for claim created, each transition, and settlement (Plan 3 R20 leaves the feed to the consumer) — none. - R39 Ruling: Provisioning step 80
expense_categoriesseeds the Malaysian categories as org rows (translation rowsen/ms/zh, account by the MY pack’s code) only when the org’s company profile country isMYand it has no category; otherwise done. Check reports warningexpense_category_unmappedfor each active category without a usable account — §20.2 step 6 and “an unmapped category” as a repair case; the seeds live ininternal/platform/expenses, notcountrypack/my(Plan 4/6 ownership) — a second country adds a seed table in this package. - R40 Ruling:
expense_claim.instance_idis a composite FK toworkflow_instance (org_id, id); list and read queries joinworkflow_instance/workflow_stateread-only for state and semantic; every write to them goes through the workflow API — one query for a claims list instead of Nworkflow.Getcalls — Task 1 verifies the columns and theUNIQUE (org_id, id)target. - R41 Ruling: The claim-type key handed to Plan 6 is the workflow kind
"expense_claim"— the contract calls it a claim-type key and Plan 6 treats it as an opaque string — none. - R42 Ruling (flag, not a change): Plan 3 R17 notes
posting.Eventcannot say “past payout”; Plan 4’s Poster (R7) mirrors every unreversed accrual and payout entry of the instance onrejected, so an upheld dispute after payout reverses the cash leg too — Plan 5 asserts this in Task 16 — none. - R43 Ruling: A line or submission refusal travels as a 422 whose
detailis[line=<uuid> ]field=<name> rule=<code>: <sentence>(several joined by"; "), becauseapperr.Problemhas no extension members andkernel/errorsis outside this plan’s ownership — §12.2 wants clients to branch on a code, and therulecodes are stable (Task 11 lists them) — the frontend parses one string until the kernel grows extension members; the codes do not change then. - R44 Ruling (user decision, Plan 6 P31): The settlement snapshot is sealed at birth and Plan 5 never opens it. It stores
payout.Snapshot’sjson.RawMessagebyte-for-byte; every reader — the claim read API, the payout rule (which needs only the row’scredit_account_idandamount), the reconciliation checks, the reseal command — either ignores the snapshot orjson.Unmarshals it intopayout.SettlementSnapshotand usesDisplay,Channel,AccountTail(no key needed). The API emits aSettlementViewbuilt from that unmarshal: channel key and name,account_tail, and per field{key, label, display}— nevervalue(even for an unmasked field,displayis shown) and neversealed. Plan 5 has no finance export and no payment file, so no Plan 5 path needs a raw value;payout.OpenSnapshotis not called anywhere in Plan 5, andTestPlan5NeverOpensASnapshot(Task 14) fails ifinternal/platform/expensesorinternal/modules/expensesnames it. Seal errors (payout.ErrNoKeyring,ErrUnknownKey,ErrSealBroken) fromSnapshotmap to 500 (a deployment fault, not the caller’s). Audit rows never carry the snapshot: the transition’s audit row namessettlement_id; a reseal’s names key ids — PDPA 2010 security principle; a raw value that is never decrypted cannot leak from Plan 5 — the payer cannot read the full account number from Plan 5 (see “Needs the user’s decision” in Self-Review Notes). - R45 Ruling (user decision, Plan 6 P32): Plan 5 owns the key-rotation reseal:
migrate reseal-payout-details [--org] [--check] [--batch](Task 21) installs the payout keyring fromPAYOUT_DETAILS_KEY/_PREVIOUS, reads org ids read-only throughDATABASE_MIGRATE_URL(the only role that sees every org; an empty list is an error), and writes throughDATABASE_URLunder RLS: per org one transaction forpayout.ResealProfiles, then batches ofexpenses.ResealSettlements(each its own transaction — one org per transaction), then a read-only count. A row is updated and audited (expense_claim_settlement/reseal, key ids only) only whenpayout.ResealSnapshotreports a change, so the command is idempotent and resumable. To allow it,expense_claim_settlementkeepsREVOKE UPDATEat table level but grantsUPDATE (snapshot), and the triggerexpense_claim_settlement_reseal_onlyrefuses any update that changes the snapshot other than itssealedmembers —cmd/migratealready holds both DSNs and the operator subcommands (provision,verify-audit); a column grant plus trigger keeps “frozen at settlement” true for everything a reader can see — a settlement row is no longer byte-immutable (its ciphertext changes at rotation), which the audit row records; the old key must stay installed until--checkexits 0 (DEPLOY_NOTES). - R46 Ruling (user decision, Plan 6 P33): Plan 5 reads a member’s live payout profile exactly once per settlement — inside
payout.Snapshot, in the transaction that enters the payout state — and never again. After that transaction commits, every flow reads only the settlement row and its frozen snapshot: the claim read API (Settlement.View), the payout rule (SettlementInThisTx: rowcredit_account_id,amount, snapshotsettlement_behaviour), acknowledgement (received), dispute and the reversal of an upheld dispute (Plan 4’s engine mirror), both reconciliation checks, and the reseal command. Plan 5 stores no copy of a profile, no profile id outside the snapshot, and has no FK tomember_payout_profile, so Plan 6’s purge of a departed member’s profiles (member.RunDeactivationHooks→payout.PurgeMemberProfiles) cannot break a settled claim; Task 16’sTestAPurgedProfileDoesNotTouchAPaidClaimproves it. The only later profile read is a new settlement (a claim re-paid after an upheld dispute, or a claim approved but not yet paid), which callsSnapshotafresh and, for a purged member, is refused withpayout.ErrNoProfile(422) — the contract says a claim never re-reads live rows for a frozen payout, and a fresh payout has nothing frozen yet — a departed member’s unpaid claim cannot be paid through a channel with fields until the user decides how (see Needs the user’s decision).
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| File | Task | Responsibility |
|---|---|---|
00070_documents.sql |
3 | document, document_ocr_suggestion. RLS, named constraints, DELETE revoked. |
00071_expense_reference.sql |
7 | expense_category extended (UNIQUE (org_id, id), account_id, employee_named_document_allowed, DELETE revoked); expense_rate (append-only). |
00072_expense_claims.sql |
9 | expense_claim_group, expense_claim, expense_claim_line, expense_claim_line_document; the two draft-only triggers. |
00073_expense_settlement.sql |
9 | expense_claim_settlement (append-only; sealed snapshot with §9.4 CHECKs and generated channel_key/account_tail; UPDATE (snapshot) only, for a reseal, guarded by expense_claim_settlement_reseal_only), expense_idempotency_key (operational). |
New Go packages
Section titled “New Go packages”| Path | Task(s) | Responsibility |
|---|---|---|
internal/platform/documents/ |
5 | Store interface, Create, Get, Lock, Verify, RecordVerification, DownloadURL, suggestions read/write, EventUploaded. |
internal/platform/documents/documentstest/ |
4 | MemStore — an in-memory Store for every test but the adapter’s. |
internal/platform/documents/s3store/ |
4 | The minio-go adapter. |
internal/platform/documents/ocr/ |
6 | Extractor, Noop, FromConfig, Subscribe. |
internal/platform/expenses/ |
7, 8, 10–16, 19, 21 | Categories, rates, step 80, line rules, groups, claims, lines, attachments, submission, transitions, sealed settlement + masked SettlementView (14), posting rules incl. the petty-cash refusal (15–16), reconciliation checks, idempotency (19), snapshot reseal (21, reseal.go). |
internal/platform/expenses/expensestest/ |
9, 14 | The test world: provisioned MY org (chart, journals, FY, workflow v1, categories; Plan 6’s channels from Task 14), OWNER + STAFF members, a fresh Poster, a payout keyring (Task 14). Non-test package, like accountingtest. |
internal/modules/expenses/ |
17–19 | Permissions, routes, handlers, boot (posting rules, reconciliation checks). |
Modified
Section titled “Modified”| Path | Task | Change |
|---|---|---|
internal/kernel/config/objectstore.go, ocr.go (new) + tests, .env.example |
2 | Object store and OCR configuration. |
internal/kernel/db/constraintnames_test.go (new) |
3 | The named-constraint guard. |
internal/kernel/db/retention_test.go |
3, 7, 9 | Append Plan 5 tables; move expense_category from unruled to amendable (Task 7). |
sqlc.yaml |
7, 9 | money.Rate overrides for expense_claim_line.fx_rate, .quantity. |
go.mod, go.sum |
4, 5 | minio-go, x/image, testcontainers MinIO module. |
constants.yaml (+ generated vocab) |
17 | Four expenses.* permissions appended. |
api/openapi.yaml (+ internal/gen/api) |
17, 18, 19 | /expenses/…, /documents/…. |
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go |
17 | Compose the module; the API builds the store and the OCR subscriber. |
cmd/migrate/reseal.go (new) + test, cmd/migrate/main.go |
21 | migrate reseal-payout-details: key-rotation reseal of profiles and claim snapshots across every org. |
docs/DEPLOY_NOTES.md |
2, 21 | New top section: object store, residency, OCR (2); the reseal command, directly above Plan 6’s rotation notes (21). |
docs/ROADMAP.md |
20 | Tick Plan 5’s lines and the statutory “Claim lines carry the tax record” line. |
Task 1: Reconcile with Plan 3 (and Plan 6’s Task 1) as built
Section titled “Task 1: Reconcile with Plan 3 (and Plan 6’s Task 1) as built”No code. This task makes the rest of the plan true. Plan 3 and Plan 6 are written elsewhere; their merged code — not their plan documents — is the contract. Run Part A as soon as Plan 3 is merged into this branch’s base; run Part B as soon as Plan 6’s Task 1 (GetChannel, Snapshot, OpenSnapshot, ResealSnapshot — the revised wave-2 §1 seam) has landed. No task marked “waits for Plan 3” starts before Part A is committed, and none marked “waits for Plan 6 T1” before Part B.
Files:
-
Modify:
docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md(this file — the briefs of later tasks, and the “Reconciliation record” appendix at the end) -
Step A1: Rebase and read.
git fetch && git rebase <base containing Plan 3>(the user merges; you only rebase this branch). Openinternal/platform/workflow/{model.go,instance.go,store.go,seed.go,authorize.go}andinternal/platform/workflow/workflowtest/fixture.goanddb/migrations/0003*_workflow_*.sql. -
Step A2: Check every Plan 3 symbol this plan uses, one by one. For each row, record in the appendix “as planned → as built” (identical, renamed, re-typed, missing):
| Symbol used here | Used by task(s) | Expected (Plan 3 plan, Task 7/8) |
|---|---|---|
workflow.KindExpenseClaim |
9, 10, 15 | const KindExpenseClaim = "expense_claim" |
workflow.Start(ctx, tx, c, workflow.StartInput{Kind, SourceID, Reporter, Amount}) |
10 | returns (Instance, error); instance currency = base |
workflow.Get, workflow.GetBySource |
10, 13 | (ctx, tx, id), (ctx, tx, kind, sourceID) → (Instance, error) |
workflow.SetAmount(ctx, tx, c, instanceID, amount) |
12 | draft-only (ErrNotDraft), writes its own audit row, takes the instance lock |
workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID, From, To, Actor, Reason}) |
13, 14 | (Decision, error); locks the instance (lockInstance) for the whole call; dispatches posting before its audit row |
workflow.AvailableTransitions(ctx, tx, c, instanceID, actor) |
10, 18 | ([]Available, error), Available{To, ActionKey, Gate, RequiresReason, Allowed, Labels, Refusal} |
workflow.Decisions(ctx, tx, instanceID) |
10, 18 | ([]Decision, error), Decision.Actor, .AuthorityMember, .Eligible |
workflow.Load(ctx, tx, definitionID), Definition.State(key) |
10, 13 | StateSpec{Key, Semantic posting.Semantic, ControlKind, …} |
workflow.Instance{ID, DefinitionID, Kind, Version, SourceID, Reporter, StateKey, Amount, Currency, AccrualOpen, EnteredStateAt, EnteredStateOn} |
10, 13, 15 | as listed |
Errors: ErrNoInstance, ErrStaleState, ErrNoSuchEdge, ErrReasonRequired, ErrNotDraft, *RefusedError{Refusal}, *SubmissionRefusedError{Steps} |
13, 19 | as listed |
Tables: workflow_instance(org_id, id, definition_id, kind, source_id, reporter_member_id, state_key, amount, currency, accrual_open, entered_state_at, …) with UNIQUE (org_id, id); workflow_state(org_id, definition_id, state_key, semantic, control_kind, …) |
9 (triggers, FK), 10 (list join), 15 (reconcile) | as listed |
Seeded flow v1 keys and semantics: draft(draft) → pending_review,reviewed,approved(pending) → category_verified(accrual, control ap) → completed(payout) → received(acknowledged); rejected(rejected) → draft; disputed(dispute) → received / rejected; edge draft→pending_review action submit |
9, 13, 16, 20 | Plan 3 Task 8 |
Seeded grants: reviewed ← superior, approved ← unit_head, category_verified, completed, disputed ← position OWNER, floors NULL |
9, 13, 20 | Plan 3 Ruling 16 |
Provisioning: step 60 workflow_definitions registered from internal/platform/workflow’s init() |
9 | linking the package registers it |
posting.Dispatch called only from Transition; accrual_open passed as PastAccrual |
13, 15, 16 | Plan 3 Ruling 5 |
-
Step A3: Edit later briefs. For every mismatch, edit the affected task’s Interfaces and code blocks in this file so they name the symbol as built. If a needed symbol is missing (for example
SetAmountdoes not exist), do not invent it: add a Ruling after the last one (R46) stating the workaround within Plan 5’s ownership, or stop and report to the user if none exists. If the trigger’s column names differ, fix Task 9’s SQL. Record every change in the appendix. -
Step A4: Commit Part A.
git add docs/superpowers/plans/2026-09-21-plan-5-expense-claims.mdgit commit -m "Plan 5: reconcile task briefs with Plan 3 as built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"- Step B1: Read Plan 6 as built. Open
internal/platform/payout/{channel,snapshot,seal,errors}.go,payouttest/env.go,internal/kernel/config/(the payout key) and Plan 6’s revised plan (Rulings P6, P15, P29–P33). Check exactly the revised wave-2 §1 surface — in particular thatSnapshotreturns sealed JSON, thatOpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error)andResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error)have exactly these signatures, and that the three seal sentinels exist:
| Symbol | Expected |
|---|---|
payout.Channel |
fields ID, Key, SettlementBehaviour, CreditAccountID, AllowedCurrencies, AllowedClaimTypes, MaxAmount *money.Amount, RequiresSettlementProof, IsActive |
payout.GetChannel(ctx, tx, id) |
(Channel, error), calling org only |
payout.Snapshot(ctx, tx, channelID, memberID, claimType, amount, currency) |
(json.RawMessage, error), returned sealed (masked members’ sealed set, value absent) |
payout.SettlementSnapshot, SnapshotChannel, SnapshotField, Names, SnapshotVersion |
version-1 shape; JSON members version, channel{key,name,…}, account_tail, fields[{key,type,label,sensitivity,value,sealed,display}] — Task 9’s CHECKs and generated columns and Task 14’s View read these names |
payout.OpenSnapshot(raw) |
(SettlementSnapshot, error) — exists; Plan 5 does not call it (R44) |
payout.ResealSnapshot(raw) |
(json.RawMessage, bool, error) — Task 21 |
payout.ResealProfiles(ctx, tx, c) |
(int, error), calling org only — Task 21 |
payout.ErrNoKeyring, ErrUnknownKey, ErrSealBroken |
sentinels; Task 19 maps to 500 |
payout.BehaviourPettyCash |
"petty_cash" — Tasks 14, 16 |
payout.NewKeyring, InstallKeyring, Key{ID, Secret}; config.PayoutDetailsKeysFromEnv |
Task 21 installs the keyring in its binary |
payouttest.NewKey, UseKeyring, ChannelRow, BankFields; payout.SealDetails, payout.PurgeMemberProfiles |
test fixtures (Tasks 9/14, 21) |
- Step B2: Record the refusal list. List every exported error Plan 6 returns from
GetChannel,Snapshot,ResealSnapshotandResealProfiles(sentinels and typed errors) with a one-line meaning, in the appendix under “Plan 6 refusals”, and fill Task 19’smapPayoutErrtable with them. Plan 6’s revised plan (Task 1errors.go) says the set is the one below; confirm or correct each row against the code:
| Plan 6 error | From | Plan 5 maps to |
|---|---|---|
ErrChannelNotFound |
GetChannel, Snapshot |
422 naming payout_channel_id |
ErrChannelInactive |
Snapshot |
422 naming payout_channel_id (Plan 5 checks first; this is the race) |
ErrNoProfile |
Snapshot |
422 naming payout_channel_id: “the claimant has no saved payout details for this channel” |
ErrMemberNotFound |
Snapshot |
500 (the claimant is always a member; a wiring bug) |
*IneligibleError{Rule, Detail} |
Snapshot |
422 naming payout_channel_id with rule=<Rule> |
*InvalidDetailsError{Problems} |
Snapshot (saved profile no longer valid against the current schema) |
422 naming payout_channel_id: the claimant must update their payout details |
ErrRegistryEntryNotFound |
Snapshot (a retired bank)*InvalidDetailsError |
— (struck; see the appendix) |
ErrNoKeyring, ErrUnknownKey, ErrSealBroken |
Snapshot, ResealSnapshot, ResealProfiles |
500 (R44); Task 21 exits non-zero naming the org |
Record whether an empty AllowedCurrencies means “all” and fix Task 14’s eligibility check if not.
Answer (Part B, deviation 1): no.
AllowedCurrenciesis a strict allow-list and can never be empty —payout.shapeProblemsrefuses “allowed_currencies needs at least one currency” andpayout_channel_currencies_validCHECKscardinality(allowed_currencies) >= 1.AllowedClaimTypesis “empty = all”.ErrRegistryEntryNotFoundis not reachable fromSnapshot(deviation 2) and has been struck from the table above and from Task 19’s map; the retired-bank case arrives as*InvalidDetailsError.
-
Step B3: Record the test fixtures. Plan 6’s plan says:
payouttest.NewKey(t, id)+payouttest.UseKeyring(t, active, nil)install a process-wide keyring restored at Cleanup;payouttest.Env.SeedChannel(t, ChannelRow)andSeedProfile(t, member, channel, details)insert through the admin pool (the profile sealed withpayout.SealDetails); provisioning steps 90–91 (registered frominternal/platform/payout’sinit(), seeds fromcountrypack/my) create the MY channels,bank_transferamong them.payouttest.Envembedsaccountingtest.Env, notexpensestest.Env, so Task 14 copies the two admin INSERTs intoexpensestestrather than calling them. Record the exact SQL, the seed channel keys and the bank-transfer field keys (payee_name,bank,account_no) as built, and fix Task 14’sEnv.Channel/SeedChannel/PayoutProfilebrief and its test values if any differ. -
Step B4: Check the constraint guard. Plan 6’s guard (
TestPayoutConstraintsAreNamed, Ruling P24) covers the three payout tables only, so both guards stay: Task 3’s repo-wide guard grandfathers goose version ≤ 69 and must pass on Plan 6’s00090names (none ends_check/_key/_excl). Rungo test ./internal/kernel/db/ -count=1; if a Plan 6 name trips it, report it — do not rename another plan’s constraint.
Result:
go test ./internal/kernel/db/ -run TestEveryConstraintIsNamed -count=1→ok github.com/mcom/erp/internal/kernel/db 10.786s. No Plan 6 name trips it. Two notes on the guard as built (Task 3 already landed): its constant isgrandfatheredThrough = 53, not 69 — Plans 0–4 end at migration00053, so Plan 6’s00090and Plan 5’s00070–00073are all fully covered, which is stricter than Ruling R3 promised and needs no change.
- Step B5: Commit Part B with message
Plan 5: reconcile task briefs with Plan 6's payout seam as built.
Task 2: Object store and OCR configuration
Section titled “Task 2: Object store and OCR configuration”Independent of Plans 3 and 6.
Files:
- Create:
internal/kernel/config/objectstore.go,internal/kernel/config/ocr.go - Test:
internal/kernel/config/objectstore_test.go,internal/kernel/config/ocr_test.go - Modify:
.env.example(append a block),docs/DEPLOY_NOTES.md(new top section)
Interfaces:
-
Consumes: nothing.
-
Produces:
package config// ObjectStore is where documents live: an S3-compatible store (MinIO) on// the Malaysian VPS (spec 11A.1, wave-2 contract §4).type ObjectStore struct {Endpoint *url.URL // scheme http|https, host[:port], no pathRegion string // default "us-east-1"Bucket stringAccessKey stringSecretKey stringPresignTTL time.Duration // default 5m, 1m..15m}func ObjectStoreFromEnv() (ObjectStore, error)// OCRProviderFromEnv returns "none" for "" or "none"; any other value is// refused: no provider is built in (Ruling R35).func OCRProviderFromEnv() (string, error) -
Step 1: Write the failing tests —
objectstore_test.go(uset.Setenv; each refusal test first shows the valid env loads):
func setStore(t *testing.T, over map[string]string) { t.Helper() env := map[string]string{ "OBJECT_STORE_ENDPOINT": "http://minio:9000", "OBJECT_STORE_REGION": "", "OBJECT_STORE_BUCKET": "erp-documents", "OBJECT_STORE_ACCESS_KEY": "erp", "OBJECT_STORE_SECRET_KEY": "s3cr3t-not-a-placeholder", "OBJECT_STORE_PRESIGN_TTL": "", } for k, v := range over { env[k] = v } for k, v := range env { t.Setenv(k, v) }}
func TestObjectStoreLoadsWithDefaults(t *testing.T) { setStore(t, nil) s, err := config.ObjectStoreFromEnv() require.NoError(t, err) require.Equal(t, "minio:9000", s.Endpoint.Host) require.Equal(t, "us-east-1", s.Region) require.Equal(t, 5*time.Minute, s.PresignTTL)}
func TestObjectStoreRequiresEveryConnectionSetting(t *testing.T) { for _, name := range []string{"OBJECT_STORE_ENDPOINT", "OBJECT_STORE_BUCKET", "OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY"} { t.Run(name, func(t *testing.T) { setStore(t, map[string]string{name: ""}) _, err := config.ObjectStoreFromEnv() require.ErrorContains(t, err, name) }) }}
func TestObjectStoreRefusesAMalformedEndpoint(t *testing.T) { for _, bad := range []string{"minio:9000", "ftp://minio", "http://minio:9000/bucket", "https://"} { setStore(t, map[string]string{"OBJECT_STORE_ENDPOINT": bad}) _, err := config.ObjectStoreFromEnv() require.ErrorContains(t, err, "OBJECT_STORE_ENDPOINT", bad) }}
func TestObjectStoreRefusesThePlaceholderSecret(t *testing.T) { setStore(t, map[string]string{"OBJECT_STORE_SECRET_KEY": "REPLACE_ME_WITH_THE_MINIO_SECRET"}) _, err := config.ObjectStoreFromEnv() require.ErrorContains(t, err, "OBJECT_STORE_SECRET_KEY")}
func TestPresignTTLIsBounded(t *testing.T) { setStore(t, map[string]string{"OBJECT_STORE_PRESIGN_TTL": "10m"}) s, err := config.ObjectStoreFromEnv() require.NoError(t, err) require.Equal(t, 10*time.Minute, s.PresignTTL) for _, bad := range []string{"30s", "16m", "forever"} { setStore(t, map[string]string{"OBJECT_STORE_PRESIGN_TTL": bad}) _, err := config.ObjectStoreFromEnv() require.ErrorContains(t, err, "OBJECT_STORE_PRESIGN_TTL", bad) }}ocr_test.go:
func TestOCRIsOffByDefault(t *testing.T) { for _, v := range []string{"", "none", " none "} { t.Setenv("OCR_PROVIDER", v) p, err := config.OCRProviderFromEnv() require.NoError(t, err) require.Equal(t, "none", p) }}
func TestAnUnknownOCRProviderRefusesToBoot(t *testing.T) { t.Setenv("OCR_PROVIDER", "some-hosted-vision-api") _, err := config.OCRProviderFromEnv() require.ErrorContains(t, err, "OCR_PROVIDER") require.ErrorContains(t, err, "Malaysia", "the refusal names the residency rule (spec 6.6, 11A.1)")}-
Step 2: Run and watch fail.
go test ./internal/kernel/config/ -run 'ObjectStore|PresignTTL|OCR' -count=1→ FAIL (undefined). -
Step 3: Implement.
objectstore.go: read the six variables with literalos.Getenv("…")calls (TestEnvReadsHaveLiteralArguments). Endpoint:url.Parse, schemehttp/https, non-empty host, path""or"/", no query. Secret: refuse any value startingREPLACE_MEwith “OBJECT_STORE_SECRET_KEY carries the .env.example placeholder; set the store’s real secret”. TTL:time.ParseDuration, empty → 5m, outside[1m, 15m]refused. Package comment onObjectStore: the bucket, its volumes and every backup stay in Malaysia; there is no default endpoint because an SDK default is AWS global.ocr.go: trim;""/"none"→"none"; elsefmt.Errorf("OCR_PROVIDER %q: no OCR provider is built in; a provider must process records in Malaysia (spec 6.6, 11A.1) and is chosen by the operator — leave it empty", v). -
Step 4: Append to
.env.example(after the BNM block):
# Object store for claim documents (receipts, e-Invoices, settlement proofs):# an S3-compatible store — MinIO by default — running in the SAME Malaysian# datacenter as this service. ITA 1967 s.82(8): records stay in Malaysia, and# that includes this bucket, its volumes and every backup of it. There is no# default endpoint on purpose. Required by the api container.OBJECT_STORE_ENDPOINT=http://localhost:9000# MinIO ignores the region; S3-compatible stores that need one take it here.OBJECT_STORE_REGION=us-east-1OBJECT_STORE_BUCKET=erp-documentsOBJECT_STORE_ACCESS_KEY=erp# A placeholder, refused at boot. Use the store's real secret.OBJECT_STORE_SECRET_KEY=REPLACE_ME_WITH_THE_MINIO_SECRET# How long an upload or download link stays valid. 1m to 15m; default 5m.OBJECT_STORE_PRESIGN_TTL=5m
# Receipt OCR. Empty or "none" (the only accepted values in this release):# no OCR runs. OCR only ever SUGGESTS values; a person confirms them. Any# provider added later must process documents in Malaysia (spec 6.6).OCR_PROVIDER=Then run go test ./internal/kernel/config/ -count=1. TestCopyingTheExampleDoesNotBoot must stay green (it loads config.Load, which does not read these); TestEveryExampleEntryIsRead and TestEveryEnvVarIsInExample must pass.
-
Step 5:
docs/DEPLOY_NOTES.md— a new section at the top, “Plan 5 — expense claims and documents”: (1) the api container refuses to boot without the fourOBJECT_STORE_*connection settings; (2) run MinIO on the same VPS/datacenter, create the bucket, enable versioning and object lock (governance mode, 7 years) so nothing can erase a document under ITA s.82A; (3) back the bucket up only to storage in Malaysia; (4) the browser uploads straight to the store, so the store’s endpoint must be reachable from users’ browsers (behind the same TLS proxy) and its CORS must allowPUTfromPUBLIC_BASE_URLwith headersContent-Typeandx-amz-checksum-sha256; (5)OCR_PROVIDERstays empty. -
Step 6: Run and commit.
go test ./internal/kernel/config/ -count=1→ PASS.
git add internal/kernel/config/objectstore.go internal/kernel/config/ocr.go \ internal/kernel/config/objectstore_test.go internal/kernel/config/ocr_test.go \ .env.example docs/DEPLOY_NOTES.mdgit commit -m "Configure the in-Malaysia object store and the OCR switch"Task 3: Migration 00070 — documents, and the named-constraint guard
Section titled “Task 3: Migration 00070 — documents, and the named-constraint guard”Independent of Plans 3 and 6.
Files:
- Create:
db/migrations/00070_documents.sql,internal/kernel/db/constraintnames_test.go - Modify:
internal/kernel/db/retention_test.go(append), thenmake generate - Test:
internal/platform/documents/schema_test.go
Interfaces:
-
Produces tables used by Tasks 5, 6:
document(id, org_id, uploaded_by, filename, content_type, byte_size, sha256, object_key, status, reject_reason, thumbnail_key, created_at, verified_at);document_ocr_suggestion(id, org_id, document_id, provider, merchant, expense_date, total, currency, tax_amount, supplier_tin, einvoice_uuid, raw, created_at). -
Step 1: Write the migration.
-- +goose Up
-- DOCUMENTS (spec 6.6). A document is the metadata of one object in the-- in-Malaysia object store (wave-2 contract §4). The bytes never pass through-- the API: the client PUTs them to a presigned URL, and verification reads-- them back from the store. A document is evidence (spec 11A.4): nothing-- deletes one (Ruling R31), and a rejected upload is kept with its reason.CREATE TABLE document ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), uploaded_by UUID NOT NULL, filename TEXT NOT NULL CONSTRAINT document_filename_present CHECK (btrim(filename) <> '' AND length(filename) <= 255), content_type TEXT NOT NULL CONSTRAINT document_content_type_allowed CHECK (content_type IN ('image/jpeg','image/png','application/pdf')), byte_size BIGINT NOT NULL CONSTRAINT document_byte_size_range CHECK (byte_size > 0 AND byte_size <= 20971520), -- Lowercase hex SHA-256, as declared by the uploader and then checked. sha256 TEXT NOT NULL CONSTRAINT document_sha256_hex CHECK (sha256 ~ '^[0-9a-f]{64}$'), object_key TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CONSTRAINT document_status_known CHECK (status IN ('pending','verified','rejected')), reject_reason TEXT, thumbnail_key TEXT, created_at TIMESTAMPTZ NOT NULL, verified_at TIMESTAMPTZ, CONSTRAINT document_tenant_id UNIQUE (org_id, id), CONSTRAINT document_object_key_once UNIQUE (object_key), CONSTRAINT document_rejection_has_reason CHECK ((status = 'rejected') = (reject_reason IS NOT NULL)), CONSTRAINT document_verified_when CHECK ((status = 'verified') = (verified_at IS NOT NULL)), CONSTRAINT document_thumbnail_only_verified CHECK (thumbnail_key IS NULL OR status = 'verified'), FOREIGN KEY (org_id, uploaded_by) REFERENCES organization_member (org_id, id));-- Dedup (Ruling R30): one verified document per uploader per content.CREATE UNIQUE INDEX document_verified_content_once ON document (org_id, uploaded_by, sha256) WHERE status = 'verified';
ALTER TABLE document ENABLE ROW LEVEL SECURITY;ALTER TABLE document FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON document USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);-- Amendable (status moves pending -> verified|rejected), never deleted.REVOKE DELETE ON document FROM erp_app;
-- OCR SUGGESTIONS (spec 6.6, Ruling R35). A suggestion, never a value: no-- record column is ever written from here. Append-only.CREATE TABLE document_ocr_suggestion ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), document_id UUID NOT NULL, provider TEXT NOT NULL CONSTRAINT document_ocr_suggestion_provider_present CHECK (btrim(provider) <> ''), merchant TEXT, expense_date DATE, total NUMERIC(19,4), currency TEXT CONSTRAINT document_ocr_suggestion_currency_code CHECK (currency ~ '^[A-Z]{3}$'), tax_amount NUMERIC(19,4), supplier_tin TEXT, einvoice_uuid TEXT, raw JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL, FOREIGN KEY (org_id, document_id) REFERENCES document (org_id, id));CREATE INDEX document_ocr_suggestion_by_document ON document_ocr_suggestion (org_id, document_id, created_at DESC);
ALTER TABLE document_ocr_suggestion ENABLE ROW LEVEL SECURITY;ALTER TABLE document_ocr_suggestion FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON document_ocr_suggestion USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON document_ocr_suggestion FROM erp_app;
-- +goose DownDROP TABLE document_ocr_suggestion;DROP TABLE document;Check organization_member has UNIQUE (org_id, id) (00021 added composite targets); if not, the FK fails at migrate time and the fix is to name the constraint that exists (read 00021_tenant_foreign_keys.sql).
-
Step 2: Retention lists. In
retention_test.goappend"document"toamendablewith the comment “Plan 5: receipts and e-Invoices are the evidence behind every claim line (spec 11A.4); status is amended, nothing is deleted (Ruling R31)”, and"document_ocr_suggestion"toappendOnlywith “Plan 5: what OCR suggested, kept beside what the claimant confirmed; never edited (Ruling R35)”. -
Step 3: Write the guard —
internal/kernel/db/constraintnames_test.go(repo-wide; Plan 6’sTestPayoutConstraintsAreNamedis package-scoped and stays beside it, Ruling R3):
package db_test
// TestEveryConstraintIsNamed (wave-2 contract §3): error mapping is keyed on// constraint names, so a Postgres-generated name — t_col_check, t_col_key,// t_col_excl, possibly followed by digits — is a name nothing maps and that// changes when a column is renamed. Constraints that already existed at// goose version 69 (Plans 0–4) are grandfathered by exact (table, name).func TestEveryConstraintIsNamed(t *testing.T) { old := constraintSet(t, testdb.StartAt(t, 69)) now := constraintSet(t, testdb.Start(t))
generated := regexp.MustCompile(`_(check|key|excl)[0-9]*$`) var fresh, bad []string for c := range now { if old[c] { continue } fresh = append(fresh, c.String()) if generated.MatchString(c.name) { bad = append(bad, c.String()) } } sort.Strings(bad) // PRESENCE: the scan sees constraints added after 69, including Plan 5's. require.Contains(t, fresh, "document.document_status_known", "the scan found no post-69 constraint by that name; the guard would be vacuous") require.Empty(t, bad, "these CHECK/UNIQUE/EXCLUDE constraints carry a Postgres-generated name; write "+ "CONSTRAINT <table>_<rule> explicitly (wave-2 contract §3):\n %s", strings.Join(bad, "\n "))}
type constraintRef struct{ table, name string }
func (c constraintRef) String() string { return c.table + "." + c.name }
func constraintSet(t *testing.T, h *testdb.Handles) map[constraintRef]bool { t.Helper() rows, err := h.Admin.Query(context.Background(), ` SELECT cl.relname, co.conname FROM pg_constraint co JOIN pg_class cl ON cl.oid = co.conrelid JOIN pg_namespace n ON n.oid = cl.relnamespace WHERE n.nspname = 'public' AND co.contype IN ('c','u','x')`) require.NoError(t, err) defer rows.Close() out := map[constraintRef]bool{} for rows.Next() { var c constraintRef require.NoError(t, rows.Scan(&c.table, &c.name)) out[c] = true } require.NoError(t, rows.Err()) require.NotEmpty(t, out, "pg_constraint returned nothing; the query is wrong") return out}- Step 4: Schema tests —
internal/platform/documents/schema_test.go(package documents_test, raw SQL throughh.Adminfor setup andh.App+set_config('app.current_org', …)inside a tx for the app role):
func TestADocumentCannotBeDeletedByTheApp(t *testing.T)// PRESENCE: the app role inserts and reads its own document row;// ABSENCE: DELETE FROM document -> permission denied (42501).
func TestARejectedDocumentMustSayWhy(t *testing.T)// status='rejected', reject_reason NULL -> 23514 on document_rejection_has_reason;// the same row with a reason inserts (accepted case first).
func TestOneVerifiedDocumentPerUploaderAndContent(t *testing.T)// two 'pending' rows with the same (uploader, sha256) insert; marking both// 'verified' -> 23505 on document_verified_content_once; a different uploader// with the same hash verifies fine.
func TestSuggestionsAreAppendOnly(t *testing.T)// insert succeeds; UPDATE and DELETE -> 42501.-
Step 5: Run.
make generate, thengo test ./internal/platform/documents/ ./internal/kernel/db/ -count=1→ PASS (the rls, tenant-FK, search-path, retention and new constraint guards included). -
Step 6: Sabotage the guard. Change
CONSTRAINT document_status_known CHECK (…)to a bareCHECK (…), re-runTestEveryConstraintIsNamed, quote its red output (it namesdocument.document_status_check), restore, re-run green. -
Step 7: Commit
git add db/migrations/00070_documents.sql internal/kernel/db/constraintnames_test.go \ internal/kernel/db/retention_test.go internal/platform/documents/schema_test.go internal/gen/sqlcgit commit -m "Add document tables and the named-constraint guard"Task 4: The object-store seam — Store, the MinIO adapter, the in-memory fake
Section titled “Task 4: The object-store seam — Store, the MinIO adapter, the in-memory fake”Independent of Plans 3 and 6. Needs Task 2.
Files:
- Create:
internal/platform/documents/store.go,internal/platform/documents/s3store/s3store.go,internal/platform/documents/documentstest/memstore.go - Test:
internal/platform/documents/s3store/s3store_test.go,internal/platform/documents/documentstest/memstore_test.go - Modify:
go.mod,go.sum(github.com/minio/minio-go/v7,github.com/testcontainers/testcontainers-go/modules/minioat the testcontainers version already ingo.mod)
Interfaces:
-
Consumes:
config.ObjectStore(Task 2). -
Produces:
package documents// Store is the only way this codebase reaches document bytes.type Store interface {// PresignPut returns a URL the client PUTs the bytes to, and the// headers it must send unchanged (they are signed): Content-Type and// x-amz-checksum-sha256 (base64 of the raw digest).PresignPut(ctx context.Context, key, contentType string, sha256 []byte, ttl time.Duration) (url string, headers map[string]string, err error)// PresignGet returns a download URL served with// Content-Disposition: attachment; filename="<filename>".PresignGet(ctx context.Context, key, filename string, ttl time.Duration) (string, error)// Open streams an object. ErrObjectMissing when nothing is stored at key.Open(ctx context.Context, key string) (io.ReadCloser, error)// Put writes a server-generated object (a thumbnail).Put(ctx context.Context, key, contentType string, body []byte) error}var ErrObjectMissing = errors.New("documents: no object is stored at that key")package s3storefunc New(cfg config.ObjectStore) (*Store, error) // does not dial; EnsureBucket doesfunc (s *Store) EnsureBucket(ctx context.Context) error // create if missing; boot calls itvar _ documents.Store = (*Store)(nil)package documentstesttype MemStore struct{ /* mu; objects map[string]memObject; puts []PresignedPut */ }func NewMemStore() *MemStore// Upload plays the client's PUT: stores body at the key of the most recent// PresignPut for that key (tests assert the presigned headers were issued).func (m *MemStore) Upload(key string, body []byte)func (m *MemStore) Object(key string) ([]byte, bool)func (m *MemStore) PresignedHeaders(key string) map[string]stringvar _ documents.Store = (*MemStore)(nil) -
Step 1: Write the failing adapter test —
s3store_test.go, against a real MinIO container (bound every call with a 60 s context;t.Cleanupterminates the container):
func TestAPresignedPutRoundTripsAndTheStoreChecksTheHash(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() c, err := minio.Run(ctx, "minio/minio:RELEASE.2025-09-07T16-13-09Z") require.NoError(t, err) t.Cleanup(func() { _ = c.Terminate(context.Background()) }) endpoint, err := c.ConnectionString(ctx) require.NoError(t, err)
u, _ := url.Parse("http://" + endpoint) s, err := s3store.New(config.ObjectStore{Endpoint: u, Region: "us-east-1", Bucket: "docs", AccessKey: c.Username, SecretKey: c.Password, PresignTTL: 5 * time.Minute}) require.NoError(t, err) require.NoError(t, s.EnsureBucket(ctx)) require.NoError(t, s.EnsureBucket(ctx), "idempotent")
body := []byte("%PDF-1.7 receipt") sum := sha256.Sum256(body) putURL, headers, err := s.PresignPut(ctx, "documents/a/1", "application/pdf", sum[:], time.Minute) require.NoError(t, err)
// PRESENCE: the right bytes with the signed headers are accepted. require.Equal(t, http.StatusOK, put(t, ctx, putURL, headers, body)) rc, err := s.Open(ctx, "documents/a/1") require.NoError(t, err) got, _ := io.ReadAll(rc) rc.Close() require.Equal(t, body, got)
// ABSENCE: other bytes under the same signed checksum are refused by the store. putURL2, headers2, err := s.PresignPut(ctx, "documents/a/2", "application/pdf", sum[:], time.Minute) require.NoError(t, err) require.NotEqual(t, http.StatusOK, put(t, ctx, putURL2, headers2, []byte("tampered"))) _, err = s.Open(ctx, "documents/a/2") require.ErrorIs(t, err, documents.ErrObjectMissing)}
func TestAPresignedGetIsAnAttachment(t *testing.T)// Put a thumbnail, PresignGet(key, "receipt.pdf"), GET it: 200, body equal,// Content-Disposition contains `attachment` and `receipt.pdf`.If the pinned MinIO image does not enforce x-amz-checksum-sha256 on a presigned PUT, keep the PRESENCE half, replace the ABSENCE half with a comment citing the observed behaviour, and rely on Task 5’s server-side verification (Ruling R30 makes that the authority). Record which in the task report.
-
Step 2: Write the fake’s test —
memstore_test.go:PresignPutrecords headers includingx-amz-checksum-sha256= base64 of the digest andContent-Type;OpenbeforeUpload→ErrObjectMissing; afterUploadreturns the bytes;PresignGetreturns a URL containing the key. -
Step 3: Run and watch fail.
go test ./internal/platform/documents/... -count=1→ FAIL. -
Step 4: Implement.
s3store.New:minio.New(u.Host, &minio.Options{Creds: credentials.NewStaticV4(access, secret, ""), Secure: u.Scheme == "https", Region: region}).PresignPut:client.PresignHeader(ctx, http.MethodPut, bucket, key, ttl, nil, http.Header{"Content-Type": {ct}, "X-Amz-Checksum-Sha256": {b64}})and return those two headers.PresignGet:PresignedGetObjectwithreqParamsresponse-content-disposition=attachment; filename="<sanitized>"(strip"and control characters).Open:GetObjectthenStat(); aminio.ToErrorResponse(err).Code == "NoSuchKey"→ErrObjectMissing.Put:PutObjectwithContentType.EnsureBucket:BucketExists→MakeBucketwith the region. -
Step 5: Run. PASS.
-
Step 6: Commit
git add go.mod go.sum internal/platform/documents/store.go internal/platform/documents/s3store internal/platform/documents/documentstestgit commit -m "Reach the object store through one Store seam, with a MinIO adapter"Task 5: documents — upload tickets, verification by content hash, thumbnails
Section titled “Task 5: documents — upload tickets, verification by content hash, thumbnails”Independent of Plans 3 and 6. Needs Tasks 3, 4.
Files:
- Create:
internal/platform/documents/documents.go,verify.go,thumbnail.go,errors.go - Test:
internal/platform/documents/documents_test.go,verify_test.go - Modify:
go.mod,go.sum(golang.org/x/image)
Interfaces:
-
Consumes:
Store,ErrObjectMissing,documentstest.MemStore(Task 4);audit.Write;eventbus.Publish;clock. -
Produces:
package documentsconst MaxBytes = 20 << 20const (StatusPending = "pending"; StatusVerified = "verified"; StatusRejected = "rejected")var AllowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "application/pdf": true}type Document struct {ID, UploadedBy uuid.UUIDFilename, ContentType, SHA256 stringByteSize int64ObjectKey stringStatus stringRejectReason, ThumbnailKey *stringCreatedAt time.TimeVerifiedAt *time.Time}type UploadTicket struct {URL string `json:"url"`Method string `json:"method"` // always "PUT"Headers map[string]string `json:"headers"`ExpiresAt time.Time `json:"expires_at"`}type CreateInput struct {UploadedBy uuid.UUIDFilename, ContentType stringByteSize int64SHA256 string // lowercase hex}// Create records a pending document and presigns its upload. When the// uploader already has a VERIFIED document with this SHA-256 it returns that// document and a nil ticket (Ruling R30's dedup) and writes nothing.func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, s Store, ttl time.Duration, in CreateInput) (Document, *UploadTicket, error)func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Document, error)func Lock(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Document, error) // FOR UPDATE// Verification is Verify's finding: what the store holds, and the// thumbnail already written (or nil).type Verification struct {OK boolReason string // set when !OK: "size", "sha256", "content_type"ThumbnailKey *string}// Verify reads the object once from the store — OUTSIDE any transaction —// and checks size, SHA-256 and magic bytes against doc; on success it// writes the thumbnail for JPEG/PNG. ErrObjectMissing when not uploaded.func Verify(ctx context.Context, s Store, doc Document) (Verification, error)// RecordVerification locks the row; a document no longer pending is// returned unchanged (a concurrent completion won). Otherwise it sets// status, reason, thumbnail and verified_at, writes audit ("verify" or// "reject") and, when verified, publishes EventUploaded.func RecordVerification(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, v Verification) (Document, error)func DownloadURL(ctx context.Context, s Store, ttl time.Duration, d Document) (string, error)func ThumbnailURL(ctx context.Context, s Store, ttl time.Duration, d Document) (*string, error)const EventUploaded = "documents.uploaded"type Uploaded struct{ OrgID, DocumentID uuid.UUID }var ErrNotFound, ErrTypeNotAllowed, ErrTooLarge, ErrBadDigest, ErrFilenameRequired errorObject keys:
documents/<org_id>/<yyyy>/<document_id>; thumbnailsthumbnails/<org_id>/<document_id>.jpg. No filename in a key (it may carry personal data). -
Step 1: Write the failing tests —
documents_test.go. A small local fixture:testdb.Start, one org (admin insert), one member (party + organization_member via admin),db.Open(…, eventbus.New()),clock.Fixed(2026-09-21T09:00Z), aMemStore.
func TestCreatePresignsAnUploadAndRecordsAPendingDocument(t *testing.T) { // Create(pdf, 1234 bytes, sha) -> ticket.Method "PUT", ticket.Headers has // Content-Type and x-amz-checksum-sha256 == base64(raw digest); // ExpiresAt = now + ttl; row status 'pending'; object_key has the org id // and year and not the filename; one audit row entity_kind 'document' // action 'create'.}func TestCreateRefusesWhatCanNeverVerify(t *testing.T) { // PRESENCE: a valid input succeeds. Then each alone: content type // "image/gif" -> ErrTypeNotAllowed; ByteSize MaxBytes+1 -> ErrTooLarge; // ByteSize 0 -> ErrTooLarge; sha "XYZ" -> ErrBadDigest; filename " " -> // ErrFilenameRequired. None leaves a row.}func TestTheSameUploaderGetsTheirVerifiedDocumentBack(t *testing.T) { // Create + Upload + Verify + RecordVerification -> verified. Create again // with the same sha: returns the same id, nil ticket, no new row. PRESENCE // of the contrast: a DIFFERENT member declaring the same sha gets a new // pending document and a ticket.}verify_test.go:
func TestVerifyAcceptsTheDeclaredBytesAndThumbnailsAnImage(t *testing.T) { // A real 800x600 PNG (image/png encode in the test), declared with its // size and sha. Upload, Verify -> OK, ThumbnailKey set, the MemStore holds // a JPEG there whose decoded bounds are at most 256 on the long side. // RecordVerification -> status verified, verified_at = clock.Now(), audit // action 'verify', and the tx's eventbus buffer holds one EventUploaded // (subscribe a handler on the bus and assert it ran after InTx returned).}func TestVerifyRejectsBytesThatAreNotWhatWasDeclared(t *testing.T) { // PRESENCE first (a matching upload verifies). Then three fresh documents: // wrong bytes -> Reason "sha256"; right hash but declared size off by one // -> Reason "size"; a PNG declared as application/pdf with its true size // and hash -> Reason "content_type". RecordVerification -> status // 'rejected', reject_reason set, audit 'reject', no event, and the object // is still in the store (Ruling R31).}func TestVerifyBeforeUploadIsNotARejection(t *testing.T) { // Verify with nothing uploaded -> ErrObjectMissing; the row stays pending.}func TestAPDFVerifiesWithoutAThumbnail(t *testing.T)func TestAHugeImageVerifiesWithoutDecoding(t *testing.T) { // A PNG header claiming 10000x10000 (image.DecodeConfig sees it) with a // valid hash: OK, ThumbnailKey nil — the 40-megapixel guard.}func TestOnlyOneCompletionWins(t *testing.T) { // Two RecordVerification calls on one pending document in two goroutines, // each in its own InTx with a 10 s context: exactly one audit row // 'verify'; both return status verified.}-
Step 2: Run and watch fail.
go test ./internal/platform/documents/ -count=1. -
Step 3: Implement.
Create: validate (ErrFilenameRequired,ErrTypeNotAllowed,ErrTooLargefor≤0or>MaxBytes,ErrBadDigestunless^[0-9a-f]{64}$). Dedup lookupSELECT … FROM document WHERE org_id=$1 AND uploaded_by=$2 AND sha256=$3 AND status='verified'. Else insert withid := uuid.New(),object_key = fmt.Sprintf("documents/%s/%d/%s", org, today.Year(), id),created_at = c.Now();audit.Write{EntityKind:"document", Action:"create", After: {filename, content_type, byte_size, sha256}}; thens.PresignPut(after the insert; a presign failure returns the error and the caller’s tx rolls back).Verify:rc, err := s.Open; read throughio.LimitReader(rc, MaxBytes+1)into a buffer while hashing; size mismatch →Reason:"size"; digest mismatch →"sha256";http.DetectContentType(first 512 bytes)must equal the declared type (application/pdffor%PDF) → else"content_type". For JPEG/PNG:image.DecodeConfig; ifw*h ≤ 40_000_000, decode, scale withdraw.CatmullRomso the long side is 256, encode JPEG quality 80,s.Put(thumbnailKey, "image/jpeg", …). A decode error after a matching hash is not a rejection: no thumbnail.RecordVerification:Lock; if not pending return it. UPDATE;audit.Write; on verifiedeventbus.Publish(ctx, EventUploaded, Uploaded{OrgID, DocumentID}).DownloadURL/ThumbnailURL: only for verified documents (ErrNotFound-free: a pending document has no URL — return""/nil).
-
Step 4: Run. PASS.
-
Step 5: Commit
git add go.mod go.sum internal/platform/documentsgit commit -m "Verify uploaded documents by content hash and thumbnail images"Task 6: OCR as a suggestion only — the interface, the no-op provider, the guard
Section titled “Task 6: OCR as a suggestion only — the interface, the no-op provider, the guard”Independent of Plans 3 and 6. Needs Tasks 5 and 7.
Files:
- Create:
internal/platform/documents/ocr/ocr.go,internal/platform/documents/suggestion.go - Test:
internal/platform/documents/ocr/ocr_test.go,internal/platform/documents/ocr/guard_test.go
Interfaces:
-
Consumes:
documents.Get,Store,EventUploaded,Uploaded(Task 5);eventbus.Bus;db.DB. -
Produces:
package documents// Suggestion is what an OCR provider read off a document. Every field is// optional; nothing here is ever copied into a record (spec 6.6).type Suggestion struct {ID uuid.UUID `json:"id"`Provider string `json:"provider"`Merchant *string `json:"merchant"`ExpenseDate *time.Time `json:"expense_date"`Total *money.Amount `json:"total"`Currency *string `json:"currency"`TaxAmount *money.Amount `json:"tax_amount"`SupplierTIN *string `json:"supplier_tin"`EInvoiceUUID *string `json:"einvoice_uuid"`Raw json.RawMessage `json:"-"`CreatedAt time.Time `json:"created_at"`}func SaveSuggestion(ctx context.Context, tx pgx.Tx, c clock.Clock, documentID uuid.UUID, s Suggestion) (uuid.UUID, error)func Suggestions(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]Suggestion, error) // newest firstpackage ocrtype Input struct {DocumentID uuid.UUIDContentType stringBody []byte}// Extractor reads a document and suggests values. A provider MUST process// the document in Malaysia (spec 11A.1). nil, nil means "nothing found".type Extractor interface {Name() stringExtract(ctx context.Context, in Input) (*documents.Suggestion, error)}// Noop is the default: OCR is off.type Noop struct{}func FromConfig(provider string) (Extractor, error) // "none" -> Noop{}; else error// Subscribe runs x after every committed verification. It opens its own// transaction (post-commit, never nested), logs every failure and returns// nil, so an OCR fault never fails an upload. With Noop it reads nothing.func Subscribe(bus *eventbus.Bus, d *db.DB, s documents.Store, x Extractor, c clock.Clock, log *slog.Logger) -
Step 1: Write the failing tests —
ocr_test.go:
type fakeOCR struct{ calls int; out *documents.Suggestion; err error }func (f *fakeOCR) Name() string { return "fake" }func (f *fakeOCR) Extract(context.Context, ocr.Input) (*documents.Suggestion, error) { f.calls++; return f.out, f.err }
func TestTheDefaultProviderIsOffAndStoresNothing(t *testing.T) { x, err := ocr.FromConfig("none") require.NoError(t, err) require.IsType(t, ocr.Noop{}, x) // Subscribe Noop, upload+verify a document through InTx: zero rows in // document_ocr_suggestion. PRESENCE of the path: the same flow with // fakeOCR{out: &Suggestion{Merchant: ptr("Kedai ABC")}} stores one row.}func TestAnUnknownProviderIsRefused(t *testing.T) // FromConfig("x") -> errorfunc TestASuggestionIsStoredNeverApplied(t *testing.T) { // fakeOCR suggests Total "12.3400" MYR: Suggestions(doc) returns it, with // Total a money.Amount; the document row itself is unchanged.}func TestAnOCRFailureDoesNotFailTheUpload(t *testing.T) { // fakeOCR{err: errors.New("down")}: RecordVerification's InTx returns nil // (no *db.PostCommitError), the document is verified, the log captured // "ocr" and "down".}guard_test.go — spec 6.6’s rule as a build failure:
// TestNothingCopiesOCRIntoARecord: the claim side must never read OCR output.// internal/platform/expenses may not import documents/ocr nor name the// suggestion table or documents.Suggestions; only the HTTP read path// (internal/modules/expenses/document_handlers.go) may show suggestions.func TestNothingCopiesOCRIntoARecord(t *testing.T) { root := repoRoot(t) files := goFiles(t, filepath.Join(root, "internal", "platform", "expenses")) // PRESENCE: the walk reaches files, and the pattern matches where it must. require.NotEmpty(t, files, "no files under internal/platform/expenses were scanned") own, _ := os.ReadFile(filepath.Join(root, "internal", "platform", "documents", "suggestion.go")) require.Regexp(t, forbidden, string(own), "the pattern no longer matches the suggestion code itself") var bad []string for _, f := range files { b, _ := os.ReadFile(f) if forbidden.Match(b) { bad = append(bad, f) } } require.Empty(t, bad, "OCR output is a suggestion, never a value (spec 6.6): %v", bad)}
var forbidden = regexp.MustCompile(`documents/ocr"|document_ocr_suggestion|documents\.Suggestions?\b`)
// repoRoot and goFiles: runtime.Caller(0) up four directories; goFiles walks// a directory with filepath.WalkDir collecting *.go (tests included — a test// that copies OCR into a line is the same hazard).internal/platform/expenses exists from Task 7, so the walk’s presence assertion holds.
-
Step 2: Run and watch fail. Step 3: Implement.
Subscribe:bus.Subscribe(documents.EventUploaded, func(ctx, p) error { … }); skip entirely whenxisNoop; elsed.InTx(ctx, …)toGetthe document, read bytes throughs.Openoutside that tx (open tx1 to read metadata, close; read bytes;x.Extract; tx2SaveSuggestion); every error →log.Warn("ocr", "document", id, "err", err); return nil. -
Step 4: Run. PASS. Sabotage: add
import _ "github.com/mcom/erp/internal/platform/documents/ocr"tointernal/platform/expenses/doc.go→TestNothingCopiesOCRIntoARecordred (quote it); restore. -
Step 5: Commit
git add internal/platform/documentsgit commit -m "Add OCR as a pluggable suggestion-only seam, off by default"Task 7: Migration 00071 and categories — the registry extended, provisioning step 80
Section titled “Task 7: Migration 00071 and categories — the registry extended, provisioning step 80”Independent of Plans 3 and 6. Needs Task 3 (migration order; the constraint guard).
Files:
- Create:
db/migrations/00071_expense_reference.sql,internal/platform/expenses/category.go,internal/platform/expenses/step.go,internal/platform/expenses/seed_my.go,internal/platform/expenses/errors.go,internal/platform/expenses/text.go - Create also:
internal/platform/expenses/doc.go(the package comment: what this package owns, and that it never reads OCR output — spec 6.6) - Modify:
internal/kernel/db/retention_test.go(moveexpense_categoryfromunruledtoamendable; appendexpense_ratetoappendOnly), thenmake generate - Test:
internal/platform/expenses/category_test.go,internal/platform/expenses/step_test.go,internal/platform/expenses/text_test.go,internal/platform/expenses/reference_schema_test.go
Interfaces:
-
Consumes:
chart.GetAccount,chart.GetAccountByCode,chart.TypeExpense(Plan 4);company.Get;provisioning.Register,provisioning.Warning;i18n.Load;audit.Write;accountingtest(tests only:New,SeedChart). -
Produces:
package expensesconst Kind = "expense_claim" // equals workflow.KindExpenseClaim; Task 9 pins them togethertype Category struct {ID uuid.UUIDKey stringAccountID *uuid.UUIDEmployeeNamedDocumentAllowed boolIsActive boolSortOrder intNames map[string]string // locale -> name, every translation row}type CategoryInput struct {Key stringNames map[string]string // "en" and "ms" required; "zh" optionalAccountID *uuid.UUIDEmployeeNamedDocumentAllowed boolSortOrder int}type CategoryPatch struct {Names map[string]string // replaces the given locales onlyAccountID **uuid.UUID // non-nil: set (inner nil clears)EmployeeNamedDocumentAllowed *boolIsActive *boolSortOrder *int}func CreateCategory(ctx context.Context, tx pgx.Tx, c clock.Clock, in CategoryInput) (Category, error)func GetCategory(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Category, error) // org rows only (Ruling R6)func ListCategories(ctx context.Context, tx pgx.Tx) ([]Category, error) // org rows, sort_order, keyfunc UpdateCategory(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, p CategoryPatch) (Category, error)// UsableAccount returns the category's account when it is set, active,// postable and of type expense; else *CategoryAccountError.func UsableAccount(ctx context.Context, tx pgx.Tx, cat Category) (chart.Account, error)// CheckRecordText refuses record-bearing text outside English/Malay scripts (Ruling R15).func CheckRecordText(field, s string) error // *LineError{Field, Rule: "record_language"}const (StepKeyCategories = "expense_categories"; StepOrderCategories = 80)const WarningCategoryUnmapped = "expense_category_unmapped"var ErrNotFound, ErrDuplicateKey, ErrInvalidCategory errortype CategoryAccountError struct{ CategoryKey, Reason string } // "unset" | "inactive" | "not_postable" | "not_expense"type LineError struct{ LineID *uuid.UUID; Field, Rule, Detail string } // shared by Tasks 11–13 -
Step 1: Write the migration.
-- +goose Up
-- EXPENSE CATEGORIES (spec 6.1, 6.7, 20.1.4). The Phase 0 registry table,-- extended. A line may only name an ORG-owned category (Ruling R6): the-- composite target below cannot match a global row, whose org_id is NULL.ALTER TABLE expense_category ADD COLUMN account_id UUID, ADD COLUMN employee_named_document_allowed BOOLEAN NOT NULL DEFAULT false, ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), ADD CONSTRAINT expense_category_tenant_id UNIQUE (org_id, id), -- A global row belongs to no chart, so it cannot name an account. ADD CONSTRAINT expense_category_global_has_no_account CHECK (org_id IS NOT NULL OR account_id IS NULL), ADD CONSTRAINT expense_category_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id);
-- Lines will reference categories: retire (is_active) or soft-delete-- (deleted_at), never erase (Ruling R6, spec 20.1.4 rule 4).REVOKE DELETE ON expense_category FROM erp_app;
-- RATES (spec 6.2): date-effective rows, never edited in place (Ruling R9).CREATE TABLE expense_rate ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), kind TEXT NOT NULL CONSTRAINT expense_rate_kind_known CHECK (kind IN ('mileage','per_diem')), -- mileage: the vehicle class (car, motorcycle); per_diem: the destination band. band TEXT NOT NULL CONSTRAINT expense_rate_band_shape CHECK (band ~ '^[a-z][a-z0-9_]{0,31}$'), effective_from DATE NOT NULL, effective_to DATE, amount NUMERIC(19,4) NOT NULL CONSTRAINT expense_rate_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT expense_rate_currency_code CHECK (currency ~ '^[A-Z]{3}$'), reason TEXT NOT NULL CONSTRAINT expense_rate_reason_present CHECK (btrim(reason) <> ''), created_by UUID, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT expense_rate_tenant_id UNIQUE (org_id, id), CONSTRAINT expense_rate_window_ordered CHECK (effective_to IS NULL OR effective_to > effective_from), CONSTRAINT expense_rate_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id));CREATE INDEX expense_rate_lookup ON expense_rate (org_id, kind, band, effective_from DESC, created_at DESC);
ALTER TABLE expense_rate ENABLE ROW LEVEL SECURITY;ALTER TABLE expense_rate FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON expense_rate USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON expense_rate FROM erp_app;
-- +goose DownDROP TABLE expense_rate;GRANT DELETE ON expense_category TO erp_app;ALTER TABLE expense_category DROP CONSTRAINT expense_category_account_fk, DROP CONSTRAINT expense_category_global_has_no_account, DROP CONSTRAINT expense_category_tenant_id, DROP COLUMN updated_at, DROP COLUMN employee_named_document_allowed, DROP COLUMN account_id;-
Step 2: Retention. Move
"expense_category"out ofunruledintoamendablewith the comment “Plan 5: lines name categories (composite FK, Ruling R6); retired with is_active or deleted_at, never erased. DELETE revoked in 00071.” Append"expense_rate"toappendOnly: “Plan 5: a rate is evidence of how a mileage or per-diem line was priced (spec 6.2); a correction is a new row.” Runmake generate. -
Step 3: Write the failing tests.
text_test.go (pure):
func TestRecordTextAcceptsEnglishAndMalay(t *testing.T) { for _, s := range []string{"Taxi to KLIA", "Makan tengah hari dengan pelanggan", "Café Ümit — RM12"} { require.NoError(t, expenses.CheckRecordText("description", s)) }}func TestRecordTextRefusesCJKScripts(t *testing.T) { for _, s := range []string{"午餐", "カフェ", "점심", "Lunch 午餐"} { var le *expenses.LineError require.ErrorAs(t, expenses.CheckRecordText("description", s), &le) require.Equal(t, "record_language", le.Rule) }}reference_schema_test.go:
func TestAGlobalCategoryCannotNameAnAccount(t *testing.T) // admin insert (org NULL, account set) -> 23514 expense_category_global_has_no_account; PRESENCE: org row with account insertsfunc TestTheAppCannotDeleteACategory(t *testing.T) // PRESENCE: UPDATE is_active works as erp_app; DELETE -> 42501func TestRatesAreAppendOnly(t *testing.T) // insert ok; UPDATE, DELETE -> 42501func TestTheRegistryTestsStillHold(t *testing.T) // go test ./internal/platform/registry/ passes unchanged — run it in Step 5, no code herecategory_test.go (e := accountingtest.New(t), ch := e.SeedChart(t)):
func TestACategoryCarriesItsAccountAndItsNames(t *testing.T) { // CreateCategory{Key "meals", Names en/ms/zh, AccountID &ch.Meals.ID}: // GetCategory returns Names with three locales; three translation rows // (entity_kind 'expense_category', field 'name') carry e.Org; one audit row.}func TestACategoryNeedsEnglishAndMalayNames(t *testing.T) // PRESENCE en+ms ok; en only -> ErrInvalidCategory; zh only -> ErrInvalidCategoryfunc TestKeysAreUniqueCaseInsensitively(t *testing.T) // "meals" then "Meals" -> ErrDuplicateKey (expense_category_org_key)func TestAnAccountMustBeAPostableExpense(t *testing.T) { // PRESENCE: ch.Travel ok. ch.Bank -> *CategoryAccountError{Reason:"not_expense"}; // ch.Expenses (header) -> "not_postable"; a deactivated expense account -> // "inactive" — from Create, Update and UsableAccount alike.}func TestPatchingNamesUpsertsOnlyTheGivenLocales(t *testing.T)func TestGlobalRowsAreNotListedOrUsable(t *testing.T) { // Admin inserts a global row 'fuel'. ListCategories omits it; GetCategory // on its id -> ErrNotFound (Ruling R6). PRESENCE: the org's own row is listed.}step_test.go:
func TestStep80IsRegisteredAt80(t *testing.T) // provisioning.Steps() contains StepKeyCategories with Order 80func TestStep80SeedsMalaysianCategoriesMappedToThePackChart(t *testing.T) { // accountingtest.New (country MY) + run countrypack's chart step through // provisioning.Run on a registry holding the real steps (link // internal/platform/countrypack and /my in the test file's imports). // Then Check -> not done; Run; Check -> done, no warnings. Every seeded // category has en, ms and zh names and an account whose code is the one // in seed_my.go; employee_named_document_allowed is false for all (R8).}func TestStep80WarnsOnAnUnmappedCategory(t *testing.T) // clear one account -> done=true, one WarningCategoryUnmapped naming it (Ref = category id)func TestStep80LeavesAnOrgWithCategoriesAlone(t *testing.T) // create one category by hand; Run is not needed (Check done); nothing else seededfunc TestStep80SeedsNothingOutsideMalaysia(t *testing.T) // company country 'SG' -> Check done, zero categories- Step 4: Implement.
text.go:var cjk = regexp.MustCompile([\x{3040}-\x{30ff}\x{3400}-\x{4dbf}\x{4e00}-\x{9fff}\x{ac00}-\x{d7af}]); the SQLCHECKs in Task 9 use the same ranges.category.go: keys^[a-z][a-z0-9_]{0,62}$(Go only — the registry’s own tests use keys likeCash). Every queryWHERE org_id = $1 AND deleted_at IS NULL. Names are written as translation rows withINSERT … ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO UPDATE SET value = EXCLUDED.value;enandmsvalues also passCheckRecordText(they are the record locales).i18n.Load(ctx, tx, kind, ids, field)returns one label per id in the request locale — use it only for list labels;Names(every locale) is read directly:SELECT locale, value FROM translation WHERE org_id = $1 AND entity_kind = 'expense_category' AND entity_id = $2 AND field = 'name'. Unique violation onexpense_category_org_key(a unique index, not a constraint — the Task 3 guard, which readspg_constraint, does not see it) →ErrDuplicateKey. Auditexpense_categorycreate/updatewith before/after.seed_my.go: the table below (key, account code, en, ms, zh, sort):
| key | code | en | ms | zh |
|---|---|---|---|---|
| travel | 6100 | Travel | Perjalanan | 差旅 |
| accommodation | 6100 | Accommodation | Penginapan | 住宿 |
| meals | 6110 | Meals | Makan | 餐饮 |
| entertainment | 6110 | Client entertainment | Hiburan pelanggan | 客户招待 |
| transport | 6120 | Transport | Pengangkutan | 交通 |
| parking_toll | 6120 | Parking and toll | Letak kereta dan tol | 停车及过路费 |
| mileage | 6120 | Mileage | Perbatuan | 里程 |
| telephone | 6130 | Telephone and internet | Telefon dan internet | 电话及网络 |
| stationery | 6140 | Printing and stationery | Percetakan dan alat tulis | 印刷及文具 |
| staff_welfare | 6150 | Staff welfare | Kebajikan kakitangan | 员工福利 |
| medical | 6150 | Medical | Perubatan | 医疗 |
| professional_fees | 6170 | Professional fees | Yuran profesional | 专业费用 |
| sundry | 6190 | Sundry expenses | Perbelanjaan pelbagai | 杂项费用 |
-
step.go:categoriesStep{}registered ininit(). Check: country ≠MY→ done; zero org categories → not done; else done, with a warning per active category whoseUsableAccountfails. Run: for each seed,chart.GetAccountByCode; a missing code leavesAccountIDnil (the warning then surfaces it);CreateCategory. -
Step 5: Run.
go test ./internal/platform/expenses/ ./internal/platform/registry/ ./internal/kernel/db/ -count=1→ PASS. -
Step 6: Commit
git add db/migrations/00071_expense_reference.sql internal/platform/expenses internal/kernel/db/retention_test.go internal/gen/sqlcgit commit -m "Extend expense categories with their account and tax-record policy; seed Malaysia's"Task 8: Rates — date-effective, never edited in place
Section titled “Task 8: Rates — date-effective, never edited in place”Independent of Plans 3 and 6. Needs Task 7.
Files:
- Create:
internal/platform/expenses/rate.go - Test:
internal/platform/expenses/rate_test.go
Interfaces:
-
Consumes:
company.Get(base currency),audit.Write,clock. -
Produces:
const (RateMileage = "mileage"; RatePerDiem = "per_diem")type Rate struct {ID uuid.UUIDKind, Band stringEffectiveFrom time.TimeEffectiveTo *time.TimeAmount money.AmountCurrency, Reason stringCreatedBy *uuid.UUIDCreatedAt time.Time}type RateInput struct {Kind, Band stringEffectiveFrom time.TimeEffectiveTo *time.TimeAmount money.AmountReason stringCreatedBy *uuid.UUID}func CreateRate(ctx context.Context, tx pgx.Tx, c clock.Clock, in RateInput) (Rate, error) // currency = basefunc GetRate(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Rate, error)type RateFilter struct{ Kind, Band string; On *time.Time } // On: only the rate effective that day per (kind, band)func ListRates(ctx context.Context, tx pgx.Tx, f RateFilter) ([]Rate, error)// ResolveRate is Ruling R9's rule. *NoRateError when nothing is effective.func ResolveRate(ctx context.Context, tx pgx.Tx, kind, band string, on time.Time) (Rate, error)var ErrInvalidRate errortype NoRateError struct{ Kind, Band string; On time.Time } -
Step 1: Write the failing tests (
accountingtest.New;accountingtest.Date):
func TestTheRateEffectiveOnADateIsTheLatestStartedOne(t *testing.T) { // car: 0.6000 from 2024-01-01, 0.7000 from 2026-01-01. // Resolve(2025-12-31) -> 0.6000; (2026-01-01) -> 0.7000; (2023-12-31) -> *NoRateError.}func TestAnEndedRateStopsApplying(t *testing.T) // 0.8 from 2026-03-01 to 2026-04-01: 2026-03-31 -> 0.8; 2026-04-01 -> the earlier rowfunc TestACorrectionIsANewRowAndTheNewestWins(t *testing.T) { // Two rows effective 2026-01-01 (0.70 then, a clock tick later, 0.75 with // reason "typo"): Resolve -> 0.75; both rows remain; the first is untouched.}func TestBandsAreSeparate(t *testing.T) // car vs motorcyclefunc TestARateIsInTheBaseCurrency(t *testing.T) // Rate.Currency == "MYR"func TestARateIsRefusedWhenMalformed(t *testing.T) // PRESENCE valid; then kind "fuel", band "Car", amount 0, blank reason, effective_to <= from -> ErrInvalidRate; nothing writtenfunc TestListOnADayShowsOnlyEffectiveRates(t *testing.T)- Step 2: Run and watch fail. Step 3: Implement. Resolve:
SELECT … FROM expense_rate WHERE org_id = $1 AND kind = $2 AND band = $3 AND effective_from <= $4 AND (effective_to IS NULL OR $4 < effective_to) ORDER BY effective_from DESC, created_at DESC, id DESC LIMIT 1created_at = c.Now(); audit expense_rate create. No update function exists — by design.
- Step 4: Run. PASS. Step 5: Commit —
git commit -m "Price mileage and per diem from append-only, date-effective rates".
Task 9: Migrations 00072–00073 — claims, lines, attachments, settlements; the test world
Section titled “Task 9: Migrations 00072–00073 — claims, lines, attachments, settlements; the test world”Waits for Plan 3’s merge and Task 1 Part A. Needs Tasks 3, 7, 8.
Files:
- Create:
db/migrations/00072_expense_claims.sql,db/migrations/00073_expense_settlement.sql,internal/platform/expenses/expensestest/env.go - Modify:
internal/kernel/db/retention_test.go,sqlc.yaml(append overridesexpense_claim_line.fx_rate,expense_claim_line.quantity→money.Rate), thenmake generate - Test:
internal/platform/expenses/claims_schema_test.go,internal/platform/expenses/expensestest/env_test.go
Interfaces:
-
Consumes: Plan 3’s
workflow_instance,workflow_state,workflow.KindExpenseClaim,workflow.Start,workflow.Transition(tests), step 60; Plan 4’s provisioning steps 70–74; Task 7’s step 80;documents(Task 5);accounting.NewPoster,accounting.NewRuleSet,posting.Register,posting.ResetForTest. -
Produces the tables every later task uses verbatim (columns in the SQL below), and:
package expensestestvar T0 = time.Date(2026, 9, 21, 9, 0, 0, 0, time.UTC) // a Monday in FY2026 period 9var Today = time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC)type Env struct {H *testdb.HandlesDB *db.DBCtx context.Context // tenancy.WithOrgOrg uuid.UUIDClock clock.Clock // clock.Fixed(T0)Owner uuid.UUID // holds OWNER: superior, unit head, verifier, payerStaff uuid.UUID // reports to OWNER; the usual claimantOwnerSeat uuid.UUIDStaffSeat uuid.UUIDStore *documentstest.MemStoreCategories map[string]uuid.UUID // step 80's seeds by key}func New(t *testing.T) *Env // one provisioned MY org; posting reset to nilfunc (e *Env) Another(t *testing.T) *Env // a second provisioned org on the same databasefunc (e *Env) Tx(t *testing.T, fn func(ctx context.Context, tx pgx.Tx) error)func (e *Env) TxErr(fn func(ctx context.Context, tx pgx.Tx) error) errorfunc (e *Env) Member(t *testing.T, name string, reportsTo *uuid.UUID) (member, seat uuid.UUID)// UsePoster registers Plan 4's Poster over a fresh RuleSet holding rules// (normally expenses.Rules(), Task 15/16); cleans up after the test.func (e *Env) UsePoster(t *testing.T, rules ...accounting.PostingRule)// Document uploads body as member and returns it verified.func (e *Env) Document(t *testing.T, member uuid.UUID, contentType string, body []byte) documents.Documentfunc (e *Env) Rate(t *testing.T, kind, band, from, amount string) expenses.Ratefunc (e *Env) Count(t *testing.T, sql string, args ...any) int // through h.Admin// Filled by Task 14 from Task 1 Part B's record:// func (e *Env) Channel(t *testing.T, key string) uuid.UUID// func (e *Env) PayoutProfile(t *testing.T, member, channel uuid.UUID) -
Step 1: Write migration 00072.
<cjk>below stands for the literal class'[-ヿ㐀-䶿一-鿿가-]'— write it out in full at each use.
-- +goose Up
-- CLAIM GROUPS (spec 6.1): a trip, an event, a batch. Org-wide (Ruling R20).CREATE TABLE expense_claim_group ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, owner_member_id UUID NOT NULL, name TEXT NOT NULL CONSTRAINT expense_claim_group_name_present CHECK (btrim(name) <> '' AND length(name) <= 200), kind TEXT NOT NULL CONSTRAINT expense_claim_group_kind_known CHECK (kind IN ('trip','event','batch')), starts_on DATE, ends_on DATE, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT expense_claim_group_tenant_id UNIQUE (org_id, id), CONSTRAINT expense_claim_group_reference_once UNIQUE (org_id, reference), CONSTRAINT expense_claim_group_dates_ordered CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on), CONSTRAINT expense_claim_group_name_language CHECK (name !~ <cjk>), CONSTRAINT expense_claim_group_owner_fk FOREIGN KEY (org_id, owner_member_id) REFERENCES organization_member (org_id, id));
-- CLAIMS (spec 6.1): the header. The workflow state lives on the instance-- (Plan 3); this row points at it (Ruling R40). Money is on the lines;-- total_amount is their base sum, kept equal to the instance amount.CREATE TABLE expense_claim ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL, claimant_member_id UUID NOT NULL, instance_id UUID NOT NULL, group_id UUID, title TEXT NOT NULL CONSTRAINT expense_claim_title_present CHECK (btrim(title) <> '' AND length(title) <= 200), currency TEXT NOT NULL CONSTRAINT expense_claim_currency_code CHECK (currency ~ '^[A-Z]{3}$'), total_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT expense_claim_total_nonnegative CHECK (total_amount >= 0), -- The claimant's preferred channel (Ruling R23). Plan 6's table is reached -- only through payout.GetChannel, so there is no FK here. payout_channel_id UUID, revision INT NOT NULL DEFAULT 1 CONSTRAINT expense_claim_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT expense_claim_tenant_id UNIQUE (org_id, id), CONSTRAINT expense_claim_reference_once UNIQUE (org_id, reference), CONSTRAINT expense_claim_instance_once UNIQUE (org_id, instance_id), CONSTRAINT expense_claim_title_language CHECK (title !~ <cjk>), CONSTRAINT expense_claim_claimant_fk FOREIGN KEY (org_id, claimant_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT expense_claim_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT expense_claim_group_fk FOREIGN KEY (org_id, group_id) REFERENCES expense_claim_group (org_id, id));CREATE INDEX expense_claim_by_claimant ON expense_claim (org_id, claimant_member_id, created_at DESC, id DESC);CREATE INDEX expense_claim_by_created ON expense_claim (org_id, created_at DESC, id DESC);
-- LINES (spec 6.1, 6.2, 6.7): the money.CREATE TABLE expense_claim_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), claim_id UUID NOT NULL, line_no INT NOT NULL CONSTRAINT expense_claim_line_number_positive CHECK (line_no >= 1), line_type TEXT NOT NULL CONSTRAINT expense_claim_line_type_known CHECK (line_type IN ('receipt','mileage','per_diem','advance_offset')), expense_date DATE NOT NULL, description TEXT NOT NULL CONSTRAINT expense_claim_line_description_present CHECK (btrim(description) <> '' AND length(description) <= 500), category_id UUID NOT NULL, currency TEXT NOT NULL CONSTRAINT expense_claim_line_currency_code CHECK (currency ~ '^[A-Z]{3}$'), amount NUMERIC(19,4) NOT NULL, fx_rate NUMERIC(19,10) NOT NULL CONSTRAINT expense_claim_line_fx_rate_positive CHECK (fx_rate > 0), fx_rate_source TEXT NOT NULL CONSTRAINT expense_claim_line_fx_rate_source_known CHECK (fx_rate_source IN ('identity','reference','override')), base_amount NUMERIC(19,4) NOT NULL, tax_code_id UUID, tax_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT expense_claim_line_tax_nonnegative CHECK (tax_amount >= 0), quantity NUMERIC(19,10), rate_id UUID, band TEXT, supplier_name TEXT, supplier_tin TEXT, einvoice_uuid TEXT, einvoice_validated_at TIMESTAMPTZ, document_basis TEXT, relaxation_reason TEXT, shares JSONB NOT NULL DEFAULT '[]'::jsonb, withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT expense_claim_line_tenant_id UNIQUE (org_id, id), CONSTRAINT expense_claim_line_claim_scoped UNIQUE (org_id, claim_id, id), CONSTRAINT expense_claim_line_number_once UNIQUE (org_id, claim_id, line_no), CONSTRAINT expense_claim_line_amount_sign CHECK (amount <> 0 AND (line_type = 'advance_offset') = (amount < 0)), CONSTRAINT expense_claim_line_base_sign CHECK (sign(base_amount) = sign(amount)), CONSTRAINT expense_claim_line_allowance_shape CHECK (line_type NOT IN ('mileage','per_diem') OR (quantity > 0 AND rate_id IS NOT NULL AND band IS NOT NULL AND fx_rate_source = 'identity' AND tax_code_id IS NULL AND document_basis IS NULL AND supplier_tin IS NULL AND einvoice_uuid IS NULL)), CONSTRAINT expense_claim_line_receipt_shape CHECK (line_type <> 'receipt' OR (quantity IS NULL AND rate_id IS NULL AND band IS NULL)), CONSTRAINT expense_claim_line_basis_known CHECK (document_basis IS NULL OR document_basis IN ('einvoice_company','einvoice_employee','foreign_supplier','relaxation_receipt')), CONSTRAINT expense_claim_line_einvoice_identifiers CHECK (document_basis IS NULL OR document_basis NOT IN ('einvoice_company','einvoice_employee') OR (supplier_tin IS NOT NULL AND einvoice_uuid IS NOT NULL)), CONSTRAINT expense_claim_line_relaxation_reason CHECK (document_basis IS DISTINCT FROM 'relaxation_receipt' OR btrim(coalesce(relaxation_reason, '')) <> ''), CONSTRAINT expense_claim_line_tin_shape CHECK (supplier_tin IS NULL OR supplier_tin ~ '^[A-Z]{1,2}[0-9]{8,13}$'), CONSTRAINT expense_claim_line_einvoice_uuid_shape CHECK (einvoice_uuid IS NULL OR einvoice_uuid ~ '^[A-Z0-9]{10,64}$'), CONSTRAINT expense_claim_line_text_language CHECK (description !~ <cjk> AND coalesce(supplier_name, '') !~ <cjk> AND coalesce(relaxation_reason, '') !~ <cjk>), CONSTRAINT expense_claim_line_shares_array CHECK (jsonb_typeof(shares) = 'array'), CONSTRAINT expense_claim_line_claim_fk FOREIGN KEY (org_id, claim_id) REFERENCES expense_claim (org_id, id), CONSTRAINT expense_claim_line_category_fk FOREIGN KEY (org_id, category_id) REFERENCES expense_category (org_id, id), CONSTRAINT expense_claim_line_tax_code_fk FOREIGN KEY (org_id, tax_code_id) REFERENCES tax_code (org_id, id), CONSTRAINT expense_claim_line_rate_fk FOREIGN KEY (org_id, rate_id) REFERENCES expense_rate (org_id, id));
-- ATTACHMENTS (spec 6.6, 11A.4): a document bound to the line it supports.-- Detaching is recorded, never erased (Ruling R36).CREATE TABLE expense_claim_line_document ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), claim_id UUID NOT NULL, line_id UUID NOT NULL, document_id UUID NOT NULL, attached_by UUID NOT NULL, attached_at TIMESTAMPTZ NOT NULL, detached_by UUID, detached_at TIMESTAMPTZ, CONSTRAINT expense_claim_line_document_detach_pair CHECK ((detached_at IS NULL) = (detached_by IS NULL)), CONSTRAINT expense_claim_line_document_line_fk FOREIGN KEY (org_id, claim_id, line_id) REFERENCES expense_claim_line (org_id, claim_id, id), CONSTRAINT expense_claim_line_document_document_fk FOREIGN KEY (org_id, document_id) REFERENCES document (org_id, id), CONSTRAINT expense_claim_line_document_attached_by_fk FOREIGN KEY (org_id, attached_by) REFERENCES organization_member (org_id, id), CONSTRAINT expense_claim_line_document_detached_by_fk FOREIGN KEY (org_id, detached_by) REFERENCES organization_member (org_id, id));CREATE UNIQUE INDEX expense_claim_line_document_active_once ON expense_claim_line_document (org_id, line_id, document_id) WHERE detached_at IS NULL;CREATE INDEX expense_claim_line_document_by_document ON expense_claim_line_document (org_id, document_id) WHERE detached_at IS NULL;
-- (ENABLE + FORCE ROW LEVEL SECURITY and CREATE POLICY org_isolation with-- USING and WITH CHECK on current_setting('app.current_org', true)::uuid for-- all four tables — write each out, as in 00070.)
REVOKE DELETE ON expense_claim_group, expense_claim, expense_claim_line, expense_claim_line_document FROM erp_app;
-- DRAFT-ONLY EDITS, in the database (Ruling R16, spec 6.4). The claim's-- workflow instance must sit in a state whose semantic is 'draft'.CREATE FUNCTION expense_claim_is_draft(p_org UUID, p_claim UUID) RETURNS boolean LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT coalesce(( SELECT s.semantic = 'draft' FROM public.expense_claim c JOIN public.workflow_instance i ON i.org_id = c.org_id AND i.id = c.instance_id JOIN public.workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE c.org_id = p_org AND c.id = p_claim), false)$$;
CREATE FUNCTION expense_claim_line_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF NOT public.expense_claim_is_draft(NEW.org_id, NEW.claim_id) THEN RAISE EXCEPTION 'expense claim % is not a draft: its lines cannot change', NEW.claim_id USING ERRCODE = '23514', CONSTRAINT = 'expense_claim_line_claim_is_draft'; END IF; RETURN NEW;END $$;CREATE TRIGGER expense_claim_line_draft_guard BEFORE INSERT OR UPDATE ON expense_claim_line FOR EACH ROW EXECUTE FUNCTION expense_claim_line_draft_guard();
CREATE FUNCTION expense_claim_draft_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF NOT public.expense_claim_is_draft(NEW.org_id, NEW.id) THEN RAISE EXCEPTION 'expense claim % is not a draft: its total, title, group and channel cannot change', NEW.id USING ERRCODE = '23514', CONSTRAINT = 'expense_claim_is_draft'; END IF; RETURN NEW;END $$;CREATE TRIGGER expense_claim_draft_guard BEFORE UPDATE OF total_amount, title, group_id, payout_channel_id ON expense_claim FOR EACH ROW EXECUTE FUNCTION expense_claim_draft_guard();
-- +goose DownDROP TABLE expense_claim_line_document;DROP TABLE expense_claim_line;DROP TABLE expense_claim;DROP TABLE expense_claim_group;DROP FUNCTION expense_claim_draft_guard();DROP FUNCTION expense_claim_line_draft_guard();DROP FUNCTION expense_claim_is_draft(UUID, UUID);(Task 1 Part A confirmed every workflow_instance/workflow_state column name used here against the merged migrations — 00033_workflow_instance.sql:14-38 and 00030_workflow_definition.sql:29-46. No correction was needed, and workflow_instance’s UNIQUE (org_id, id) — the target of expense_claim_instance_fk — is present at 00033_workflow_instance.sql:31.)
- Step 2: Write migration 00073.
-- +goose Up
-- SETTLEMENTS (spec 9.4, Ruling R25): what a payout was made against, frozen.-- One row per payout; a disputed-then-repaid claim keeps both. txid ties the-- row to the transaction that paid, which is what the payout rule reads.---- snapshot is payout.Snapshot's JSON exactly as returned: every masked-- field's value is AES-256-GCM ciphertext under PAYOUT_DETAILS_KEY (Plan 6-- Rulings P15, P31). Plan 5 never decrypts it (Ruling R44). The CHECKs below-- make "a masked value is never stored in plaintext" a database fact.CREATE TABLE expense_claim_settlement ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), claim_id UUID NOT NULL, channel_id UUID NOT NULL, credit_account_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT expense_claim_settlement_amount_positive CHECK (amount > 0), currency TEXT NOT NULL CONSTRAINT expense_claim_settlement_currency_code CHECK (currency ~ '^[A-Z]{3}$'), snapshot JSONB NOT NULL, -- Spec 9.4 mitigation 3: the two things reports filter on, plaintext in the -- sealed snapshot (Plan 6 P31), lifted into columns. channel_key TEXT GENERATED ALWAYS AS (snapshot->'channel'->>'key') STORED, account_tail TEXT GENERATED ALWAYS AS (snapshot->>'account_tail') STORED, proof_document_id UUID, settled_by UUID NOT NULL, settled_at TIMESTAMPTZ NOT NULL, txid xid8 NOT NULL DEFAULT pg_current_xact_id(), CONSTRAINT expense_claim_settlement_tenant_id UNIQUE (org_id, id), -- Spec 9.4 mitigation 2: the required keys are present. CONSTRAINT expense_claim_settlement_snapshot_shape CHECK ( jsonb_typeof(snapshot) = 'object' AND snapshot->'version' = '1'::jsonb AND btrim(coalesce(snapshot->'channel'->>'key', '')) <> '' AND jsonb_typeof(snapshot->'fields') = 'array'), -- Plan 6 P31, enforced here: a masked field never carries its value in -- plaintext, and always carries its ciphertext. CONSTRAINT expense_claim_settlement_masked_not_plain CHECK ( NOT jsonb_path_exists(snapshot, '$.fields[*] ? (@.sensitivity == "masked" && exists(@.value))')), CONSTRAINT expense_claim_settlement_masked_sealed CHECK ( NOT jsonb_path_exists(snapshot, '$.fields[*] ? (@.sensitivity == "masked" && !(exists(@.sealed)))')), CONSTRAINT expense_claim_settlement_claim_fk FOREIGN KEY (org_id, claim_id) REFERENCES expense_claim (org_id, id), CONSTRAINT expense_claim_settlement_account_fk FOREIGN KEY (org_id, credit_account_id) REFERENCES account (org_id, id), CONSTRAINT expense_claim_settlement_proof_fk FOREIGN KEY (org_id, proof_document_id) REFERENCES document (org_id, id), CONSTRAINT expense_claim_settlement_settled_by_fk FOREIGN KEY (org_id, settled_by) REFERENCES organization_member (org_id, id));CREATE INDEX expense_claim_settlement_by_claim ON expense_claim_settlement (org_id, claim_id, settled_at DESC);CREATE INDEX expense_claim_settlement_by_channel ON expense_claim_settlement (org_id, channel_key, settled_at DESC);-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK.)REVOKE UPDATE, DELETE ON expense_claim_settlement FROM erp_app;
-- KEY ROTATION (Ruling R45, Plan 6 Ruling P32). The one permitted change to a-- settlement: re-encrypting its sealed values under the active payout key.-- The column grant confines UPDATE to snapshot (has_table_privilege stays-- false, so the retention guard still calls this table append-only); the-- trigger confines the change to the "sealed" members — everything a reader-- sees, and which fields are sealed, must be identical before and after.GRANT UPDATE (snapshot) ON expense_claim_settlement TO erp_app;
CREATE FUNCTION expense_settlement_unsealed(p JSONB) RETURNS JSONB LANGUAGE sql IMMUTABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT (p - 'fields') || jsonb_build_object('fields', coalesce(( SELECT jsonb_agg((f - 'sealed') || jsonb_build_object('sealed', f ? 'sealed') ORDER BY n) FROM jsonb_array_elements(p->'fields') WITH ORDINALITY AS e(f, n)), '[]'::jsonb))$$;
CREATE FUNCTION expense_claim_settlement_reseal_only() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF public.expense_settlement_unsealed(NEW.snapshot) IS DISTINCT FROM public.expense_settlement_unsealed(OLD.snapshot) THEN RAISE EXCEPTION 'settlement %: only a key-rotation reseal may change a snapshot', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'expense_claim_settlement_reseal_only'; END IF; RETURN NEW;END $$;CREATE TRIGGER expense_claim_settlement_reseal_only BEFORE UPDATE ON expense_claim_settlement FOR EACH ROW EXECUTE FUNCTION expense_claim_settlement_reseal_only();
-- IDEMPOTENCY KEYS (spec 12.2, Ruling R28). Operational: prunable.CREATE TABLE expense_idempotency_key ( org_id UUID NOT NULL REFERENCES organization (id), member_id UUID NOT NULL, key TEXT NOT NULL CONSTRAINT expense_idempotency_key_length CHECK (length(key) BETWEEN 1 AND 255), request_hash TEXT NOT NULL CONSTRAINT expense_idempotency_request_hash_hex CHECK (request_hash ~ '^[0-9a-f]{64}$'), status_code INT NOT NULL DEFAULT 0, response JSONB, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT expense_idempotency_pk PRIMARY KEY (org_id, member_id, key), CONSTRAINT expense_idempotency_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id));-- (RLS: ENABLE, FORCE, org_isolation USING + WITH CHECK.)
-- +goose DownDROP TABLE expense_idempotency_key;DROP TABLE expense_claim_settlement;DROP FUNCTION expense_claim_settlement_reseal_only();DROP FUNCTION expense_settlement_unsealed(JSONB);-
Step 3: Retention.
amendable+=expense_claim_group,expense_claim,expense_claim_line,expense_claim_line_document(comment: “Plan 5: the claim record and its evidence (spec 11A.4). Edited only as a draft (DB-enforced, Ruling R16); lines withdrawn and documents detached by a timestamp, never erased.”).appendOnly+=expense_claim_settlement(“Plan 5: what each payout was made against, frozen at settlement (spec 9.4). erp_app holds UPDATE on the snapshot column only, for a key-rotation reseal whose trigger forbids any visible change (Ruling R45); has_table_privilege UPDATE is false”).operational+=expense_idempotency_key(“Plan 5: replay protection for transition requests; not a record — the decision log and audit trail are”).make generate. -
Step 4: Write the fixture —
expensestest/env.go.New:testdb.Start;db.Open(…, eventbus.New()); org + company profile via admin, copying the SQL ofaccountingtest’s unexportedseedOrg(countryMY, baseMYR, FY start2026-01-01, default localeen);posting.ResetForTest()+t.Cleanup(posting.ResetForTest).- Owner member via admin (party + organization_member, as
accountingtest.SeedMember). provisioning.Run(ctx, tx, clock, provisioning.Input{OwnerPermissions: member.NewPermissionSet([]member.Permission{{Key: "expenses.fixture", Description: "fixture"}}), OwnerMemberID: owner})inside oneInTx. The file blank-imports every step package:internal/platform/countrypack,countrypack/my,accounting/analytic,accounting/fiscal,internal/platform/workflow, and (for step 80) nothing —expensestestimportsexpensesdirectly.OwnerSeat:SELECT id, org_unit_id FROM org_position WHERE org_id=$1 AND code='OWNER'.Member(t, "Staff", &OwnerSeat): admin party + member; in one txorgstruct.CreatePosition(ctx, tx, c, orgstruct.PositionInput{OrgUnitID: unit, Code: "STAFF-"+short, Title: name, ReportsToPositionID: reportsTo})andorgstruct.Assign(ctx, tx, c, orgstruct.AssignmentInput{MemberID: m, PositionID: seat, IsPrimary: true, ValidFrom: Jan1}).Categories:expenses.ListCategorieskeyed byKey.Document:documents.Create→Store.Upload→documents.Verify→documents.RecordVerification(two transactions, Verify between).
-
Step 5: Write the failing tests —
claims_schema_test.go(build rows withworkflow.Start+ admin inserts where the platform API does not exist yet).Lock order (Task 1 Part A, fact A).
workflow.Transitiondispatches posting and must therefore run before anyaudit.Writein its transaction — andworkflow.Startandworkflow.SetAmounteach write one. So a test that starts (or re-prices) an instance and then transitions it must use two transactions: start in the first, transition in the second.instance.go’sTransitiondoc comment states the constraint.
func TestKindMatchesTheWorkflowSeed(t *testing.T) { require.Equal(t, workflow.KindExpenseClaim, expenses.Kind) }
func TestALineCanBeWrittenOnlyWhileTheClaimIsADraft(t *testing.T) { // Start an instance (Staff, 0), insert expense_claim + one receipt line as // erp_app inside e.Tx: PRESENCE — the insert and an UPDATE of the line // succeed. Then workflow.Transition draft -> pending_review (Staff); an // UPDATE of the line and a new INSERT both fail 23514 with constraint // 'expense_claim_line_claim_is_draft'; UPDATE expense_claim SET // total_amount fails on 'expense_claim_is_draft'; UPDATE expense_claim SET // revision = revision + 1 still succeeds (not a guarded column).}func TestTheTaxRecordShapeIsEnforcedByTheDatabase(t *testing.T) { // PRESENCE: einvoice_company with TIN "C20880050010" and uuid // "F9D425P6DS7D8IU0000000000A" inserts. Then, one each: basis // einvoice_company without einvoice_uuid -> expense_claim_line_einvoice_identifiers; // relaxation_receipt without reason -> expense_claim_line_relaxation_reason; // TIN "12345" -> expense_claim_line_tin_shape; description "午餐" -> // expense_claim_line_text_language; mileage with a tax code -> // expense_claim_line_allowance_shape; receipt with amount -1 -> // expense_claim_line_amount_sign.}func TestALineCannotNameAGlobalCategory(t *testing.T) // global row via admin: FK violation on expense_claim_line_category_fkfunc TestClaimRecordsCannotBeDeleted(t *testing.T) // 42501 on each of the four tables, after PRESENCE of UPDATE on expense_claim.revisionfunc TestSettlementsAreAppendOnlyAndRememberTheirTransaction(t *testing.T) // txid = pg_current_xact_id() inside the inserting tx; UPDATE of amount/credit_account_id/channel_id and DELETE 42501 (snapshot: see the reseal test below)func TestASettlementNeverHoldsAMaskedValueInPlaintext(t *testing.T) { // Hand-written snapshot JSON in Plan 6's version-1 shape (no Plan 6 code // needed): {"version":1,"channel":{"key":"bank_transfer",...}, // "account_tail":"6789","fields":[{"key":"payee_name","type":"text", // "value":"Siti","display":"Siti"},{"key":"account_no","type":"account_no", // "sensitivity":"masked","sealed":{"kid":"k","nonce":"AAAA","ct":"AAAA"}, // "display":"****6789"}]}. // PRESENCE: it inserts; channel_key = 'bank_transfer', account_tail = '6789'. // Then, one each (23514 naming the constraint): the masked field with // "value":"1234566789" added -> expense_claim_settlement_masked_not_plain; // the masked field without "sealed" -> expense_claim_settlement_masked_sealed; // "version":2, or no channel key, or "fields" an object -> // expense_claim_settlement_snapshot_shape.}func TestOnlyAResealMayRewriteASettlementSnapshot(t *testing.T) { // The settlement above, as erp_app. PRESENCE: UPDATE snapshot replacing // only account_no's "sealed" object ({"kid":"k2","nonce":"BBBB","ct":"BBBB"}) // succeeds. Then, one each: changing a field's "display" -> 23514 // expense_claim_settlement_reseal_only; dropping payee_name's "value" -> // reseal_only; UPDATE amount -> 42501 (no column privilege); DELETE -> // 42501. has_table_privilege('erp_app', 'expense_claim_settlement', // 'UPDATE') is false and has_column_privilege(…, 'snapshot', 'UPDATE') true.}func TestIdempotencyKeysArePrunable(t *testing.T) // erp_app DELETE succeedsenv_test.go:
func TestTheWorldIsProvisioned(t *testing.T) { e := expensestest.New(t) // the published expense_claim v1 exists (def, _ := workflow.Latest(ctx, tx, // workflow.KindExpenseClaim)), FY2026 is open, AP control resolves, 13 // categories exist and all are mapped, Staff's superior resolves to Owner // (workflow.Route(ctx, tx, def, staff, money 100.00, today) — it takes the // loaded Definition, not a kind — is Routable at every step).}-
Step 6: Run.
make generate;go test ./internal/platform/expenses/... ./internal/kernel/db/ -count=1→ PASS (search-path guard sees the five new functions; constraint guard sees the new names; tenant-FK guard sees the composite keys including theworkflow_instanceone). -
Step 7: Sabotage. Drop the
BEFORE UPDATEhalf ofexpense_claim_line_draft_guard(make itBEFORE INSERTonly) →TestALineCanBeWrittenOnlyWhileTheClaimIsADraftred; quote; restore. Then makeexpense_settlement_unsealedreturnp - 'fields'→TestOnlyAResealMayRewriteASettlementSnapshot’s display change is accepted (red); quote; restore. -
Step 8: Commit
git add db/migrations/00072_expense_claims.sql db/migrations/00073_expense_settlement.sql sqlc.yaml \ internal/kernel/db/retention_test.go internal/gen/sqlc internal/platform/expensesgit commit -m "Add claim, line, attachment and settlement tables with draft-only edits in the database"Task 10: Groups and claim headers — create, read, list, edit
Section titled “Task 10: Groups and claim headers — create, read, list, edit”Waits for Plan 3’s merge (Task 1 Part A). Needs Task 9.
Files:
- Create:
internal/platform/expenses/group.go,internal/platform/expenses/claim.go,internal/platform/expenses/visibility.go,internal/platform/expenses/cursor.go - Modify:
internal/platform/expenses/errors.go - Test:
internal/platform/expenses/group_test.go,claim_test.go,visibility_test.go,cursor_test.go
Interfaces:
-
Consumes:
workflow.Start,workflow.Get,workflow.Load,workflow.AvailableTransitions,workflow.Decisions(Plan 3);numbering.Allocate,numbering.Format;company.Get;audit.Write;activity.Record;CheckRecordText(Task 7). -
Produces:
const (GroupTrip = "trip"; GroupEvent = "event"; GroupBatch = "batch")type Group struct {ID, OwnerID uuid.UUIDReference, Name stringKind stringStartsOn, EndsOn *time.TimeIsActive boolCreatedAt time.Time}type GroupInput struct { OwnerID uuid.UUID; Name, Kind string; StartsOn, EndsOn *time.Time }type GroupPatch struct { Name *string; StartsOn, EndsOn **time.Time; IsActive *bool }func CreateGroup(ctx context.Context, tx pgx.Tx, c clock.Clock, in GroupInput) (Group, error)func GetGroup(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Group, error)func ListGroups(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Group, error)func UpdateGroup(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, p GroupPatch) (Group, error)type Claim struct {ID, ClaimantID, InstanceID uuid.UUIDReference, Title, Currency stringGroupID, PayoutChannelID *uuid.UUIDTotal money.AmountRevision intState string // workflow_instance.state_keySemantic posting.Semantic // of that stateEnteredStateAt time.TimeCreatedAt, UpdatedAt time.Time}type ClaimInput struct { ClaimantID uuid.UUID; Title string; GroupID, PayoutChannelID *uuid.UUID }type ClaimPatch struct { Title *string; GroupID, PayoutChannelID **uuid.UUID }func CreateClaim(ctx context.Context, tx pgx.Tx, c clock.Clock, in ClaimInput) (Claim, error)func GetClaim(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Claim, error)func LockClaim(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Claim, error) // FOR UPDATE OF the claim row onlyfunc UpdateClaim(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID, expectedRevision int, p ClaimPatch) (Claim, error)const (ScopeMine = "mine"; ScopeAll = "all")type Cursor struct{ CreatedAt time.Time; ID uuid.UUID }func (c Cursor) Encode() stringfunc DecodeCursor(s string) (Cursor, error) // ErrBadCursortype ListFilter struct { Scope string; Member uuid.UUID; GroupID *uuid.UUID; After *Cursor; Limit int } // Limit 1..200, default 50func ListClaims(ctx context.Context, tx pgx.Tx, f ListFilter) ([]Claim, *Cursor, error)// ToAct is the approval queue (Ruling R21): claims not filed by member on// which member has an allowed transition now, oldest in state first, ≤ 200.func ToAct(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]Claim, error)// CanRead is Ruling R21's visibility rule.func CanRead(ctx context.Context, tx pgx.Tx, c clock.Clock, cl Claim, member uuid.UUID, readAll bool) (bool, error)var ErrNotClaimant, ErrNotOwner, ErrNotDraft, ErrGroupInactive, ErrInvalidGroup, ErrInvalidClaim, ErrBadCursor errortype RevisionMismatchError struct{ Current int } -
Step 1: Write the failing tests (
e := expensestest.New(t)).
func TestCreatingAClaimPinsAWorkflowInstanceAndNumbersIt(t *testing.T) { // CreateClaim{Staff, "KL client visit"}: Reference "EC-2026-0001"; // Currency "MYR"; Total 0; Revision 1; State "draft", Semantic draft; // workflow.GetBySource(Kind, claim.ID) is claim.InstanceID with Reporter // Staff; one audit row (entity_kind 'expense_claim', action 'create'); // one activity row (subject 'expense_claim', verb 'created'). A second // claim is EC-2026-0002.}func TestAnAbortedCreateGivesItsNumberBack(t *testing.T) { // Create inside a tx that then returns an error; the next committed claim // is EC-2026-0001 (spec 20.1.3: a gap is what an auditor asks about).}func TestAClaimNeedsATitleInEnglishOrMalay(t *testing.T) // PRESENCE ok; "" -> ErrInvalidClaim; "差旅" -> *LineError{Rule:"record_language"}func TestOnlyTheClaimantEditsTheDraftAtTheRevisionTheySaw(t *testing.T) { // UpdateClaim(Staff, rev 1, Title) -> rev 2. Owner -> ErrNotClaimant. // Staff with rev 1 again -> *RevisionMismatchError{Current: 2}.}func TestAHeaderIsFrozenOnceSubmitted(t *testing.T) { // Move the claim to pending_review with workflow.Transition directly // (Task 13's wrapper does not exist yet; this is a DB-level fact) after // SetAmount to 10 — then UpdateClaim(Title) -> ErrNotDraft, and the raw // UPDATE the function would have issued is refused by the trigger too. // SetAmount audits, so it goes in its OWN transaction, before the one that // calls workflow.Transition (Task 1 Part A, fact A).}func TestAClaimCanJoinOnlyAnActiveGroup(t *testing.T) // PRESENCE active ok; retired -> ErrGroupInactivefunc TestGroupsAreNumberedAndEditedByTheirOwner(t *testing.T) // EG-2026-0001; UpdateGroup by Staff when Owner owns -> ErrNotOwnerfunc TestListingIsKeysetPaginated(t *testing.T) { // 5 claims by Staff, created at T0+1s..+5s (advance a clock.Fixed per // claim). Limit 2: pages [5,4], [3,2], [1], next nil on the last; no // repeats, no gaps. ScopeMine for Owner -> empty; ScopeAll -> all 5 (the // handler checks read_all; the platform does not).}func TestACursorRoundTripsAndGarbageIsRefused(t *testing.T) // Encode/Decode; "!!" -> ErrBadCursorvisibility_test.go:
func TestWhoCanReadAClaim(t *testing.T) { // A third member Outsider with no seat on the chain. // Draft: Staff yes; Owner no (no allowed transition yet, not in the log); // Outsider no; Outsider with readAll yes. // After submission (pending_review): Owner yes (can review now). // After Owner reviews: Owner still yes (in the decision log). // ToAct(Owner) lists it while pending; ToAct(Staff) never lists Staff's own.}-
Step 2: Run and watch fail.
go test ./internal/platform/expenses/ -count=1. -
Step 3: Implement.
CreateClaim, in this order (row locks before the first audit row): validate title (non-blank, ≤ 200,CheckRecordText); group (GetGroup, active);year := clock.TodayUTC(c).Year();n := numbering.Allocate(ctx, tx, "expense_claim", year);id := uuid.New();inst := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: Kind, SourceID: id, Reporter: in.ClaimantID, Amount: money.Zero()});base := company.Get(…).BaseCurrency; INSERT withnumbering.Format("EC", year, n),created_at = updated_at = c.Now();audit.Write{EntityKind: "expense_claim", Action: "create", After: …};activity.Record{Subject: "expense_claim", SubjectID: id, Verb: "created", ActorMemberID: &claimant, Summary: ref + " " + title}.- The one read query, shared by
GetClaim,LockClaim,ListClaims,ToAct:
SELECT c.id, c.reference, c.claimant_member_id, c.instance_id, c.group_id, c.title, c.currency, c.total_amount, c.payout_channel_id, c.revision, c.created_at, c.updated_at, i.state_key, s.semantic, i.entered_state_at FROM expense_claim c JOIN workflow_instance i ON i.org_id = c.org_id AND i.id = c.instance_id JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key WHERE c.org_id = $1 AND …`LockClaim` appends `FOR UPDATE OF c` (the instance row is locked later, by the workflow engine — Ruling R22's order).-
UpdateClaim:LockClaim→ claimant → revision →Semantic == posting.DraftelseErrNotDraft→ validate → UPDATE (…revision = revision + 1, updated_at = $n) → auditupdatewith before/after. -
ListClaims: keyset(c.created_at, c.id) < ($after_at, $after_id)orderedDESC; fetchLimit+1to know whether a next page exists. ScopeMine addsc.claimant_member_id = $2. -
ToAct: candidatesWHERE c.claimant_member_id <> $2 AND s.semantic NOT IN ('draft','acknowledged')orderedi.entered_state_at ASC LIMIT 1000; keep those whereworkflow.AvailableTransitions(ctx, tx, c, inst, member)has anyAllowed; stop at 200. -
CanRead: claimant → true; readAll → true; anyAllowedinAvailableTransitions→ true; member inDecisionsasActororAuthorityMember→ true. -
Cursor.Encode:base64.RawURLEncodingofcreated_at.UTC().Format(time.RFC3339Nano) + "|" + id. -
Step 4: Run. PASS.
-
Step 5: Commit —
git commit -m "File claims and groups, numbered and pinned to their workflow".
Task 11: Line rules — the four types, the tax record, the money, as pure functions
Section titled “Task 11: Line rules — the four types, the tax record, the money, as pure functions”Independent of Plans 3 and 6 (pure; no database). Needs Task 7 (LineError, CheckRecordText, Category).
Files:
- Create:
internal/platform/expenses/linerules.go - Test:
internal/platform/expenses/linerules_test.go
Interfaces:
-
Consumes:
money.Amount,money.Rate,analytic.Share;Category,LineError,CheckRecordText(Task 7). -
Produces:
const (LineReceipt = "receipt"; LineMileage = "mileage"; LinePerDiem = "per_diem"; LineAdvanceOffset = "advance_offset")const (BasisEInvoiceCompany = "einvoice_company"; BasisEInvoiceEmployee = "einvoice_employee"BasisForeignSupplier = "foreign_supplier"; BasisRelaxationReceipt = "relaxation_receipt")// LineInput is what a claimant sends for one line.type LineInput struct {Type stringExpenseDate time.TimeDescription stringCategoryID uuid.UUIDCurrency string // receipt only; allowances are in baseAmount *money.Amount // receipt onlyQuantity *money.Rate // mileage km, per-diem daysBand string // mileage vehicle class, per-diem destination bandTaxCodeID *uuid.UUID // receipt onlySupplierName *stringSupplierTIN *stringEInvoiceUUID *stringEInvoiceValidatedAt *time.TimeDocumentBasis *stringRelaxationReason *stringShares []analytic.Share}// NormalizeLine trims and upper-cases identifiers, then checks everything// that needs no database. Returns the normalized input or a *LineError.func NormalizeLine(in LineInput, today time.Time) (LineInput, error)// AllowanceAmount is rate × quantity at Scale (Ruling R10).func AllowanceAmount(rate money.Amount, qty money.Rate) money.Amount// InclusiveTax is gross × r / (100 + r) (Ruling R13); r = 0 gives 0.func InclusiveTax(gross money.Amount, rate money.Rate) (money.Amount, error)// CheckBasisAgainstCategory refuses einvoice_employee on a category that// does not permit it (spec 6.7, Ruling R8).func CheckBasisAgainstCategory(basis *string, cat Category) errorLineError.Rulevalues (the API’s stable codes):required,unavailable,future_date,too_long,record_language,positive,currency_code,not_for_type,basis_unknown,einvoice_identifiers,relaxation_reason,tin_shape,einvoice_uuid_shape,employee_named_not_allowed,percentage,no_rate,inactive,invalid. -
Step 1: Write the failing tests — table-driven; the first row of every table is an accepted input (presence).
var today = time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC)
func receipt() expenses.LineInput { a := money.MustParse("45.60") return expenses.LineInput{Type: expenses.LineReceipt, ExpenseDate: today, Description: "Lunch with client", CategoryID: uuid.New(), Currency: "myr", Amount: &a}}
func TestNormalizeLineAcceptsEachTypeAndNormalizes(t *testing.T) { got, err := expenses.NormalizeLine(receipt(), today) require.NoError(t, err) require.Equal(t, "MYR", got.Currency) km := money.MustParseRate("123.5") mileage := expenses.LineInput{Type: expenses.LineMileage, ExpenseDate: today, Description: "KL to Seremban return", CategoryID: uuid.New(), Quantity: &km, Band: "car"} _, err = expenses.NormalizeLine(mileage, today) require.NoError(t, err) // per_diem with Quantity 2 days, Band "domestic" -> ok. // receipt with SupplierTIN " c20880050010 " -> "C20880050010".}
func TestNormalizeLineRefusals(t *testing.T) { cases := []struct { name string edit func(*expenses.LineInput) field string rule string }{ {"unknown type", func(l *expenses.LineInput) { l.Type = "fuel" }, "type", "invalid"}, {"advance offset before Plan 7", func(l *expenses.LineInput) { l.Type = expenses.LineAdvanceOffset }, "type", "unavailable"}, {"future date", func(l *expenses.LineInput) { l.ExpenseDate = today.AddDate(0, 0, 1) }, "expense_date", "future_date"}, {"blank description", func(l *expenses.LineInput) { l.Description = " " }, "description", "required"}, {"chinese description", func(l *expenses.LineInput) { l.Description = "午餐" }, "description", "record_language"}, {"receipt without amount", func(l *expenses.LineInput) { l.Amount = nil }, "amount", "required"}, {"zero amount", func(l *expenses.LineInput) { z := money.Zero(); l.Amount = &z }, "amount", "positive"}, {"bad currency", func(l *expenses.LineInput) { l.Currency = "RM" }, "currency", "currency_code"}, {"receipt with quantity", func(l *expenses.LineInput) { q := money.OneRate(); l.Quantity = &q }, "quantity", "not_for_type"}, {"unknown basis", func(l *expenses.LineInput) { b := "payslip"; l.DocumentBasis = &b }, "document_basis", "basis_unknown"}, {"e-invoice without identifiers", func(l *expenses.LineInput) { b := expenses.BasisEInvoiceCompany; l.DocumentBasis = &b }, "einvoice_uuid", "einvoice_identifiers"}, {"relaxation without reason", func(l *expenses.LineInput) { b := expenses.BasisRelaxationReceipt; l.DocumentBasis = &b }, "relaxation_reason", "relaxation_reason"}, {"bad TIN", func(l *expenses.LineInput) { s := "12345"; l.SupplierTIN = &s }, "supplier_tin", "tin_shape"}, {"bad e-invoice uuid", func(l *expenses.LineInput) { s := "abc"; l.EInvoiceUUID = &s }, "einvoice_uuid", "einvoice_uuid_shape"}, {"share over 100", func(l *expenses.LineInput) { l.Shares = []analytic.Share{{AnalyticAccountID: uuid.New(), Percentage: money.MustParseRate("101")}} }, "shares", "percentage"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { in := receipt() tc.edit(&in) _, err := expenses.NormalizeLine(in, today) var le *expenses.LineError require.ErrorAs(t, err, &le) require.Equal(t, tc.field, le.Field) require.Equal(t, tc.rule, le.Rule) }) } // Allowance-specific: mileage with Amount -> "amount"/"not_for_type"; // with TaxCodeID -> "tax_code_id"/"not_for_type"; with DocumentBasis -> // "document_basis"/"not_for_type"; without Quantity -> "quantity"/"required"; // Band "Car" -> "band"/"invalid".}
func TestAllowanceAmountIsExactDecimal(t *testing.T) { require.Equal(t, "74.1000", expenses.AllowanceAmount(money.MustParse("0.60"), money.MustParseRate("123.5")).String()) require.Equal(t, "0.1000", expenses.AllowanceAmount(money.MustParse("0.10"), money.MustParseRate("1")).String())}
func TestInclusiveTaxBacksTheTaxOutOfTheGross(t *testing.T) { tax, err := expenses.InclusiveTax(money.MustParse("108.00"), money.MustParseRate("8")) require.NoError(t, err) require.Equal(t, "8.0000", tax.String()) zero, _ := expenses.InclusiveTax(money.MustParse("50.00"), money.MustParseRate("0")) require.True(t, zero.IsZero())}
func TestAnEmployeeNamedEInvoiceNeedsTheCategoryToAllowIt(t *testing.T) { b := expenses.BasisEInvoiceEmployee require.NoError(t, expenses.CheckBasisAgainstCategory(&b, expenses.Category{EmployeeNamedDocumentAllowed: true})) var le *expenses.LineError require.ErrorAs(t, expenses.CheckBasisAgainstCategory(&b, expenses.Category{}), &le) require.Equal(t, "employee_named_not_allowed", le.Rule) require.NoError(t, expenses.CheckBasisAgainstCategory(nil, expenses.Category{}))}-
Step 2: Run and watch fail. Step 3: Implement.
InclusiveTax:gross.MulDiv(r, money.MustParseRate("100").Add(r))— rounded once, at the end. (Amended after the final review, finding M3: the original two-stepr.Div(100+r)thenMulRaterounded twice and gave 91449.4734 for RM 1,234,567.89 at 8% against an exact 91449.4733.) The TIN/UUID/basis rules are the Go twins of Task 9’sCHECKs — cite the constraint name in a comment beside each. -
Step 4: Run.
go test ./internal/platform/expenses/ -run 'NormalizeLine|Allowance|InclusiveTax|EmployeeNamed' -count=1→ PASS. Step 5: Commit —git commit -m "State the four line types and the tax record as pure rules".
Task 12: Lines and attachments — priced, converted, frozen, draft-only
Section titled “Task 12: Lines and attachments — priced, converted, frozen, draft-only”Waits for Plan 3’s merge (Task 1 Part A). Needs Tasks 5, 8, 10, 11.
Files:
- Create:
internal/platform/expenses/line.go,internal/platform/expenses/attachment.go - Test:
internal/platform/expenses/line_test.go,internal/platform/expenses/attachment_test.go
Interfaces:
-
Consumes:
LockClaim,Claim,RevisionMismatchError,ErrNotClaimant,ErrNotDraft(Task 10);NormalizeLine,AllowanceAmount,InclusiveTax,CheckBasisAgainstCategory(Task 11);GetCategory,UsableAccount(Task 7);ResolveRate,NoRateError(Task 8);fx.Resolve,fx.NoRateError,chart.GetTaxCode,analytic.Plan(Plan 4);workflow.SetAmount(Plan 3);documents.Lock(Task 5). -
Produces:
type Line struct {ID, ClaimID, CategoryID uuid.UUIDLineNo intType stringExpenseDate time.TimeDescription, Currency stringAmount, BaseAmount money.AmountFXRate money.RateFXRateSource stringTaxCodeID *uuid.UUIDTaxAmount money.AmountQuantity *money.RateRateID *uuid.UUIDBand *stringSupplierName, SupplierTIN *stringEInvoiceUUID *stringEInvoiceValidatedAt *time.TimeDocumentBasis *stringRelaxationReason *stringShares []analytic.ShareWithdrawnAt *time.TimeDocuments []uuid.UUID // actively attached}func AddLine(ctx context.Context, tx pgx.Tx, c clock.Clock, claimID, actor uuid.UUID, expectedRevision int, in LineInput) (Line, Claim, error)func UpdateLine(ctx context.Context, tx pgx.Tx, c clock.Clock, claimID, lineID, actor uuid.UUID, expectedRevision int, in LineInput) (Line, Claim, error)func WithdrawLine(ctx context.Context, tx pgx.Tx, c clock.Clock, claimID, lineID, actor uuid.UUID, expectedRevision int) (Claim, error)func ListLines(ctx context.Context, tx pgx.Tx, claimID uuid.UUID) ([]Line, error) // line_no order, withdrawn includedfunc AttachDocument(ctx context.Context, tx pgx.Tx, c clock.Clock, claimID, lineID, documentID, actor uuid.UUID, expectedRevision int) (Claim, error)func DetachDocument(ctx context.Context, tx pgx.Tx, c clock.Clock, claimID, lineID, documentID, actor uuid.UUID, expectedRevision int) (Claim, error)// ClaimsOfDocument lists the claims a document is actively attached to or// is the settlement proof of — what document read access is decided on.func ClaimsOfDocument(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]uuid.UUID, error)var ErrLineNotFound, ErrLineWithdrawn, ErrNotAttachable, ErrNotYourDocument, ErrDocumentNotVerified,ErrDocumentOnAnotherClaim, ErrAlreadyAttached, ErrNotAttached error -
Step 1: Write the failing tests (
e := expensestest.New(t); a claim by Staff;accountingtest-style reference rates are already seeded? — no: seedfx.StoreReferenceUSD 4.2000 on 2026-09-18 in the test, the wayaccountingtest.SeedReferenceRatedoes).
func TestAReceiptLineInForeignCurrencyFreezesItsBaseAtTheExpenseDate(t *testing.T) { // USD 100.00 dated 2026-09-19 -> FXRate 4.2, FXRateSource "reference", // BaseAmount 420.0000; claim Total 420.0000, Revision 2; the instance // amount (workflow.Get) is 420.0000. Then a NEWER reference rate for // 2026-09-19 is stored: ListLines still says 420.0000 (frozen, spec 6.5). // An UpdateLine that changes only the description keeps 420.0000; one // that changes the date to 2026-09-21 re-resolves.}func TestAMileageLineIsPricedFromTheRateOnItsDate(t *testing.T) { // Rates car 0.60 from 2026-01-01 and 0.70 from 2026-09-20. 100 km on // 2026-09-19 -> Amount 60.0000, RateID the first; on 2026-09-21 -> 70.0000. // A date before 2026-01-01 -> *LineError{Field:"band", Rule:"no_rate"}.}func TestAPerDiemLineIsDaysTimesTheBandRate(t *testing.T)func TestATaxCodeRecordsTheSSTInsideTheGross(t *testing.T) { // SVT8 (seeded by the MY pack, valid from 2024-03-01): gross 108.00 -> // TaxAmount 8.0000; a tax code not yet valid on the expense date (SVT8 // with a 2024-01-01 line) -> LineError{Field:"tax_code_id", Rule:"invalid"}.}func TestAnInactiveCategoryIsRefused(t *testing.T)func TestAnEmployeeNamedEInvoiceFollowsTheCategory(t *testing.T) { // meals (allowed=false): einvoice_employee -> employee_named_not_allowed. // UpdateCategory(allowed=true): the same line saves (PRESENCE after).}func TestSharesAreValidatedAgainstTheAxes(t *testing.T) // 60/40 on cost_centre ok; 60/30 -> LineError{Field:"shares", Rule:"percentage"} (from *analytic.TotalError)func TestLinesAreEditableOnlyByTheClaimantInDraft(t *testing.T) { // Owner -> ErrNotClaimant; stale revision -> *RevisionMismatchError; // after workflow.Transition to pending_review -> ErrNotDraft (and the DB // trigger would refuse the same write — Task 9 proved that).}func TestWithdrawingALineTakesItOutOfTheTotal(t *testing.T) // two lines 10 + 20 -> 30; withdraw the 20 -> Total 10, instance amount 10; ListLines still returns it with WithdrawnAt setfunc TestAnUnknownCurrencyRateIsALineError(t *testing.T) // "JPY" with no reference -> LineError{Field:"currency", Rule:"no_rate"}func TestLineWritesTakeTheClaimLockBeforeAnyAudit(t *testing.T) { // Two concurrent AddLine calls on one claim at the same revision, each in // its own InTx with a 10 s context: exactly one succeeds; the other gets // *RevisionMismatchError (not a deadlock, not a lost update). Line numbers // are 1 and — after a retry at the new revision — 2.}attachment_test.go:
func TestAttachingBindsAVerifiedDocumentOfTheClaimant(t *testing.T) { // e.Document(Staff, pdf) -> AttachDocument ok; ListLines shows it; one // audit row 'attach'. Owner's document -> ErrNotYourDocument; a pending // document -> ErrDocumentNotVerified; the same one twice -> ErrAlreadyAttached.}func TestOneReceiptCannotSupportTwoClaims(t *testing.T) { // PRESENCE: two lines of claim A share the document. ABSENCE: a line of // claim B -> ErrDocumentOnAnotherClaim. After detaching from A (both // lines), B may attach it.}func TestEvidenceMayBeAddedWhilePendingButNotRemoved(t *testing.T) { // After submission: AttachDocument ok (pending semantic); DetachDocument -> // ErrNotDraft. After accrual (move the instance with workflow.Transition as // Owner through reviewed, approved, category_verified, with a no-op Poster // registered — posting.ResetForTest(); posting.Register(nopPoster{})): // AttachDocument -> ErrNotAttachable.}func TestClaimsOfDocument(t *testing.T)-
Step 2: Run and watch fail.
-
Step 3: Implement.
AddLine/UpdateLine, in this order:LockClaim; claimant; revision;Semantic == posting.DraftelseErrNotDraft.NormalizeLine(in, clock.TodayUTC(c)).GetCategory(not found or inactive →LineError{Field:"category_id", Rule:"inactive"});CheckBasisAgainstCategory.- Receipt: tax code —
chart.GetTaxCode, active andValidFrom ≤ dateand (ValidTonil ordate < ValidTo) else{tax_code_id, invalid};TaxAmount = InclusiveTax(amount, tc.Rate). FX — on update, if amount, currency and date are unchanged keep the storedFXRate/FXRateSource/BaseAmount; elseq := fx.Resolve(ctx, tx, currency, base, date)(*fx.NoRateError→{currency, no_rate}),BaseAmount = amount.MulRate(q.Rate), sourceq.Source(identity/reference/override). - Mileage / per diem:
ResolveRate(ctx, tx, type, band, date)(*NoRateError→{band, no_rate});Amount = BaseAmount = AllowanceAmount(rate.Amount, qty); currency base;FXRate = 1, sourceidentity. analytic.Plan(ctx, tx, chart.TypeExpense, BaseAmount, shares, true); map*analytic.TotalError/*RequiredError/ErrAxisNotApplicable/ErrNotFound/ErrAccountInactiveto{shares, percentage|required|invalid}.- INSERT (
line_no = coalesce(max(line_no),0)+1for this claim — the claim lock serializes it) or UPDATE. total := SELECT coalesce(sum(base_amount),0) … WHERE withdrawn_at IS NULL;UPDATE expense_claim SET total_amount, revision = revision + 1, updated_at.workflow.SetAmount(ctx, tx, c, claim.InstanceID, total)(takes the instance lock, writes its own audit row).audit.Write{EntityKind: "expense_claim_line", Action: "create"|"update", …}— last.
AttachDocument:LockClaim; claimant; revision; semanticdraftorpendingelseErrNotAttachable; line exists and not withdrawn;documents.Lock(Ruling R36’s serialization); uploader = claimant; verified;SELECT DISTINCT claim_id FROM expense_claim_line_document WHERE org_id=$1 AND document_id=$2 AND detached_at IS NULLnames another claim →ErrDocumentOnAnotherClaim; INSERT (unique violation onexpense_claim_line_document_active_once→ErrAlreadyAttached);revision + 1; auditattach.DetachDocument: draft only; setsdetached_at,detached_by.
-
Step 4: Run. PASS. Sabotage: skip the claim lock in
AddLine→ the concurrency test goes red (two line 1s → unique violationexpense_claim_line_number_once, or a lost total); quote; restore. -
Step 5: Commit —
git commit -m "Price, convert and freeze claim lines; bind documents to them".
Task 13: Submission and transitions — the claim’s side of the workflow
Section titled “Task 13: Submission and transitions — the claim’s side of the workflow”Waits for Plan 3’s merge (Task 1 Part A). Needs Task 12.
Files:
- Create:
internal/platform/expenses/submission.go,internal/platform/expenses/transition.go,internal/platform/expenses/settlement.go(interim — Task 14 replaces its body) - Test:
internal/platform/expenses/submission_test.go,internal/platform/expenses/transition_test.go
Interfaces:
-
Consumes:
LockClaim,GetClaim,ListLines,UsableAccount,CheckBasisAgainstCategory(Tasks 7–12);workflow.Load,workflow.Get,workflow.Transition,workflow.Decision,workflow.ErrStaleState(Plan 3);documents.Get;analytic.Plan;activity.Record;audit.Write. -
Caller constraint (Task 1 Part A, fact A;
internal/platform/workflow/instance.go:288-298):workflow.Transitiondispatches posting, which takesnumber_sequencerow locks andFOR SHAREon every account a line touches, so nothing in this transaction may callaudit.Writebefore it — not this package’s own audit rows (Ruling R22 already puts them after) and notworkflow.Startorworkflow.SetAmount, each of which audits.def.State(key)returns(StateSpec, bool). -
Produces:
// SubmissionError lists everything that stops a draft leaving draft, at// once, each naming its line and field.type SubmissionError struct{ Problems []LineError }func CheckSubmission(ctx context.Context, tx pgx.Tx, c clock.Clock, cl Claim) errortype TransitionInput struct {ClaimID uuid.UUIDFrom, To stringActor uuid.UUIDReason stringPayoutChannelID *uuid.UUID // entering a payout-semantic state (Task 14)ProofDocumentID *uuid.UUID // ditto, when the channel requires proof}// Transition is Ruling R22's sequence. The caller's transaction must not// commit on error; the handler maps InTx's error through ledger.MapError.func Transition(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Claim, workflow.Decision, error)// settlement.go (interim, replaced whole by Task 14):var ErrPayoutUnavailable = errors.New("expenses: payout channels are not wired into this build")func settle(ctx context.Context, tx pgx.Tx, c clock.Clock, cl Claim, in TransitionInput) (*Settlement, error) // returns ErrPayoutUnavailabletype Settlement struct{ ID uuid.UUID } // Task 14 widens it -
Step 1: Write the failing tests.
submission_test.go:
func TestACompleteDraftPassesSubmission(t *testing.T) { // One receipt line (MYR 45.60, meals, basis einvoice_company with TIN and // uuid, one verified document attached) + one mileage line: nil.}func TestSubmissionListsEveryProblemAtOnce(t *testing.T) { // Three receipt lines: #1 no basis, #2 basis ok but no document, #3 // einvoice_employee on a category that allowed it at save and was switched // off since. One *SubmissionError with three Problems, each LineID set: // {document_basis, required}, {documents, required}, // {document_basis, employee_named_not_allowed}. A claim with no active line // -> {lines, required}. A category whose account was cleared since -> // {category_id, inactive}.}transition_test.go (e.UsePoster(t, expenses.Rules()...) is not available until Task 15 — register a no-op poster here: posting.ResetForTest(); posting.Register(nopPoster{}); t.Cleanup(posting.ResetForTest), so these tests prove the claim side only):
func TestSubmittingRunsTheChecksThenTheWorkflow(t *testing.T) { // PRESENCE: a complete draft -> Transition{draft -> pending_review, Staff} // ok; claim.State "pending_review"; decision.Actor Staff; one audit row // (entity 'expense_claim', action 'transition', after names from/to and // the decision id); one activity row (verb 'submitted'). // ABSENCE: an incomplete draft -> *SubmissionError, the instance still in // draft, no decision row.}func TestAStaleFromIsRefusedByTheEngine(t *testing.T) // From "draft" on a pending claim -> workflow.ErrStaleStatefunc TestOnlyGrantedMembersMoveAClaim(t *testing.T) // Staff reviewing own -> *workflow.RefusedError; Owner reviewing -> okfunc TestRejectionReturnsTheClaimToEditing(t *testing.T) { // pending_review -> rejected (Owner, reason) -> draft (Staff): AddLine works again.}func TestEnteringPayoutIsRefusedUntilChannelsAreWired(t *testing.T) { // Walk to category_verified (no-op poster), then -> completed: // ErrPayoutUnavailable, the instance still in category_verified. // (Task 14 replaces this test.)}func TestTheClaimLockIsTakenBeforeTheInstanceLock(t *testing.T) { // Tx A: LockClaim(claim) and hold (wait on a channel, 10 s timeout). // Tx B: Transition(submit) — must block, not deadlock and not succeed. // Release A; B completes. Proves B's first lock is the claim row // (Ruling R22). Both goroutines bounded by context timeouts.}-
Step 2: Run and watch fail.
-
Step 3: Implement
Transition:cl := LockClaim(in.ClaimID).def := workflow.Load(ctx, tx, instance.DefinitionID)(read the instance withworkflow.Get; do not lock it here);from, _ := def.State(in.From),to, ok := def.State(in.To)— unknownTo→ letworkflow.TransitionreturnErrNoSuchEdge.- If
from.Semantic == posting.Draft && to.Semantic != posting.Draft && to.Semantic != posting.Rejected && to.Semantic != posting.Cancelled:CheckSubmission. (This is exactly the engine’s ownisSubmission,internal/platform/workflow/instance.go:261— withdrawing a draft asks nobody to decide, so it needs neither a routable chain nor complete evidence. In the seeded v1 flowdrafthas only one exit, so the two conditions coincide today; they must not diverge if a later version addsdraft → cancelled.) - If
to.Semantic == posting.Payout:s, err := settle(…). d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: cl.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}).cl2 := GetClaim;audit.Write{EntityKind: "expense_claim", EntityID: cl.ID, Action: "transition", ActorID: &in.Actor, Before: {state}, After: {state, decision_id, settlement_id}};activity.Record{Verb: "submitted" when leaving draft, "settled" when entering payout, else "transitioned"}.CheckSubmission: iterate active lines; collect problems (never stop at the first); re-runUsableAccount,CheckBasisAgainstCategory,analytic.Plan; for receipts requireDocumentBasis != niland at least one attached document whosedocuments.Get(...).Status == verified.
-
Step 4: Run. PASS. Step 5: Commit —
git commit -m "Submit and move claims through the workflow, claim lock first".
Task 14: Settlement — the sealed payout snapshot, frozen on the claim
Section titled “Task 14: Settlement — the sealed payout snapshot, frozen on the claim”Waits for Plan 6’s merge (gate P6: Task 1 Part B done). Needs Task 13.
Files:
- Modify:
internal/platform/expenses/settlement.go(replace the interim body),internal/platform/expenses/errors.go,internal/platform/expenses/expensestest/env.go(install a payout keyring inNew; blank-importinternal/platform/payoutso Plan 6’s provisioning steps 90–91 seed the MY channels; addPayoutKey,Channel,SeedChannel,PayoutProfileexactly as Task 1 Part B recorded) - Create:
internal/platform/expenses/nosnapshotopen_test.go - Test:
internal/platform/expenses/settlement_test.go; deleteTestEnteringPayoutIsRefusedUntilChannelsAreWiredfromtransition_test.go
Interfaces:
-
Consumes (Plan 6, the revised wave-2 §1 — nothing else from it):
payout.Channel,payout.GetChannel(ctx, tx, id) (Channel, error),payout.Snapshot(ctx, tx, channelID, memberID, claimType, amount, currency) (json.RawMessage, error)(returned sealed),payout.SettlementSnapshot/SnapshotChannel/SnapshotField/Names(unmarshal only),payout.BehaviourPettyCash; tests:payouttest.NewKey(t, id) payout.Key,payouttest.UseKeyring(t, active payout.Key, previous *payout.Key) *payout.Keyring,payouttest.ChannelRow,payouttest.BankFields,payout.SealDetails(orgID, memberID, channelID, details) (Sealed, error),payout.SaveProfile(ctx, tx, c, memberID, channelID, details) (ProfileView, error),payout.PurgeMemberProfiles(ctx, tx, c, memberID) error.documents.Get. Part B:payouttest.Envembeds*accountingtest.Env(a pointer), soexpensestestcannot embed it — it copiespayouttest’s two admin INSERTs, whose exact SQL is pinned in the appendix (“Theexpensestesthelpers Task 14 adds”).payouttest.BankFields()itself is importable and matches the seededbank_transferschema (its labels carryen/ms; thecountrypack/myseed carriesen/ms/zh). -
Produces:
type Settlement struct {ID, ClaimID, ChannelID, CreditAccountID uuid.UUIDAmount money.AmountCurrency string// Snapshot is payout.Snapshot's sealed JSON, byte-for-byte (Ruling R44).// Never logged, audited, or sent over the API; use View.Snapshot json.RawMessageChannelKey string // generated columnAccountTail *string // generated columnProofDocumentID *uuid.UUIDSettledBy uuid.UUIDSettledAt time.Time}// SettlementView is the only shape a settlement leaves this package in:// masked displays, no value, no ciphertext (Ruling R44). Needs no key.type SettlementView struct {ID, ChannelID uuid.UUIDChannelKey, ChannelName stringAccountTail *stringAmount money.AmountCurrency stringFields []SettlementFieldViewProofDocumentID *uuid.UUIDSettledBy uuid.UUIDSettledAt time.Time}type SettlementFieldView struct {Key stringLabel payout.NamesDisplay string}// View unmarshals Snapshot into payout.SettlementSnapshot and copies only// channel key/name, account_tail and each field's key, label and display.func (s Settlement) View() (SettlementView, error)func Settlements(ctx context.Context, tx pgx.Tx, claimID uuid.UUID) ([]Settlement, error) // newest first// SettlementInThisTx is the settlement written by the calling transaction// (Ruling R25). ErrNoSettlement otherwise.func SettlementInThisTx(ctx context.Context, tx pgx.Tx, claimID uuid.UUID) (Settlement, error)var ErrChannelRequired, ErrChannelInactive, ErrChannelPettyCash, ErrChannelCurrency,ErrChannelClaimType, ErrChannelMaxAmount, ErrProofRequired, ErrProofNotVerified, ErrNoSettlement errorpackage expensestest // additions// Env gains: PayoutKey payout.Key — New calls// e.PayoutKey = payouttest.NewKey(t, "test-1"); payouttest.UseKeyring(t, e.PayoutKey, nil)// (restored at Cleanup), so every test has a keyring as cmd/api does.// A provisioned seed channel by key (Plan 6 steps 90–91). The seven MY keys// as built: cash, bank_transfer, duitnow, ewallet, cheque, crypto (seeded// INACTIVE), petty_cash. Each is seeded AllowedCurrencies = {base currency}// and AllowedClaimTypes = {} (empty = every claim type).// SELECT id FROM payout_channel WHERE org_id = $1 AND key = $2 (e.H.Admin)func (e *Env) Channel(t *testing.T, key string) uuid.UUID// Admin insert, payouttest.SeedChannel's SQL unchanged. Its defaults, applied// before the INSERT: Behaviour "" -> payout.BehaviourExternal; Currencies nil// -> {"MYR"}; ClaimTypes nil -> {}; Fields nil -> {}; MaxAmount "" -> NULL;// is_active = !r.Inactive. Full column list in the Part B appendix.func (e *Env) SeedChannel(t *testing.T, r payouttest.ChannelRow) uuid.UUID// payout.SealDetails + admin insert, exactly payouttest.SeedProfile's body;// returns the profile id.func (e *Env) PayoutProfile(t *testing.T, member, channel uuid.UUID, details map[string]string) uuid.UUIDErrPayoutUnavailableis deleted. -
Step 1: Write the failing tests (no-op poster, as Task 13; a claim walked to
category_verified;ch := e.Channel(t, "bank_transfer");e.PayoutProfile(t, e.Staff, ch, map[string]string{"payee_name": "Siti Aminah", "bank": "maybank", "account_no": "5140 1234 6789"})).
func TestPayingAClaimFreezesTheSealedSnapshot(t *testing.T) { // Transition{-> completed, Owner, PayoutChannelID: &ch}: one settlement; // CreditAccountID == GetChannel(ch).CreditAccountID; Amount == claim // Total; Currency "MYR"; SettledBy Owner; ChannelKey "bank_transfer"; // *AccountTail "6789". // Sealed at rest (Ruling R44): the stored snapshot bytes contain neither // "514012346789" nor "5140 1234 6789"; unmarshalled into // payout.SettlementSnapshot, the account_no field has Sealed != nil, // Value == "", Display "****6789"; payee_name keeps its plaintext value. // Byte-for-byte: calling payout.Snapshot again with the same arguments // in the same tx yields JSON whose parsed form equals the stored one in // every member except each Sealed nonce/ciphertext (fresh nonce per seal).}func TestASettlementLeavesThisPackageMasked(t *testing.T) { // Settlements(claim)[0].View(): ChannelKey, ChannelName, AccountTail // "6789"; Fields = payee_name {display "Siti Aminah"}, bank {display // "Maybank"}, account_no {display "****6789"}. json.Marshal(view) contains // neither "514012346789" nor "\"sealed\"" nor "\"value\"". // The transition's audit row (entity 'expense_claim', action 'transition') // and its activity row: After names settlement_id; neither row's JSON // contains "snapshot", "sealed", "6789" or the payee name.}func TestAMissingKeyringIsADeploymentFault(t *testing.T) { // PRESENCE: with the fixture keyring, payment succeeds (another claim). // Then payout.InstallKeyring(nil) (restore in t.Cleanup): Transition -> // errors.Is(err, payout.ErrNoKeyring); the claim stays category_verified; // no settlement row.}func TestTheClaimsPreferredChannelIsTheDefault(t *testing.T) // PayoutChannelID nil in the body, set on the claim in draft -> used; neither -> ErrChannelRequiredfunc TestPlan5sOwnChannelChecks(t *testing.T) { // One case each, PRESENCE first: an inactive channel -> ErrChannelInactive; // the petty-cash channel -> ErrChannelPettyCash (Ruling R24); a channel whose // AllowedClaimTypes excludes "expense_claim" -> ErrChannelClaimType; a // MaxAmount below the total -> ErrChannelMaxAmount; RequiresSettlementProof // without a proof -> ErrProofRequired; with a pending document -> // ErrProofNotVerified. Each leaves the claim in category_verified and no // settlement row. (Channels via e.SeedChannel(t, payouttest.ChannelRow{…}).)}func TestPlan6sRefusalPassesThroughTyped(t *testing.T) { // A member with no payout profile -> errors.Is(err, payout.ErrNoProfile) // (Part B deviation 5: raised only because every seeded MY channel has at // least the payee_name field). // And *payout.IneligibleError: Plan 5's own R23 checks refuse currency, // claim type and max amount FIRST, so the only rule of Plan 6's that Plan 5 // does not duplicate is payout.RuleAmount. Use a claim whose total is zero // (every line withdrawn) -> errors.As(*payout.IneligibleError) with // Rule == payout.RuleAmount. (Part B deviation 3.)}func TestASettlementIsVisibleOnlyToItsOwnTransaction(t *testing.T) // SettlementInThisTx in a later tx -> ErrNoSettlement; Settlements() lists itnosnapshotopen_test.go — the guard (Ruling R44), in the same style as Task 6’s OCR import guard:
// TestPlan5NeverOpensASnapshot: Plan 5 renders masked displays only. The one// function that yields a raw payout value, payout.OpenSnapshot, belongs to a// future payer's path, not to anything in this plan.func TestPlan5NeverOpensASnapshot(t *testing.T) { var scanned int for _, dir := range []string{"../../platform/expenses", "../../modules/expenses"} { // walk *.go (including tests); count files; fail naming file:line on // any occurrence of "OpenSnapshot". } require.Greater(t, scanned, 5, "PRESENCE: the walk found the packages' files")}-
Step 2: Run and watch fail.
go test ./internal/platform/expenses/ -run 'Settle|Snapshot|Channel|Keyring|Plan6|Plan5' -count=1→ FAIL (undefined). -
Step 3: Implement
settle: channel id = body’s, else claim’s, elseErrChannelRequired;ch := payout.GetChannel; Ruling R23’s checks in the order listed — Part B deviation 1:ch.AllowedCurrenciesis a strict allow-list that can never be empty (Go and a DB CHECK both refuse an empty one), so the currency check is unconditional (slices.Contains(ch.AllowedCurrencies, cl.Currency), elseErrChannelCurrency), whilech.AllowedClaimTypeskeeps its “empty = every claim type” reading; proof viadocuments.Get(verified; any uploader — the payer attaches the bank slip);snap, err := payout.Snapshot(ctx, tx, ch.ID, cl.ClaimantID, Kind, cl.Total, cl.Currency)— returnerrwrapped with%wand no snapshot or value in the message; INSERT settlement (snapshot = $n::jsonbfromsnapuntouched) withsettled_at = c.Now()andRETURNING id. No audit here: Task 13’s post-transition audit row carriessettlement_idonly.SettlementInThisTx:… WHERE org_id = $1 AND claim_id = $2 AND txid = pg_current_xact_id() ORDER BY settled_at DESC LIMIT 1.View:json.Unmarshalintopayout.SettlementSnapshot; copy the listed members; neverValue, neverSealed. -
Step 4: Run. PASS. Sabotage: make
settlestorejson.Marshalofpayout.OpenSnapshot(snap)instead ofsnap→TestPlan5NeverOpensASnapshotfails namingsettlement.go, andTestPayingAClaimFreezesTheSealedSnapshotfails with 23514 onexpense_claim_settlement_masked_not_plain; quote both; restore. -
Step 5: Commit —
git commit -m "Freeze the sealed payout snapshot on the claim at settlement; show masked views only".
Task 15: The accrual posting rule and the two reconciliation checks
Section titled “Task 15: The accrual posting rule and the two reconciliation checks”Waits for Plan 3’s merge (Task 1 Part A). Needs Task 13. Runs in parallel with Task 14 (disjoint files).
Files:
- Create:
internal/platform/expenses/posting.go,internal/platform/expenses/reconcile.go - Test:
internal/platform/expenses/accrual_test.go,internal/platform/expenses/reconcile_test.go
Interfaces:
-
Consumes:
accounting.PostingRule,accounting.Source,accounting.Draft,accounting.NewPoster,accounting.NewRuleSet(Plan 4);ledger.Line,ledger.Unreversed,ledger.SourceBalance,ledger.Get,ledger.SemanticAccrual,ledger.SemanticPayout;chart.JournalPurchase;reconcile.Check,reconcile.Finding,reconcile.Register;workflow.Get;GetClaim,ListLines,GetCategory,UsableAccount. -
Produces:
// AccrualRule posts a claim's lines against AP at category_verified (Ruling R26).type AccrualRule struct{}func (AccrualRule) Kind() string // Kindfunc (AccrualRule) Semantic() posting.Semantic // posting.Accrualfunc (AccrualRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error)// Rules is every posting rule this package owns; modules/expenses registers// them at boot. Task 16 appends PayoutRule{}.func Rules() []accounting.PostingRulevar ErrInstanceMismatch, ErrTotalsDrift errorconst (CheckAccrual = "expense_claim_accrual"; CheckAP = "expense_claim_ap") -
Step 1: Write the failing tests —
accrual_test.go(e.UsePoster(t, expenses.Rules()...); a claim with a receipt line MYR 108.00 onmealswithSVT8and 60/40 cost-centre shares, and a mileage line 100 km × 0.60 onmileage; walked by Staff and Owner tocategory_verifiedthroughexpenses.Transition):
func TestCategoryVerificationAccruesTheClaim(t *testing.T) { // One journal_entry: semantic accrual, source_kind "expense_claim", // source_id claim.ID, instance_id claim.InstanceID, journal PUR, currency // MYR, dated Today. Lines: Dr 6110 108.0000 (tax_code SVT8, party = Staff's // party, memo "EC-2026-0001 L1 Lunch with client", two analytic // distributions 64.8000/43.2000); Dr 6120 60.0000; Cr 2110 168.0000 // (party Staff). The entry balances; nothing was posted at submission, // review or approval (count journal_entry for the instance before).}func TestAnAccrualThatCannotPostStopsTheTransition(t *testing.T) { // Clear meals' account between approval and verification (UpdateCategory // as an admin tx): Transition -> *CategoryAccountError (wrapped by the // Poster); the instance is still 'approved'; no entry; no decision row for // category_verified.}func TestRejectingAfterAccrualMirrorsIt(t *testing.T) { // category_verified -> rejected (Owner, reason): a reversal entry; every // account nets to zero for the claim; -> draft; edit; resubmit; walk to // category_verified again: a second accrual posts (the first is reversed, // so Plan 4 R8's once-only rule allows it).}func TestARuleRefusesAClaimWhoseTotalsDrifted(t *testing.T) { // Build() directly with a Source whose Event names the claim, after an // admin UPDATE made total_amount disagree with the lines -> ErrTotalsDrift. // PRESENCE first: Build on the untouched claim returns a Draft.}func TestEveryAccrualIsTraceable(t *testing.T) { // Spec 11A.7: from the entry, source_id -> the claim -> each Dr line's memo // names a line whose receipt has an attached verified document, and // workflow.Decisions(instance) names the member who verified the category.}reconcile_test.go:
func TestTheChecksPassOnAHealthyLedger(t *testing.T) // accrued claim + a draft claim: both checks pass (reconcile.Default().Run → the two keys present and passed)func TestAnOrphanIsNamed(t *testing.T) { // PRESENCE: passes. Then admin UPDATE workflow_instance SET accrual_open = // false for the accrued claim (simulating drift): CheckAccrual fails with // one Finding whose Ref is the claim id and whose Detail says "accrual_open // false but an unreversed accrual entry exists".}func TestAnAPMismatchIsNamed(t *testing.T) { // PRESENCE: passes on the accrued claim. Then tamper as admin, on ONE // pooled connection: `SET session_replication_role = replica` (disables // the draft trigger for that session only), `UPDATE expense_claim SET // total_amount = 200 WHERE …`, `RESET session_replication_role`. // CheckAP now reports one Finding for the claim naming expected // -200.0000 and actual -168.0000.}-
Step 2: Run and watch fail.
-
Step 3: Implement
AccrualRule.Build:cl := GetClaim(src.Event.SourceID);cl.InstanceID != src.Event.InstanceID→ErrInstanceMismatch;inst := workflow.Get; active lines;sum(base).Cmp(cl.Total) != 0 || cl.Total.Cmp(inst.Amount) != 0→ErrTotalsDrift(money.Amountwraps a decimal: never compare with==/!=); claimant partySELECT party_id FROM organization_member WHERE org_id = $1 AND id = $2; oneledger.Lineper active line (Debit: BaseAmount,TaxCodeID,PartyID,Memo,Distribution: Shares); one credit onsrc.Control.ID.Draft{JournalType: chart.JournalPurchase, Currency: src.BaseCurrency, Description: "Expense claim " + cl.Reference + " " + cl.Title}.reconcile.go: tworeconcile.Checks registered ininit(); eachFinding.Refis the claim id andDetailnames the numbers. -
Step 4: Run. PASS. Sabotage: make the accrual’s credit amount
total − 0.0001→TestCategoryVerificationAccruesTheClaimfails withledger.ErrUnbalancedfrom the Poster; quote; restore. -
Step 5: Commit —
git commit -m "Accrue verified claims to AP and reconcile claims against the ledger".
Task 16: The payout posting rule
Section titled “Task 16: The payout posting rule”Waits for Plan 6’s merge (gate P6). Needs Tasks 14, 15.
Files:
- Modify:
internal/platform/expenses/posting.go(addPayoutRule, append it toRules()) - Test:
internal/platform/expenses/payout_test.go
Interfaces:
-
Consumes:
SettlementInThisTx(Task 14);payout.SettlementSnapshot,payout.BehaviourPettyCash(unmarshal only, Ruling R44);ledger.Unreversed,ledger.SourceBalance,ledger.Get;chart.ListJournals,chart.JournalBank; tests:payout.PurgeMemberProfiles(ctx, tx, c, memberID) error(Plan 6 Task 9),reconcile.Default. -
Produces:
// PayoutRule clears the accrual's control account against the channel's// credit account at completed (Ruling R26). Reversal is Plan 4's mirror.type PayoutRule struct{}func (PayoutRule) Kind() stringfunc (PayoutRule) Semantic() posting.Semantic // posting.Payoutfunc (PayoutRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error)var ErrNotAccrued, ErrAccrualShape, ErrSettlementMismatch error// ErrPettyCashDeferred: the settlement's snapshot names a petty_cash// channel; its credit belongs to Plan 8's float rule (Ruling R24).var ErrPettyCashDeferred error -
Step 1: Write the failing tests (
e.UsePoster(t, expenses.Rules()...); the Task 15 claim, accrued; channel and profile from the fixture):
func TestPayingAClaimClearsAPAgainstTheChannelsAccount(t *testing.T) { // Transition -> completed with the bank-transfer channel: a payout entry // (semantic payout, same source and instance): Dr 2110 168.0000 party // Staff / Cr <channel.CreditAccountID> 168.0000; journal = the active // journal whose default account is that credit account (BNK for 1520), // else the default bank journal. SourceBalance(claim, 2110) is 0/0. // No fx_difference line (Ruling R5). -> received (Staff) posts nothing.}func TestAPayoutReadsOnlyItsOwnSettlement(t *testing.T) { // Build() directly with a payout Source in a tx that wrote no settlement -> // ErrNoSettlement (the claim's earlier settlement, if any, is not reused).}func TestAPayoutFollowsTheAccrualsActualControlAccount(t *testing.T) { // After the accrual, retire 2110 and make a new AP control account (the // org re-points AP): the payout still debits 2110, the account the // accrual credited, and 2110 nets to zero.}func TestAnUpheldDisputeAfterPayoutReversesBothLegs(t *testing.T) { // Ruling R42: completed -> disputed (Staff, reason) -> rejected (Owner, // reason): two reversal entries, payout's first; 2110, 6110, 6120 and the // bank account all net to zero for this claim; both settlements remain // (Settlements() returns one; after re-walk and a second payout, two).}func TestCannotPayWhatWasNeverAccrued(t *testing.T) // Build with no unreversed accrual -> ErrNotAccruedfunc TestAPurgedProfileDoesNotTouchAPaidClaim(t *testing.T) { // Ruling R46, Plan 6 P33. Pay the claim (bank transfer). Record the // settlement row and View(). Then, in one tx: // payout.PurgeMemberProfiles(ctx, tx, c, e.Staff) — Staff's saved profile // is gone (the member keeps their claims). Afterwards: the settlement row // and View() are unchanged; completed -> received (Staff) succeeds; // on a second claim of Staff's, paid before the purge, completed -> // disputed (Staff) -> rejected (Owner) reverses both legs and 2110 nets // to zero; reconcile.Default().Run passes both expense checks. // ABSENCE, the one later read: a third claim of Staff's, verified but // unpaid at the purge -> Transition(-> completed) is refused with // errors.Is(err, payout.ErrNoProfile), still category_verified, no // settlement row. // Part B: payout.Snapshot raises ErrNoProfile only when the channel has at // least one field (a fieldless channel snapshots an empty fields array // instead) — every seeded MY channel carries payee_name, so pay through a // seeded one, not a bare e.SeedChannel row. PurgeMemberProfiles itself // needs the keyring installed (ErrNoKeyring otherwise), which the fixture // does; it deletes an unopenable profile all the same, auditing it // "unreadable" with no values. It is also registered as a // member.OnDeactivate hook ("payout.profiles") from payout's init(), so // member.RunDeactivationHooks reaches it — this test calls it directly.}func TestAPettyCashSettlementIsNeverCreditedToTheChannelAccount(t *testing.T) { // Ruling R24, Plan 6 P6. The accrued claim; in ONE tx, insert a settlement // row directly (erp_app, hand-built version-1 snapshot, amount = total): // PRESENCE: with "settlement_behaviour":"external", Build returns a Draft // whose credit line is the row's credit_account_id. Rolled back; then the // same with "settlement_behaviour":"petty_cash" and credit_account_id = // the cash-in-hand account: Build -> ErrPettyCashDeferred, no Draft, and // no journal_entry for the instance's payout semantic. // Part B: Plan 6 seeds a real "petty_cash" channel (e.Channel(t, // "petty_cash")) whose behaviour is payout.BehaviourPettyCash and whose // credit account is the default CASH journal's — payout.CheckDefinition // forces a petty_cash channel through a cash journal and forbids it any // account_no or registry_ref field. Take credit_account_id from that // channel rather than inventing one.}-
Step 2: Run and watch fail. Step 3: Implement
PayoutRule.Build: claim;s := SettlementInThisTx;var snap payout.SettlementSnapshot; json.Unmarshal(s.Snapshot, &snap)(no key needed; Ruling R44) and, ifsnap.Channel.SettlementBehaviour == payout.BehaviourPettyCash, returnErrPettyCashDeferredbefore reading the ledger — the one branch Plan 8 replaces;open := ledger.Unreversed(ctx, tx, instance)(entry headers only); the oneSemantic == ledger.SemanticAccrualheader, elseErrNotAccrued;acc := ledger.Get(ctx, tx, header.ID)for its lines; its credit lines (l.Credit.IsPositive()) must be exactly one, elseErrAccrualShape— that line’sAccountIDis the control;bal := ledger.SourceBalance(ctx, tx, Kind, cl.ID, control);amount := bal.Amount.Neg(),base := bal.Base.Neg();amount.Cmp(s.Amount) != 0→ErrSettlementMismatch; journal: the first activechart.JournalfromListJournalswhoseDefaultAccountID != nil && *DefaultAccountID == s.CreditAccountID, elseJournalType: chart.JournalBank. Lines:{AccountID: control, Debit: amount, Base: &base, PartyID},{AccountID: s.CreditAccountID, Credit: amount, PartyID}.Description: "Payment of expense claim " + cl.Reference. -
Step 4: Run. PASS. Step 5: Commit —
git commit -m "Pay claims out against the settled channel's account".
Task 17: modules/expenses — composition, permissions, rules at boot, reference and document routes
Section titled “Task 17: modules/expenses — composition, permissions, rules at boot, reference and document routes”Waits for Plan 3’s merge. Needs Tasks 6, 7, 8, 16 (boot registers expenses.Rules(), which must hold both rules).
Files:
- Create:
internal/modules/expenses/permissions.go,routes.go,boot.go,http.go,errors.go,reference_handlers.go,document_handlers.go - Modify:
constants.yaml(append 4 permissions) →make generate;api/openapi.yaml(append paths and schemas) →make generate;internal/app/app.go,internal/app/routes_test.go(composedModules),cmd/api/main.go,cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go,cmd/migrate/wiring_test.go,cmd/orgadmin/wiring_test.go,cmd/worker/wiring_test.go - Test:
internal/modules/expenses/routes_test.go,boot_test.go,reference_handlers_test.go,document_handlers_test.go
Interfaces:
- Consumes: Tasks 5–8, 12 (
ClaimsOfDocument), 10 (CanRead), 15–16 (Rules);accounting.RegisterPostingRule;member.RegisterPermission,member.Can,member.Permissions;httpx.Protected;s3store.New,ocr.FromConfig,ocr.Subscribe;config.ObjectStoreFromEnv,config.OCRProviderFromEnv. - Produces:
Routes in this task (all underpackage expenses // internal/modules/expensesconst (PermissionClaimCreate = "expenses.claim.create"PermissionClaimRead = "expenses.claim.read"PermissionClaimReadAll = "expenses.claim.read_all"PermissionClaimTransition = "expenses.claim.transition"PermissionSetupManage = "expenses.setup.manage")type Deps struct {DB *db.DBClock clock.ClockStore documents.StorePresignTTL time.Duration}func Register(g *gin.RouterGroup, deps Deps)
/v1):
| Method | Path | Permission |
|---|---|---|
| GET | /expenses/categories |
expenses.claim.read |
| POST | /expenses/categories |
expenses.setup.manage |
| PATCH | /expenses/categories/:id |
expenses.setup.manage |
| GET | /expenses/rates |
expenses.claim.read |
| POST | /expenses/rates |
expenses.setup.manage |
| POST | /documents |
expenses.claim.create |
| POST | /documents/:id/complete |
expenses.claim.create |
| GET | /documents/:id |
expenses.claim.read |
- Step 1: Write the failing tests.
routes_test.go:TestEveryRouteRequiresItsExactPermissionexactly in the shape ofinternal/modules/accounting/routes_test.go— the table above, the five permission strings pinned, and gin’s own table under/v1/expenses/and/v1/documents/equal to it. (Tasks 18–19 extend the table.)boot_test.go:TestLinkingTheModuleRegistersBothPostingRules—accounting.DefaultRules().Lookup("expense_claim", posting.Accrual)and(…, posting.Payout)both found;TestLinkingTheModuleRegistersTheReconciliationChecks—reconcile.Default().Runon a fresh org listsexpense_claim_accrualandexpense_claim_ap;TestLinkingTheModuleRegistersStep80.reference_handlers_test.go(the env pattern ofinternal/modules/accounting/chart_handlers_test.go: a provisioned org,httpx.NewRouter, this module alone, members with minted tokens; an admin withsetup.manage+claim.read, a clerk with onlyclaim.read):
func TestListingCategoriesShowsLabelsInTheRequestLocale(t *testing.T) // Accept-Language zh -> label "餐饮" for meals; name stays the ms/en record namefunc TestCreatingACategoryNeedsSetupManage(t *testing.T) // clerk 403; admin 201 with account_idfunc TestACategoryAccountMustBeAPostableExpense(t *testing.T) // 1520 -> 422 naming account_idfunc TestRatesAreCreatedNotEdited(t *testing.T) // POST 201; there is no PATCH route (404/405 from gin); amount as JSON number -> 400func TestRatesOnADay(t *testing.T) // GET ?kind=mileage&on=2026-09-21 -> only the effective row per banddocument_handlers_test.go(Deps.Store=documentstest.MemStore):
func TestUploadIsPresignedAndCompleted(t *testing.T) { // POST /v1/documents {filename, content_type, byte_size, sha256} -> 201 // {document: {status: "pending"}, upload: {method: "PUT", url, headers}}. // MemStore.Upload(key, body); POST /v1/documents/{id}/complete -> 200 // status "verified"; GET /v1/documents/{id} -> download_url set.}func TestCompletingBeforeUploadIs409(t *testing.T)func TestAMismatchedUploadIsRejectedNotFailed(t *testing.T) // complete -> 200 with status "rejected", reject_reason "sha256"func TestSomeoneElsesDocumentIsNotFound(t *testing.T) { // PRESENCE: the uploader reads it. Another member without read_all -> 404; // after the document is attached to a claim that member can read (they // are its approver now) -> 200.}func TestSuggestionsAreShownReadOnly(t *testing.T) // insert a suggestion via documents.SaveSuggestion; GET shows it under "ocr_suggestions"; no route writes one-
Step 2: Run and watch fail.
-
Step 3: Implement.
permissions.go:init()registers the five with descriptions (claim.transition: “Use the claim transition endpoint; authority to decide still comes from the workflow’s grants”).constants.yaml: appendexpenses.claim.read,expenses.claim.read_all,expenses.claim.transition,expenses.setup.manageafter the lastaccounting.*line (Ruling R37: leave the three existingexpenses.*lines alone).boot.go:init()—for _, r := range platformexpenses.Rules() { accounting.RegisterPostingRule(r) }; blank-import nothing else (step 80 and the reconciliation checks register frominternal/platform/expenses’s owninit(), which this import already links).http.go: copydecodeStrict,optional,decodeProblem,probeFields,parseID,parseDate,actor,blank,firstErrfrominternal/modules/accounting/http.go(a module does not import another module); addifMatch(c) (int, error)(missing →apperr.KindBadRequest“If-Match is required: send the ETag of the claim you are editing”; malformed → 400) andsetETag(c, rev).document_handlers.go:POST /documents→ oneInTxwithdocuments.Create.POST /documents/:id/complete→ tx1documents.Get(uploader must be the caller, else 404) →documents.Verifyoutside any tx → tx2documents.RecordVerification.GET /documents/:id→ uploader, ormember.Can(read_all), or any claim fromClaimsOfDocumentpassesCanRead— else 404; response carriesdownload_url,thumbnail_url(presigned,PresignTTL) andocr_suggestionsfromdocuments.Suggestions.errors.gomapErr(extended by Tasks 18–19):ErrNotFound/documents.ErrNotFound404;ErrDuplicateKey409;*CategoryAccountError,ErrInvalidCategory,ErrInvalidRate,documents.ErrTypeNotAllowed/ErrTooLarge/ErrBadDigest/ErrFilenameRequired422 naming the field;documents.ErrObjectMissing409 “nothing has been uploaded to this document’s link yet”.- Composition:
app.Deps.Expenses expenses.Deps;app.Routercallsexpenses.Register(api, deps.Expenses);composedModules["expenses"]lists the five keys.cmd/api/main.go:storeCfg := config.ObjectStoreFromEnv()(fatal on error),store := s3store.New(storeCfg),store.EnsureBucket(ctx)(fatal),x := ocr.FromConfig(config.OCRProviderFromEnv())(fatal),ocr.Subscribe(database.Bus(), database, store, x, clock.System(), logger),Expenses: expenses.Deps{DB, Clock, Store: store, PresignTTL: storeCfg.PresignTTL}.cmd/migrate,cmd/orgadmin,cmd/worker: blank-importinternal/modules/expenses(permissions, rules, step 80); theirwiring_test.gogain the new permission keys and step keyexpense_categories. Part B (deviation 7), two things not to get wrong: (a)cmd/apialready installs the payout keyring (config.PayoutDetailsKeysFromEnv→payout.NewKeyring→payout.InstallKeyring,cmd/api/main.go:54-67, Plan 6’s Task 6) — do not add a second. (b) Blank-importing this module intocmd/workertransitively linksinternal/platform/payout(throughinternal/platform/expenses). That does not break Plan 6’s Ruling P28, which is about the payout module (internal/modules/payout), andinternal/app/binaries_test.go’s module-parity check covers onlycmd/{api,migrate,orgadmin}.cmd/workerinstalls no payout keyring and needs none: onlysettle— an HTTP path — callspayout.Snapshot, andPayoutRule.Buildmerelyjson.Unmarshals the frozen snapshot (Ruling R44). Do not addconfig.PayoutDetailsKeysFromEnvtocmd/worker; it would refuse to boot for a key it never uses. api/openapi.yaml: append the eight operations withCategory,CategoryInput,CategoryPatch,ExpenseRate,ExpenseRateInput,Document,UploadTicket,OCRSuggestionschemas; money fields$ref: '#/components/schemas/Money', quantity/rate fieldsRate.
-
Step 4: Run.
make generate && go test ./internal/modules/expenses/ ./internal/app/ ./cmd/... -count=1→ PASS. -
Step 5: Commit —
git commit -m "Compose the expenses module: permissions, posting rules, reference and document routes".
Task 18: Claim, group, line and attachment routes
Section titled “Task 18: Claim, group, line and attachment routes”Waits for Plan 3’s merge. Needs Task 17.
Files:
- Create:
internal/modules/expenses/claim_handlers.go,line_handlers.go,group_handlers.go - Modify:
routes.go,routes_test.go,errors.go,api/openapi.yaml→make generate - Test:
internal/modules/expenses/claim_handlers_test.go,line_handlers_test.go
Interfaces:
- Consumes: Tasks 10–13 (
CreateClaim,GetClaim,UpdateClaim,ListClaims,ToAct,CanRead,ListLines,AddLine,UpdateLine,WithdrawLine,AttachDocument,DetachDocument,SettlementsandSettlement.View(Task 14 — the only way a settlement reaches the wire, Ruling R44), groups);workflow.AvailableTransitions,workflow.Decisions. - Routes:
| Method | Path | Permission |
|---|---|---|
| GET | /expenses/groups |
expenses.claim.read |
| POST | /expenses/groups |
expenses.claim.create |
| PATCH | /expenses/groups/:id |
expenses.claim.create |
| GET | /expenses/claims |
expenses.claim.read |
| POST | /expenses/claims |
expenses.claim.create |
| GET | /expenses/claims/:id |
expenses.claim.read |
| PATCH | /expenses/claims/:id |
expenses.claim.create |
| POST | /expenses/claims/:id/lines |
expenses.claim.create |
| PATCH | /expenses/claims/:id/lines/:lineId |
expenses.claim.create |
| DELETE | /expenses/claims/:id/lines/:lineId |
expenses.claim.create |
| POST | /expenses/claims/:id/lines/:lineId/documents |
expenses.claim.create |
| DELETE | /expenses/claims/:id/lines/:lineId/documents/:documentId |
expenses.claim.create |
The claim read shape (GET /expenses/claims/:id, and the body of every claim mutation’s response):
{ "claim": { "id", "reference", "claimant_member_id", "title", "currency", "total": "168.0000", "state", "semantic", "group_id", "payout_channel_id", "revision" }, "lines": [ { "id", "line_no", "type", "expense_date", "description", "category_id", "currency", "amount", "fx_rate", "fx_rate_source", "base_amount", "tax_code_id", "tax_amount", "quantity", "rate_id", "band", "supplier_name", "supplier_tin", "einvoice_uuid", "einvoice_validated_at", "document_basis", "relaxation_reason", "shares", "withdrawn", "documents" } ], "available_transitions": [ { "to", "action_key", "gate", "requires_reason", "allowed", "labels", "refusal" } ], "decisions": [ { "from", "to", "actor_member_id", "authority_member_id", "via", "reason", "decided_at" } ], "settlements": [ { "id", "channel_id", "channel_key", "channel_name", "account_tail", "amount", "currency", "fields": [ { "key", "label", "display" } ], "proof_document_id", "settled_by", "settled_at" } ] }with header ETag: "<revision>". settlements is built from Settlement.View() only (Ruling R44): there is no snapshot, value or sealed member anywhere in the response, and the OpenAPI SettlementView schema has additionalProperties: false.
- Step 1: Write the failing tests (env: provisioned org, Staff (create + read), Owner (all five), Outsider (read only)):
func TestAClaimIsFiledEditedAndReadWithItsETag(t *testing.T) { // POST /claims {title} -> 201, ETag "1". POST /lines with If-Match "1" -> // 201, ETag "2", total "45.6000". PATCH /claims/{id} with If-Match "1" -> // 409 naming the current revision. Without If-Match -> 400. // GET -> 200 with lines, available_transitions (submit allowed for Staff), // empty decisions.}func TestMoneyIsAStringBothWays(t *testing.T) // "amount": 45.6 (a JSON number) -> 400 naming "amount"; the response's amounts are stringsfunc TestALineErrorNamesItsFieldAndRule(t *testing.T) // basis einvoice_company without uuid -> 422 whose detail starts "field=einvoice_uuid rule=einvoice_identifiers:" (Ruling R43)func TestAClaimNobodyShouldSeeIsNotFound(t *testing.T) // PRESENCE Staff 200; Outsider 404; Outsider with read_all 200func TestScopeAllNeedsReadAll(t *testing.T) // ?scope=all as Staff -> 403; as Owner -> 200func TestTheQueueShowsWhatICanDecide(t *testing.T) // ?scope=to_act as Owner after Staff submits -> the claim; as Staff -> emptyfunc TestListingPagesWithACursor(t *testing.T) // limit=2, next_cursor round-trips; limit=500 -> 422func TestAttachAndDetach(t *testing.T) // POST documents {document_id} with If-Match -> 200; DELETE -> 200; another claimant's doc -> 422func TestAGroupCollectsClaims(t *testing.T) // POST /groups {name, kind: "trip"} -> EG-2026-0001; PATCH /claims/{id} {group_id} ok; GET /claims?group_id= lists itfunc TestAPaidClaimShowsOnlyMaskedPayoutDetails(t *testing.T) { // A claim paid through the fixture (expenses.Transition in a tx, as Task // 14). GET as Staff and as Owner: settlements[0].account_tail "6789", // fields' account_no display "****6789"; the raw body contains neither // the full account number nor "sealed" nor "snapshot" nor "value".}-
Step 2: Run and watch fail. Step 3: Implement. Each handler: one
InTx;ifMatchon every mutation; claim reads build the shape above (AvailableTransitionswith the caller as actor,Decisions,Settlements). Visibility: after loading,CanRead(…, member.Can(ctx, tx, caller, PermissionClaimReadAll))false → 404.mapErrgains:ErrNotClaimant,ErrNotOwner403;*RevisionMismatchError409 (“the claim is at revision N; reload and retry”);ErrNotDraft,ErrNotAttachable,ErrDocumentOnAnotherClaim,ErrAlreadyAttached409;*LineError→ 422 with detail[line=<id> ]field=<f> rule=<r>: <sentence>(Ruling R43;*SubmissionErrorjoins its problems with"; ");ErrGroupInactive,ErrInvalidClaim,ErrInvalidGroup,ErrBadCursor,ErrLineNotFound,ErrLineWithdrawn,ErrNotYourDocument,ErrDocumentNotVerified,ErrNotAttached→ 404/422 as named in each error’s comment; a Postgres refusal onexpense_claim_is_draft/expense_claim_line_claim_is_draft→ 409 (the DB said what Go should have). -
Step 4: Run.
make generate && go test ./internal/modules/expenses/ ./internal/app/ -count=1→ PASS. -
Step 5: Commit —
git commit -m "Serve claims, lines, groups and attachments with ETags and cursors".
Task 19: The transition route — Idempotency-Key, error mapping, commit-time errors
Section titled “Task 19: The transition route — Idempotency-Key, error mapping, commit-time errors”Waits for Plan 3’s merge and Plan 6’s Task 1. Needs Tasks 14, 16, 18.
Files:
- Create:
internal/platform/expenses/idempotency.go,internal/modules/expenses/transition_handlers.go - Modify:
internal/modules/expenses/routes.go,routes_test.go,errors.go,api/openapi.yaml→make generate - Test:
internal/platform/expenses/idempotency_test.go,internal/modules/expenses/transition_handlers_test.go
Interfaces:
-
Consumes:
expenses.Transition(Task 13), settlement errors (Task 14),ledger.MapError; the Plan 6 refusal list from Task 1 Part B. -
Produces:
package expenses // platform// Replay is a stored response for a repeated key.type Replay struct{ Status int; Body json.RawMessage }// BeginIdempotent claims (member, key) for this request inside tx, FIRST// (Ruling R22). A second concurrent request with the same key blocks on the// row until the first commits or rolls back. Returns a Replay when the key// already holds a stored response for the same request hash;// ErrIdempotencyKeyReused when it holds one for a different hash.func BeginIdempotent(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, key, requestHash string) (*Replay, error)func FinishIdempotent(ctx context.Context, tx pgx.Tx, member uuid.UUID, key string, status int, body []byte) errorvar ErrIdempotencyKeyReused errorRoute:
POST /expenses/claims/:id/transitions—expenses.claim.transition. Body{"from","to","reason"?,"payout_channel_id"?,"proof_document_id"?}. HeaderIdempotency-Keyrequired (400 without). Response 200: the claim read shape of Task 18. -
Step 1: Write the failing tests.
idempotency_test.go:
func TestAKeyReplaysItsStoredResponse(t *testing.T) // Begin (nil) -> Finish(200, body) -> commit; Begin again same hash -> Replay{200, body}func TestAKeyWithADifferentRequestIsRefused(t *testing.T) // -> ErrIdempotencyKeyReusedfunc TestAFailedRequestGivesItsKeyBack(t *testing.T) // Begin, then the tx rolls back; Begin again -> nil (fresh)func TestConcurrentSameKeyRequestsSerialize(t *testing.T) { // Two goroutines, 10 s contexts: A Begins and holds; B Begins and blocks; // A Finishes and commits; B gets the Replay. Never both nil.}transition_handlers_test.go (poster: expensestest.New calls posting.ResetForTest(), which wipes the Poster that internal/modules/accounting’s init() registered, so every test calls e.UsePoster(t, platformexpenses.Rules()...) right after New — Plan 4’s real accounting.NewPoster over a fresh RuleSet holding this plan’s two rules, exactly as the Global Constraints require):
func TestTheSeededFlowEndToEndOverHTTP(t *testing.T) { // Staff submits (Idempotency-Key k1); Owner reviews, approves, verifies // the category (accrual posts: GET /v1/accounting/entries is Plan 4's — // assert through ledger.List in the test instead); Owner pays with // payout_channel_id (payout posts; settlements has one row); Staff // acknowledges. Each response is the claim shape with the new state.}func TestTheSameKeyReplaysAndDoesNotMoveTwice(t *testing.T) { // Replay the submit with k1 -> 200 and the SAME body; the decision log has // one submit. The same k1 with a different body -> 422.}func TestMissingIdempotencyKeyIs400(t *testing.T)func TestRefusalsMapToStableStatuses(t *testing.T) { // Table, accepted case first where it applies: // stale from -> 409; unknown to -> 422; missing reason on reject -> 422; // Staff approving -> 403 carrying the workflow refusal code; // incomplete draft -> 422 listing every problem; unroutable submission // (a claimant with no superior: a member seated on a root position) -> // 422 naming the step; channel checks (Task 14's errors) -> 422 naming // payout_channel_id or proof_document_id; each Plan 6 refusal Task 1 Part B // recorded (payout.ErrChannelNotFound, ErrChannelInactive, ErrNoProfile, // *IneligibleError, *InvalidDetailsError) -> 422; a closed fiscal period at // accrual -> 409 naming the period (fiscal.PeriodNotOpenError).}func TestAnAnalyticRefusalAtPostingIs422(t *testing.T) { // The immediate path: after submission make the cost_centre axis required // (admin: is_required = true) for a line without shares. ledger.Post calls // analytic.Plan synchronously, so Transition returns *analytic.RequiredError // before COMMIT: 422 naming the axis; the claim is still 'approved'.}func TestACommitTimeLedgerErrorIsMapped(t *testing.T) { // The deferred path (wave-2 §3): Go's own checks catch every real // imbalance first, so the test forces one at COMMIT. As admin, in this // test's database only (dropped in t.Cleanup): a function raising // ERRCODE '23514' with CONSTRAINT = 'analytic_distribution_required', and // CREATE CONSTRAINT TRIGGER force_commit_refusal AFTER INSERT ON // journal_line DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE // FUNCTION it. PRESENCE first: with the trigger absent, the verification // succeeds (a separate claim). Then: the transition to category_verified // returns 422 mapped by ledger.MapError to *analytic.RequiredError, not // 500; the claim is still 'approved'; no journal_entry committed.}-
Step 2: Run and watch fail. Step 3: Implement. Handler: parse id, body (
decodeStrict), key;hash := sha256(method + path + canonical body);err := InTx(func(ctx, tx) { replay := BeginIdempotent(...); if replay != nil { remember it; return nil }; cl, _, err := expenses.Transition(...); build the claim shape; FinishIdempotent(200, body) }); thenerr = ledger.MapError(err)beforemapErr(wave-2 §3: balance and distribution errors surface at COMMIT).mapErradditions:workflow.ErrStaleState409;workflow.ErrNoSuchEdge,workflow.ErrReasonRequired422;*workflow.RefusedError403 with the refusal code;*workflow.SubmissionRefusedError422 naming each step;*SubmissionError422 listing problems; Task 14’s channel errors 422;mapPayoutErr(the table from Task 1 Part B) 422 — exactly five rows, all namingpayout_channel_id:payout.ErrChannelNotFound(also Plan 6’s answer when the channel’s credit account will not join, Part B deviation 4),payout.ErrChannelInactive(the race past Plan 5’s own check),payout.ErrNoProfile(“the claimant has no saved payout details for this channel”),*payout.IneligibleErrorwithrule=<e.Rule>(one ofpayout.RuleAmount,RuleCurrency,RuleClaimType,RuleMaxAmount), and*payout.InvalidDetailsErrorjoining eachProblem.Key + ": " + Problem.Reasonwith"; "(“the claimant must update their payout details” — this is also how a retired bank arrives, so do not mappayout.ErrRegistryEntryNotFound: it never escapesSnapshot, Part B deviation 2);payout.ErrMemberNotFoundstays 500 (a wiring bug);*fiscal.PeriodNotOpenError409;*chart.NoControlAccountError409 (“accounting is not set up: no AP control account”);*accounting.NoPostingRuleError500 (a wiring bug);*analytic.RequiredError,*analytic.TotalError,analytic.ErrAxisNotApplicable422 namingshares;ledger.ErrUnbalanced500 (a rule bug);payout.ErrNoKeyring,payout.ErrUnknownKey,payout.ErrSealBroken500 with the fixed detail “payout details cannot be sealed or read on this server; contact the operator” (a deployment fault; the wrapped error is logged, never echoed);ErrPettyCashDeferred422 namingpayout_channel_id(“petty-cash reimbursement arrives with petty-cash floats (Plan 8)”; unreachable throughsettle, mapped for safety);ErrIdempotencyKeyReused422. -
Step 4: Run. PASS. Sabotage: remove the
ledger.MapErrorcall →TestACommitTimeLedgerErrorIsMappedred (500); quote; restore. -
Step 5: Commit —
git commit -m "Move claims over HTTP with idempotency keys and mapped refusals".
Task 20: End to end, the roadmap, and the one full run
Section titled “Task 20: End to end, the roadmap, and the one full run”Waits for everything above.
Files:
-
Create:
internal/modules/expenses/e2e_test.go -
Modify:
docs/ROADMAP.md -
Step 1: Write the end-to-end test — one test, the plan in sequence, over HTTP where a route exists:
func TestAMalaysianClaimFromReceiptToBank(t *testing.T) { // 0. e.UsePoster(t, platformexpenses.Rules()...) right after the fixture // (posting.ResetForTest wiped the init-registered Poster); a payout // keyring "test-1" is installed by the fixture, as cmd/api installs one. // 1. A provisioned MY org through onboarding (steps 10–80 and Plan 6's // 90 payout_registries / 91 payout_channels): GET /v1/org/readiness // shows expense_categories, payout_registries and payout_channels all // done, and no warning at all — Plan 5's step 80 raises no // expense_category_unmapped, and Plan 6's channels step skips inactive // channels, so the inactive "crypto" seed raises no // payout_channel_invalid either (Task 1 Part B, table row 12). // 2. Admin POSTs a mileage rate (car 0.6000 from 2026-01-01). // 3. Staff uploads two receipts (presign, MemStore upload, complete) — one // a PNG (thumbnail present), one a PDF; OCR is off: no suggestions. // 4. Staff files "Client visit Seremban" in a new trip group; adds a meals // receipt (MYR 108.00, SVT8, einvoice_company with TIN and uuid, shares // 60/40), a USD 25.00 parking receipt (foreign_supplier, dated // 2026-09-19 at the seeded 4.2000 -> 105.0000), and 100 km mileage. // Total 273.0000. Attaches the receipts. // 5. Staff submits; Owner reviews, approves, verifies the category: the // accrual has four lines (three Dr, one Cr 2110 273.0000). // 6. Owner pays via the bank-transfer channel: the settlement snapshot is // stored sealed (no raw account number in the row; GET shows only // "****" displays); the payout clears 2110 against the channel's account. // 7. Staff acknowledges (received). // 8. GET /v1/accounting/reconciliation passes, including // expense_claim_accrual and expense_claim_ap. // 9. Traceability (spec 11A.4): from each journal line, the claim line, its // document (verified, sha256 recorded), the category, and the decision // log's approver for every step resolve; audit.VerifyChain is intact. // 10. A second claim is rejected after accrual: its accrual is mirrored // and reconciliation still passes. // 11. Key rotation (Ruling R45): install "test-2" with "test-1" previous; // expenses.ResealSettlements + payout.ResealProfiles in the org's own // transactions; PendingReseal == 0; GET the paid claim: identical // settlements view; audit.VerifyChain still intact. // 12. Departure (Ruling R46): payout.PurgeMemberProfiles(Staff); GET the // paid claim: identical; reconciliation passes.}-
Step 2: Run it.
go test ./internal/modules/expenses/ -run TestAMalaysianClaimFromReceiptToBank -count=1 -v→ PASS. -
Step 3: Tick the roadmap. Under Plan 5 — Expense claims add
Plan: [2026-09-21-plan-5-expense-claims.md](superpowers/plans/2026-09-21-plan-5-expense-claims.md) (21 tasks)and tick the four bullets, with indented notes: “OCR ships as an interface with a no-op default; the provider is the operator’s choice and must process in Malaysia (Ruling R35)” and “Virus scanning deferred (Ruling R32)”. Under Statutory compliance tick “Claim lines carry the tax record” with “— Plan 5 (Tasks 9, 11, 13)”. Touch no other line. -
Step 4: The one full run.
make lint && go test -p 2 -count=1 ./...→ every package passes. A failure outside this plan’s packages is reported with its package and test name, not fixed by editing another plan’s code. -
Step 5: Commit —
git commit -m "Prove a Malaysian claim from receipt to bank end to end; tick Plan 5".
Task 21: migrate reseal-payout-details — re-seal every stored snapshot after a key rotation
Section titled “Task 21: migrate reseal-payout-details — re-seal every stored snapshot after a key rotation”Waits for Plan 6’s merge (gate P6 — it needs Plan 6’s Tasks 1, 4, 6: ResealSnapshot, ResealProfiles, config.PayoutDetailsKeysFromEnv). Needs Tasks 9, 14, 17. Runs beside Task 18 (disjoint files); Task 20 runs after it. Numbered last only because it was added in revision (user decision, Plan 6 Ruling P32).
Plan 6 Ruling P32: a previous payout key may be removed only after every member profile and every claim snapshot sealed under it is resealed, and the operator command that does both across all organizations is Plan 5’s. Until this task ships, docs/DEPLOY_NOTES.md (Plan 6 §2) forbids dropping PAYOUT_DETAILS_KEY_PREVIOUS.
Files:
- Create:
internal/platform/expenses/reseal.go,cmd/migrate/reseal.go - Modify:
cmd/migrate/main.go(onecaseand the usage string),docs/DEPLOY_NOTES.md(a subsection in Plan 5’s section) - Test:
internal/platform/expenses/reseal_test.go,cmd/migrate/reseal_test.go
Interfaces:
-
Consumes:
payout.ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error),payout.ResealProfiles(ctx, tx, c) (int, error),payout.SettlementSnapshot,payout.NewKeyring,payout.InstallKeyring,payout.Key,payout.ErrUnknownKey;config.PayoutDetailsKeysFromEnv() (config.PayoutDetailsKeys, error)wherePayoutDetailsKeys{Active config.KeyedSecret; Previous *config.KeyedSecret}andKeyedSecret{ID string; Secret []byte}(Part B table row 10 — confirmed as built; the keyring-build pattern to copy verbatim iscmd/api/main.go:54-67); Task 9’s column grant andexpense_claim_settlement_reseal_onlytrigger;audit.Write,audit.WithOrigin;db.Open,db.InTx,tenancy.WithOrg. Tests:expensestest(Task 14’sPayoutKey,Channel,PayoutProfile),payouttest.NewKey,payouttest.UseKeyring,payout.OpenSnapshot(test-only,cmd/migrate— outside the R44 guard’s directories). -
Produces:
package expenses // internal/platform/expenses/reseal.go// ResealSettlements re-encrypts, under the active payout key, up to limit// of the calling org's settlement snapshots holding any value sealed under// another key (Ruling R45). The rows are locked in id order by one SELECT// … FOR UPDATE before the first audit.Write. A row is UPDATEd, and audited,// only when payout.ResealSnapshot reports a change. more is true when the// batch was full (the caller runs another transaction).func ResealSettlements(ctx context.Context, tx pgx.Tx, c clock.Clock, activeKeyID string, limit int) (n int, more bool, err error)// PendingReseal counts the calling org's settlements still holding a value// sealed under a key other than activeKeyID. Read-only.func PendingReseal(ctx context.Context, tx pgx.Tx, activeKeyID string) (int, error)const EntitySettlement = "expense_claim_settlement" // audit entity kind; action "reseal"package main // cmd/migrate/reseal.gotype orgReseal struct {Org uuid.UUIDProfilesResealed, SettlementsResealed intProfilesPending, SettlementsPending int}type resealReport struct{ Orgs []orgReseal }func (r resealReport) Pending() int// runReseal walks orgs in order. Per org: one transaction for// payout.ResealProfiles; then transactions of ResealSettlements(batch) until// !more; then one read-only transaction counting what remains. Every// transaction names exactly one org (the database refuses cross-org).// check=true skips the writes and only counts.func runReseal(ctx context.Context, app *db.DB, c clock.Clock, orgs []uuid.UUID,activeKeyID string, batch int, check bool) (resealReport, error)// listOrganizations reads every organization id through the migration// role, read-only; it refuses an empty result.func listOrganizations(ctx context.Context, migrateDSN string) ([]uuid.UUID, error)func resealPayoutDetails(args []string) // the subcommand -
Step 1: Write the failing platform tests —
reseal_test.go(e := expensestest.New(t); the no-op poster as Task 13; two claims paid throughe.Channel(t, "bank_transfer")withe.PayoutProfile(t, e.Staff, ch, …), so two settlements sealed under"test-1"):
func TestResealMovesEverySnapshotToTheActiveKey(t *testing.T) { // Rotate: k2 := payouttest.NewKey(t, "test-2"); // payouttest.UseKeyring(t, k2, &e.PayoutKey). // PRESENCE: PendingReseal(ctx, tx, "test-2") == 2. // ResealSettlements(ctx, tx, c, "test-2", 100) -> n 2, more false. // PendingReseal == 0. For each settlement: every fields[*].sealed.kid is // "test-2"; View() equals the View() taken before the rotation; // channel_key, account_tail, amount, credit_account_id, settled_at, txid // unchanged. Two audit rows, entity EntitySettlement, action "reseal", // Before {"key_ids":["test-1"]}, After {"key_ids":["test-2"]}; neither // row's JSON contains "6789", "ct", "nonce" or the payee name.}func TestResealIsIdempotent(t *testing.T) { // After a full reseal, ResealSettlements again -> n 0, more false, and the // audit_log count for action 'reseal' is unchanged.}func TestResealIsResumableInBatches(t *testing.T) { // limit 1: first tx -> n 1, more true (commit); second tx -> n 1, more // true; third -> n 0, more false. Exactly two reseal audit rows: an // interrupted run loses nothing and repeats nothing.}func TestAValueUnderAnUnknownKeyStopsTheBatch(t *testing.T) { // Install only k3 ("test-3"; neither test-1 nor test-2): ResealSettlements // -> errors.Is(err, payout.ErrUnknownKey); the error names the settlement // id and the key id "test-1", and nothing else from the snapshot. // Part B: payout wraps it as `payout: reseal snapshot field <key>: payout: // payout details sealed under a key this process does not hold (key id // "test-1")`, so the key id arrives on its own. The field KEY ("account_no") // rides along — a schema name, never a value; assert the message carries // no account number, payee name or base64.}func TestResealTouchesOnlyTheCallingOrg(t *testing.T) { // e2 := e.Another(t) with its own paid claim. Reseal in e's org: // PendingReseal in e2's org is still 1.}-
Step 2: Run and watch fail.
go test ./internal/platform/expenses/ -run Reseal -count=1→ FAIL (undefined). -
Step 3: Implement
reseal.go.
// Values under activeKeyID need nothing; anything else is resealed. The// jsonpath reads only the plaintext "kid" members; no value is decrypted in// SQL or here — payout.ResealSnapshot re-encrypts inside Plan 6.const pendingPredicate = `jsonb_path_exists(s.snapshot, '$.fields[*].sealed.kid ? (@ != $kid)', jsonb_build_object('kid', $2::text))`
func ResealSettlements(ctx context.Context, tx pgx.Tx, c clock.Clock, activeKeyID string, limit int) (int, bool, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return 0, false, fmt.Errorf("expenses: reseal settlements: %w", tenancy.ErrNoOrg) } if limit < 1 { return 0, false, fmt.Errorf("expenses: reseal settlements: limit %d", limit) } rows, err := tx.Query(ctx, `SELECT s.id, s.snapshot FROM expense_claim_settlement s WHERE s.org_id = $1 AND `+pendingPredicate+` ORDER BY s.id LIMIT $3 FOR UPDATE`, orgID, activeKeyID, limit) if err != nil { return 0, false, fmt.Errorf("expenses: reseal settlements: %w", err) } type row struct { id uuid.UUID raw json.RawMessage } var all []row for rows.Next() { var r row if err := rows.Scan(&r.id, &r.raw); err != nil { rows.Close() return 0, false, fmt.Errorf("expenses: reseal settlements: %w", err) } all = append(all, r) } rows.Close() if err := rows.Err(); err != nil { return 0, false, fmt.Errorf("expenses: reseal settlements: %w", err) } type keyIDs struct { KeyIDs []string `json:"key_ids"` } n := 0 for _, r := range all { // every lock is held; audit rows follow (wave-2 §3) before, err := sealedKeyIDs(r.raw) if err != nil { return n, false, fmt.Errorf("expenses: reseal settlement %s: %w", r.id, err) } next, changed, err := payout.ResealSnapshot(r.raw) if err != nil { return n, false, fmt.Errorf("expenses: reseal settlement %s (sealed under %v): %w", r.id, before, err) } if !changed { continue } if _, err := tx.Exec(ctx, `UPDATE expense_claim_settlement SET snapshot = $1 WHERE org_id = $2 AND id = $3`, next, orgID, r.id); err != nil { return n, false, fmt.Errorf("expenses: reseal settlement %s: %w", r.id, err) } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySettlement, EntityID: r.id, Action: "reseal", Before: keyIDs{before}, After: keyIDs{[]string{activeKeyID}}}); err != nil { return n, false, err } n++ } return n, len(all) == limit, nil}
// sealedKeyIDs lists the distinct key ids of raw's sealed values, sorted.// It reads the plaintext "kid" members only (json.Unmarshal into// payout.SettlementSnapshot; no key needed).func sealedKeyIDs(raw json.RawMessage) ([]string, error)PendingReseal: SELECT count(*) FROM expense_claim_settlement s WHERE s.org_id = $1 AND + pendingPredicate (with $2 = activeKeyID).
-
Step 4: Run.
go test ./internal/platform/expenses/ -run Reseal -count=1→ PASS. Sabotage: drop both the pending predicate from the SELECT and theif !changed { continue }guard →TestResealIsIdempotentred (a second run writes two more audit rows); quote; restore. -
Step 5: Commit —
git commit -m "Reseal claim settlement snapshots under the active payout key". -
Step 6: Write the failing command tests —
cmd/migrate/reseal_test.go(e := expensestest.New(t),e2 := e.Another(t); one paid claim in each org and one saved payout profile in each;app, _ := db.Open(ctx, e.H.AppDSN, eventbus.New())):
func TestResealCommandWalksEveryOrgOneTransactionEach(t *testing.T) { // Rotate: k2 := payouttest.NewKey(t, "test-2"); UseKeyring(t, k2, &e.PayoutKey). // PRESENCE: runReseal(…, check=true) -> Pending() == 4 (per org: 1 // profile, 1 settlement); nothing written (audit count unchanged). // runReseal(…, check=false, batch=1) -> each org: ProfilesResealed 1, // SettlementsResealed 1, both Pending 0; report.Pending() == 0. // Under k2 ALONE (UseKeyring(t, k2, nil)): payout.OpenSnapshot on each // stored snapshot recovers the original account number — the reseal kept // the record (this test is outside the R44 guard's directories).}func TestResealCommandIsIdempotentAndResumable(t *testing.T) { // Interrupt: in e's org, run expenses.ResealSettlements(limit 1) alone and // commit. Then runReseal over both orgs: totals add up to one reseal per // settlement and per profile — audit rows 'reseal' == 4 overall. Run it // again: every count 0, no new audit rows.}func TestResealCommandStopsAtTheFirstUnreadableOrg(t *testing.T) { // Only k3 installed: runReseal -> error naming e.Org and wrapping // payout.ErrUnknownKey; e2's rows untouched (its Pending unchanged).}func TestListingOrganizationsRefusesAnEmptyAnswer(t *testing.T) { // PRESENCE: listOrganizations(ctx, e.H.AdminDSN) contains e.Org and e2.Org. // With the app-role DSN (RLS, no tenant) -> error "no organizations // visible", never an empty success.}-
Step 7: Run and watch fail.
go test ./cmd/migrate/ -run Reseal -count=1→ FAIL (undefined). -
Step 8: Implement
cmd/migrate/reseal.go.resealPayoutDetails(args):- Flags:
--org <uuid>(optional: one org),--check(count only),--batch(default 500, 1–5000). - Keys:
keys, err := config.PayoutDetailsKeysFromEnv()(fatal on error — the same refusalscmd/apihas); buildpayout.NewKeyring(payout.Key{ID: keys.Active.ID, Secret: keys.Active.Secret}, previous)exactly ascmd/api/main.go:54-67does (Part B table row 10);payout.InstallKeyring. Plan 6 P30 says this binary holds no key by default; this subcommand is the one that installs one. - Orgs:
--orggiven → that one. OtherwiselistOrganizations(ctx, os.Getenv("DATABASE_MIGRATE_URL")):SELECT id FROM organization ORDER BY idover a pgx connection closed before any write; it reads ids only; zero rows is an error (“no organizations visible to DATABASE_MIGRATE_URL; it must be the migration role, which is not subject to row-level security”) so a mis-set role can never report success over nothing. - Writes:
app := db.Open(ctx, os.Getenv("DATABASE_URL"), nil)— the application role, under RLS, likeprovisionandverify-audit.ctx = audit.WithOrigin(ctx, audit.Origin{RequestID: "migrate-reseal-payout-details"}). runReseal: per org,txCtx := tenancy.WithOrg(ctx, org);app.InTx(txCtx, …payout.ResealProfiles…)— confirmed unbatched as built (oneSELECT … FOR UPDATEover every profile of the org under a non-active kid, no limit), so it is one transaction per org, exactly as written here; loopapp.InTx(txCtx, …expenses.ResealSettlements(…, keys.Active.ID, batch)…)whilemore; thenapp.InTx(txCtx, …)countingexpenses.PendingResealand profiles still under another kid (SELECT count(*) FROM member_payout_profile WHERE org_id = $1 AND details_kid <> $2— the column Plan 6’s deploy note §2 step 3 documents; read-only). Stop at the first error, wrapped with the org id.- Print one line per org —
org <id>: profiles resealed N (pending P), settlements resealed M (pending Q)— thendone: nothing remains under a previous keyorPENDING: …; exit 1 whenreport.Pending() > 0or on error. Never print a value, a snapshot or ciphertext.cmd/migrate/main.go:case "reseal-payout-details": resealPayoutDetails(os.Args[2:]); return, and append| migrate reseal-payout-details [--org <uuid>] [--check] [--batch N]tousage.
- Flags:
-
Step 9: Run.
go test ./cmd/migrate/ ./internal/platform/expenses/ -count=1→ PASS (includingwiring_test.go). -
Step 10: Deploy note. In
docs/DEPLOY_NOTES.md, inside the “Plan 5 — expense claims and documents” section (which sits directly above Plan 6’s), add:
### Rotating the payout-details key: the reseal command (completes Plan 6 §2)
Plan 6 §2 step 2 needs every member profile **and every claim settlement snapshot** re-encryptedunder the new key before the old one is removed. This release ships the command that does both:
```sh# 1. After deploying PAYOUT_DETAILS_KEY=<new kid>:… and PAYOUT_DETAILS_KEY_PREVIOUS=<old kid>:…# (same environment as the api container, plus DATABASE_MIGRATE_URL):migrate reseal-payout-details # every organization, one transaction per org per batch# 2. Confirm — exits 0 only when nothing remains under any key but the active one:migrate reseal-payout-details --check```
It is safe to interrupt and re-run: finished batches are committed, and a snapshot already under theactive key is never rewritten or re-audited. Each resealed snapshot writes one audit row(`expense_claim_settlement` / `reseal`) naming key ids only. A reseal changes only ciphertext; thedatabase refuses any other change to a settlement (Ruling R45).
The claims equivalent of Plan 6 §2 step 3's check, as the migration role:`SELECT count(*) FROM expense_claim_settlement WHERE jsonb_path_exists(snapshot, '$.fields[*].sealed.kid ? (@ == "<old kid>")');` → `0`.
**Never drop the previous key until this has run** and `--check` exits 0. Claim snapshots arestatutory records kept seven years; a value sealed under a dropped key is unreadable forever.- Step 11: Commit —
git add internal/platform/expenses/reseal.go internal/platform/expenses/reseal_test.go cmd/migrate/reseal.go cmd/migrate/reseal_test.go cmd/migrate/main.go docs/DEPLOY_NOTES.md && git commit -m "Add migrate reseal-payout-details: reseal profiles and claim snapshots across every org".
Parallel groups
Section titled “Parallel groups”Tasks in one wave touch disjoint files and may run in parallel; waves run in order. “Needs” names the tasks whose output a task consumes. P3 = waits for Plan 3’s merge and Task 1 Part A. P6 = waits for Plan 6’s merge into this branch’s base (wave-2 §5: Plan 6 merges before Plan 5 — Tasks 14, 16, 21 use Plan 6’s Tasks 1, 4, 5, 6 and 9, not only Task 1) and Task 1 Part B. Part B itself may run as soon as Plan 6’s Task 1 is visible.
| Wave | Tasks | Gate | Needs | Why they are disjoint |
|---|---|---|---|---|
| 1 | 2 config · 3 migration 00070 + constraint guard | — | — | 2: kernel/config, .env.example, DEPLOY_NOTES.md. 3: the only migration/retention/sqlc toucher in the wave. |
| 2 | 4 store seam · 7 migration 00071 + categories + step 80 | — | 4: 2 · 7: 3 | 4: documents/store.go, s3store/, documentstest/, go.mod. 7: migration, retention, sqlc, expenses/{category,step,seed_my,errors,text}.go (7 does not touch go.mod). |
| 3 | 5 documents · 8 rates · 11 line rules (pure) | — | 5: 3, 4 · 8: 7 · 11: 7 | 5: documents/*.go + go.mod (x/image). 8: expenses/rate.go. 11: expenses/linerules.go. |
| 4 | 6 OCR · 1A reconcile with Plan 3 | 1A: P3 | 6: 5, 7 | 6: documents/ocr/, documents/suggestion.go. 1A: this plan document only. |
| 5 | 9 migrations 00072–73 (incl. the sealed-snapshot CHECKs and the reseal grant/trigger) + test world | P3 | 1A, 3, 7, 8 | Migrations, retention, sqlc, expensestest/. Alone among migration tasks. Needs no Plan 6 code: its snapshot tests use hand-written JSON. |
| 6 | 10 groups + claims | P3 | 9 | expenses/{group,claim,visibility,cursor}.go. |
| 7 | 12 lines + attachments | P3 | 5, 8, 10, 11 | expenses/{line,attachment}.go. |
| 8 | 13 submission + transitions | P3 | 12 | expenses/{submission,transition}.go, interim settlement.go. |
| 9 | 15 accrual rule + reconciliation · 1B reconcile with Plan 6 | 15: P3 · 1B: Plan 6 Task 1 | 15: 13 | 15: expenses/{posting,reconcile}.go. 1B: this plan document only. |
| 10 | 14 sealed settlement + masked view + no-open guard | P6 | 1B, 13 | expenses/{settlement,errors,nosnapshotopen_test}.go, expensestest/env.go (keyring, Channel, SeedChannel, PayoutProfile). |
| 11 | 16 payout rule (petty-cash refusal, purge test) | P6 | 14, 15 | expenses/posting.go (append), payout_test.go. |
| 12 | 17 module composition, reference + document routes | P3, P6 | 6, 7, 8, 16 | Every shared file (app.go, cmd/*, constants.yaml, openapi.yaml); runs alone. |
| 13 | 18 claim routes · 21 reseal command | 18: P3 · 21: P6 | 18: 10–14, 17 · 21: 9, 14, 17 | 18: openapi.yaml, modules/expenses/{claim,line,group}_handlers.go, routes.go, errors.go. 21: expenses/reseal.go, cmd/migrate/{reseal,main}.go, docs/DEPLOY_NOTES.md. No shared file. |
| 14 | 19 transition route | P3, P6 | 14, 16, 18 | openapi.yaml + module + expenses/idempotency.go; alone. |
| 15 | 20 end to end + roadmap + full run | all | all (1–19, 21) | Alone. |
Waits for Plan 3’s merge: 1A, 9, 10, 12, 13, 15, 17, 18, 19, 20, 21. Waits for Plan 6 (P6): 1B (Task 1 only), 14, 16, 17 (boot registers the payout rule), 19, 20, 21. Needs neither: 2, 3, 4, 5, 6, 7, 8, 11.
Critical path: 3 → 7 → 8 → (P3) 9 → 10 → 12 → 13 → 15 → (P6) 14 → 16 → 17 → 18 → 19 → 20 (Task 21 runs beside 18). Task 21 is numbered last only because it was added in revision.
Notes for later plans
Section titled “Notes for later plans”Binding hand-offs recorded by this plan’s rulings. Each later plan’s author must honour them or amend the ruling with the user.
- Plan 8 (petty cash), Ruling R24 — user decision via Plan 6 P6. Plan 5 refuses petty-cash payouts in two places until Plan 8:
settle’sErrChannelPettyCashcheck (internal/platform/expenses/settlement.go) andPayoutRule.Build’spetty_cashbranch returningErrPettyCashDeferred(internal/platform/expenses/posting.go). Plan 8 removes the first and turns the second into a credit to the paying holder’s float account — neverChannel.CreditAccountID. The debit side (AP control at the accrual’s frozen base) andSettlementInThisTxstay as they are. - Any plan that adds a payment file or finance export, Ruling R44. Plan 5 never calls
payout.OpenSnapshot;TestPlan5NeverOpensASnapshotguardsinternal/platform/expensesandinternal/modules/expenses. A path that must show a raw account number to the person making the transfer belongs in its own package, behind its own permission, with an audit row naming who revealed which settlement (never the value) — and it must be decided with the user first (see Needs the user’s decision). - Every future payout-key rotation, Ruling R45. Run
migrate reseal-payout-details, then--check, before removingPAYOUT_DETAILS_KEY_PREVIOUS. A plan adding another table that stores sealed payout data must add it to that command.
Self-Review Notes
Section titled “Self-Review Notes”Spec coverage.
- ROADMAP “Claim header + lines, four line types (§6.1, §6.2)” → Tasks 9, 10, 11, 12 (advance_offset in the schema and enum, refused until Plan 7 — R11).
- “Date-effective rates, never edited in place (§6.2)” → Tasks 7 (append-only table), 8 (resolution), 12 (frozen
rate_id). - “Claim groups and references (§20.1.3)” → Task 10 (numbering in the creating tx, gap test).
- “Documents: presigned upload, thumbnails, OCR as suggestion only (§6.6)” → Tasks 3–6, 17; content-hash verification (wave-2 §4) → Tasks 4, 5; residency → Task 2 (config, DEPLOY_NOTES), R34; OCR provider left to the user → R35.
- Statutory “Claim lines carry the tax record” (§6.7, §11A.6) → Task 9 (columns + named CHECKs), 11 (rules), 12 (category policy at save), 13 (completeness at submission).
- §6.3 seeded flow → consumed, not built (Plan 3); verified in Task 1A and exercised in 13–16, 19–20. §6.4 posting boundary → R16 triggers (Task 9) and Task 15’s rejection test. §6.5 money/FX → R5, Task 12’s frozen-base test. §5.4 “claimant never picks approver” → R20, no approver input anywhere. §9.4 settlement snapshot → Tasks 14, 16; its three mitigations: validate in one place (Plan 6’s
Snapshot), the required-keys CHECK and the two generated columns (channel_key,account_tail) onexpense_claim_settlement(Task 9, R25). §6.5 FX gain/loss on settlement → none arises: claims reimburse in base (R5). §10.3 rules → Tasks 15–16; reversal is Plan 4’s (no rule). §10.6 AP/orphans → Task 15 (R27). §11A.3 → R15. §11A.4/11A.7 traceability → Task 15’s traceability test, Task 20 step 9. §12.2 Idempotency-Key, If-Match, cursors, server-computed transitions → Tasks 18–19. §20.2 step 6 (categories) → Task 7 (step 80). - Wave-2 §3 lessons: named constraints (Task 3 guard), pinned functions (Task 9 functions), no SET CONSTRAINTS (Global Constraints), commit-time errors (Task 19’s
ledger.MapError+ sabotage), one org per tx (Global Constraints), transitions holdlockInstance(R22 — onlyworkflow.Transitiondispatches), bounded concurrency tests (Tasks 5, 12, 13, 19), lock order (R22, Task 13’s lock test). - Plan 6 as revised (user decisions): sealed snapshot stored verbatim, masked-only API, never opened by Plan 5 → R44, Tasks 9 (CHECKs), 14 (View, guard, keyring), 18, 19 (seal errors → 500). Key rotation → R45, Task 21 (command, column grant + reseal-only trigger in Task 9, DEPLOY_NOTES). Petty cash deferred to Plan 8 by typed refusal → R24, Tasks 14, 16, Notes for later plans. Purge on deactivation → R46, Task 16’s purge test, Task 20 step 12; confirmed: no flow re-reads a live profile after the settling transaction.
- Not covered, by ruling: virus scanning (R32), partial rejection (R18), petty-cash payout (R24 — refused until Plan 8, which owns the float credit), foreign-currency reimbursement (R5).
Placeholder scan. No TBD/TODO. Two deliberate deferrals are staged and tested, not left open: Task 13’s interim settle (replaced whole by Task 14, with its test deleted there), and Task 1’s reconciliation tables (the plan names every symbol it will check). Tasks 17–18 point at existing files (modules/accounting/http.go, chart_handlers_test.go) as the pattern to copy — real code, not unwritten code.
Type consistency. Revision pass: Plan 3’s plan doc (Task 7 contract) matches Task 1A’s table — Start/Get/GetBySource/SetAmount/Transition/AvailableTransitions/Decisions/Load/Latest/Route, Instance, Decision{Actor, AuthorityMember, Via, …}, Available, the seven errors; def.State(key) works because Definition embeds Graph. Plan 4’s merged code was checked symbol by symbol and three slips were fixed: money.Amount compared with Cmp, not != (Tasks 15, 16); the accrual’s credit line found with Credit.IsPositive() after ledger.Get (ledger.Unreversed returns headers only); chart.Journal.DefaultAccountID is a pointer. Task 19’s commit-time test was rebuilt: ledger.Post runs analytic.Plan synchronously, so the “required axis after submission” case is an immediate *analytic.RequiredError (its own 422 test) and the deferred path is forced with a test-only deferred constraint trigger raising analytic_distribution_required. i18n.Load returns one request-locale label, so category Names read translation directly (Task 7). Module tests call e.UsePoster after expensestest.New, whose posting.ResetForTest wipes the init-registered Poster (Tasks 19, 20). Plan 6’s revised plan: Snapshot, SettlementSnapshot JSON member names (channel.key, channel.settlement_behaviour, account_tail, fields[].sensitivity/value/sealed/display), ResealSnapshot, ResealProfiles(ctx, tx, c) (int, error), PurgeMemberProfiles(ctx, tx, c, memberID) error, BehaviourPettyCash, payouttest.NewKey/UseKeyring/ChannelRow, and IneligibleError/InvalidDetailsError/sentinels are used with those exact names in Tasks 1B, 9, 14, 16, 19, 21. LineError{LineID, Field, Rule, Detail} is defined once (Task 7/11) and used by 11, 12, 13, 18. Claim.Semantic is posting.Semantic everywhere. Settlement is widened in Task 14 from Task 13’s interim struct; SettlementInThisTx (14) is what PayoutRule (16) reads. Rules() returns AccrualRule{} after Task 15 and AccrualRule{}, PayoutRule{} after Task 16; Task 17’s boot test asserts both. Kind == workflow.KindExpenseClaim is pinned by a test in Task 9. TransitionInput (Task 13) carries PayoutChannelID/ProofDocumentID, consumed in Task 14 and filled from the HTTP body in Task 19. Settlement.View()/SettlementView/SettlementFieldView (14) are what Task 18 serializes. ResealSettlements/PendingReseal/EntitySettlement (21, platform) are what runReseal (21, cmd) calls.
Needs the user’s decision.
- How the payer learns the full account number. Plan 6 masks every raw value everywhere (P12) and Plan 5 never opens the snapshot (R44), and the snapshot is only frozen in the transaction that records the payment. So nobody can read the claimant’s full account number from the system before transferring money. Options: (a) a permissioned, audited “reveal for payment” step: freeze the snapshot at a pre-payment state and open it for a payer role (the only
OpenSnapshotcaller, in its own package); (b) a bank bulk-payment file export (a later plan); (c) the payer gets the details outside the system. Plan 5 as written does (c) by default. - A departed member’s approved but unpaid claim. After the purge (Plan 6 P33) a new settlement calls
Snapshot, which refusesErrNoProfile, and every seeded MY channel has at least a payee-name field. So such a claim cannot be paid (R46). Options: (a) delay the purge until the member’s claims are settled; (b) let the payer enter payout details at settlement; (c) accept it and pay outside the system. - Resealing rewrites the settlement row’s ciphertext (R45). The table stays append-only for every reader-visible member, the change is audited, and the DB refuses anything else. Confirm this is acceptable for the audit-compliance constraint, or choose a separate snapshot-ciphertext table instead.
Reconciliation record (filled by Task 1)
Section titled “Reconciliation record (filled by Task 1)”Part A — Plan 3 as built
Section titled “Part A — Plan 3 as built”Checked against the merged tree at base 27b3368 (internal/platform/workflow/**,
db/migrations/00030_workflow_definition.sql, db/migrations/00033_workflow_instance.sql).
Every line reference below is to that tree. Verdict: every symbol Plan 5 assumes exists
with the planned name and signature — no renames, no re-types, nothing missing. Five
facts the plan did not know are recorded under “Additional facts” and three later-task
edits followed from them.
| # | Symbol as planned | As built | Verdict | Evidence |
|---|---|---|---|---|
| 1 | workflow.KindExpenseClaim = "expense_claim" |
const KindExpenseClaim = "expense_claim" |
identical | internal/platform/workflow/seed.go:18 |
| 2 | workflow.Start(ctx, tx, c, StartInput{Kind, SourceID, Reporter, Amount}) (Instance, error); currency = base |
func Start(ctx context.Context, tx pgx.Tx, c clock.Clock, in StartInput) (Instance, error); StartInput{Kind, SourceID, Reporter, Amount}; currency is company.Get(...).BaseCurrency; starts in the definition’s draft-semantic state; writes its own audit.Write (workflow_instance/start) |
identical | instance.go:159, :66-71, :171-179, :180,190, :201-204 |
| 3 | workflow.Get(ctx, tx, id), workflow.GetBySource(ctx, tx, kind, sourceID) → (Instance, error) |
exactly these; both return ErrNoInstance on no rows |
identical | instance.go:133, :141, :123-125 |
| 4 | workflow.SetAmount(ctx, tx, c, instanceID, amount) — draft-only (ErrNotDraft), own audit row, takes the instance lock |
func SetAmount(ctx, tx, c, id uuid.UUID, amount money.Amount) error; lockInstance first, ErrNotDraft when the current state’s semantic is not posting.Draft, audit.Write (workflow_instance/amount) last |
identical | instance.go:211, :219, :227-229, :234-235 |
| 5 | workflow.Transition(ctx, tx, c, TransitionInput{InstanceID, From, To, Actor, Reason}) (Decision, error); holds lockInstance for the whole call; dispatches posting before its audit row |
exactly these; lockInstance is step 1, posting.Dispatch is called after the decision INSERT and before audit.Write |
identical | instance.go:299, :73-78, :304, :385-395, :397-403, :405-411 |
| 6 | workflow.AvailableTransitions(ctx, tx, c, instanceID, actor) ([]Available, error); Available{To, ActionKey, Gate, RequiresReason, Allowed, Labels, Refusal} |
func AvailableTransitions(ctx, tx, c clock.Clock, id, actor uuid.UUID) ([]Available, error); struct fields exactly as listed (Refusal *Refusal, JSON to/action_key/gate/requires_reason/allowed/labels/refusal) |
identical | instance.go:418, :99-107 |
| 7 | workflow.Decisions(ctx, tx, instanceID) ([]Decision, error); Decision.Actor, .AuthorityMember, .Eligible |
exactly these; ordered by seq, oldest first; Actor/AuthorityMember are uuid.UUID, Eligible []uuid.UUID |
identical | instance.go:466, :80-97, :476 |
| 8 | workflow.Load(ctx, tx, definitionID), Definition.State(key) → StateSpec{Key, Semantic posting.Semantic, ControlKind, …} |
func Load(ctx, tx, id uuid.UUID) (Definition, error); Definition embeds Graph, so def.State(key) resolves to func (g Graph) State(key string) (StateSpec, bool) — two return values; StateSpec{Key, Semantic posting.Semantic, ControlKind, DisplayOrder, IsTerminal, EscalateAfterDays, Labels} |
identical (two-value form; Task 13 Step 3 already writes from, _ := def.State(...)) |
store.go:430, model.go:228-237, model.go:145-152, model.go:76-84 |
| 9 | workflow.Instance{ID, DefinitionID, Kind, Version, SourceID, Reporter, StateKey, Amount, Currency, AccrualOpen, EnteredStateAt, EnteredStateOn} |
all twelve present with those names and types; one extra field CreatedAt time.Time. Version is the pinned definition’s version, read through the join on workflow_definition |
identical (superset) | instance.go:50-64, :111-116 |
| 10 | Errors ErrNoInstance, ErrStaleState, ErrNoSuchEdge, ErrReasonRequired, ErrNotDraft, *RefusedError{Refusal}, *SubmissionRefusedError{Steps} |
all seven exist with those names. RefusedError{Refusal Refusal} where Refusal{Code, Detail, Gaps}; SubmissionRefusedError{Steps []StepRouting} where StepRouting{State, Resolution, WinningFloor, Eligible, Gaps, Routable} |
identical | instance.go:22-30, :34-38, :40-48, authorize.go:41-45, authorize.go:101-108 |
| 11 | workflow_instance(org_id, id, definition_id, kind, source_id, reporter_member_id, state_key, amount, currency, accrual_open, entered_state_at, …) with UNIQUE (org_id, id) |
every column present with those exact names (plus entered_state_on DATE, created_at); UNIQUE (org_id, id) present — Ruling R40’s composite-FK target is real. Also UNIQUE (org_id, kind, source_id). erp_app has SELECT/INSERT/UPDATE, no DELETE |
identical | db/migrations/00033_workflow_instance.sql:14-38, :31, :30, :115-116 |
| 12 | workflow_state(org_id, definition_id, state_key, semantic, control_kind, …) |
exactly these (plus id, display_order, is_terminal, escalate_after_days); UNIQUE (org_id, definition_id, state_key); erp_app has SELECT/INSERT only, so Plan 5’s join is read-only by grant as well as by rule |
identical | db/migrations/00030_workflow_definition.sql:29-46, :141-142 |
| 13 | Seeded flow v1: draft(draft) → pending_review,reviewed,approved(pending) → category_verified(accrual, control ap) → completed(payout) → received(acknowledged); rejected(rejected) → draft; disputed(dispute) → received/rejected; edge draft→pending_review action submit |
exactly this graph, nine states and fifteen edges. Gates as built: submit reporter, review/approve/verify_category/pay target, acknowledge reporter, the four reject edges advance (the one from category_verified sets ReversesAccrual), revise reporter, dispute reporter (from completed and from received), dismiss/uphold source (uphold reverses) |
identical | seed.go:79-106 |
| 14 | Seeded grants: reviewed ← superior, approved ← unit_head, category_verified/completed/disputed ← position OWNER, floors NULL |
five grants, exactly those states and grantee kinds; MinAmount unset on all five (floors NULL). Nuance: the three position grants name ownerSeatOf(...) — the organization’s active root position, preferring the one coded OWNER and otherwise the lowest code — not literally the code OWNER |
identical (with the ownerSeatOf nuance) |
seed.go:107-113, :129-155 |
| 15 | Provisioning step 60 workflow_definitions, registered from the package’s init() |
StepKey = "workflow_definitions", StepOrder = 60, func init() { provisioning.Register(definitionsStep{}) }; SeedDefaults publishes v1 only for a kind with no version, and needs an active root seat (ErrNoOwnerSeat) |
identical | seed.go:19-20, :29-30, :37, :160-194 |
| 16 | posting.Dispatch called only from Transition; accrual_open passed as PastAccrual |
instance.go:397 is the only non-test caller of posting.Dispatch in the tree; pastAccrual := inst.AccrualOpen is read before the state UPDATE and passed as Event.PastAccrual; accrual_open is set true on an accrual destination and false on rejected/cancelled |
identical | instance.go:347-354, :397-401; repo-wide grep for posting.Dispatch |
Additional facts the plan did not record (no symbol changed)
Section titled “Additional facts the plan did not record (no symbol changed)”- A.
Transition’s caller constraint (lock order).instance.go:288-298states it explicitly: because every transition is dispatched to the Poster, and the audit chain’s advisory lock must be the last lock a transaction takes, the caller must not write anyaudit.Write— including the ones insideworkflow.Startandworkflow.SetAmount— beforeworkflow.Transitionin the same transaction. Production paths already comply (Ruling R22 orders the claim’s own audit rows afterTransition, andCreateClaim/AddLineare separate requests), but a test that starts or re-prices an instance and then transitions it in one transaction breaks the order. Recorded in Tasks 9, 10 and 13 (see edits below). - B.
isSubmissionis narrower than “leaving draft”. The engine runs its routability check (and therefore Plan 5’sCheckSubmissionshould match it) only when the move leaves adraft-semantic state for a destination that is neither draft nor reversing (rejected/cancelled):instance.go:261-263. In the seeded v1 flowdrafthas exactly one exit (pending_review), so nothing diverges today; Task 13’s condition is aligned anyway. - C. Two more exported errors exist beyond the seven the plan lists:
ErrInstanceExists(instance.go:23, raised byStarton theUNIQUE (org_id, kind, source_id)index) andErrNegativeAmount(instance.go:28, raised byStart/SetAmount). Neither is reachable from Plan 5 —CreateClaimstarts against a freshly generateduuid, and a claim total is a sum of positive line amounts — so Task 19 maps neither and an escape would surface as 500, which is the right answer for a Plan 5 bug. - D. Seeded state and edge labels carry
enandzhonly (seed.go:39,ls(en, zh)); there is nomslabel on a workflow state or action. Task 18 serializesAvailable.Labelsverbatim, so the response simply has two locales. Nothing to change; noted so nobody writes a test assertingms. - E.
workflow.Route’s signature isRoute(ctx, tx, def Definition, reporter uuid.UUID, amount money.Amount, on time.Time) ([]StepRouting, error)(authorize.go:114) — it takes a loadedDefinition, not a kind. Plan 5 mentions it only in Task 9’sTestTheWorldIsProvisionedprose; that test mustworkflow.Latest(ctx, tx, workflow.KindExpenseClaim)first.Latest(ctx, tx, kind) (Definition, error)is atstore.go:422. - F.
workflowtest.World(workflowtest/fixture.go:33,44) is Plan 3’s own fixture and provisions nothing Plan 5 needs; Plan 5’sexpensestestbuilds onaccountingtestinstead (Task 9). No change.
Later-task edits made by Part A
Section titled “Later-task edits made by Part A”| Task | Edit | Why |
|---|---|---|
| 9, Step 5 | Added the note that an instance must be started in a different transaction from the workflow.Transition the test then performs. |
Fact A |
10, TestAHeaderIsFrozenOnceSubmitted |
Same note, naming SetAmount and Transition explicitly. |
Fact A |
| 13, Step 3 item 3 | The CheckSubmission trigger now excludes reversing destinations, matching workflow.isSubmission; added the caller-constraint reminder to the Interfaces block. |
Facts A and B |
9, TestTheWorldIsProvisioned |
workflow.Route is now shown taking the definition from workflow.Latest. |
Fact E |
No symbol was missing, so no new Ruling was needed (Step A3’s fallback was not used). No Go code was touched.
Part B — Plan 6’s payout seam as built
Section titled “Part B — Plan 6’s payout seam as built”Checked against the merged tree at base 89372ea (internal/platform/payout/**,
internal/platform/countrypack/my/payout.go, internal/platform/identity/member/deactivation.go,
internal/kernel/config/config.go, internal/modules/payout/**, db/migrations/00090_payout.sql,
cmd/api/main.go). Verdict: every symbol Plan 5 assumes exists with the planned name and
signature — no renames, no re-types, nothing missing. Seven numbered deviations below concern
what the code does (reachability and semantics), not what it is called; each adjusts a later
brief without weakening a ruling.
| # | Symbol as planned | As built | Verdict | Evidence |
|---|---|---|---|---|
| 1 | payout.Channel{ID, Key, SettlementBehaviour, CreditAccountID, AllowedCurrencies, AllowedClaimTypes, MaxAmount *money.Amount, RequiresSettlementProof, IsActive} |
exactly these nine fields, those types, no others (the comment forbids adding any; extras live on Definition) |
identical | channel.go:32-42 |
| 2 | payout.GetChannel(ctx, tx, id) (Channel, error), calling org only |
func GetChannel(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Channel, error); delegates to GetDefinition, which reads tenancy.OrgFromContext and filters c.org_id = $2; unknown and foreign ids are both ErrChannelNotFound; active or not; takes no lock |
identical | channel.go:90-96, :99-113 |
| 3 | payout.Snapshot(ctx, tx, channelID, memberID, claimType, amount, currency) (json.RawMessage, error), returned sealed |
func Snapshot(ctx context.Context, tx pgx.Tx, channelID, memberID uuid.UUID, claimType string, amount money.Amount, currency string) (json.RawMessage, error); masked fields carry Sealed and no Value; unmasked carry Value; takes no lock and writes nothing, so Plan 5 may call it anywhere in its transaction (Ruling R22’s order is safe) |
identical | snapshot.go:78-175, :66-77 |
| 4 | payout.SettlementSnapshot, SnapshotChannel, SnapshotField, Names, SnapshotVersion; JSON version, channel{key,name,…}, account_tail, fields[{key,type,label,sensitivity,value,sealed,display}] |
all five exist. SnapshotVersion = 1. SettlementSnapshot{Version, Channel, MemberID, ProfileID *uuid.UUID, ClaimType, Amount money.Amount, Currency, Fields, AccountTail *string} — five members the plan did not list (member_id, profile_id, claim_type, amount, currency), all additive. SnapshotChannel{ID, Key, Name, Names, SettlementBehaviour, CreditAccountID, CreditAccountCode, RequiresSettlementProof}. SnapshotField additionally carries registry,omitempty. Names = map[string]string. account_tail has no omitempty, so it is always present (JSON null when absent) — Task 9’s generated account_tail column is well defined on every row |
identical (superset) | snapshot.go:19, :24-37, :39-48, :55-64, names.go:14 |
| 5 | payout.OpenSnapshot(raw) (SettlementSnapshot, error) — exists, Plan 5 never calls it |
exactly this signature; fills Value, clears Sealed. No non-test caller anywhere; Plan 5’s guard (Task 14) stands |
identical | snapshot.go:183-200 |
| 6 | payout.ResealSnapshot(raw) (json.RawMessage, bool, error) |
exactly this; returns raw untouched with false when every sealed value is already under the active key; re-marshals otherwise. Fails parseSnapshot on a version other than 1 |
identical | snapshot.go:206-233, :235-244 |
| 7 | payout.ResealProfiles(ctx, tx, c) (int, error), calling org only |
func ResealProfiles(ctx context.Context, tx pgx.Tx, c clock.Clock) (int, error); one SELECT … WHERE org_id = $1 AND details_kid <> $2 ORDER BY p.id FOR UPDATE, no limit (one transaction per org, as Task 21 plans); audits member_payout_profile/reseal with {"key_id": …} before/after — key ids only |
identical | profile.go:296-347 |
| 8 | payout.ErrNoKeyring, ErrUnknownKey, ErrSealBroken |
all three, with those names and meanings. ErrUnknownKey is wrapped as %w (key id %q), so the key id reaches Task 21’s message without any value |
identical | errors.go:30-39, seal.go:110-112 |
| 9 | payout.BehaviourPettyCash = "petty_cash" |
const BehaviourPettyCash = "petty_cash" (with BehaviourExternal = "external" and SettlementBehaviours); the DB repeats the closed set in payout_channel_behaviour_valid |
identical | channel.go:17-22, 00090_payout.sql:64 |
| 10 | payout.NewKeyring, InstallKeyring, Key{ID, Secret}; config.PayoutDetailsKeysFromEnv |
Key{ID string; Secret []byte}; NewKeyring(active Key, previous *Key) (*Keyring, error); InstallKeyring(k *Keyring) *Keyring (process-wide, returns the one it replaced). config.PayoutDetailsKeysFromEnv() (PayoutDetailsKeys, error) with PayoutDetailsKeys{Active KeyedSecret; Previous *KeyedSecret} and KeyedSecret{ID string; Secret []byte} — exactly Task 21’s assumption. The build pattern Task 21 copies is cmd/api/main.go:54-67 |
identical | seal.go:17-21, :34, :72, config.go:268-314 |
| 11 | payouttest.NewKey, UseKeyring, ChannelRow, BankFields; payout.SealDetails, payout.PurgeMemberProfiles |
NewKey(t, id string) payout.Key; UseKeyring(t, active payout.Key, previous *payout.Key) *payout.Keyring (installs, restores at Cleanup); ChannelRow{Key, Name, Behaviour, CreditAccountID, Fields, Currencies, ClaimTypes, MaxAmount string, RequiresProof, Inactive}; BankFields() []payout.Field = payee_name/bank/account_no; SealDetails(orgID, memberID, channelID uuid.UUID, details map[string]string) (Sealed, error); PurgeMemberProfiles(ctx, tx, c, memberID) error. payouttest.Env embeds *accountingtest.Env (a pointer) — Part B’s prediction holds: Task 14 copies the two admin INSERTs into expensestest rather than calling them |
identical | payouttest/env.go:32-174, seal.go:162-169, purge.go:35 |
| 12 | Provisioning steps 90–91 from internal/platform/payout’s init(), seeds from countrypack/my |
StepRegistries = "payout_registries" / OrderRegistries = 90, StepChannels = "payout_channels" / OrderChannels = 91, both provisioning.Registered in init(); countrypack/my’s init() calls payout.RegisterCountrySeeds("MY", …); internal/modules/payout/boot.go blank-imports both. The channels step’s Check skips inactive channels, so a freshly provisioned MY org raises no payout_channel_invalid warning |
identical | steps.go:18-33, :67-88, countrypack/my/payout.go:14-16, modules/payout/boot.go |
| 13 | Seeded MY channels, bank_transfer among them; bank fields payee_name, bank, account_no |
seven channels in payoutChannels, keys cash(10), bank_transfer(20), duitnow(30), ewallet(40), cheque(50), crypto(60, seeded inactive), petty_cash(70). bank_transfer: behaviour external, credit = default bank journal’s account, RequiresSettlementProof: true, fields payee_name (text, required), bank (registry_ref my_banks, required), account_no (account_no, required, masked). petty_cash: behaviour petty_cash, credit = default cash journal’s account, one field payee_name. SeedChannels sets AllowedCurrencies = {base currency} and leaves AllowedClaimTypes empty (= all). Registries seeded: my_banks (22 entries, maybank first), my_ewallets (6), crypto_networks (5) — all marked UNVERIFIED (Plan 6 Ruling P34) |
identical | countrypack/my/payout.go:28-48, :52-88, seed.go:146-166 |
| 14 | Purge on deactivation | payout’s init() calls member.OnDeactivate("payout.profiles", PurgeMemberProfiles); member.RunDeactivationHooks(ctx, tx, c, memberID) error runs them. PurgeMemberProfiles requires an installed keyring (ErrNoKeyring even when nothing is readable) but deletes a profile whose ciphertext will not open, auditing it unreadable with no values |
identical | purge.go:17-19, :35-49, deactivation.go:22,38,61 |
| 15 | Task 3’s repo-wide constraint guard passes on Plan 6’s 00090 |
no constraint name in 00090_payout.sql ends _check, _key or _excl; the suffixes used are _pk, _fk, _uniq, _format, _present, _valid, _shape, _positive, _length. go test ./internal/kernel/db/ -run TestEveryConstraintIsNamed -count=1 → ok, 10.786s (the guard already exists in the tree; its grandfatheredThrough is 53, so 00090 is fully covered) |
identical | db/migrations/00090_payout.sql, internal/kernel/db/constraintnames_test.go:19 |
Plan 6 refusals
Section titled “Plan 6 refusals”Every exported error reachable from the four functions Plan 5 calls. Snapshot is the only one
that refuses for a caller-fixable reason; GetChannel has exactly one refusal;
ResealSnapshot/ResealProfiles refuse only for seal reasons. This is the list Task 19’s
mapPayoutErr implements. ErrRegistryEntryNotFound is not in it — see deviation 2.
| Plan 6 error | Meaning | Reachable from | Plan 5 maps to |
|---|---|---|---|
ErrChannelNotFound |
no such channel in this organization (unknown and foreign ids are indistinguishable). From Snapshot it also covers a channel whose credit_account_id joins no account row of the org — the snapshot query INNER JOINs account (deviation 4) |
GetChannel, Snapshot |
422 naming payout_channel_id |
ErrChannelInactive |
the channel is retired; nothing new is paid against it | Snapshot (checked after the row is read, before eligibility) |
422 naming payout_channel_id (Plan 5 checks IsActive first; this is the race) |
*IneligibleError{Rule, Detail} |
one of spec 9.6’s four rules failed. Rule is one of payout.RuleAmount (“amount”), RuleCurrency, RuleClaimType, RuleMaxAmount. Only RuleAmount (amount zero or negative) has no Plan 5 counterpart — the other three are refused by Plan 5’s own R23 checks first (deviation 3) |
Snapshot |
422 naming payout_channel_id with rule=<Rule> |
ErrMemberNotFound |
the member id is not a row of organization_member in this org |
Snapshot |
500 (the claimant is always a member; a wiring bug) |
ErrNoProfile |
the member has saved no details for this channel. Raised only when the channel has at least one field (a fieldless channel snapshots an empty fields array instead) — every seeded MY channel has one, so Ruling R46 holds (deviation 5) |
Snapshot |
422 naming payout_channel_id: “the claimant has no saved payout details for this channel” |
*InvalidDetailsError{Problems []FieldProblem} |
the saved profile no longer validates against the channel’s current field_schema: FieldProblem{Key, Reason} per field, sorted by key. This is also how a retired or unknown registry entry surfaces (Reason: "not an active entry of registry my_banks") |
Snapshot (through ValidateDetails) |
422 naming payout_channel_id: the claimant must update their payout details; join Key + ": " + Reason with "; " |
ErrNoKeyring |
this process installed no payout keyring (PAYOUT_DETAILS_KEY) |
Snapshot, ResealSnapshot, ResealProfiles, PurgeMemberProfiles |
500 (R44), fixed detail, wrapped error logged not echoed; Task 21 exits non-zero naming the org |
ErrUnknownKey |
sealed under a key id this process holds neither as active nor previous; wrapped %w (key id %q) |
ResealSnapshot, ResealProfiles (and Snapshot, when the member’s stored profile is under a dropped key) |
500 (R44); Task 21 exits non-zero naming the org and the key id |
ErrSealBroken |
AES-GCM authentication failed — altered, or moved to another row/snapshot/field. Never carries plaintext | same three | 500 (R44); Task 21 exits non-zero naming the org |
Not reachable from Plan 5’s four functions, and therefore not mapped:
ErrDuplicateChannelKey, ErrDuplicateRegistryKey, ErrRegistryInUse, ErrRegistryEntryNotFound,
*InvalidSchemaError, *InvalidChannelError, *NoSeedsError — all belong to channel/registry
administration, which is internal/modules/payout’s. An escape would surface as 500, the right
answer for a Plan 5 bug.
The expensestest helpers Task 14 adds, as payouttest builds them
Section titled “The expensestest helpers Task 14 adds, as payouttest builds them”payouttest.Env embeds *accountingtest.Env, so Plan 5 cannot embed it; Task 14 copies the two
admin INSERTs verbatim. accountingtest.Env exposes H *testdb.Handles with H.Admin *pgxpool.Pool,
H.AppDSN and H.AdminDSN, which is all the copies need.
package expensestest // Task 14 additions — exact shapes
// Env gains: PayoutKey payout.Key. New does, before anything else:// e.PayoutKey = payouttest.NewKey(t, "test-1") // payout.Key{ID, Secret []byte}, 32 random bytes// payouttest.UseKeyring(t, e.PayoutKey, nil) // installs, restores at Cleanup// and blank-imports internal/platform/payout so provisioning steps 90-91 run.
// Channel reads a channel provisioned by Plan 6's step 91, by key// ("cash", "bank_transfer", "duitnow", "ewallet", "cheque", "crypto", "petty_cash").func (e *Env) Channel(t *testing.T, key string) uuid.UUID// SELECT id FROM payout_channel WHERE org_id = $1 AND key = $2 (e.H.Admin)
// SeedChannel is payouttest.Env.SeedChannel's SQL, unchanged. Defaults applied// before the INSERT: Behaviour "" -> payout.BehaviourExternal; Currencies nil ->// {"MYR"}; ClaimTypes nil -> {} (empty = every claim type); Fields nil -> {};// MaxAmount "" -> NULL; is_active = !r.Inactive.func (e *Env) SeedChannel(t *testing.T, r payouttest.ChannelRow) uuid.UUID// INSERT INTO payout_channel (org_id, key, name, is_active, settlement_behaviour, credit_account_id,// field_schema, allowed_currencies, allowed_claim_types, max_amount,// requires_settlement_proof)// VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::numeric,$11) RETURNING id (e.H.Admin)
// PayoutProfile is payouttest.Env.SeedProfile's body: unvalidated but sealed// exactly as SaveProfile seals, bound to (org, member, channel).func (e *Env) PayoutProfile(t *testing.T, member, channel uuid.UUID, details map[string]string) uuid.UUID// s, err := payout.SealDetails(e.Org, member, channel, details) // Sealed{KeyID, Nonce, Ciphertext}// INSERT INTO member_payout_profile (org_id, member_id, channel_id, details_kid, details_nonce, details_ct)// VALUES ($1,$2,$3,$4,$5,$6) RETURNING id (e.H.Admin)A test that needs a registry entry of its own copies
INSERT INTO payout_registry_entry (org_id, registry, key, name) VALUES ($1,$2,$3,$4) RETURNING id
(payouttest.Env.SeedRegistryEntry); a provisioned org already has my_banks/maybank, so
{"payee_name": "Siti Aminah", "bank": "maybank", "account_no": "5140 1234 6789"} validates
against the seeded bank_transfer schema (account_no normalizes to 514012346789,
display ****6789, account_tail "6789").
Deviations (the code’s behaviour, not its names)
Section titled “Deviations (the code’s behaviour, not its names)”No ruling is weakened by any of these; each tightens or narrows a later brief.
AllowedCurrenciesis never empty, and it is a strict allow-list.eligiblerefuses when!slices.Contains(c.AllowedCurrencies, currency)(snapshot.go:251) — an empty slice would refuse every currency, not allow all. It cannot be empty:shapeProblemsrefuses “allowed_currencies needs at least one currency” (define.go:316) andpayout_channel_currencies_validCHECKscardinality(allowed_currencies) >= 1(00090_payout.sql:67). Ruling R23’s “base currency allowed whenAllowedCurrenciesis non-empty” is therefore unconditional in practice: Plan 5 always checks membership. (AllowedClaimTypes, by contrast, is “empty = all”, in code and in the migration comment.) → Task 14 brief adjusted.ErrRegistryEntryNotFoundnever escapesSnapshot.normalizeturns a missing or inactive registry entry into aFieldProblem{Reason: "not an active entry of registry <r>"}inside*InvalidDetailsError(fields.go:231-238), anddisplayswallows it and falls back to the raw key (fields.go:266-269). → the row is removed from Task 19’smapPayoutErrtable; the*InvalidDetailsErrorrow already gives it the same 422 sentence.*IneligibleErroris only reachable onRuleAmountor a race. Plan 5’s own R23 checks (currency, claim type, max amount) run beforepayout.Snapshot, so they refuse first with Plan 5’s typed errors. The one rule Plan 5 does not duplicate isRuleAmount(amount zero or negative). → Task 14’sTestPlan6sRefusalPassesThroughTypednow uses a zero-total claim.SnapshotanswersErrChannelNotFoundfor a broken credit account too. Its query INNER JOINsaccount ON a.org_id = c.org_id AND a.id = c.credit_account_id(snapshot.go:91-98), so an unresolvable credit account ispgx.ErrNoRows→ErrChannelNotFound. The 422 namingpayout_channel_idremains the right answer. Recorded so nobody debugs it as a missing channel.ErrNoProfileneeds the channel to have a field.snapshot.go:123-126: with no profile row andlen(d.Fields) == 0, the snapshot is built with an emptyfieldsarray instead. All seven seeded MY channels carry at leastpayee_name, so Ruling R46 and Task 16’s ABSENCE case hold. → Task 16 brief notes it.payouttest.Envembeds*accountingtest.Env(a pointer). It also offersBankChannel(t),SeedRegistryEntry(t, registry, key, name),Other(t, name)and aCashJournal, which the plan did not list. Task 14’s copy-the-SQL approach is confirmed correct;payouttest.BankFields()is importable as-is and matches the seededbank_transferschema apart from thezhlabels (payouttest.BankFieldshasen/ms;countrypack/myseedsen/ms/zh).cmd/workerwill linkinternal/platform/payout. Task 17 blank-importsinternal/modules/expensesintocmd/worker, andinternal/platform/expensesimportsinternal/platform/payout. Plan 6’s Ruling P28 says cmd/worker does not link the payout module (internal/modules/payout), which stays true, andinternal/app/binaries_test.goexempts cmd/worker from the module-parity check. cmd/worker installs no payout keyring: nothing on a worker path seals or opens (onlysettle, in an HTTP handler, callspayout.Snapshot;PayoutRule.Buildonlyjson.Unmarshals — Ruling R44). → Task 17 brief notes it so nobody addsconfig.PayoutDetailsKeysFromEnvtocmd/workerand breaks a worker that has no key.
Later-task edits made by Part B
Section titled “Later-task edits made by Part B”| Task | Edit | Why |
|---|---|---|
| 14 | Step 3’s R23 checklist: the currency check is unconditional (AllowedCurrencies is never empty); AllowedClaimTypes empty still means all. |
Deviation 1 |
| 14 | TestPlan6sRefusalPassesThroughTyped: the *IneligibleError case is now a zero-total claim (payout.RuleAmount), not a MaxAmount case Plan 5 refuses first. |
Deviation 3 |
| 14 | Interfaces: payouttest.Env embeds *accountingtest.Env; expensestest copies the SQL. Channel/SeedChannel/PayoutProfile pinned to the SQL recorded above; PayoutProfile now returns the profile id. |
B3 |
| 16 | TestAPurgedProfileDoesNotTouchAPaidClaim: notes that ErrNoProfile needs a channel with a field (all seeded MY channels have one) and that PurgeMemberProfiles itself needs the keyring installed. |
Deviation 5, and purge.go:44 |
| 16 | TestAPettyCashSettlementIsNeverCreditedToTheChannelAccount: names the seeded petty_cash channel and the fact that Plan 6 forces such a channel through a cash journal. |
Table row 13 |
| 17 | cmd/worker’s blank import pulls in internal/platform/payout; no keyring is installed there and none is needed. cmd/api already installs one (Plan 6) — Task 17 must not add a second. |
Deviation 7 |
| 19 | mapPayoutErr: ErrRegistryEntryNotFound dropped; *InvalidDetailsError joins Key + ": " + Reason; *IneligibleError rule= values pinned to the four payout.Rule* constants; ErrUnknownKey/ErrSealBroken reachability corrected. |
Deviations 2, 3, 4; the refusal table |
| 20 | Step 1 item 1 names Plan 6’s steps 90–91 explicitly and scopes “no warnings” (the channels step skips inactive channels, so the inactive crypto seed raises none). |
Table row 12 |
| 21 | Interfaces: cmd/api/main.go:54-67 named as the keyring-build pattern to copy; ResealProfiles confirmed unbatched; the unknown-key error’s wrapped shape recorded. |
Table rows 7, 8, 10 |
No symbol was missing or renamed, so no new Ruling was needed. No Go code was touched.