Skip to content

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):

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 edit internal/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.ResealSnapshot and payout.ResealProfiles (Task 21’s reseal command only), payout.BehaviourPettyCash, the typed refusals Task 1 records (including ErrNoKeyring, ErrUnknownKey, ErrSealBroken), and, in tests only, payouttest and payout.PurgeMemberProfiles. Plan 5 never calls payout.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.Snapshot returned it: masked members are AES-256-GCM ciphertext. No API response carries a value or sealed member of a masked field — only display. 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–00089 only; this plan uses 00070–00073. Provisioning step orders 80–89 only; this plan uses 80. Permissions expenses.* only. OpenAPI paths /expenses/… and /documents/… only (served under /v1).
  • Shared files are append-only (wave-1 contract §3): constants.yaml permissions list, api/openapi.yaml, internal/app/app.go, internal/app/routes_test.go (composedModules), the blank imports in cmd/api, cmd/migrate, cmd/orgadmin, cmd/worker and their wiring_test.go, internal/kernel/db/retention_test.go lists, .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 with make generate, never hand-merged.
  • Tenant isolation, both layers. Every new table: org_id UUID NOT NULL, ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid. Every SQL statement names org_id = $n for each org-scoped table it touches (join equalities count) and every INSERT names org_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, UNIQUE and EXCLUDE is written CONSTRAINT <name> …, and no name ends in _check, _key or _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_temp and schema-qualifies every table (public.expense_claim). TestEveryFunctionPinsItsSearchPath enforces the first half.
  • No SET CONSTRAINTS in non-test Go (TestNothingIssuesSetConstraints).
  • Every table is in exactly one list in internal/kernel/db/retention_test.go. Claims, lines, attachments, groups and documents are amendable (DELETE revoked). Rates, settlements and OCR suggestions are appendOnly. The idempotency store is operational.
  • Every state change writes audit.Write on the same pgx.Tx, after every row lock the transaction will take (wave-2 §3 lock order). The order everywhere in this plan: idempotency key row → expense_claim row (FOR UPDATE) → document row (FOR UPDATE, attach only) → workflow_instance row (taken inside workflow.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 through ledger.MapError before 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) are money.Rate (NUMERIC(19,10)). Never float64. On the wire both are strings; the OpenAPI Money and Rate schemas already map to them.
  • The business date is clock.TodayUTC(c); every DATE handled in Go is midnight UTC. Never time.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.Protected with an expenses.* permission registered in the module’s init(), and is documented in api/openapi.yaml in the same commit (internal/app/routes_test.go).
  • posting and accounting.DefaultRules() are process-global. Tests that need a Poster call posting.ResetForTest() first and in t.Cleanup, register accounting.NewPoster(clock, rs) over a fresh accounting.NewRuleSet() holding expenses.Rules(), and never use t.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.WithTimeout and clean up in t.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 generate after any migration, OpenAPI or constants.yaml change. One full go test -p 2 -count=1 ./... in Task 20 only.
  • Git commits end with: Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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 module internal/modules/expenses that serves both /v1/expenses/… and /v1/documents/… — the ownership table names only modules/expenses, and documents have no second consumer yet — a later modules/documents is a file move plus one app.go line.
  • R2 Ruling: Named exceptions outside the ownership table: internal/kernel/config/objectstore.go and ocr.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 (append money.Rate column overrides) — config must be read in kernel/config for TestEveryEnvVarIsInExample, and Plan 4 set the precedent with config/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 later CHECK/UNIQUE/EXCLUDE whose name ends _check, _key or _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’s 00090 — Plans 3–4’s migrations predate the rule and renumbering their constraints now is churn — a deliberate name that happens to end _key must be renamed.
  • R4 Ruling: All SQL is hand-written pgx in the platform packages, no db/queries/expenses*.sql; make generate still refreshes internal/gen/sqlc/models.go, and sqlc.yaml gains money.Rate overrides 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_source and base_amount come from fx.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’s org_id is NOT 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.go names “a reference to a table on db/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_id column on expense_category (composite FK to account; CHECK (org_id IS NOT NULL OR account_id IS NULL)), required to be an active, postable expense account 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_employee lines are refused.
  • R9 Ruling: expense_rate is org-only (not a registry) and append-only (UPDATE and DELETE revoked). The rate for (kind, band, date) is, among rows with effective_from ≤ date and (effective_to NULL or date < effective_to), the greatest effective_from, ties broken by newest created_at (a correction is a new row). A line freezes rate_id and 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.Rate decimals stored NUMERIC(19,10), > 0; amount = rate.Amount.MulRate(quantity) — a float must never touch money and Rate is 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 named CHECK in the table); advance_offset is 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 with TaxCodeID on 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_employee need supplier_tin and einvoice_uuid; relaxation_receipt needs relaxation_reason; supplier_tin matches ^[A-Z]{1,2}[0-9]{8,13}$ after upper-casing; einvoice_uuid matches ^[A-Z0-9]{10,64}$ after upper-casing; mileage/per-diem lines carry no basis. At submission (completeness): every receipt line has a document_basis, at least one verified attached document, and — for einvoice_employee — a category that permits it, re-read then. einvoice_validated_at is 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 named CHECK using !~ '[぀-ヿ㐀-䶿一-鿿가-힯]' — §11A.3 requires English or Malay records and zh is 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 UPDATE trigger on expense_claim_line and a BEFORE UPDATE OF total_amount, title, group_id, payout_channel_id trigger on expense_claim refuse the write unless the claim’s workflow instance is in a draft-semantic state (read from Plan 3’s workflow_instance and workflow_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 JSONB array [{analytic_account_id, percentage}] validated with analytic.Plan(ctx, tx, "expense", base, shares, true) at save, at submission and again by the accrual rule (through ledger.Post); the posted analytic_distribution is 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-NNNN for claims and EG-YYYY-NNNN for groups, from numbering.Allocate scopes expense_claim / expense_claim_group, period = calendar year of clock.TodayUTC at 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_act returns 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.Transition is the only caller of posting.Dispatch, and it holds lockInstance for 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_id while in draft; the actor moving the claim into the payout-semantic state may name another in the transition body. Plan 5 checks the exported Channel fields itself (active; settlement_behaviour = external; base currency allowed when AllowedCurrencies is non-empty; "expense_claim" allowed when AllowedClaimTypes is non-empty; total ≤ MaxAmount; a proof document when RequiresSettlementProof) with its own errors, then calls payout.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_behaviour is petty_cash, and it never credits Channel.CreditAccountID (the seeded cash-in-hand placeholder) for such a claim. Until Plan 8 lands, deferring means refusing with a typed error, twice: settle refuses a petty_cash channel before payout.Snapshot is called (ErrChannelPettyCash, 422 naming payout_channel_id), and PayoutRule.Build independently refuses a settlement whose snapshot says petty_cash (ErrPettyCashDeferred, which aborts the transition) — so no code path, including a direct Build call, posts petty cash to cash in hand. Skipping the posting instead was rejected: the claim would reach completed with 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 the settle check and turns PayoutRule.Build’s petty_cash branch 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, holding Snapshot’s sealed JSON verbatim, the channel’s CreditAccountID read at that moment, amount, currency, proof document, actor and txid 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') and account_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 a fields array, that no masked field carries a value, and that every masked field carries sealed — so a plaintext account number cannot be stored even by a bug. The only permitted UPDATE is a key-rotation reseal of snapshot (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 purchase journal: 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 on Source.Control for the claim total (with PartyID); 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, reads ledger.SourceBalance on 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 default bank journal — §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 (instance accrual_open ⇔ an unreversed accrual entry exists for it) and expense_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-Key is required on POST /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 is operational with 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); GET returns ETag: "<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 signs Content-Type and x-amz-checksum-sha256 and lives OBJECT_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 document rejected with its reason (object kept); a match marks it verified and, 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, PresignHeader signs extra headers); its adapter is tested against the testcontainers MinIO module; every other test uses documentstest.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/api refuses to boot without OBJECT_STORE_ENDPOINT, OBJECT_STORE_BUCKET, OBJECT_STORE_ACCESS_KEY, OBJECT_STORE_SECRET_KEY (region defaults us-east-1, MinIO’s default); there is no default endpoint (an SDK default would be AWS global); .env.example carries a refused placeholder secret; residency is a deployment fact recorded in DEPLOY_NOTES.md and 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} with ocr.Noop as the only implementation; OCR_PROVIDER accepts only ""/none in Phase 1 and refuses anything else at boot; extraction runs post-commit on documents.uploaded, logs and swallows its own failures, and stores results only in append-only document_ocr_suggestion, exposed read-only on GET /v1/documents/{id}; a guard fails if internal/platform/expenses imports documents/ocr or names the suggestion table — §6.6 “a suggestion, never a value”, wave-2 §4 — the user picks a provider later; adding it is one Extractor and one FromConfig case.
  • 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 draft or pending-semantic (a reviewer may ask for more evidence); detaching only in draft; a document must be verified and 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 in constants.yaml); append expenses.claim.read, expenses.claim.read_all, expenses.claim.transition, expenses.setup.manage. The Phase 0 placeholders expenses.claim.approve and expenses.claim.verify are left in constants.yaml untouched 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.Record rows for claim created, each transition, and settlement (Plan 3 R20 leaves the feed to the consumer) — none.
  • R39 Ruling: Provisioning step 80 expense_categories seeds the Malaysian categories as org rows (translation rows en/ms/zh, account by the MY pack’s code) only when the org’s company profile country is MY and it has no category; otherwise done. Check reports warning expense_category_unmapped for each active category without a usable account — §20.2 step 6 and “an unmapped category” as a repair case; the seeds live in internal/platform/expenses, not countrypack/my (Plan 4/6 ownership) — a second country adds a seed table in this package.
  • R40 Ruling: expense_claim.instance_id is a composite FK to workflow_instance (org_id, id); list and read queries join workflow_instance/workflow_state read-only for state and semantic; every write to them goes through the workflow API — one query for a claims list instead of N workflow.Get calls — Task 1 verifies the columns and the UNIQUE (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.Event cannot say “past payout”; Plan 4’s Poster (R7) mirrors every unreversed accrual and payout entry of the instance on rejected, 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 detail is [line=<uuid> ]field=<name> rule=<code>: <sentence> (several joined by "; "), because apperr.Problem has no extension members and kernel/errors is outside this plan’s ownership — §12.2 wants clients to branch on a code, and the rule codes 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’s json.RawMessage byte-for-byte; every reader — the claim read API, the payout rule (which needs only the row’s credit_account_id and amount), the reconciliation checks, the reseal command — either ignores the snapshot or json.Unmarshals it into payout.SettlementSnapshot and uses Display, Channel, AccountTail (no key needed). The API emits a SettlementView built from that unmarshal: channel key and name, account_tail, and per field {key, label, display} — never value (even for an unmasked field, display is shown) and never sealed. Plan 5 has no finance export and no payment file, so no Plan 5 path needs a raw value; payout.OpenSnapshot is not called anywhere in Plan 5, and TestPlan5NeverOpensASnapshot (Task 14) fails if internal/platform/expenses or internal/modules/expenses names it. Seal errors (payout.ErrNoKeyring, ErrUnknownKey, ErrSealBroken) from Snapshot map to 500 (a deployment fault, not the caller’s). Audit rows never carry the snapshot: the transition’s audit row names settlement_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 from PAYOUT_DETAILS_KEY/_PREVIOUS, reads org ids read-only through DATABASE_MIGRATE_URL (the only role that sees every org; an empty list is an error), and writes through DATABASE_URL under RLS: per org one transaction for payout.ResealProfiles, then batches of expenses.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 when payout.ResealSnapshot reports a change, so the command is idempotent and resumable. To allow it, expense_claim_settlement keeps REVOKE UPDATE at table level but grants UPDATE (snapshot), and the trigger expense_claim_settlement_reseal_only refuses any update that changes the snapshot other than its sealed members — cmd/migrate already 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 --check exits 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: row credit_account_id, amount, snapshot settlement_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 to member_payout_profile, so Plan 6’s purge of a departed member’s profiles (member.RunDeactivationHooks → payout.PurgeMemberProfiles) cannot break a settled claim; Task 16’s TestAPurgedProfileDoesNotTouchAPaidClaim proves 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 calls Snapshot afresh and, for a purged member, is refused with payout.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 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).
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).
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). Open internal/platform/workflow/{model.go,instance.go,store.go,seed.go,authorize.go} and internal/platform/workflow/workflowtest/fixture.go and db/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 SetAmount does 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.

