Skip to content

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.

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_temp and schema-qualifies its tables. TestEveryFunctionPinsItsSearchPath enforces this.
  • No SET CONSTRAINTS in non-test Go.
  • Commit-time errors. Any handler that posts to the ledger passes db.InTx’s error through ledger.MapError, because balance and distribution errors surface at COMMIT.
  • One org per transaction. The database refuses at COMMIT when app.current_org differs 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.Post and ledger.Reverse before any audit.Write of your own in the same transaction. Posting takes number_sequence row locks and, through the ledger’s line guard, FOR SHARE on every account a line touches. Taking those after your own audit row can deadlock against a concurrent post or chart.UpdateAccount. This comes from Plan 4’s final review.

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.

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.