Phase 1, wave 2 — contracts for planning Plans 5 and 6 in parallel
Plans 5 (expense claims) and 6 (payout channels) are planned together, and built once their inputs exist. Plan 5 consumes Plan 3 (workflow engine, being built on another device) and Plan 4 (accounting core). Plan 6 consumes Plan 4 and the Plan 2 member model. This document fixes what the two plans share, the same way the wave-1 contracts did for Plans 3 and 4. Every rule in the wave-1 document’s §5 still binds.
Authority: the architecture spec
(2026-09-20-erp-architecture-design.md),
§6 for Plan 5 and §9 for Plan 6. This document only divides the work.
1. The seam: Plan 6 owns payout channels, Plan 5 consumes them
Section titled “1. The seam: Plan 6 owns payout channels, Plan 5 consumes them”Plan 6 builds internal/platform/payout and exports exactly these, which Plan 5 calls:
// Channel is one org-defined payout channel (§9.2).type Channel struct { ID uuid.UUID Key string SettlementBehaviour string // "external" | "petty_cash" — closed set CreditAccountID uuid.UUID AllowedCurrencies []string AllowedClaimTypes []string // claim-type keys; empty = all MaxAmount *money.Amount RequiresSettlementProof bool IsActive bool}
// GetChannel reads one channel of the calling org.func GetChannel(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Channel, error)
// Snapshot freezes what a payout will be made against: the channel and the// member's payout profile values, validated against the channel's field_schema.// The claim stores the returned JSON; it is never re-read from live rows.// The JSON is returned already sealed: every masked field's value is// AES-256-GCM ciphertext under the payout keyring (PAYOUT_DETAILS_KEY), bound// to the snapshot's member, channel and field; display, labels and// account_tail stay plaintext. Store it exactly as returned.func Snapshot(ctx context.Context, tx pgx.Tx, channelID, memberID uuid.UUID, claimType string, amount money.Amount, currency string) (json.RawMessage, error)
// SettlementSnapshot is the version-1 shape of Snapshot's JSON. Unmarshalling// stored JSON into it directly gives every field's Display (and an unmasked// field's Value) with no key: the approver's read.type SettlementSnapshot struct { /* Version, Channel, MemberID, ProfileID, ClaimType, Amount, Currency, Fields []SnapshotField, AccountTail — see Plan 6 Task 1 */ }
// OpenSnapshot parses a stored snapshot and decrypts its sealed values into// SnapshotField.Value. Only the path that makes the transfer calls it.func OpenSnapshot(raw json.RawMessage) (SettlementSnapshot, error)
// ResealSnapshot re-encrypts a stored snapshot's sealed values under the// active key during a key rotation; the bool reports whether anything changed.func ResealSnapshot(raw json.RawMessage) (json.RawMessage, bool, error)Refusals are typed errors that Plan 6 defines, and Plan 5 maps them to HTTP. Plan 6 never imports anything from Plan 5. It treats claim types only as string keys.
Revised with Plan 6’s user decisions (Rulings P15, P30–P33). Member payout details are
encrypted at rest (Malaysia PDPA 2010). OpenSnapshot and ResealSnapshot join the seam;
GetChannel and Snapshot keep their signatures. Sealing refusals are payout.ErrNoKeyring,
payout.ErrUnknownKey and payout.ErrSealBroken (Plan 5 maps them to 500). The keyring is
process-wide and installed at boot by cmd/api; Plan 5’s tests get one from payouttest.New
or payouttest.UseKeyring. Plan 5 owns the operator command that reseals claim snapshots
(and calls payout.ResealProfiles) during a key rotation. A departed member’s saved profiles
are purged through member.RunDeactivationHooks; claim snapshots are never touched.
Plan 5’s payout posting rule credits Channel.CreditAccountID, except when the snapshot’s
settlement_behaviour is petty_cash: then it defers to Plan 8, which credits the paying
holder’s float account (user decision, Plan 6 Ruling P6). It registers through
accounting.RegisterPostingRule for kind expense_claim, with semantics accrual and
payout. Reversal is Plan 4’s engine-generic mirror (R7). Plan 5 writes no reversal rule.
When built, Plan 6’s Task 1 must deliver GetChannel, Snapshot, OpenSnapshot and
ResealSnapshot before any Plan 5 task that consumes them.
2. Ownership
Section titled “2. Ownership”| Plan 5 — expense claims | Plan 6 — payout channels | |
|---|---|---|
| Go packages | internal/platform/expenses/…, internal/platform/documents/…, internal/modules/expenses/… |
internal/platform/payout/…, internal/modules/payout/…, Malaysia seeds under internal/platform/countrypack/my/ (payout data only) |
| Migrations | 00070–00089 |
00090–00109 |
| Provisioning step order | 80–89 |
90–99 |
| Permissions | expenses.* |
payout.* |
| OpenAPI paths | /v1/expenses/…, /v1/documents/… |
/v1/payout/… |
00024–00029 stay free for hotfixes on main. The wave-1 shared-file rule (§3) applies
unchanged.
3. Lessons from Plan 4’s reviews: binding on both plans
Section titled “3. Lessons from Plan 4’s reviews: binding on both plans”- Named constraints. Every CHECK, UNIQUE and EXCLUDE constraint has an explicit name. Error mapping is keyed on those names, and a guard test fails on any Postgres-generated name.
- Pinned database functions. Every function or trigger sets
search_path = pg_catalog, public, pg_tempand schema-qualifies its tables.TestEveryFunctionPinsItsSearchPathenforces this. - No
SET CONSTRAINTSin non-test Go. - Commit-time errors. Any handler that posts to the ledger passes
db.InTx’s error throughledger.MapError, because balance and distribution errors surface at COMMIT. - One org per transaction. The database refuses at COMMIT when
app.current_orgdiffers from a ledger row’s org. - Workflow transitions. A transition that dispatches posting must hold the workflow
instance’s row lock (Plan 3’s
lockInstance). The Poster’s once-only rule (R8) depends on it. - Tests. Concurrency tests bound every wait with context timeouts and clean up in
t.Cleanup, so a regression fails instead of hanging. Every guard is shown failing once by sabotage, and every refusal test shows the accepted case first. - Lock order. Take row and advisory locks before the transaction’s first
audit.Write, and always in the same order across code paths (parent before child, or the reverse, but consistently). - Never write an audit row before posting. Call
workflow.Transition,ledger.Postandledger.Reversebefore anyaudit.Writeof your own in the same transaction. Posting takesnumber_sequencerow locks and, through the ledger’s line guard,FOR SHAREon every account a line touches. Taking those after your own audit row can deadlock against a concurrent post orchart.UpdateAccount. This comes from Plan 4’s final review.
4. Statutory: records stay in Malaysia
Section titled “4. Statutory: records stay in Malaysia”The deployment is a Docker VPS in a Malaysian datacenter (ITA s.82(8)). Plan 5 stores documents in an S3-compatible object store running there (MinIO by default).
- Its bucket, its volumes and every backup stay in Malaysia.
- Presigned URLs are short-lived.
- Uploads are verified by content hash.
- Documents are never deleted while the retention clock runs.
OCR stays in Plan 5 as an interface and suggestion only (§6.6).
- The extraction interface and its result shape are built.
- The provider is pluggable and disabled by default, with a no-op implementation.
- OCR output is never written into a record field without the user confirming it.
- Any provider added later must process data in Malaysia. The provider choice is left to the user.
5. Merge order
Section titled “5. Merge order”Plan 3 merges first, then Plan 4 (or the reverse, per the wave-1 rules), then Plan 6, then Plan 5. If the build order differs, the later-merging plan renumbers only within its own range.