Terminal window
git add docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md
git 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 that Snapshot returns sealed JSON, that OpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error) and ResealSnapshot(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, ResealSnapshot and ResealProfiles (sentinels and typed errors) with a one-line meaning, in the appendix under “Plan 6 refusals”, and fill Task 19’s mapPayoutErr table with them. Plan 6’s revised plan (Task 1 errors.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) — corrected: unreachable, it arrives as *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. AllowedCurrencies is a strict allow-list and can never be empty — payout.shapeProblems refuses “allowed_currencies needs at least one currency” and payout_channel_currencies_valid CHECKs cardinality(allowed_currencies) >= 1. AllowedClaimTypes is “empty = all”. ErrRegistryEntryNotFound is not reachable from Snapshot (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) and SeedProfile(t, member, channel, details) insert through the admin pool (the profile sealed with payout.SealDetails); provisioning steps 90–91 (registered from internal/platform/payout’s init(), seeds from countrypack/my) create the MY channels, bank_transfer among them. payouttest.Env embeds accountingtest.Env, not expensestest.Env, so Task 14 copies the two admin INSERTs into expensestest rather 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’s Env.Channel / SeedChannel / PayoutProfile brief 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’s 00090 names (none ends _check/_key/_excl). Run go 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 is grandfatheredThrough = 53, not 69 — Plans 0–4 end at migration 00053, so Plan 6’s 00090 and Plan 5’s 00070–00073 are 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 path
    Region string // default "us-east-1"
    Bucket string
    AccessKey string
    SecretKey string
    PresignTTL 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 (use t.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 literal os.Getenv("…") calls (TestEnvReadsHaveLiteralArguments). Endpoint: url.Parse, scheme http/https, non-empty host, path "" or "/", no query. Secret: refuse any value starting REPLACE_ME with “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 on ObjectStore: 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"; else fmt.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):

Terminal window
# 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-1
OBJECT_STORE_BUCKET=erp-documents
OBJECT_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 four OBJECT_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 allow PUT from PUBLIC_BASE_URL with headers Content-Type and x-amz-checksum-sha256; (5) OCR_PROVIDER stays empty.

  • Step 6: Run and commit. go test ./internal/kernel/config/ -count=1 → PASS.

Terminal window
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.md
git 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), then make 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 Down
DROP 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.go append "document" to amendable with 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" to appendOnly with “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’s TestPayoutConstraintsAreNamed is 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 through h.Admin for setup and h.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, then go 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 bare CHECK (…), re-run TestEveryConstraintIsNamed, quote its red output (it names document.document_status_check), restore, re-run green.

  • Step 7: Commit

Terminal window
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/sqlc
git 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/minio at the testcontainers version already in go.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 s3store
    func New(cfg config.ObjectStore) (*Store, error) // does not dial; EnsureBucket does
    func (s *Store) EnsureBucket(ctx context.Context) error // create if missing; boot calls it
    var _ documents.Store = (*Store)(nil)
    package documentstest
    type 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]string
    var _ 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.Cleanup terminates 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: PresignPut records headers including x-amz-checksum-sha256 = base64 of the digest and Content-Type; Open before Upload → ErrObjectMissing; after Upload returns the bytes; PresignGet returns 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: PresignedGetObject with reqParams response-content-disposition=attachment; filename="<sanitized>" (strip " and control characters). Open: GetObject then Stat(); a minio.ToErrorResponse(err).Code == "NoSuchKey" → ErrObjectMissing. Put: PutObject with ContentType. EnsureBucket: BucketExists → MakeBucket with the region.

  • Step 5: Run. PASS.

  • Step 6: Commit

Terminal window
git add go.mod go.sum internal/platform/documents/store.go internal/platform/documents/s3store internal/platform/documents/documentstest
git 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 documents
    const MaxBytes = 20 << 20
    const (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.UUID
    Filename, ContentType, SHA256 string
    ByteSize int64
    ObjectKey string
    Status string
    RejectReason, ThumbnailKey *string
    CreatedAt time.Time
    VerifiedAt *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.UUID
    Filename, ContentType string
    ByteSize int64
    SHA256 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 bool
    Reason 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 error

    Object keys: documents/<org_id>/<yyyy>/<document_id>; thumbnails thumbnails/<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), a MemStore.

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, ErrTooLarge for ≤0 or >MaxBytes, ErrBadDigest unless ^[0-9a-f]{64}$). Dedup lookup SELECT … FROM document WHERE org_id=$1 AND uploaded_by=$2 AND sha256=$3 AND status='verified'. Else insert with id := 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}}; then s.PresignPut (after the insert; a presign failure returns the error and the caller’s tx rolls back).
    • Verify: rc, err := s.Open; read through io.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/pdf for %PDF) → else "content_type". For JPEG/PNG: image.DecodeConfig; if w*h ≤ 40_000_000, decode, scale with draw.CatmullRom so 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 verified eventbus.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

Terminal window
git add go.mod go.sum internal/platform/documents
git 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 first
    package ocr
    type Input struct {
    DocumentID uuid.UUID
    ContentType string
    Body []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() string
    Extract(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") -> error
func 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 when x is Noop; else d.InTx(ctx, …) to Get the document, read bytes through s.Open outside that tx (open tx1 to read metadata, close; read bytes; x.Extract; tx2 SaveSuggestion); 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" to internal/platform/expenses/doc.go → TestNothingCopiesOCRIntoARecord red (quote it); restore.

  • Step 5: Commit

Terminal window
git add internal/platform/documents
git 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 (move expense_category from unruled to amendable; append expense_rate to appendOnly), then make 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 expenses
    const Kind = "expense_claim" // equals workflow.KindExpenseClaim; Task 9 pins them together
    type Category struct {
    ID uuid.UUID
    Key string
    AccountID *uuid.UUID
    EmployeeNamedDocumentAllowed bool
    IsActive bool
    SortOrder int
    Names map[string]string // locale -> name, every translation row
    }
    type CategoryInput struct {
    Key string
    Names map[string]string // "en" and "ms" required; "zh" optional
    AccountID *uuid.UUID
    EmployeeNamedDocumentAllowed bool
    SortOrder int
    }
    type CategoryPatch struct {
    Names map[string]string // replaces the given locales only
    AccountID **uuid.UUID // non-nil: set (inner nil clears)
    EmployeeNamedDocumentAllowed *bool
    IsActive *bool
    SortOrder *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, key
    func 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 error
    type 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 Down
DROP 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 of unruled into amendable with 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" to appendOnly: “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.” Run make 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 inserts
func TestTheAppCannotDeleteACategory(t *testing.T) // PRESENCE: UPDATE is_active works as erp_app; DELETE -> 42501
func TestRatesAreAppendOnly(t *testing.T) // insert ok; UPDATE, DELETE -> 42501
func TestTheRegistryTestsStillHold(t *testing.T) // go test ./internal/platform/registry/ passes unchanged — run it in Step 5, no code here

category_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 -> ErrInvalidCategory
func 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 80
func 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 seeded
func 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 SQL CHECKs 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 like Cash). Every query WHERE org_id = $1 AND deleted_at IS NULL. Names are written as translation rows with INSERT … ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO UPDATE SET value = EXCLUDED.value; en and ms values also pass CheckRecordText (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 on expense_category_org_key (a unique index, not a constraint — the Task 3 guard, which reads pg_constraint, does not see it) → ErrDuplicateKey. Audit expense_category create/update with 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 in init(). Check: country ≠ MY → done; zero org categories → not done; else done, with a warning per active category whose UsableAccount fails. Run: for each seed, chart.GetAccountByCode; a missing code leaves AccountID nil (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

Terminal window
git add db/migrations/00071_expense_reference.sql internal/platform/expenses internal/kernel/db/retention_test.go internal/gen/sqlc
git 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.UUID
    Kind, Band string
    EffectiveFrom time.Time
    EffectiveTo *time.Time
    Amount money.Amount
    Currency, Reason string
    CreatedBy *uuid.UUID
    CreatedAt time.Time
    }
    type RateInput struct {
    Kind, Band string
    EffectiveFrom time.Time
    EffectiveTo *time.Time
    Amount money.Amount
    Reason string
    CreatedBy *uuid.UUID
    }
    func CreateRate(ctx context.Context, tx pgx.Tx, c clock.Clock, in RateInput) (Rate, error) // currency = base
    func 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 error
    type 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 row
func 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 motorcycle
func 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 written
func 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 1

created_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 overrides expense_claim_line.fx_rate, expense_claim_line.quantity → money.Rate), then make 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 expensestest
    var T0 = time.Date(2026, 9, 21, 9, 0, 0, 0, time.UTC) // a Monday in FY2026 period 9
    var Today = time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC)
    type Env struct {
    H *testdb.Handles
    DB *db.DB
    Ctx context.Context // tenancy.WithOrg
    Org uuid.UUID
    Clock clock.Clock // clock.Fixed(T0)
    Owner uuid.UUID // holds OWNER: superior, unit head, verifier, payer
    Staff uuid.UUID // reports to OWNER; the usual claimant
    OwnerSeat uuid.UUID
    StaffSeat uuid.UUID
    Store *documentstest.MemStore
    Categories map[string]uuid.UUID // step 80's seeds by key
    }
    func New(t *testing.T) *Env // one provisioned MY org; posting reset to nil
    func (e *Env) Another(t *testing.T) *Env // a second provisioned org on the same database
    func (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) error
    func (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.Document
    func (e *Env) Rate(t *testing.T, kind, band, from, amount string) expenses.Rate
    func (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 Down
DROP 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 Down
DROP 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:

    1. testdb.Start; db.Open(…, eventbus.New()); org + company profile via admin, copying the SQL of accountingtest’s unexported seedOrg (country MY, base MYR, FY start 2026-01-01, default locale en); posting.ResetForTest() + t.Cleanup(posting.ResetForTest).
    2. Owner member via admin (party + organization_member, as accountingtest.SeedMember).
    3. provisioning.Run(ctx, tx, clock, provisioning.Input{OwnerPermissions: member.NewPermissionSet([]member.Permission{{Key: "expenses.fixture", Description: "fixture"}}), OwnerMemberID: owner}) inside one InTx. The file blank-imports every step package: internal/platform/countrypack, countrypack/my, accounting/analytic, accounting/fiscal, internal/platform/workflow, and (for step 80) nothing — expensestest imports expenses directly.
    4. 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 tx orgstruct.CreatePosition(ctx, tx, c, orgstruct.PositionInput{OrgUnitID: unit, Code: "STAFF-"+short, Title: name, ReportsToPositionID: reportsTo}) and orgstruct.Assign(ctx, tx, c, orgstruct.AssignmentInput{MemberID: m, PositionID: seat, IsPrimary: true, ValidFrom: Jan1}).
    5. Categories: expenses.ListCategories keyed by Key.
    6. 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 with workflow.Start + admin inserts where the platform API does not exist yet).

    Lock order (Task 1 Part A, fact A). workflow.Transition dispatches posting and must therefore run before any audit.Write in its transaction — and workflow.Start and workflow.SetAmount each 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’s Transition doc 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_fk
func TestClaimRecordsCannotBeDeleted(t *testing.T) // 42501 on each of the four tables, after PRESENCE of UPDATE on expense_claim.revision
func 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 succeeds

env_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 the workflow_instance one).

  • Step 7: Sabotage. Drop the BEFORE UPDATE half of expense_claim_line_draft_guard (make it BEFORE INSERT only) → TestALineCanBeWrittenOnlyWhileTheClaimIsADraft red; quote; restore. Then make expense_settlement_unsealed return p - 'fields' → TestOnlyAResealMayRewriteASettlementSnapshot’s display change is accepted (red); quote; restore.

  • Step 8: Commit

Terminal window
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/expenses
git 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.UUID
    Reference, Name string
    Kind string
    StartsOn, EndsOn *time.Time
    IsActive bool
    CreatedAt 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.UUID
    Reference, Title, Currency string
    GroupID, PayoutChannelID *uuid.UUID
    Total money.Amount
    Revision int
    State string // workflow_instance.state_key
    Semantic posting.Semantic // of that state
    EnteredStateAt time.Time
    CreatedAt, 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 only
    func 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() string
    func DecodeCursor(s string) (Cursor, error) // ErrBadCursor
    type ListFilter struct { Scope string; Member uuid.UUID; GroupID *uuid.UUID; After *Cursor; Limit int } // Limit 1..200, default 50
    func 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 error
    type 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 -> ErrGroupInactive
func TestGroupsAreNumberedAndEditedByTheirOwner(t *testing.T) // EG-2026-0001; UpdateGroup by Staff when Owner owns -> ErrNotOwner
func 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; "!!" -> ErrBadCursor

visibility_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 with numbering.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.Draft else ErrNotDraft → validate → UPDATE (… revision = revision + 1, updated_at = $n) → audit update with before/after.

  • ListClaims: keyset (c.created_at, c.id) < ($after_at, $after_id) ordered DESC; fetch Limit+1 to know whether a next page exists. ScopeMine adds c.claimant_member_id = $2.

  • ToAct: candidates WHERE c.claimant_member_id <> $2 AND s.semantic NOT IN ('draft','acknowledged') ordered i.entered_state_at ASC LIMIT 1000; keep those where workflow.AvailableTransitions(ctx, tx, c, inst, member) has any Allowed; stop at 200.

  • CanRead: claimant → true; readAll → true; any Allowed in AvailableTransitions → true; member in Decisions as Actor or AuthorityMember → true.

  • Cursor.Encode: base64.RawURLEncoding of created_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 string
    ExpenseDate time.Time
    Description string
    CategoryID uuid.UUID
    Currency string // receipt only; allowances are in base
    Amount *money.Amount // receipt only
    Quantity *money.Rate // mileage km, per-diem days
    Band string // mileage vehicle class, per-diem destination band
    TaxCodeID *uuid.UUID // receipt only
    SupplierName *string
    SupplierTIN *string
    EInvoiceUUID *string
    EInvoiceValidatedAt *time.Time
    DocumentBasis *string
    RelaxationReason *string
    Shares []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) error

    LineError.Rule values (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-step r.Div(100+r) then MulRate rounded 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’s CHECKs — 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.UUID
    LineNo int
    Type string
    ExpenseDate time.Time
    Description, Currency string
    Amount, BaseAmount money.Amount
    FXRate money.Rate
    FXRateSource string
    TaxCodeID *uuid.UUID
    TaxAmount money.Amount
    Quantity *money.Rate
    RateID *uuid.UUID
    Band *string
    SupplierName, SupplierTIN *string
    EInvoiceUUID *string
    EInvoiceValidatedAt *time.Time
    DocumentBasis *string
    RelaxationReason *string
    Shares []analytic.Share
    WithdrawnAt *time.Time
    Documents []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 included
    func 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: seed fx.StoreReference USD 4.2000 on 2026-09-18 in the test, the way accountingtest.SeedReferenceRate does).

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 set
func 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:

    1. LockClaim; claimant; revision; Semantic == posting.Draft else ErrNotDraft.
    2. NormalizeLine(in, clock.TodayUTC(c)).
    3. GetCategory (not found or inactive → LineError{Field:"category_id", Rule:"inactive"}); CheckBasisAgainstCategory.
    4. Receipt: tax code — chart.GetTaxCode, active and ValidFrom ≤ date and (ValidTo nil or date < ValidTo) else {tax_code_id, invalid}; TaxAmount = InclusiveTax(amount, tc.Rate). FX — on update, if amount, currency and date are unchanged keep the stored FXRate/FXRateSource/BaseAmount; else q := fx.Resolve(ctx, tx, currency, base, date) (*fx.NoRateError → {currency, no_rate}), BaseAmount = amount.MulRate(q.Rate), source q.Source (identity/reference/override).
    5. Mileage / per diem: ResolveRate(ctx, tx, type, band, date) (*NoRateError → {band, no_rate}); Amount = BaseAmount = AllowanceAmount(rate.Amount, qty); currency base; FXRate = 1, source identity.
    6. analytic.Plan(ctx, tx, chart.TypeExpense, BaseAmount, shares, true); map *analytic.TotalError/*RequiredError/ErrAxisNotApplicable/ErrNotFound/ErrAccountInactive to {shares, percentage|required|invalid}.
    7. INSERT (line_no = coalesce(max(line_no),0)+1 for this claim — the claim lock serializes it) or UPDATE.
    8. total := SELECT coalesce(sum(base_amount),0) … WHERE withdrawn_at IS NULL; UPDATE expense_claim SET total_amount, revision = revision + 1, updated_at.
    9. workflow.SetAmount(ctx, tx, c, claim.InstanceID, total) (takes the instance lock, writes its own audit row).
    10. audit.Write{EntityKind: "expense_claim_line", Action: "create"|"update", …} — last.
    • AttachDocument: LockClaim; claimant; revision; semantic draft or pending else ErrNotAttachable; 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 NULL names another claim → ErrDocumentOnAnotherClaim; INSERT (unique violation on expense_claim_line_document_active_once → ErrAlreadyAttached); revision + 1; audit attach. DetachDocument: draft only; sets detached_at, detached_by.
  • Step 4: Run. PASS. Sabotage: skip the claim lock in AddLine → the concurrency test goes red (two line 1s → unique violation expense_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.Transition dispatches posting, which takes number_sequence row locks and FOR SHARE on every account a line touches, so nothing in this transaction may call audit.Write before it — not this package’s own audit rows (Ruling R22 already puts them after) and not workflow.Start or workflow.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) error
    type TransitionInput struct {
    ClaimID uuid.UUID
    From, To string
    Actor uuid.UUID
    Reason string
    PayoutChannelID *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 ErrPayoutUnavailable
    type 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.ErrStaleState
func TestOnlyGrantedMembersMoveAClaim(t *testing.T) // Staff reviewing own -> *workflow.RefusedError; Owner reviewing -> ok
func 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:

    1. cl := LockClaim(in.ClaimID).
    2. def := workflow.Load(ctx, tx, instance.DefinitionID) (read the instance with workflow.Get; do not lock it here); from, _ := def.State(in.From), to, ok := def.State(in.To) — unknown To → let workflow.Transition return ErrNoSuchEdge.
    3. If from.Semantic == posting.Draft && to.Semantic != posting.Draft && to.Semantic != posting.Rejected && to.Semantic != posting.Cancelled: CheckSubmission. (This is exactly the engine’s own isSubmission, 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 flow draft has only one exit, so the two conditions coincide today; they must not diverge if a later version adds draft → cancelled.)
    4. If to.Semantic == posting.Payout: s, err := settle(…).
    5. d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: cl.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}).
    6. 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-run UsableAccount, CheckBasisAgainstCategory, analytic.Plan; for receipts require DocumentBasis != nil and at least one attached document whose documents.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 in New; blank-import internal/platform/payout so Plan 6’s provisioning steps 90–91 seed the MY channels; add PayoutKey, Channel, SeedChannel, PayoutProfile exactly as Task 1 Part B recorded)
  • Create: internal/platform/expenses/nosnapshotopen_test.go
  • Test: internal/platform/expenses/settlement_test.go; delete TestEnteringPayoutIsRefusedUntilChannelsAreWired from transition_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.Env embeds *accountingtest.Env (a pointer), so expensestest cannot embed it — it copies payouttest’s two admin INSERTs, whose exact SQL is pinned in the appendix (“The expensestest helpers Task 14 adds”). payouttest.BankFields() itself is importable and matches the seeded bank_transfer schema (its labels carry en/ms; the countrypack/my seed carries en/ms/zh).

  • Produces:

    type Settlement struct {
    ID, ClaimID, ChannelID, CreditAccountID uuid.UUID
    Amount money.Amount
    Currency 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.RawMessage
    ChannelKey string // generated column
    AccountTail *string // generated column
    ProofDocumentID *uuid.UUID
    SettledBy uuid.UUID
    SettledAt 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.UUID
    ChannelKey, ChannelName string
    AccountTail *string
    Amount money.Amount
    Currency string
    Fields []SettlementFieldView
    ProofDocumentID *uuid.UUID
    SettledBy uuid.UUID
    SettledAt time.Time
    }
    type SettlementFieldView struct {
    Key string
    Label payout.Names
    Display 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 error
    package 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.UUID

    ErrPayoutUnavailable is 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 -> ErrChannelRequired
func 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 it

nosnapshotopen_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, else ErrChannelRequired; ch := payout.GetChannel; Ruling R23’s checks in the order listed — Part B deviation 1: ch.AllowedCurrencies is 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), else ErrChannelCurrency), while ch.AllowedClaimTypes keeps its “empty = every claim type” reading; proof via documents.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) — return err wrapped with %w and no snapshot or value in the message; INSERT settlement (snapshot = $n::jsonb from snap untouched) with settled_at = c.Now() and RETURNING id. No audit here: Task 13’s post-transition audit row carries settlement_id only. SettlementInThisTx: … WHERE org_id = $1 AND claim_id = $2 AND txid = pg_current_xact_id() ORDER BY settled_at DESC LIMIT 1. View: json.Unmarshal into payout.SettlementSnapshot; copy the listed members; never Value, never Sealed.

  • Step 4: Run. PASS. Sabotage: make settle store json.Marshal of payout.OpenSnapshot(snap) instead of snap → TestPlan5NeverOpensASnapshot fails naming settlement.go, and TestPayingAClaimFreezesTheSealedSnapshot fails with 23514 on expense_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 // Kind
    func (AccrualRule) Semantic() posting.Semantic // posting.Accrual
    func (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.PostingRule
    var ErrInstanceMismatch, ErrTotalsDrift error
    const (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 on meals with SVT8 and 60/40 cost-centre shares, and a mileage line 100 km × 0.60 on mileage; walked by Staff and Owner to category_verified through expenses.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.Amount wraps a decimal: never compare with ==/!=); claimant party SELECT party_id FROM organization_member WHERE org_id = $1 AND id = $2; one ledger.Line per active line (Debit: BaseAmount, TaxCodeID, PartyID, Memo, Distribution: Shares); one credit on src.Control.ID. Draft{JournalType: chart.JournalPurchase, Currency: src.BaseCurrency, Description: "Expense claim " + cl.Reference + " " + cl.Title}. reconcile.go: two reconcile.Checks registered in init(); each Finding.Ref is the claim id and Detail names the numbers.

  • Step 4: Run. PASS. Sabotage: make the accrual’s credit amount total − 0.0001 → TestCategoryVerificationAccruesTheClaim fails with ledger.ErrUnbalanced from the Poster; quote; restore.

  • Step 5: Commit — git commit -m "Accrue verified claims to AP and reconcile claims against the ledger".


Waits for Plan 6’s merge (gate P6). Needs Tasks 14, 15.

Files:

  • Modify: internal/platform/expenses/posting.go (add PayoutRule, append it to Rules())
  • 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() string
    func (PayoutRule) Semantic() posting.Semantic // posting.Payout
    func (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 -> ErrNotAccrued
func 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, if snap.Channel.SettlementBehaviour == payout.BehaviourPettyCash, return ErrPettyCashDeferred before reading the ledger — the one branch Plan 8 replaces; open := ledger.Unreversed(ctx, tx, instance) (entry headers only); the one Semantic == ledger.SemanticAccrual header, else ErrNotAccrued; acc := ledger.Get(ctx, tx, header.ID) for its lines; its credit lines (l.Credit.IsPositive()) must be exactly one, else ErrAccrualShape — that line’s AccountID is 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 active chart.Journal from ListJournals whose DefaultAccountID != nil && *DefaultAccountID == s.CreditAccountID, else JournalType: 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:
    package expenses // internal/modules/expenses
    const (
    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.DB
    Clock clock.Clock
    Store documents.Store
    PresignTTL time.Duration
    }
    func Register(g *gin.RouterGroup, deps Deps)
    Routes in this task (all under /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: TestEveryRouteRequiresItsExactPermission exactly in the shape of internal/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().Run on a fresh org lists expense_claim_accrual and expense_claim_ap; TestLinkingTheModuleRegistersStep80.
    • reference_handlers_test.go (the env pattern of internal/modules/accounting/chart_handlers_test.go: a provisioned org, httpx.NewRouter, this module alone, members with minted tokens; an admin with setup.manage + claim.read, a clerk with only claim.read):
func TestListingCategoriesShowsLabelsInTheRequestLocale(t *testing.T) // Accept-Language zh -> label "餐饮" for meals; name stays the ms/en record name
func TestCreatingACategoryNeedsSetupManage(t *testing.T) // clerk 403; admin 201 with account_id
func TestACategoryAccountMustBeAPostableExpense(t *testing.T) // 1520 -> 422 naming account_id
func TestRatesAreCreatedNotEdited(t *testing.T) // POST 201; there is no PATCH route (404/405 from gin); amount as JSON number -> 400
func TestRatesOnADay(t *testing.T) // GET ?kind=mileage&on=2026-09-21 -> only the effective row per band
  • document_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: append expenses.claim.read, expenses.claim.read_all, expenses.claim.transition, expenses.setup.manage after the last accounting.* line (Ruling R37: leave the three existing expenses.* lines alone).
    • boot.go: init() — for _, r := range platformexpenses.Rules() { accounting.RegisterPostingRule(r) }; blank-import nothing else (step 80 and the reconciliation checks register from internal/platform/expenses’s own init(), which this import already links).
    • http.go: copy decodeStrict, optional, decodeProblem, probeFields, parseID, parseDate, actor, blank, firstErr from internal/modules/accounting/http.go (a module does not import another module); add ifMatch(c) (int, error) (missing → apperr.KindBadRequest “If-Match is required: send the ETag of the claim you are editing”; malformed → 400) and setETag(c, rev).
    • document_handlers.go: POST /documents → one InTx with documents.Create. POST /documents/:id/complete → tx1 documents.Get (uploader must be the caller, else 404) → documents.Verify outside any tx → tx2 documents.RecordVerification. GET /documents/:id → uploader, or member.Can(read_all), or any claim from ClaimsOfDocument passes CanRead — else 404; response carries download_url, thumbnail_url (presigned, PresignTTL) and ocr_suggestions from documents.Suggestions.
    • errors.go mapErr (extended by Tasks 18–19): ErrNotFound/documents.ErrNotFound 404; ErrDuplicateKey 409; *CategoryAccountError, ErrInvalidCategory, ErrInvalidRate, documents.ErrTypeNotAllowed/ErrTooLarge/ErrBadDigest/ErrFilenameRequired 422 naming the field; documents.ErrObjectMissing 409 “nothing has been uploaded to this document’s link yet”.
    • Composition: app.Deps.Expenses expenses.Deps; app.Router calls expenses.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-import internal/modules/expenses (permissions, rules, step 80); their wiring_test.go gain the new permission keys and step key expense_categories. Part B (deviation 7), two things not to get wrong: (a) cmd/api already 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 into cmd/worker transitively links internal/platform/payout (through internal/platform/expenses). That does not break Plan 6’s Ruling P28, which is about the payout module (internal/modules/payout), and internal/app/binaries_test.go’s module-parity check covers only cmd/{api,migrate,orgadmin}. cmd/worker installs no payout keyring and needs none: only settle — an HTTP path — calls payout.Snapshot, and PayoutRule.Build merely json.Unmarshals the frozen snapshot (Ruling R44). Do not add config.PayoutDetailsKeysFromEnv to cmd/worker; it would refuse to boot for a key it never uses.
    • api/openapi.yaml: append the eight operations with Category, CategoryInput, CategoryPatch, ExpenseRate, ExpenseRateInput, Document, UploadTicket, OCRSuggestion schemas; money fields $ref: '#/components/schemas/Money', quantity/rate fields Rate.
  • 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, Settlements and Settlement.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 strings
func 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 200
func TestScopeAllNeedsReadAll(t *testing.T) // ?scope=all as Staff -> 403; as Owner -> 200
func TestTheQueueShowsWhatICanDecide(t *testing.T) // ?scope=to_act as Owner after Staff submits -> the claim; as Staff -> empty
func TestListingPagesWithACursor(t *testing.T) // limit=2, next_cursor round-trips; limit=500 -> 422
func TestAttachAndDetach(t *testing.T) // POST documents {document_id} with If-Match -> 200; DELETE -> 200; another claimant's doc -> 422
func TestAGroupCollectsClaims(t *testing.T) // POST /groups {name, kind: "trip"} -> EG-2026-0001; PATCH /claims/{id} {group_id} ok; GET /claims?group_id= lists it
func 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; ifMatch on every mutation; claim reads build the shape above (AvailableTransitions with the caller as actor, Decisions, Settlements). Visibility: after loading, CanRead(…, member.Can(ctx, tx, caller, PermissionClaimReadAll)) false → 404. mapErr gains: ErrNotClaimant, ErrNotOwner 403; *RevisionMismatchError 409 (“the claim is at revision N; reload and retry”); ErrNotDraft, ErrNotAttachable, ErrDocumentOnAnotherClaim, ErrAlreadyAttached 409; *LineError → 422 with detail [line=<id> ]field=<f> rule=<r>: <sentence> (Ruling R43; *SubmissionError joins its problems with "; "); ErrGroupInactive, ErrInvalidClaim, ErrInvalidGroup, ErrBadCursor, ErrLineNotFound, ErrLineWithdrawn, ErrNotYourDocument, ErrDocumentNotVerified, ErrNotAttached → 404/422 as named in each error’s comment; a Postgres refusal on expense_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) error
    var ErrIdempotencyKeyReused error

    Route: POST /expenses/claims/:id/transitions — expenses.claim.transition. Body {"from","to","reason"?,"payout_channel_id"?,"proof_document_id"?}. Header Idempotency-Key required (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) // -> ErrIdempotencyKeyReused
func 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) }); then err = ledger.MapError(err) before mapErr (wave-2 §3: balance and distribution errors surface at COMMIT). mapErr additions: workflow.ErrStaleState 409; workflow.ErrNoSuchEdge, workflow.ErrReasonRequired 422; *workflow.RefusedError 403 with the refusal code; *workflow.SubmissionRefusedError 422 naming each step; *SubmissionError 422 listing problems; Task 14’s channel errors 422; mapPayoutErr (the table from Task 1 Part B) 422 — exactly five rows, all naming payout_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.IneligibleError with rule=<e.Rule> (one of payout.RuleAmount, RuleCurrency, RuleClaimType, RuleMaxAmount), and *payout.InvalidDetailsError joining each Problem.Key + ": " + Problem.Reason with "; " (“the claimant must update their payout details” — this is also how a retired bank arrives, so do not map payout.ErrRegistryEntryNotFound: it never escapes Snapshot, Part B deviation 2); payout.ErrMemberNotFound stays 500 (a wiring bug); *fiscal.PeriodNotOpenError 409; *chart.NoControlAccountError 409 (“accounting is not set up: no AP control account”); *accounting.NoPostingRuleError 500 (a wiring bug); *analytic.RequiredError, *analytic.TotalError, analytic.ErrAxisNotApplicable 422 naming shares; ledger.ErrUnbalanced 500 (a rule bug); payout.ErrNoKeyring, payout.ErrUnknownKey, payout.ErrSealBroken 500 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); ErrPettyCashDeferred 422 naming payout_channel_id (“petty-cash reimbursement arrives with petty-cash floats (Plan 8)”; unreachable through settle, mapped for safety); ErrIdempotencyKeyReused 422.

  • Step 4: Run. PASS. Sabotage: remove the ledger.MapError call → TestACommitTimeLedgerErrorIsMapped red (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 (one case and 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) where PayoutDetailsKeys{Active config.KeyedSecret; Previous *config.KeyedSecret} and KeyedSecret{ID string; Secret []byte} (Part B table row 10 — confirmed as built; the keyring-build pattern to copy verbatim is cmd/api/main.go:54-67); Task 9’s column grant and expense_claim_settlement_reseal_only trigger; audit.Write, audit.WithOrigin; db.Open, db.InTx, tenancy.WithOrg. Tests: expensestest (Task 14’s PayoutKey, 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.go
    type orgReseal struct {
    Org uuid.UUID
    ProfilesResealed, SettlementsResealed int
    ProfilesPending, 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 through e.Channel(t, "bank_transfer") with e.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 the if !changed { continue } guard → TestResealIsIdempotent red (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):

    1. Flags: --org <uuid> (optional: one org), --check (count only), --batch (default 500, 1–5000).
    2. Keys: keys, err := config.PayoutDetailsKeysFromEnv() (fatal on error — the same refusals cmd/api has); build payout.NewKeyring(payout.Key{ID: keys.Active.ID, Secret: keys.Active.Secret}, previous) exactly as cmd/api/main.go:54-67 does (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.
    3. Orgs: --org given → that one. Otherwise listOrganizations(ctx, os.Getenv("DATABASE_MIGRATE_URL")): SELECT id FROM organization ORDER BY id over 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.
    4. Writes: app := db.Open(ctx, os.Getenv("DATABASE_URL"), nil) — the application role, under RLS, like provision and verify-audit. ctx = audit.WithOrigin(ctx, audit.Origin{RequestID: "migrate-reseal-payout-details"}).
    5. runReseal: per org, txCtx := tenancy.WithOrg(ctx, org); app.InTx(txCtx, …payout.ResealProfiles…) — confirmed unbatched as built (one SELECT … FOR UPDATE over every profile of the org under a non-active kid, no limit), so it is one transaction per org, exactly as written here; loop app.InTx(txCtx, …expenses.ResealSettlements(…, keys.Active.ID, batch)…) while more; then app.InTx(txCtx, …) counting expenses.PendingReseal and 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.
    6. Print one line per org — org <id>: profiles resealed N (pending P), settlements resealed M (pending Q) — then done: nothing remains under a previous key or PENDING: …; exit 1 when report.Pending() > 0 or 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] to usage.
  • Step 9: Run. go test ./cmd/migrate/ ./internal/platform/expenses/ -count=1 → PASS (including wiring_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-encrypted
under 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 the
active 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; the
database 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 are
statutory 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".

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.


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’s ErrChannelPettyCash check (internal/platform/expenses/settlement.go) and PayoutRule.Build’s petty_cash branch returning ErrPettyCashDeferred (internal/platform/expenses/posting.go). Plan 8 removes the first and turns the second into a credit to the paying holder’s float account — never Channel.CreditAccountID. The debit side (AP control at the accrual’s frozen base) and SettlementInThisTx stay as they are.
  • Any plan that adds a payment file or finance export, Ruling R44. Plan 5 never calls payout.OpenSnapshot; TestPlan5NeverOpensASnapshot guards internal/platform/expenses and internal/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 removing PAYOUT_DETAILS_KEY_PREVIOUS. A plan adding another table that stores sealed payout data must add it to that command.

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) on expense_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 hold lockInstance (R22 — only workflow.Transition dispatches), 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.

  1. 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 OpenSnapshot caller, 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.
  2. A departed member’s approved but unpaid claim. After the purge (Plan 6 P33) a new settlement calls Snapshot, which refuses ErrNoProfile, 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.
  3. 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.

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-298 states 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 any audit.Write — including the ones inside workflow.Start and workflow.SetAmount — before workflow.Transition in the same transaction. Production paths already comply (Ruling R22 orders the claim’s own audit rows after Transition, and CreateClaim/AddLine are 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. isSubmission is narrower than “leaving draft”. The engine runs its routability check (and therefore Plan 5’s CheckSubmission should match it) only when the move leaves a draft-semantic state for a destination that is neither draft nor reversing (rejected/cancelled): instance.go:261-263. In the seeded v1 flow draft has 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 by Start on the UNIQUE (org_id, kind, source_id) index) and ErrNegativeAmount (instance.go:28, raised by Start/SetAmount). Neither is reachable from Plan 5 — CreateClaim starts against a freshly generated uuid, 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 en and zh only (seed.go:39, ls(en, zh)); there is no ms label on a workflow state or action. Task 18 serializes Available.Labels verbatim, so the response simply has two locales. Nothing to change; noted so nobody writes a test asserting ms.
  • E. workflow.Route’s signature is Route(ctx, tx, def Definition, reporter uuid.UUID, amount money.Amount, on time.Time) ([]StepRouting, error) (authorize.go:114) — it takes a loaded Definition, not a kind. Plan 5 mentions it only in Task 9’s TestTheWorldIsProvisioned prose; that test must workflow.Latest(ctx, tx, workflow.KindExpenseClaim) first. Latest(ctx, tx, kind) (Definition, error) is at store.go:422.
  • F. workflowtest.World (workflowtest/fixture.go:33,44) is Plan 3’s own fixture and provisions nothing Plan 5 needs; Plan 5’s expensestest builds on accountingtest instead (Task 9). No change.
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

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.

  1. AllowedCurrencies is never empty, and it is a strict allow-list. eligible refuses when !slices.Contains(c.AllowedCurrencies, currency) (snapshot.go:251) — an empty slice would refuse every currency, not allow all. It cannot be empty: shapeProblems refuses “allowed_currencies needs at least one currency” (define.go:316) and payout_channel_currencies_valid CHECKs cardinality(allowed_currencies) >= 1 (00090_payout.sql:67). Ruling R23’s “base currency allowed when AllowedCurrencies is 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.
  2. ErrRegistryEntryNotFound never escapes Snapshot. normalize turns a missing or inactive registry entry into a FieldProblem{Reason: "not an active entry of registry <r>"} inside *InvalidDetailsError (fields.go:231-238), and display swallows it and falls back to the raw key (fields.go:266-269). → the row is removed from Task 19’s mapPayoutErr table; the *InvalidDetailsError row already gives it the same 422 sentence.
  3. *IneligibleError is only reachable on RuleAmount or a race. Plan 5’s own R23 checks (currency, claim type, max amount) run before payout.Snapshot, so they refuse first with Plan 5’s typed errors. The one rule Plan 5 does not duplicate is RuleAmount (amount zero or negative). → Task 14’s TestPlan6sRefusalPassesThroughTyped now uses a zero-total claim.
  4. Snapshot answers ErrChannelNotFound for a broken credit account too. Its query INNER JOINs account ON a.org_id = c.org_id AND a.id = c.credit_account_id (snapshot.go:91-98), so an unresolvable credit account is pgx.ErrNoRows → ErrChannelNotFound. The 422 naming payout_channel_id remains the right answer. Recorded so nobody debugs it as a missing channel.
  5. ErrNoProfile needs the channel to have a field. snapshot.go:123-126: with no profile row and len(d.Fields) == 0, the snapshot is built with an empty fields array instead. All seven seeded MY channels carry at least payee_name, so Ruling R46 and Task 16’s ABSENCE case hold. → Task 16 brief notes it.
  6. payouttest.Env embeds *accountingtest.Env (a pointer). It also offers BankChannel(t), SeedRegistryEntry(t, registry, key, name), Other(t, name) and a CashJournal, 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 seeded bank_transfer schema apart from the zh labels (payouttest.BankFields has en/ms; countrypack/my seeds en/ms/zh).
  7. cmd/worker will link internal/platform/payout. Task 17 blank-imports internal/modules/expenses into cmd/worker, and internal/platform/expenses imports internal/platform/payout. Plan 6’s Ruling P28 says cmd/worker does not link the payout module (internal/modules/payout), which stays true, and internal/app/binaries_test.go exempts cmd/worker from the module-parity check. cmd/worker installs no payout keyring: nothing on a worker path seals or opens (only settle, in an HTTP handler, calls payout.Snapshot; PayoutRule.Build only json.Unmarshals — Ruling R44). → Task 17 brief notes it so nobody adds config.PayoutDetailsKeysFromEnv to cmd/worker and breaks a worker that has no key.
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.