Skip to content

Deploy notes

What an operator has to do, or know, when a release changes how the service is deployed. Newest first. Each entry says what breaks if it is ignored.

.env.example is the reference for every variable; this file is the list of the ones that changed and what that means for a running deployment.

Standing procedures — the records-in-Malaysia provisioning checklist, key custody, backups and restore tests, migrations, re-running provisioning — are in RUNBOOK.md.


No migration, no environment variable, no job, no new permission. New optional query parameters on 30 list endpoints (27 screens): q (at most 100 characters; every word must match one of the shown columns, case-insensitive), sort (one key from the operation’s x-sort-keys in api/openapi.yaml, - for descending), and named filters (<column>_from/<column>_to ranges, repeatable pick lists). A request without them behaves as before, except for the changes below. Nothing for an operator to do; an API client that sends these parameters should read this list.

  • active=false now means inactive only. It used to mean “all” on GET /payables/suppliers, /receivables/customers, /inventory/items and /inventory/locations. Absent, or both active=true&active=false, lists all. An empty active= is ignored.

  • active is validated. Any value other than true or false (active=1, active=yes) is 400 on suppliers, customers, items and locations (it used to be read as “not true”, i.e. all), and on asset classes, bank accounts and the members directory, where active is new.

  • q semantics changed where it already existed: suppliers and customers (was one substring of code or legal name; now every word over code, legal name, TIN and currency, so a one-word query returns a superset), employees (was one phrase over name or number; now every word over number, name, status key, hire date and last day), items (was a code prefix or a name in any locale; now a substring of code, the name in the request’s Accept-Language locale else English, type, unit and category). A q over 100 characters is now 400 invalid_query on every one of these lists.

  • Repeatable filters. state (purchase invoices, payments, WHT remittances, receipts, the eight trade document lists), status (payment batches, employees, vacancies, e-invoice submissions, float register), type and category_id (items) accept the parameter repeated (any of). A single value works as before (status on the float register is new); an empty value is ignored. class_id on GET /assets is repeatable too.

  • New filter parameters besides the ranges: GET /assets/classes active and method, GET /hr/vacancies kind (unknown kind 422).

  • GET /assets totals in the response now cover only the listed rows, i.e. after the filters (the handler sums the returned list; with no filter that is every asset of the chosen status, as before).

  • Payments and receipts q also match the base-currency amount. The screens show no base-amount column: the base amount is a second line under the amount, and only on a row in a foreign currency. On a base-currency row it equals the amount, so the extra match is the same number.

  • New 400s (application/problem+json, code): invalid_sort (the message lists the allowed keys), invalid_query (q too long), invalid_cursor (a cursor issued under another sort, or a sorted cursor sent without sort); any other malformed cursor keeps its existing 422. Malformed dates and amounts in the new ranges are 422, as elsewhere.

  • Payment batches holding payslips are now excluded in the query for a caller without payroll.run.read, on every request: such a caller gets full pages where it used to get short ones.

  • Members directory (GET /org/members): sort=roles and role_id need org.roles.manage, sort=positions needs org.structure.read (400 invalid_sort / 403 otherwise). With no parameter the response is unchanged.

  • e-Invoice submissions: issued_from/issued_to and the issued date in q are Kuala Lumpur calendar dates. The web screen now pages with Load more (it showed the newest 50).

  • Table exports follow their list. GET …/export for suppliers, customers, purchase invoices, the eight trade document lists and journal entries takes the same q and filters as its list (and the same sort, except journal entries, which export oldest first whatever the list’s sort), so the file holds what the screen shows across every page. The active=false and repeatable state changes above apply to these exports too.

Web (no server effect, listed because links change): the router now reads q, sort and f_* as exact text on every route (web/src/app/search.ts), so e.g. a claims list link with ?q=123 keeps 123 as text (before, it was parsed as a number, failed the claims search schema and was dropped). The operations-reports tab is in the URL (?report=<key>).

Not verified: no browser pass, no Playwright run, no load measurement (search is ILIKE '%x%' without an index).

Migration 01172 adds anonymised_at and anonymised_by to crm_account, crm_contact and crm_lead, and gives the Owner and Admin roles of existing organizations the new key crm.data.anonymise (perm_version bumped, so held sessions pick it up). New organizations get it through provisioning as before. No environment variable, no job, no provisioning re-run.

  • Holders of crm.data.anonymise see Erase personal data on a person account, on each contact and on a lead. It cannot be undone: the name becomes a placeholder (shown as “Erased”), email, phone and title are cleared, the subject and body of the activities logged on that lead or account are blanked, and the record is archived. The row stays; erp_app still holds no DELETE on CRM tables. An audit entry names who did it, never the erased values. Nothing cascades: a converted lead’s account and contact are erased separately.
  • An erased record stays erased: editing it, linking it to a customer, converting it, or adding a contact or activity to it is refused 422 record_erased. Moving and archiving stay open.
  • Not erased: transition reasons are free text in the immutable workflow log and are not erased; staff should not record personal data in reasons (for example a Lost reason).
  • Not erased either: a person account’s customer link is kept and the linked receivables party (its legal name) is receivables data, handled in receivables, not by this action. Erasing a person account also blanks the names and activities of its opportunities (they carry the person’s name) and refuses new text under them. An erased account’s drawer shows only “Linked to a receivables customer”, not the party’s name.
  • Review who holds the Admin role before the deploy if that matters: they gain this key.

CRM v1 — accounts, contacts, leads, opportunities, activities

Section titled “CRM v1 — accounts, contacts, leads, opportunities, activities”

Migrations 01170 (schema) and 01171 (the Owner and Admin roles of existing organizations get the twelve crm.* keys; perm_version bumped; module_install_key_known learns crm). Optional module crm, on by default for every existing organization: the platform has no default-off switch (a missing module_install row means on), so everyone holding the Owner or Admin role sees Accounts, Leads and Pipeline after the deploy. An administrator who does not want it switches the module off in Modules. New workflow kinds crm_lead and crm_opportunity. No environment variable, no job.

  • Re-run provisioning for every existing organization (POST /v1/org/provision, or migrate provision --org ... --owner-member ...): step workflow_definitions publishes the seeded crm_lead and crm_opportunity graphs. Until then every lead and opportunity route that needs a definition (lists, the pipeline’s stages, creating, converting) answers 409 workflow_not_provisioned, and the CRM pages show that message instead of a board.
  • Who may move a record. The lead’s or opportunity’s owner when it was created is its workflow reporter, and only the reporter moves or converts it (403 not_reporter for anyone else). Changing the owner later does not change who may move it.

Phase 5 payroll P1 — salaries, pay items, the monthly run, payslips

Section titled “Phase 5 payroll P1 — salaries, pay items, the monthly run, payslips”

Migrations 00900–00903 (00904–00919 reserved). Permission keys payroll.salary.read, payroll.salary.write, payroll.item.manage, payroll.run.read, payroll.run.prepare, payroll.run.approve (Owner only: 00903 for existing organizations, the registry for new ones). Optional module payroll (requires hr and payables), on by default. New control kind payroll_payable. New workflow kinds payroll_run and payslip. No environment variable, no job.

  • Not for real payroll until P2’s statutory lines. EPF, SOCSO, EIS and PCB are not calculated in P1; they are entered by hand on each run, and the run screen says so. Do not run real payroll on P1 alone (owner question 3).
  • Re-run provisioning for existing organizations (POST /v1/org/provision or migrate provision): step workflow_definitions publishes payroll_run and payslip; step payroll_accounts (order 84) creates 2180 Salaries payable (payroll_payable), 2190 Statutory deductions payable, 6210 Salaries and wages and seeds the payroll setting with the last two. Until then creating a run answers 409 workflow_missing or payroll_not_configured.
  • Before the first run an administrator chooses the payout channel in Payroll setup (an active bank-transfer channel crediting the bank account batches pay from). A channel restricted by “Used for” must tick Salaries.
  • Who sees what. The accrual posts gross pay, deductions and net pay per named employee (each line carries the employee’s party and a memo with the payslip reference), so anyone with journal or ledger read, or a party or subledger view of 2180, sees each person’s salary month by month; bank reconciliation shows each payslip’s payout amount. Accepted P1 limit. Owner decision: aggregate the expense debit and the deductions credit into one line per run without a party (the per-payslip net line must stay: the payout rule and the batch’s account locks key on it). Run detail shows basic pay to payroll.run.read holders (Owner only by default). Payslip-bearing payment batches are hidden from members without payroll.run.read.
  • Access trail. Salary-history reads, recurring pay item reads, GET /payroll/runs/{id}, GET /payroll/runs (run list) and an admin’s read of another member’s payslip each write one id-only audit row (run id and payslip ids, run ids for the list, employee id and item ids; never an amount). The write routes that answer the same run detail (create, recalculate, inputs) audit the write, not a second read row. Reading a payment batch that holds payslips (GET /payables/batches/{id}), the batch list when it returns a payslip-bearing batch to a payroll.run.read holder (GET /payables/batches, batch ids) and the batch candidates when they include payslips each write one id-only payment_batch read row (no amount, name or payee), and none when there are no payslips. Run and payslip figures also reach the ledger and bank reconciliation (above).
  • Retention. The application never erases payslip, payslip_line, salary or payroll_run rows: erp_app has no DELETE on them (00900, enforced by the retention drift test), salary and payslip_line are append-only and payslip and payroll_run are narrowed by freeze triggers. Any statutory retention period (Malaysian practice: at least 7 years, Income Tax Act 1967 s.82 and Employment Act records) is therefore met by the application as long as the database is retained. The owner and an adviser must still confirm the period itself (owner decision 4), and make sure backups outlive it. This is not a legal conclusion.
  • Switching payroll off leaves existing payslip batches unreadable and unapprovable until the module is re-enabled.
  • Owner decisions still open: (1) confirm the provisional unpaid-leave divisor (below); (2) confirm the provisional proration basis (below); (3) statutory lines manual or zero in P1 (entered by hand; P2 calculates them); (4) confirm the payroll retention period (see Retention above; the application erases nothing, backups must outlive the period); (5) may an approver approve a run containing their own payslip (the engine refuses an approver who entered an input on the run, except in a one-member organization; a salary row or pay item for one’s own record is not approved by anyone); (6) aggregating the accrual’s expense and deductions lines.
  • Unpaid leave: working days over working days. PROVISIONAL until the owner and an adviser confirm it against the Employment Act daily-rate figure (outside-authority list). Leave is counted in working days, and with the divisor setting empty it is divided by the month’s working days under the same calendar (the leave working week, less public holidays applying to the organization), worked out at each calculation. A whole month of unpaid leave deducts exactly the basic. A fixed divisor of 1 to 31 (26, say) can still be set; the month’s calendar days as a moving divisor is no longer an option (a fixed 28 to 31 approximates it). Unpaid maternity or paternity types cannot exist (leave_type_pay_by_kind), so payroll and the leave module count unpaid days alike. With the setting empty, the basic of a partial month (a starter, a leaver, a mid-month salary change) is prorated by working days on the same calendar, per salary row: basic × working days employed under the row ÷ working days in the month. Deduction and proration share one basis, so every worked day is paid in full and a fully unpaid span nets exactly zero. A row whose days are only weekend or holiday days has no basic line. Setting a fixed divisor keeps calendar-day proration (and with it the mixed basis, which can deduct worked days in a short span). Recurring items stay prorated by calendar days. Payslips calculated before this change keep what they stored; recalculate a draft run to apply it.
  • Deferred or documented (final review): approving a run after its month’s period is closed is refused by the ledger (the accrual is dated the period’s last day): approve before closing the month, or decide to post on the approval date. Item lines show an English type-name snapshot to ms and zh readers, and an adjustment’s description is not shown on the payslip. A payroll.salary.write or payroll.item.manage holder can change their own salary or items with no approval. A workflow-editor-added pending-to-pending step is treated as prepare-side by the permission check. A recurring item under RM0.16 prorated over one day rounds to zero and fails preparation. No notification tells an approver that a run awaits them or a payee that they were paid. Payslip existence is distinguishable by id (403 vs 404).
  • Generated files (api.gen.go, schema.d.ts, vocab.gen.go, vocab.ts) conflict on merge: re-run make generate. The client OrgModule.key enum gained assets and payroll.
  • The Down-migrations lose records once payroll is used: 00900’s drops every salary, run and payslip; 00902’s refuses while a payslip is on a batch line. Roll the code back, not these migrations.

Phase 5 H2 — grades, salary bands, headcount, vacancies

Section titled “Phase 5 H2 — grades, salary bands, headcount, vacancies”

Migrations 00821–00824; permission keys hr.grade.manage, hr.headcount.manage, hr.headcount.override, hr.vacancy.read, hr.vacancy.request, hr.vacancy.close, hr.report.read; new workflow kind vacancy. No environment variable, no job.

  • The worker links every module (it now does; it did not before). Permission keys are registered by each module’s init(), so the worker, which seeds Owners for organizations provisioned from the operator console, must link them all; internal/app’s TestEveryBinaryLinksTheSameModules now pins it. Keys for existing organizations come from 00802 and 00824 (they also bump perm_version); keys for new organizations come from the hr module’s init().
  • Every existing position gets an approved headcount (00822): greatest(1, the most people who ever held it as their primary at once). Nothing held today becomes a violation. A seat that was once job-shared keeps the larger number; HR should review them (Headcount screen) and lower any that are now too high — lowering ends no assignment.
  • From 00822 a primary assignment beyond a position’s approved headcount is refused (member_position_within_headcount, HTTP 409 over_headcount), on the org chart and on an HR transfer alike. Scripts that seat people directly in SQL are refused too. The fix is to raise the headcount (hr.headcount.manage) or to have an Owner who is not the requester allow the one assignment (hr.headcount.override, used once). Acting (non-primary) assignments are never refused. orgstruct.Assign now takes the seat FOR NO KEY UPDATE, so every assignment to one seat is serialised, acting ones included (LOCK_ORDER.md).
  • Existing organizations get the vacancy workflow by re-running provisioning (step workflow_definitions; POST /v1/org/provision or migrate provision --org … --owner-member …). Until then requesting a vacancy answers 409 workflow_missing.
  • The committed semantic is Phase 4 O1’s 00700. This lane’s copy (00820) was dropped when Phase 4 merged first; H2 relies on 00700 and nothing in 00821+ names 00820.
  • workflow.CommitmentKinds (internal/platform/workflow/model.go) gains vacancy (H3 adds leave_request), beside Phase 4’s four trade kinds.
  • vacancy_fixed compares values: a vacancy’s fields are immutable, but an UPDATE that rewrites a field to the same value passes. Not a defect while the Go guards stand.
  • Generated files (api.gen.go, schema.d.ts) conflict when this branch merges with another that touched api/openapi.yaml: resolve by re-running make generate, never by hand.
  • The Down-migrations lose records once H2 has been used: 00823’s drops the vacancies and every hire’s link to one; 00822’s drops every override and every headcount — roll the code back, not these migrations.

Migrations 00841 and 00842. 00840 is deliberately not used in this lane. The committed semantic leave_request ends in is Phase 4 O1’s 00700.

Permission keys hr.leave.request, hr.leave.manage, hr.leave.read_all, hr.leave.calendar, hr.leave.adjust, hr.leave.medical.read (00842: the Owner all six; Employee, Approver, Finance and Admin hr.leave.request; Approver hr.leave.calendar; perm_version bumped); optional module leave (requires hr), on by default; River periodic jobs hr_leave_grants and hr_leave_reroute (each daily and on start, maintenance queue). No new environment variable.

  • 00842 widens the module_install_key_known CHECK, which fixes an H1 defect. The CHECK did not list hr, so H1’s hr module could not be switched on or off at all. It now knows hr and leave beside Phase 4’s inventory, procurement, sales and assets (00713, 00772; 00842 sorts after them and replaces their list). Its Down deletes the hr and leave install rows before restoring 00772’s list, so an organization that had switched either off finds it on again after a rollback.
  • 00803 and 00842 were edited after first commit; no repair migration is needed. The merge of Phase 4 into phase-5-hr-design (4553ca14) added Phase 4’s keys to both CHECK lists (fixed_asset; inventory, procurement, sales, assets). Checked 2026-10-02: the earlier text was never on any remote branch (origin/main went straight from e0525f8b to 4553ca14, which already had the edit; origin/phase-5-hr-design was last pushed before either file existed), the dev database erp-dev-pg was rebuilt after the merge and holds the full lists, and the VPS test stack was never brought up (erp.mcommsg.website does not resolve). If a database that ran the earlier text does turn up, 00903 already rebuilds module_install_key_known in full, but import_job_kind_known must be re-added with 00803’s current list by hand.
  • Re-run provisioning for every existing organization (POST /v1/org/provision, or migrate provision --org ... --owner-member ...): the leave_request workflow is a seeded graph, and until it is published a leave request fails no published definition. Readiness shows the workflow_definitions step incomplete until then.
  • Nothing is seeded per organization. An administrator creates the leave types and their entitlement rules (Leave setup, Types) and enters the public holidays (Public holidays). Until a year has a holiday, every working weekday counts as a leave day and the screens say so. The hr_leave_grants job grants only once an active paid leave type has a rule set in force; the leave module switch does not gate the job (Ruling H3-18). An administrator may press “Create starter types” (Types tab; POST /leave/types/starter) for annual, sick, hospitalisation, maternity, paternity and unpaid types: it is optional, creates no entitlement rules, and a second press changes nothing. Migration 00843 adds leave_request.recorded_by and lets the header bell point at a leave request.
  • The Employment Act figures are UNVERIFIED (leave_statutory_floor: annual 8/12/16, sick 14/18/22, hospitalisation 60, maternity 98, paternity 7, recalled from the 2022 amendments, not checked against the Act). They bind every organization’s rules as seeded, and grants can be made from them as soon as rules are entered. A correction is a migration. See “Needs an outside authority” item 15 in the ROADMAP.
  • No leave is granted to a pending employee record: the members 00801 copied have no hire date until HR records the hire (H1).
  • The worker must run both jobs. hr_leave_grants grants each year’s leave (its first run in January is the year’s grant and the roll of last year’s leave; it uses the worker’s default time zone); hr_leave_reroute logs approvals routed around leave. A worker that is down grants late, never twice. The carry-forward setting must exist before 1 January.
  • The worker refuses to start without PAYOUT_DETAILS_KEY (H1’s entry below); this lane adds no other environment variable.
  • Approvers on leave are passed over in every workflow, not only leave requests: every workflow resolves workflow.Unavailable, which platform/hr registers from init(). A binary that does not link platform/hr would not know who is on leave; the cmd/api and cmd/worker wiring tests pin it.
  • The OWNER seat may approve anyone’s leave (a second grant beside the seat above the requester; Ruling H3-11).
  • Who approves the Owner’s own leave (owner decision 2026-09-30, Ruling H3-20): another holder of the OWNER seat if there is one; in a one-member organization the Owner, under the sole-owner exception with a reason. In any other organization an administrator must configure it before the Owner can submit leave: in Workflows, Leave request, add a grant on the Approved step naming a member or a position, with a minimum amount of 1.00 (the OWNER-seat floor), and publish. Until then the submission is refused owner_leave_approver_missing. The requester never approves their own leave; the database refuses it (leave_take_by_requester).
  • Medical certificates need documents.document.upload, which no default role holds. Until the owner decides (ROADMAP, H3 follow-ups), an administrator must grant it, or sick and hospitalisation leave that requires a certificate cannot be submitted.
  • Down-migrations refuse or destroy once leave has been used: 00841’s tables are append-only and its Down drops them with every balance. Roll the code back, not this migration.

Phase 5 H1 — employee record and lifecycle

Section titled “Phase 5 H1 — employee record and lifecycle”

Migrations 00800–00803; permission keys hr.employee.read, hr.employee.manage, hr.employee.sensitive, hr.employee.lifecycle_override (the Owner’s; 00802 grants existing Owners and bumps perm_version); optional module hr, on by default; River periodic job hr_apply_leavings (hourly, on the maintenance queue).

  • The worker now requires PAYOUT_DETAILS_KEY (and reads PAYOUT_DETAILS_KEY_PREVIOUS during a rotation), the same values as the api: hr_apply_leavings deactivates leavers, and deactivation purges their saved payout profiles, which needs the keyring. A worker without it refuses to start. The VPS erp.env and scripts/dev-stack.sh already give both containers the key; a deployment that set it only on the api must add it to the worker.

  • Every member with an employee number gets a pending employee record (00801). It has no hire date: HR records each hire (the Employees screen, or the employees import, which completes a pending record matched by its number). Until then H3 grants such a person no leave.

  • Numbers two members shared are copied once, for the member who had it first. List the rest (as a superuser or a role with BYPASSRLS — every table has FORCE ROW LEVEL SECURITY):

    SELECT m.org_id, m.id AS member_id, m.employee_no
    FROM organization_member m
    WHERE btrim(coalesce(m.employee_no, '')) <> ''
    AND NOT EXISTS (SELECT 1 FROM employee e WHERE e.org_id = m.org_id AND e.party_id = m.party_id);
  • organization_member.employee_no is read-only from 00801: a script that updates it now fails with organization_member_employee_no_read_only. Change numbers on the employee record.

  • The worker must run hr_apply_leavings (hourly, registered in cmd/worker): it is what ends a leaver’s login and seats after their last day. A worker that is down leaves leavers signed in; the job catches up on start. A leaving that keeps failing is logged each hour and shows on the worker’s erp_hr_leavings_failing gauge (failures in the last run): alert when it stays above 0 for a few hours.

  • At the last day, saved payout profiles are deleted. member.Deactivate calls payout.PurgeMemberProfiles (the PDPA erasure ruling), so a leaver’s payout profiles are purged. The H1-9 plan text saying they are untouched is wrong. Consequence, unverified: a claim or advance top-up paid by bank transfer after the last day may have no saved payout profile to pay to.

  • Generated files (api.gen.go, schema.d.ts) conflict when this branch merges with another that touched api/openapi.yaml: resolve by re-running make generate, never by hand.

  • Import kinds and Phase 4 O1/O4: 00803 sorts after 00714 and 00773 and replaces their import_job_kind_known list, so it lists Phase 4’s items and fixed_asset as well as employees.

  • Identity numbers persist in import_job.file_bytes. An employees import that carries an identity column keeps the uploaded file, identity numbers included; import_job is append-only.

  • Down-migrations refuse once H1 has been used: 00803 while an employees import is recorded (import_job is append-only); 00800’s Down drops the lifecycle with the tables — roll the code back, not these migrations.


Phase 4 O4 — fixed assets (00771–00773)

Section titled “Phase 4 O4 — fixed assets (00771–00773)”

Migrations 00771–00773; permission keys assets.asset.read, assets.asset.manage, assets.depreciation.run, assets.disposal.create, assets.disposal.transition; optional module assets; import kind fixed_asset. No environment variable.

  • Migrations. 00771 the five fixed-asset tables; 00772 switches on the optional module assets and grants its five keys to every existing Owner and Finance role (perm_version bumps, so members pick them up at their next token refresh); 00773 the fixed_asset import kind. There is no 00770: the plan’s copy of the effect semantic (its Task 1) was dropped because lane O2’s 00720 had already landed it.
  • Migration order. 00772 sorts before 00773 but touches nothing 00773 does. A fresh or normal deploy applies them in order. A database that somehow applied 00773 before 00772 (for example a branch build run against a shared database) needs goose up -allow-missing once.
  • Re-run provisioning (POST /v1/org/provision, once per organization, after migrating): it seeds the asset_disposal workflow. Until then creating a disposal answers “no published definition”.
  • Deploy the worker, orgadmin and migrate with the api. cmd/worker registers the assets_depreciation worker and its periodic job and carries the disposal’s posting rule; cmd/orgadmin and cmd/migrate link the assets module, so organizations they provision get the asset keys. An old binary provisions organizations without them and never runs depreciation.
  • New River job assets_depreciation on the maintenance queue, every 24 hours (not on start): for each organization with the module on and at least one asset, it runs the previous month’s depreciation once. Nothing to configure. A month whose period is closed gets a skipped run and the next run catches it up — reopening the period is not needed. A scheduled run’s journal entry is a manual entry naming the member on the organization’s root (Owner) seat as its poster; an organization with nobody on that seat is logged (no member holds the organization's root seat) and must run the month by hand.
  • Period close gains a check. asset_register_gl compares the register with the class accounts (RUNBOOK §7.3). An organization whose classes name an account that already carries costs not registered as assets will see its next close refused until those costs are registered (Fixed assets → Register asset) or the close is made unreconciled. It also appears in the operator console’s integrity table.
  • Accounts. Nothing is provisioned: an organization creates a Depreciation expense and a Disposal gain or loss account in its chart before its first asset class.

Migrations 00740–00743; permission keys procurement.{requisition,order,receipt}.{read,create,transition}, sales.{quotation,order,delivery}.{read,create,transition} and payables.invoice.match_override; document layout kinds purchase_order and quotation. No environment variable.

  • 00742 grants the Owner every new key, Finance the eighteen trade keys, Employee the requisition keys and inventory.item.read, Approver requisition read and transition, and bumps perm_version.
  • Deploy the worker with the api. cmd/worker now links the trade module, so organizations the worker provisions get the O3 keys; an old worker provisions organizations without them.
  • Separation of duties is enforced by the database: a goods receipt cannot be posted by any member who approved (or held the authority for) its order. A one-member organization posts it under the sole-owner exception, with a reason on the audit row.
  • Purchase invoices matched to receipts are checked at approval. The tolerance is exact until an administrator sets it (Payables → Invoices → Three-way match tolerance, org.profile.manage). A mismatch blocks approval; an Owner who did not prepare the invoice may approve it with a reason (purchase_invoice_match_override, append-only, and the audit log).
  • A matched invoice clears GRNI; its price difference goes to Purchase price variance (5300), not into stock still held (spec Ruling R-1). Review 5300 at month end.
  • An invoiced receipt, delivery or return cannot be cancelled; void the invoice first.
  • Down-migrations refuse once O3 has been used: 00740 while a receipt was posted under the sole-owner exception, 00743 while an order or quotation layout is saved. Roll the code back, not these migrations.
  • 00742’s Down removes inventory.item.read from every Employee role, including a grant an administrator made by hand before the migration (the migration cannot tell the two apart). Re-grant it after a rollback if Employees had it.

Migrations 00720–00727; permission keys inventory.adjustment.read, inventory.adjustment.create, inventory.adjustment.transition, inventory.transfer.create, and org.profile.manage (listed in constants.yaml before, registered only now). No environment variable.

  • Re-run provisioning for every existing organization (POST /v1/org/provision; RUNBOOK §6). Two new steps — inventory_stock_accounts (81: 1580 Inventory, owned by module inventory; 2170 GRNI, control kind grni; 5200 COGS; 5300 purchase price variance; 5400 Stock adjustment) and inventory_adjustment_reasons (83: count, damage) — and step 60 seeds the five stock graphs (goods receipt, purchase return, delivery order, sales return, stock adjustment). Until then those documents cannot be created and stock cannot post.
  • Deploy the worker with the api. It now carries the stock posting rules and the nightly stock_valuation integrity check (RUNBOOK §7.3); an old worker never runs that check.
  • 00727 grants the Owner role the four stock keys and org.profile.manage, and bumps perm_version, so Owners’ tokens are reissued.
  • The costing method is fixed once stock has moved (00723); choose FIFO or weighted average first. Stock can never go below zero, and a move is never edited (corrections are reversing moves).
  • Known limitation (FIFO): a goods receipt whose purchase return was later cancelled cannot be cancelled itself (ErrLayerConsumed): the cancelled return’s goods come back as a new layer (R-O2-6), so the receipt’s own layer is no longer untouched. Correct it with a new purchase return instead. Accepted for O2.

Phase 4 O1 — document chain and item master

Section titled “Phase 4 O1 — document chain and item master”

Migrations 00700–00702 and 00710–00714; permission keys inventory.item.read and inventory.item.manage; optional modules inventory, procurement and sales. No environment variable.

  • Re-run provisioning for every existing organization (POST /v1/org/provision; see RUNBOOK §6). Step 60, workflow_definitions, now seeds four more graphs — purchase_requisition, purchase_order, quotation, sales_order — and reports the step incomplete until it has. Until then, creating one of those documents fails with “no published definition”.
  • Three optional modules arrive switched on for every organization (no module_install row means on). procurement and sales have no screens until O3; inventory shows Items and Locations to Owners and Finance. An organization that should not see them switches them off in Settings → Modules; procurement and sales must go off before inventory.
  • 00713 grants the Owner and Finance roles both item keys and bumps perm_version, so members’ tokens are reissued with them. It also widens module_install_key_known to the three new modules.
  • Down-migrations refuse once O1 has been used: 00700 while any published workflow state is committed, 00712 while an item custom field is defined, 00713 while one of the three modules has a module_install row, 00714 while an items import is recorded. Roll the code back, not these migrations.
  • Units and categories are shared registries: an organization’s item or trade line may name a global unit or category or one of its own, never another organization’s (00710’s registry_ref_guard; a foreign key alone would not stop it, because foreign-key checks bypass row-level security).
  • Receipts, deliveries, returns and stock adjustments exist in the schema with no workflow until O2 and O3 register theirs. Nothing in O1 posts to the ledger.

Phase 2d D1 — segregation of duties (X1, X2)

Section titled “Phase 2d D1 — segregation of duties (X1, X2)”

Migration 00470, no permission key, no environment variable.

  • X2 — money out is never approved by its preparer. A supplier payment, WHT remittance or SST payment can no longer be approved by the member who prepared it, whatever grant they hold, including an Owner with an all_states grant (403 self_approval), when the organization has another active member. Items the Owner submitted and meant to approve themselves now wait for another member with an approving grant, and a new one whose only approver would be its preparer is refused at submission. List the in-flight ones whose preparer is the only active holder of a member or position grant on their current step, so an administrator can grant a second approver before deploying. Every table involved has FORCE ROW LEVEL SECURITY: run it as a superuser or a role with BYPASSRLS, or it returns no rows. Grants resolved through the org chart (superior, unit_head, levels_up) are not expanded here, and grant amount bands (min_amount) are ignored, so a second holder whose band does not cover the amount still hides the item; check both on the workflow screen:
    WITH holder AS (
    SELECT g.org_id, g.definition_id, g.state_key, g.all_states, m.id AS member_id
    FROM workflow_gate_grant g
    JOIN organization_member m ON m.org_id = g.org_id AND m.active
    WHERE m.id = g.grantee_member_id
    OR EXISTS (SELECT 1 FROM member_position p
    WHERE p.org_id = g.org_id AND p.member_id = m.id AND p.position_id = g.grantee_position_id
    AND p.withdrawn_at IS NULL AND p.valid_from <= current_date
    AND (p.valid_to IS NULL OR p.valid_to > current_date)))
    SELECT i.org_id, i.kind, i.source_id, i.state_key, i.amount, i.currency, i.reporter_member_id
    FROM workflow_instance i
    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 i.kind IN ('supplier_payment', 'wht_remittance', 'tax_return_payment') AND s.semantic = 'pending'
    AND (SELECT count(*) FROM organization_member m WHERE m.org_id = i.org_id AND m.active) > 1
    AND EXISTS (SELECT 1 FROM holder h WHERE h.org_id = i.org_id AND h.definition_id = i.definition_id
    AND (h.all_states OR h.state_key = i.state_key) AND h.member_id = i.reporter_member_id)
    AND NOT EXISTS (SELECT 1 FROM holder h WHERE h.org_id = i.org_id AND h.definition_id = i.definition_id
    AND (h.all_states OR h.state_key = i.state_key) AND h.member_id <> i.reporter_member_id);
  • X1 — the sole-owner exception. An organization with exactly one active member may approve its own supplier payment, WHT remittance or SST payment, verify its own supplier bank change and payout details, approve and mark paid its own payment batch and write its files, and approve its own budget, each with a mandatory reason (English or Bahasa Malaysia, at most 500 characters; 422 sole_owner_reason_required when blank). The audit row carries sole_owner_exception and the reason; an engine decision also has authority_via = 'sole_owner_exception'. 00470 adds the flag columns party_supplier.bank_verified_sole_owner, member_payout_profile.verified_sole_owner, payment_batch.approved_sole_owner, budget_version.approved_sole_owner and payment_batch_file.generated_sole_owner, and relaxes each “second person” CHECK (and the batch-file trigger) to allow a flagged row only. The exception ends the moment a second member is active.
  • Rollback: safe only until the exception has been used. 00470 Down restores the old batch-file trigger, drops the five flag columns, and re-adds payment_batch_approver_differs, member_payout_profile_verifier_differs and party_supplier_bank_verifier_differs as validated CHECKs (budget_version_preparer_not_approver stays NOT VALID). Once any batch, payout profile or supplier bank change has been decided by its own maker under the exception, the Down fails on that CHECK and, being one transaction, changes nothing (the Down never rewrites a decided record); past that point, fix forward instead. A budget approved under the exception does not stop the Down, but its flag column is dropped. Either way the audit rows and workflow_decision rows flagged sole_owner_exception stay (the audit chain is append-only). The old code never writes the audit flag for these kinds and does not read it, so after a rollback those rows are history only: only the audit log and workflow_decision.authority_via say the decision was taken under the exception.

Phase 2d D2 — cash posts on the day it moved (X4, X8)

Section titled “Phase 2d D2 — cash posts on the day it moved (X4, X8)”

Migration 00480, no permission key, no environment variable. Supplier payments post on payment_date, AR receipts on receipt_date, and a batch’s payouts on its value date, each moved to the first open date (with the reason in the entry) when that period is closed. Marking a batch paid before its value date is refused (value_date_future). A payment’s approval amount is now the gross settled, so an approver may meet a higher band than before on payments with tax withheld. The gross is set when a draft is saved: a payment already submitted before deploy keeps its net approval amount and is approved against the lower band. List them (superuser or BYPASSRLS, as for D3) and, where the gross falls in a higher band, have them rejected, revised, saved again and resubmitted:

SELECT p.org_id, p.reference, i.state_key, i.amount AS approval_amount,
p.base_amount + sum(d.base_amount) AS gross_base
FROM supplier_payment p
JOIN workflow_instance i ON i.org_id = p.org_id AND i.id = p.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
JOIN wht_deduction d ON d.org_id = p.org_id AND d.payment_id = p.id AND d.withdrawn_at IS NULL
WHERE s.semantic = 'pending'
GROUP BY p.org_id, p.id, p.reference, i.state_key, i.amount, p.base_amount
HAVING i.amount < p.base_amount + sum(d.base_amount);
  • Known limit (M-6): marking a batch paid re-dates each paid supplier payment to the batch value date, and re-freezes its withholding due date from it. The withholding code’s validity (valid_from/valid_to) and the payment’s FX rate are checked at the payment’s own date when it is saved, and are not re-checked at the value date. This matters only if a code’s validity boundary falls between the two dates, or if a foreign-currency batch’s rate has moved; check such payments by hand before marking the batch paid.

Phase 2d D3 — tax configuration guards (X3, X5, X6, X7)

Section titled “Phase 2d D3 — tax configuration guards (X3, X5, X6, X7)”

Migration 00490 (X7), no permission key, no environment variable. New refusals, each coded and translated (reactivating a retired tax code and tightening an account to sst_payable or wht_payable re-run X3/X7); none applies retroactively — existing rows keep working, only new writes are refused. Run these before deploying to know who will meet a refusal. They read across organizations, and every table involved has FORCE ROW LEVEL SECURITY, so run them as a superuser or a role with BYPASSRLS (the postgres role in DATABASE_MIGRATE_URL); as any other role they return no rows, which reads as “nobody affected”:

  • X3 — purchase_code_sst_payable, wht_payable_non_withholding: creating a purchase or both code on the sst_payable account (SST gives no input-tax credit, so a purchase never touches 2140), or a non-withholding code on wht_payable. Existing ones still post; retire each (PATCH active: false) and create a correct one:
    SELECT t.org_id, t.code, t.usage, a.code AS account, a.control_kind
    FROM tax_code t JOIN account a ON a.org_id = t.org_id AND a.id = t.account_id
    WHERE (a.control_kind = 'sst_payable' AND t.usage IN ('purchase', 'both'))
    OR (a.control_kind = 'wht_payable' AND t.usage <> 'withholding');
  • X5 — return_base_currency, wht_base_currency: an organization whose base currency is not MYR can no longer create or update its SST registration, save a supplier payment that withholds tax, or create/update a WHT remittance. Existing drafts can still be approved, paid or voided, and an existing registration’s returns still finalise. The base currency itself can no longer change while a tax registration or a live WHT deduction exists (company.ErrBaseCurrencyInTaxUse, sentence base_currency_in_tax_use; no HTTP route changes the base currency today). Affected organizations:
    SELECT cp.org_id, cp.base_currency,
    EXISTS (SELECT 1 FROM tax_registration r WHERE r.org_id = cp.org_id) AS sst_registered,
    EXISTS (SELECT 1 FROM wht_deduction d WHERE d.org_id = cp.org_id) AS has_wht
    FROM company_profile cp WHERE cp.base_currency <> 'MYR';
  • X6 — a WHT deduction is now due the day before the same date next month (clamped; unverified, stricter reading). A deduction’s due_on is frozen when its payment is saved, so existing deductions keep the day-later date and the register’s late flag reads it; re-saving a draft payment rewrites its deductions with the new date.
  • X7 — migration 00490 adds tax_code.standard_code: the country-pack withholding code a withholding code derives from (its payment type — W109-INT interest, W109-ROY royalty, …), fixed with the rate. The API now requires it on a withholding code (wht_standard_code when missing, unknown, or of another section) and refuses a rate below that code’s rate without a treaty reference (treaty_reference_required). The backfill makes each Malaysia pack code its own standard and gives an organization’s own code its section’s pack code where the section has exactly one (s109B, s109F); every other withholding code stays NULL — it keeps posting, but can no longer be reactivated once retired. Review these by hand (payment type unknown, or below its standard with no treaty); retire and re-create any that need a treaty or a standard code:
    SELECT t.org_id, t.code, t.pack_kind, t.rate, t.standard_code, s.rate AS standard_rate
    FROM tax_code t LEFT JOIN tax_code s ON s.org_id = t.org_id AND s.code = t.standard_code
    WHERE t.usage = 'withholding' AND t.treaty_reference IS NULL
    AND (t.standard_code IS NULL OR t.rate < s.rate);

Phase 2 D4 — bank reconciliation lock segregation and the close gate (00500–00502)

Section titled “Phase 2 D4 — bank reconciliation lock segregation and the close gate (00500–00502)”
  • Closing a period or year now waits for the bank. Every active bank account with anything to reconcile by the period’s end must be reconciled and locked through that end (BR-11); otherwise the close is refused (409 bank_unreconciled, the accounts listed). Month-end routines that closed before the bank statement arrived now stop there. A bank whose statements do not end on the period’s last day (the 25th, or the last business day) is locked through the period end only once the next statement is locked, so a month-end close waits a statement cycle or uses the override. A payment or receipt posted after a lock but dated inside it (D2) also holds the close and deactivation, until a later reconciliation is locked or the Owner unlocks and re-locks that one; the refusal flags such an account posted_after_lock. 00502 records each new lock’s transaction snapshot so “posted after” is exact even for a posting that commits while the lock is taken; locks taken before it compare posted_at with locked_at.
  • New key accounting.period.close_unreconciled (00500), granted to existing Owners (and to new Owners with every key); perm_version bumps. Its holder may close anyway with a reason, audited with the list of accounts. No other role gets it.
  • X9: a member who made a manual or journal match or an opening clearance in a reconciliation cannot lock it (409 lock_own_matches); with one active member, the sole-owner exception applies (a reason, audited).
  • Finance now holds banking.reconciliation.lock (00501 for existing Finance roles, provisioning for new ones; perm_version bumps). The key also allows opening clearance. Unlocking stays Owner-only. Without this the Owner, who clears the opening, could never have the first reconciliation locked.
  • Rollback: 00502 Down drops the snapshot column; 00501 then 00500 Down remove the keys; the code must roll back with them.

The five lanes below (C1 business date, C2 tax controls and reports, C3 e-Invoice, C4 payables and batches, C5 banking and budgets) each have their own section with the detail; this is the shape of deploying them together.

Migrations. 00430–00436 (C2), 00440–00444 (C3), 00450–00452 (C4), 00460–00464 (C5); C1 has none. Each lane owns its range (internal/kernel/db/migrationranges_test.go) and changes only its own objects, so the order between lanes does not matter: one migrate up applies them all.

Permission keys. Two new ones, each granted by migration to existing Owners only (and to new Owners with every key); perm_version bumps so live sessions refresh once:

  • accounting.opening_balance.post (00436) — post or reverse a go-live balance on 2140/2160. No other role gets it.
  • banking.reconciliation.unlock (00462) — unlock the latest bank reconciliation lock. Finance does not get it.

Also (O-9): on-screen reports and exports now need the owning module’s read key; a custom role holding only reports.* loses the AP/AR aging, customer statement, WHT register, budget vs actual and SST return screens and exports, and the bank reconciliation export, until it is granted that key.

Environment. One new optional variable, MYINVOIS_SUBMIT_RPM (default 12); see C3.

Provisioning. No lane needs a re-run of POST /v1/org/provision. By hand, per organization: C2 — an organization 00430 could not map shows control_kind_unmapped in readiness until an administrator sets the SST/WHT payable kind, and a two-number registration must be saved with its one number before its next return finalises; C4 — each employee payee’s bank details must be verified before their first batch; C2 — a return finalised before deploy with imported-services SST is amended only after its manual journal is reversed (C2’s section has the procedure).

Rollback. Roll the migrations back newest first (00464 down to 00430); since the lanes touch only their own objects, stopping partway leaves a consistent schema. These Downs fail after first use — roll forward instead:

  • 00430 while a workflow state names sst_payable/wht_payable;
  • 00432 while a 0% (treaty) withholding deduction exists;
  • 00443 once a decision rests on the sole-owner exception;
  • 00451 while a batch has a claim or advance line;
  • 00460 once a batch auto-match exists;
  • 00461 once a date has been locked twice (and, when it succeeds, it drops every unlock, so an unlocked period reads as locked again — do not roll back past it after an unlock);
  • 00463 while an export row is a bank_reconciliation.

These succeed but drop recorded data: 00431 (registration numbers), 00434 (adjustment expense accounts), 00442 (recorded portal checks), 00450 (payout-profile verifications), 00452 (settlement batch references). 00464 is NOT VALID and its Down only drops it.


Phase 2c C1 — business date is the organization’s day (O-1)

Section titled “Phase 2c C1 — business date is the organization’s day (O-1)”

“Today” is now the calendar day in company_profile.timezone (default Asia/Kuala_Lumpur), not the UTC day. Between 00:00 and 08:00 MYT every business date the system assigns — invoice and posting dates, allocation effective dates, numbering years, fiscal/SST/WHT period checks, due-date and future-date refusals, aging and report default as-of — is the Malaysian date instead of the previous day.

  • No migration, no provisioning, no configuration. The zone is read from company_profile in the same round trip as the tenant setting, once per transaction. An organization with no profile row is treated as Kuala Lumpur, never UTC.
  • Existing rows are not rewritten. Anything dated before the deploy keeps the UTC date it was given; the change applies from deploy. A document created between 00:00 and 08:00 MYT before the deploy therefore carries the previous day’s date, as it always did.
  • Unchanged on purpose: MyInvois IssueDate/IssueTime stay UTC (the protocol says so); document object keys keep a UTC year (a storage partition).
  • The Go binary now embeds the zone database (time/tzdata), so the image needs no /usr/share/zoneinfo.

Phase 2 completion C2 — tax controls and report visibility (00430–00436)

Section titled “Phase 2 completion C2 — tax controls and report visibility (00430–00436)”

No provisioning step and no new variables. What changes for a running deployment:

  • 00430 (O-7) gives each existing organization’s SST payable account sst_payable (the tax registration’s account; with no registration, the single account the active positive-rate sales codes name) and its WHT payable account wht_payable (the single account the active withholding codes name). account_ledger_guard is suspended for those two statements only: the change adds a refusal of future manual lines and rewrites no posted line. An organization where none or several accounts qualify is left alone and shows control_kind_unmapped in readiness. The supported fix (00433): a member with accounting.account.manage sets the kind on the right account — Chart of accounts → the account → Control kind “SST payable” / “Withholding tax payable”, i.e. PATCH /v1/accounting/accounts/{id} {"control_kind":"sst_payable"}. This works even when the account has postings: the ledger guard lets exactly this tightening through (no kind → sst_payable/wht_payable), audited as an account update; loosening or changing a kind stays refused. To list the organizations still to do, as the migration role:

    SELECT o.id, o.name, k.kind FROM organization o CROSS JOIN (VALUES ('sst_payable'),('wht_payable')) k(kind)
    WHERE NOT EXISTS (SELECT 1 FROM account a WHERE a.org_id = o.id AND a.is_active AND a.control_kind = k.kind);

    One account per organization carries each kind (a control kind resolves to exactly one account; the pack names one 2160 for every section), and from deploy a new withholding code must credit the wht_payable account. An organization whose existing withholding codes name several accounts keeps the others as plain liabilities: point future withholding at the wht_payable account by retiring those codes and re-creating them on it.

    From deploy, **manual journals and banking’s journal-from-line refuse 2140/2160. Imported-services SST now posts itself when a return is finalised (00434, each imported-services adjustment names its expense account; a draft saved before deploy, or an amendment copying an adjustment from a return finalised before it, is refused at Finalise with 409 adjustment_account_missing until the account is chosen). Amending a return finalised before deploy that carries an imported-services adjustment: that version’s SST was booked on 2140 by a manual journal, and the amendment’s accrual reverses only taxreturn’s own entries, so the tax would be booked twice. First reverse the manual journal (the ordinary reversal, dated as the journal itself so the period reconciles; it is allowed although 2140 now refuses new manual lines), then finalise. Until the request confirms {"prior_manual_booking_reversed": true} Finalise refuses with 409 prior_manual_booking_unreversed; the web asks for the confirmation, and it is recorded on the finalise audit row. A go-live balance on 2140/2160 is posted once, as the first entry on the account, with POST /v1/accounting/entries {"opening": true, …} (no web form yet): it needs accounting.opening_balance.post (00436 grants it to existing Owners; new Owners get it with every key; no other role does) and is dated on the first day of the earliest fiscal year. A mistaken one may be reversed (the same key) and entered again while nothing else has posted to the account; the WHT drift test reads 2160 as open deductions plus that opening balance. Post it before closing the first period: once that period is closed, neither the opening nor its re-entry can be posted until the period is reopened.

  • 00431 (O-4) adds tax_registration.registration_no, copied from a one-number company profile. A registration whose profile holds two numbers (A;B) is left blank: finalising its next return refuses (registration_no_missing) until an administrator saves the registration with the one number the return is filed under.

  • Rolling back 00430 clears both kinds from every organization, newly provisioned MY ones included (the code being rolled back to has no such kinds), and fails while a workflow state names either kind (only taxreturn’s or payables’ own graphs may, since fix round 1 I-1; none of the seeded graphs do).

  • 00432 (O-5) adds tax_code.treaty_reference and lets a withholding deduction be zero at a 0% treaty code. Its Down fails while a 0% deduction exists.

  • Report views and exports (O-9): viewing on screen, requesting or downloading an AP/AR aging, customer statement, WHT register, budget vs actual or SST return export now also needs the module’s read key (403 report_module_permission). Owner and Finance already hold them; a custom role holding only reports.* loses those exports.

  • 00435 re-creates wht_remittance_line_guard so a zero (treaty-exempt) deduction can never be on a remittance line; nothing to do.

  • WHT remittances (O-5): a remittance with a recorded receipt number can no longer be voided (409 receipt_recorded_no_void).


Migrations 00440–00444, no provisioning:

  • 00440: an e-Invoice’s statutory link (submission_uid, uuid, long_id, validation_url, validated_at) is write-once (the einvoice_submission_frozen trigger).
  • 00441: purchase_invoice.cancelled_at is write-once; a sales document’s first cancelled_at must come with its move to a cancelled state (a deferred constraint trigger). A raw UPDATE of either, outside the app, is now refused.
  • 00442: the recorded MyInvois portal check (Ruling O-3c) on einvoice_submission.
  • 00443: workflow_decision.authority_via also takes sole_owner_exception (Ruling O-3a).
  • 00444: making a member active again takes a lock on its organization row, as adding one does, so the sole-owner exception’s member count cannot miss it.

Down migrations are for before first use only. Once a decision rests on the exception, 00443’s Down fails (decision rows are append-only and still carry the value), and 00442’s Down drops the recorded portal checks (the audit rows keep their history). Roll forward instead.

Environment. MYINVOIS_SUBMIT_RPM (optional, default 12) paces the submission POST. The API and the worker now pace every MyInvois call at the per-endpoint limits the MyInvois design records from the SDK (login 12, get submission 300, document details 125, recent 12, cancel 12 per minute, per intermediary client ID); the design records no figure for submit, so it is configured. Each process paces on its own: two API and worker processes on one client ID can together reach twice a limit. Add to the sandbox checklist: confirm LHDN’s submit limit and set MYINVOIS_SUBMIT_RPM to it, and that the window watch’s document-details calls stay under 125 a minute at the expected volume. Each 15-second sweep first submits and polls for every organization, then spends at most 31 document-details calls on the window watch (125 a minute), carrying the rest to the next sweep; at ~1,000 documents a day each valid document is then looked at less often than every 15 minutes rather than over the limit.


Phase 2 completion C4 — payables and batches

Section titled “Phase 2 completion C4 — payables and batches”

Migrations only (00450–00452); no new variable, job, permission or provisioning step.

  • 00450: an employee’s payout details carry verified_by/verified_at. Every existing profile starts unverified, so before an organization’s first batch of claims or advances a holder of payables.supplier.verify_bank (not the employee) must verify each payee’s bank details — from the new-batch dialog. Saving new details clears the verification.
  • 00451: a batch line is a supplier payment, an expense claim’s payout or an advance’s payout. Its Down refuses while any claim or advance line exists.
  • 00452: a claim’s or advance’s settlement records the batch that paid it, and its bank memo carries the batch reference. Its Down drops that column (the batch line still names the source).
  • O-12: an approved purchase invoice now posts on its invoice date, not the approval date; a date in a closed period posts on the first open date after it and says so in the entry description.

Migrations 00460–00464, no variable, job or provisioning step:

  • 00460 — a bank statement must start after the previous one ends (bank_statement_chain); the auto-match rule batch is allowed on bank_match.rule. Its Down restores the old bank_match_rule_known CHECK and therefore fails once any rule = 'batch' match exists.
  • 00461 — bank_reconciliation_unlock (append-only, RLS). bank_locked_through ignores unlocked locks, and bank_reconciliation_once is dropped so an unlocked date can be locked again. Its Down re-adds that UNIQUE and therefore fails once any date has been locked twice. When no date was re-locked the Down succeeds but drops every unlock row: each unlocked lock counts as live again (bank_locked_through reverts), so a period edited after its unlock silently reads as locked under the stale snapshot. The unlock events survive only in audit_log. Do not roll back past 00461 once an unlock has been recorded.
  • 00462 — existing Owners gain banking.reconciliation.unlock; perm_version bumps so live sessions refresh once. Finance does not gain it.
  • 00463 — report_export_report_known is the ten reports 00404 listed plus bank_reconciliation; the Down restores 00404’s list (and fails if an export row uses it).
  • 00464 — budget_version_preparer_not_approver, NOT VALID: versions approved by their own preparer before this release are kept unchanged; every approval from now on is checked.

The batch auto-match finds a batch’s payout lines by the batch reference on each payout’s bank-line memo (payment-batches Ruling B15). A future batch line kind whose payout does not carry that reference will not be auto-matched (manual matching still works).


The eight lanes below (payment batches, MyInvois, SST-02, WHT, AR, budgets, bank reconciliation, AP) each have their own section with the detail; this is the shape of deploying all of them together.

Migration order. Each lane owns a number range (internal/kernel/db/migrationranges_test.go): AP 00300–00309, AR 00310–00329, payment batches 00330–00339, WHT 00340–00349, bank reconciliation 00350–00369, budgets 00370–00379, MyInvois/ self-billed e-Invoice 00380–00399, SST-02 00400–00409, Phase 2 integration 00410–00419 (00410: a sales document’s cancelled_at is write-once, a trigger; no provisioning). The ranges interleave in merge order, not number order — 00330/00332 (payment batches) and the WHT/budgets/ MyInvois report-export fixups land below 0034x–0038x. A fresh production database applies every one of them in a single migrate up. A database that already has some of them — a lane branch, staging, or anything migrated partway — needs goose’s allow-missing, which migrate up already runs; see “Wave 3 base — migrations apply out of order” below.

The report-export CHECK chain. report_export_report_known was rewritten five times as lanes merged out of order and each rewrite clobbered the previous lane’s reports: 00343 and 00373 are interim unions (Ruling T9-R1), 00384 is the Phase 2 integration union, and 00404 is the final one, listing all ten reports (trial_balance, profit_and_loss, balance_sheet, general_ledger, ap_aging, wht_register, ar_aging, customer_statement, budget_vs_actual, tax_return). After a full migrate up only 00404’s list counts. Rolling back past a union migration fails if any report_export row already uses a report the older list being restored doesn’t know. Each union’s Down restores the list in force just before it on the merged order. TestTheExportCheckAcceptsEveryReport guards the chain.

Re-provisioning. Run POST /v1/org/provision once per organization, after migrating, to cover every lane that needs it in one pass: AP (Trade payables 2105, purchase tax codes, three workflow kinds), AR (three workflow kinds), MyInvois (step 78, sales workflow v2), WHT (account 2160, six withholding codes, wht_remittance), and SST-02 (tax_return_payment). Payment batches, budgets and bank reconciliation need no provisioning step. There is no all-organizations batch; see each lane’s section and the Wave 3 base note below.

Role grants reaching existing organizations through migrations:

  • AP 00305 — Owners gain payables.*; Finance gains it without verify_bank, plus accounting.ledger.read.
  • AR 00313 — Owner and Finance gain receivables.*.
  • Payment batches 00331 — Owners gain payables.batch.read/.manage/.approve; Finance gains read and manage.
  • WHT 00342 — Owner and Finance gain payables.wht.read, .create, .transition.
  • Bank reconciliation 00353 — Owners gain all six banking.* keys; Finance gains five (not banking.reconciliation.lock).
  • Budgets 00371 — Owners gain accounting.budget.manage and .approve; Finance gains accounting.budget.manage.
  • MyInvois 00382 — Owners gain einvoice.read, .submission.manage, .settings.manage; Finance gains read and .submission.manage.
  • SST-02 00402 — Owner and Finance gain the five taxreturns.* keys.

Each of these bumps perm_version, so live sessions refresh once.

Owner decisions open before go-live. Each lane in ROADMAP.md lists its own open decisions (owner’s call, not defects) — AP, AR, payment batches, MyInvois, SST-02, WHT, bank reconciliation and budgets each have a bullet list under their checklist item. Read those before switching a customer into any of these lanes; none is fixed by this integration.


Migrations 00330–00332 add payment_batch, payment_batch_line and payment_batch_file (the bank file’s bytes and SHA-256, kept seven years in Postgres; erp_app cannot update or delete a file). Existing Owners gain payables.batch.read, .manage and .approve; existing Finance roles gain read and manage. perm_version bumps so live sessions refresh once. 00332 makes the database refuse a bank file written by the batch’s preparer (Go already did). No new variable, job or provisioning step. Bank files contain full account numbers: the Postgres backups that already hold supplier bank details now also hold these files.

v1 ships one file format, generic_csv_v1 — this project’s own CSV layout, not any bank’s. The owner must confirm the exact bytes a receiving bank’s DuitNow bulk / GIRO upload actually expects (column order, delimiter, encoding, line endings) before this batch file is presented to a bank for production payment; see the design spec’s For the owner to confirm (docs/superpowers/specs/2026-09-27-payment-batches-design.md).

Migrations 00380–00382 add e-Invoice submission and self-billed purchase e-Invoicing (spec 2026-09-27-myinvois-design.md). After migrating, run POST /v1/org/provision for every organization: step 78 moves an unedited organization’s sales workflow graphs to v2 (the issued → voided “Cancelled” exit); an organization that customized its sales graph is left untouched with a warning instead.

Environment. The worker submits and signs, so it needs MYINVOIS_API_URL, MYINVOIS_IDENTITY_URL, MYINVOIS_PORTAL_URL, MYINVOIS_CLIENT_ID, MYINVOIS_CLIENT_SECRET, and for signing MYINVOIS_SIGNING_CERT_FILE + MYINVOIS_SIGNING_KEY_FILE — Docker secrets, mode 0400, mounted on the worker container only, excluded from backups. The API cancels and reads status at LHDN but never signs, so it needs only the first five (no signing key).

Converting the CA’s .p12 (once, when the certificate is issued or renewed):

openssl pkcs12 -in cert.p12 -clcerts -nokeys -out cert.pem
openssl pkcs12 -in cert.p12 -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt -out key.pem

Delete the intermediate .p12 and any decrypted copies once cert.pem/key.pem are in place. Put the certificate’s expiry (logged by the worker at boot) in the operator’s calendar.

Customer onboarding. Each organization must appoint this provider as intermediary in the MyInvois portal before its “in scope” switch is turned on — a customer switched on without that step gets forbidden failures until it is authorized there.

Go-live checklist — run against the LHDN sandbox, before any customer is switched in scope. Everything shipped in this release was verified only against cmd/myinvois-fake, our own local stand-in; none of it has touched LHDN.

  1. Login as intermediary onbehalfof a sandbox TIN.
  2. Submit and validate one document of each type 01, 02, 11, 12.
  3. Submit one deliberately invalid document and confirm LHDN’s own errors are what is shown.
  4. Submit one signed version 1.1 document and confirm it validates (signature shape, documentHash hex).
  5. Cancel one document within 72 hours.
  6. Confirm the PDF’s QR code opens the document in the sandbox portal.
  7. Submit the credit note of a pre-mandate invoice (UUID NA) and confirm it is accepted, or change the rule if the sandbox rejects it.
  8. Re-read the code tables (states, classifications, tax types) against the SDK and diff them against this release’s goldens (internal/platform/countrypack/my/myinvois’s own output — not yet checked against the SDK’s own samples) — including that schemeID is NA and that quantities are encoded to 10 decimals.
  9. go test -tags myinvois_sandbox ./internal/platform/countrypack/my/myinvois/... passes.

Also confirm before go-live, from behaviour observed only against the fake:

  • The Recent endpoint (documents/recent, used for unknown-outcome recovery) takes date-only vs. date-time submission-date parameters correctly, and its page size (100) and totalPages shape match the SDK.
  • LHDN’s real 10-minute duplicate rule behaves as D9 assumes (resend only once the window has passed with the document still not found).
  • /connect/token answering non-2xx is treated as “login refused”, including when the identity URL path itself is wrong (a 404 from a typo’d path looks the same as a real refusal).

Record the sandbox checklist’s date and the operator who ran it in the runbook.

Never point MYINVOIS_API_URL at a loopback address in production — the fake validates everything unconditionally, so a misconfigured deployment would “succeed” without LHDN ever seeing a document.

  • PDF header lines shrink instead of overflowing. Every PDF (report exports and the AR invoice) now draws a header line that is too wide for the page, or for the space beside an invoice’s e-Invoice QR code, in a smaller font, down to 5pt. Before, it ran off the page. Lines that already fitted are unchanged, and exports stored earlier keep their bytes.

Migrations 00400–00403 add the tax-return engine: tax_registration, tax_return, tax_return_line, tax_return_adjustment and tax_return_payment. They sort after every other lane’s 0040x, so apply them last among this release’s migrations. Existing Owner and Finance roles gain the five taxreturns.* keys (return.read, return.manage, return.finalise, payment.create, payment.transition) through 00402; perm_version bumps so live sessions refresh once. 00404 restores budget_vs_actual to the report-export CHECK, which 00403 had dropped when it added tax_return; it allows all ten reports.

After migrating, re-provision every organization (POST /v1/org/provision) so it gains the tax_return_payment workflow kind. No chart or tax-code change: the engine reads AR’s existing sales tax codes and account 2140 (the reconciliation target) as they already stand.

Each SST-registered organization must then set up its registration — SST registration number, effective date, first taxable period’s end, months per period — on the Tax returns screen before its first return; that screen is also what writes the SST number onto the company profile (previously blank). Nothing is generated retroactively for a period before the registration’s effective date.

Ruling SST-R11 renamed several of this feature’s routes and fields to match the design spec’s API section before the web client was built against them; api/openapi.yaml and the generated TypeScript client are already consistent with the final names, so there is nothing for an operator to rename.

Migrations 00340–00342 add withholding tax: 00340 widens tax_code_usage_known to admit usage = 'withholding' — safe, additive, no existing row’s usage changes — and adds wht_deduction, wht_remittance and wht_remittance_line; 00341 widens report_export_report_known to admit wht_register; 00342 grants existing Owner and Finance roles payables.wht.read, payables.wht.create and payables.wht.transition by migration. perm_version bumps so live sessions refresh once.

After migrating, run POST /v1/org/provision for every organization: it creates account 2160 Withholding tax payable (step payables_withholding, order 77) and the six MY withholding codes W107A-10, W107A-3, W109-INT, W109-ROY, W109B, W109F, and seeds the wht_remittance workflow kind. Nothing else to configure.

The seeded rates and sections (10%/3% s.107A contract payments, 15% s.109 interest / 10% s.109 royalty, 10% s.109B, 10% s.109F) are unverified against the Income Tax Act 1967 or current LHDN guidance — a tax agent must confirm them before a paying customer relies on this feature. See the design spec’s For the owner to confirm (docs/superpowers/specs/2026-09-27-withholding-tax-design.md).

Migrations 00310–00314 add party_customer, sales_invoice*, customer_receipt* and ar_allocation, and ar_aging / the customer statement as report_export kinds. Existing Owner and Finance roles gain the receivables.* keys through 00313; perm_version bumps so live sessions refresh once.

After migrating, re-provision every organization (POST /v1/org/provision) so it gains the sales_invoice, customer_credit_note and customer_receipt workflow kinds (step workflow_definitions). Without it, invoices cannot be issued in that organization. No chart or tax-code change: Trade receivables 1540 and the sales SST codes already exist.

Known limit (same as AP): on a foreign-currency credit note, when one allocation row clears both the credit note and its invoice, their base (MYR) outstanding can differ by about 0.0001 — one side may show a 0.0001 base residue. Document-currency amounts are exact.

Migrations 00370 (three tables — budget, budget_version, budget_line — never deleted), 00371 (existing Owners gain accounting.budget.manage and accounting.budget.approve; existing Finance roles gain accounting.budget.manage; perm_version bumps so live sessions refresh once), 00372 (report_export accepts budget_vs_actual). No provisioning step, no configuration, no new environment variable.

Migrations 00350–00353 add four tables for bank statements and their matches (bank_account, bank_statement, bank_statement_line, bank_match + bank_match_item + bank_unmatch) and two for locked reconciliations (bank_reconciliation, bank_reconciliation_item), all ENABLE/FORCE ROW LEVEL SECURITY. Statement files are stored verbatim in Postgres, capped at 5 MiB each and never deleted (retention: amendable/append-only per table, see global-constraints.md) — size database backups accordingly.

Existing Owners gain the six banking.* keys; existing Finance roles gain five of them (everything but banking.reconciliation.lock — Finance reconciles, only the Owner locks, spec D10). perm_version bumps so live sessions refresh once. Nothing to provision: each organization links its own bank accounts on the new Bank accounts screen, after creating (or reusing) GL accounts of control kind bank.

Deferred minors (tracked in the lane’s progress ledger, not fixed): the untested error-mapping list and Content-Disposition filename* (Task 9); a READ COMMITTED concurrent post can raise a spurious ErrDifference and should be retried, property-test list-membership hardening, ErrNotInOrder’s wording on a re-lock, and the voided/second-account filters (Task 7); the ItemTable row-key fallback collision in ReconciliationTab (Task 12).

Operator console, backend (branch ops-groundwork)

Section titled “Operator console, backend (branch ops-groundwork)”

Plan: 2026-09-28-operator-console.md.

  • Migrations 00230–00241; they create two roles. erp_operator (LOGIN) is what the new bin/operator connects as; erp_ops_owner (NOLOGIN, BYPASSRLS) owns the reviewed ops functions. Creating a BYPASSRLS role needs a superuser DATABASE_MIGRATE_URL; a CREATEROLE-only migration role fails at 00233.

  • Set erp_operator’s password. The migration gives it the dev password erp_operator: ALTER ROLE erp_operator PASSWORD '…' and put it in OPERATOR_DATABASE_URL. The console refuses to start as any role that can read organization data (erp_app, the owner, a superuser).

  • The console is private. OPERATOR_LISTEN_ADDR must name a loopback or private-network address (:8090 and 0.0.0.0:8090 are refused). Never publish it through the public reverse proxy. It needs TOTP_ENCRYPTION_KEY.

  • First operator: bin/operator create-account --email <you> prints the password and an authenticator link once. The account activates on its first sign-in.

  • Optional: OPERATOR_API_URL (the api’s private URL), OPERATOR_GRAFANA_URL, OPERATOR_BACKUP_STATUS_FILE (without it the backups page shows UNKNOWN).

  • Nightly reconciliation now runs in the worker (ops_reconcile, queue maintenance, every 24 h, not on start) and writes pass/fail per organization to ops.check_result. Nothing to configure.

  • Operator actions (create org, re-provision, verify now, retry job, suspend) are run by the worker from a new River queue, operator, polled every 15 s. The worker needs PUBLIC_BASE_URL and TOTP_ENCRYPTION_KEY for “create organization” (the owner’s invitation link is sealed with it); without them that action fails with misconfigured. Periodic jobs run only on River’s elected leader, so an old worker left running beside a new one keeps the new jobs from being scheduled until it stops. An action still running 30 minutes after a worker claimed it (the worker died) is closed as failed, abandoned, and not run again (migration 00241): check the organization before asking for it again.

  • Suspension (organization.status, migration 00240): a suspended organization’s members cannot sign in or refresh (403 org-suspended), and tokens already issued are refused within the liveness cache’s 5 s. Its jobs keep running and nothing is deleted. The web shows the refusal’s own sentence (F4). Provider actions on an organization also appear in its Activity feed.

  • Metrics (API_METRICS_ADDR, WORKER_METRICS_ADDR) and the object-store change (OBJECT_STORE_CREATE_BUCKET: create the locked bucket before the first start) are in .env.example and RUNBOOK §1.1.

  • Service-health dashboard (V2, progress item I10): import deploy/grafana/erp-service-health.json into Grafana (Dashboards → New → Import) and pick the Prometheus data source. It expects the two scrape jobs to be named exactly erp-api and erp-worker; until Prometheus scrapes them, its “API” and “Worker” tiles read NOT SCRAPED and the charts are empty. Scrape over the private network only:

    scrape_configs:
    - job_name: erp-api
    static_configs: [{ targets: ["<api private host>:9464"] }] # API_METRICS_ADDR
    - job_name: erp-worker
    static_configs: [{ targets: ["<worker private host>:9465"] }] # WORKER_METRICS_ADDR

    internal/app/metrics_test.go fails if the dashboard queries a metric the code no longer emits.

    The “Rendering and exports” row (module extraction Task 1) reads erp_render_duration_seconds, erp_render_output_bytes, erp_export_queue_wait_seconds, erp_export_rows and process_resident_memory_bytes; scrape worker-reports under the same erp-worker job (deploy/vps/README.md). Re-import the dashboard JSON to get the row.

  • Worker queue split (module extraction Task 2): WORKER_QUEUES (name:slots pairs; unset serves every queue) chooses what an instance serves. Compose now runs worker (maintenance, einvoice, operator) and worker-reports (reports). Deploy both together: a stack that sets WORKER_QUEUES on one instance and not the other has every queue served twice or not at all. The instance serving maintenance alone runs the session prune, the exchange-rate sync and the erp_river_jobs gauge; River’s periodic jobs are registered on every instance and enqueued once by the elected leader.

Migrations 00300–00306 add the tables party_supplier, purchase_invoice*, supplier_payment* and ap_allocation; the ap_trade control kind; tax_code.usage, which sets every existing code to sales; and ap_aging as a report_export kind. Existing Owners gain payables.*; existing Finance roles gain it without verify_bank, plus accounting.ledger.read. perm_version bumps so live sessions refresh once.

After migrating, run POST /v1/org/provision for every organization: it creates account 2105 Trade payables (step payables_trade_payables), the MY purchase tax codes PST10, PST5, PSVT6, PSVT8, PNT (step payables_purchase_tax_codes), and seeds purchase_invoice, supplier_credit_note, supplier_payment (step workflow_definitions). Nothing else to configure. Purchase lines accept only purchase/both tax codes; expense claims are unchanged.

Figma batch 2 — notifications (the header bell)

Section titled “Figma batch 2 — notifications (the header bell)”

No new container, variable or job. Run the migrations as usual.

  • Migration 00270 adds notification. It is operational (erp_app holds DELETE); its Down drops the table and every notification with it, which loses nothing of record.
  • Migration 00271 adds notification_prune(), a SECURITY DEFINER function owned by the migration role. That role must be a superuser or hold BYPASSRLS (RUNBOOK: owner / superuser); otherwise the daily prune fails loudly in the worker log and nothing is pruned.
  • The worker now serves a maintenance River queue with one periodic job, notification_prune (daily, and once at start), deleting notifications older than 90 days.
  • Notifications are written after each commit (the event bus) by the API and the worker, in their own transaction. A failure is logged at WARN (a panic at ERROR) as notifications and never fails the request.

Figma batch 2 — profile photo and job title

Section titled “Figma batch 2 — profile photo and job title”

No new container, variable or job. Run the migrations as usual.

  • Migration 00255 adds organization_member.photo_document_id and admits image/webp as a document type. Its Down refuses once any WebP document exists (documents are never deleted); plan a restore from backup instead.
  • Photos use the existing object store. photo_url on GET /v1/me and GET /v1/org/members is a stable API path, GET /v1/org/members/{id}/photo, which redirects (302, no-store) to a freshly presigned link. The browser fetches it with the session token and follows the redirect with fetch, so the object store must answer CORS GET from the web origin, as it already does for uploads.
  • WebP is accepted for every document, receipts included.

No new container, variable or job. Run the migrations as usual.

  • Migration 00190 adds three tables (analytic account approvers, an instance’s shares, sign-offs) and the account_approver grant kind. Its Down refuses while any published grant is account_approver. Published versions are never deleted, so once a version with the rule has been published, 00190 cannot be rolled back: plan a restore from backup instead of a down migration.
  • workflow.definition.publish now also governs who approves each analytic account (P14). Holders of accounting.account.manage can create projects but not choose their approvers. Review who holds workflow.definition.publish before upgrading: after it, they also decide who approves every project.
  • Approvers are assigned per project and per step. A project-approval step is a state whose grants include an account_approver marker; its other grants are the step’s default. A project with no assignment for a step (nor an ancestor with one) is approved by that step’s default; with no default either, a claim charged to it cannot reach the step (no_account_approver). Assign them on the Project approvers screen (/accounting/approvers).
  • Existing workflows are unchanged. The rule takes effect only in a version an administrator publishes with it, and only for claims started after that; claims already in flight stay on the version they started on.
  • Every draft claim’s line edits now write shares, on every workflow version. A line edit that changes how the claim’s amount splits across analytic accounts rewrites the instance’s workflow_instance_share rows and adds a shares row to the audit log (workflow_instance), whether or not the claim’s workflow has a project-approval step. Routing is unchanged for a workflow without one; expect the extra audit rows, and the table’s growth, from the first edit after the upgrade.
  • Revision 3 — amount bands per project step. Migration 00191 is additive: it adds min_amount and swaps the exclusion constraint to allow more than one row per step, with no data change — every existing cell becomes one band starting at 0. A cell is now a list of bands (spec P16): the first starts at 0, each next band’s starting amount strictly exceeds the last, each names at least one approver, and no approver is named twice in the same band. The band that decides a step is the one whose starting amount is the greatest at or below the project’s share of the claim; any one of that band’s approvers may sign. AssignApprover is replaced by SetApproverCell (the whole cell, replacing whatever is live) and EndApprover now ends the whole cell, not a single row.

Plan 10 — the web app, served by its own nginx container

Section titled “Plan 10 — the web app, served by its own nginx container”

Plan 10 adds a browser app. It is one more container, web, built from web/Dockerfile (context: the repository root). It serves the app and proxies /v1 to the api, so the browser sees one origin. The api has no CORS and never will, so point your TLS-terminating reverse proxy at web:8080, not at api:3000. What breaks if you don’t: the app loads, but every /v1 call goes to the wrong host, and login fails.

services:
web:
image: mcom-web:<release> # docker build -f web/Dockerfile -t mcom-web:<release> .
environment:
API_UPSTREAM: api:3000 # the api service and its PORT
OBJECT_STORE_PUBLIC_ORIGIN: https://files.erp.example.com.my # required, see 3
depends_on: [api]
read_only: false # nginx renders its config at start
restart: unless-stopped
# no ports: — only your TLS proxy reaches it, on the compose network
api:
# unchanged, except TRUSTED_PROXIES (2) and PUBLIC_BASE_URL (4)

The container runs as uid 101 and listens on 8080. It refuses to start without OBJECT_STORE_PUBLIC_ORIGIN.

The api’s TCP peer is now the web container, not your TLS proxy. Set TRUSTED_PROXIES to the compose network’s subnet, which covers both web and the TLS proxy:

Terminal window
TRUSTED_PROXIES=172.18.0.0/16 # docker network inspect <project>_default → IPAM.Config.Subnet

Left empty, every request looks as if it came from web. Every client then shares one login-throttle bucket, and the audit trail records web’s address (the failure mode in Plan 2b §2 above). web always overwrites X-Request-Id with its own id, so trusting it does not let a client author the request id in the audit trail.

3. OBJECT_STORE_PUBLIC_ORIGIN and OBJECT_STORE_ENDPOINT

Section titled “3. OBJECT_STORE_PUBLIC_ORIGIN and OBJECT_STORE_ENDPOINT”

Receipts are uploaded by the browser straight to the object store with a presigned PUT, and thumbnails and report downloads are fetched from it the same way. So:

  • OBJECT_STORE_ENDPOINT (api and worker) must be an address the user’s browser can reach, because presigned URLs carry its host. The compose-internal http://minio:9000 produces URLs nobody outside the VPS can open. Put the store behind your TLS proxy (for example https://files.erp.example.com.my), and use that.
  • OBJECT_STORE_PUBLIC_ORIGIN (web) is the same scheme and host, with no path. The Content-Security-Policy allows it for images, uploads (connect-src) and the PDF receipt viewer, which embeds the presigned GET URL with <object> (object-src; see web/nginx.conf). A mismatch breaks uploads with a CSP error in the browser console, and nothing in the server logs.
  • The bucket needs a CORS rule allowing PUT and GET from the app’s origin, because the browser calls it cross-origin. For MinIO: mc anonymous is not it. Set MINIO_API_CORS_ALLOW_ORIGIN=https://erp.example.com.my on the MinIO server, or the equivalent for your store.
  • The store is still in Malaysia (RUNBOOK §1.1). Exposing it publicly changes only who can reach it with a signed URL, not where it is.
  • PUBLIC_BASE_URL is the address of the web app as users see it (https://erp.example.com.my). Invitation links are built from it and open the web app’s /invitations/accept page.
  • The refresh cookie is Path=/v1/auth; Secure; HttpOnly; SameSite=Lax, host-only. It only works when the app and /v1 share an origin and that origin is HTTPS. Serving the app on one host and the API on another, or mounting /v1 under a different path at the proxy, silently logs every user out on each page load.
  • Mounting the app under a path (https://example.com.my/erp) is not supported by this image. The cookie path and the SPA’s absolute asset URLs both assume the root.

web sets CSP (default-src 'self', plus the object store as in 3), nosniff, Referrer-Policy: no-referrer (invitation tokens travel in a query string), X-Frame-Options: DENY and frame-ancestors 'none', and COOP. HSTS is your TLS proxy’s job. Do not add a second CSP at the proxy: browsers enforce both, so the stricter one wins and a proxy-level default-src 'self' would block the object store.

6. org.roles.manage is full administrative power: grant it deliberately

Section titled “6. org.roles.manage is full administrative power: grant it deliberately”

The role editor (Team → Roles) lets a holder of org.roles.manage grant any registered permission to any role, including roles they hold themselves. The same holder can also give any member any role, including themselves. That is by design, as with any administrator right, but it means whoever holds org.roles.manage can reach every other permission in the organization, including approving, paying and closing periods. Treat it as equivalent to the Owner role.

Only two changes are refused, because either would lock the organization out:

  • removing org.roles.manage from the Owner role (owner_role_manage_required);
  • a change that would take org.roles.manage away from the person making it (self_lockout).

Every change writes audit_log in the same transaction, with the before and after sets:

  • role rows: created, renamed and permissions_set;
  • organization_member rows: roles_set.

That makes a self-grant visible after the fact, but it does not prevent one. Review those rows alongside the other access reviews your auditors ask for. Provisioning and migration 00170 give org.roles.manage to the Owner role only, so no other role holds it unless someone grants it.


  1. River’s schema ships as goose migrations 00110–00116; run migrate up (the privileged migrate job) before starting api or worker. Never run River’s own river migrate-up in production — goose owns the schema.
  2. The worker now needs the four OBJECT_STORE_* settings, like the api, and refuses to boot without them.
  3. The worker polls (PollOnly), so it works behind pgbouncer in transaction mode; expect up to a second before an export starts.
  4. Export files live under reports/<org>/<export>/ in the documents bucket; the bucket’s versioning and 7-year object lock (Plan 5/9 object-store section) cover them. Nothing deletes an export.
  5. River deletes finished job rows after 24 h; the record of an export is report_export and audit_log, which are kept.

Plan 8 — petty cash reaches an existing organization only by hand

Section titled “Plan 8 — petty cash reaches an existing organization only by hand”

Plan 8 ships three seeded workflow kinds and six new permission keys. A new organization gets both from orgadmin create-org. An existing one gets neither on deploy, and the two halves are repaired in two different ways.

1. The three workflow kinds — re-provision (the wave-3 note, applied to Plan 8)

Section titled “1. The three workflow kinds — re-provision (the wave-3 note, applied to Plan 8)”

petty_cash_spend, float_topup and float_closeout are registered kinds seeded by provisioning step 60. As the wave-3 base note below says, nothing runs that step for existing organizations. Until it is re-run, recording a spend, requesting a top-up or closing out a float in an existing organization fails with workflow.Start → ErrNoDefinition, and GET /v1/org/readiness shows step 60 missing.

Per existing organization: POST /v1/org/provision (as a holder of identity.org.administer), or migrate provision --org <uuid> --owner-member <uuid>. There is no all-organizations batch. RUNBOOK §6.2.

2. The six pettycash.* permissions — grant them by hand

Section titled “2. The six pettycash.* permissions — grant them by hand”

Re-provisioning does not fix this half. The role-seeding step short-circuits for an organization whose Owner role already holds any grant at all (internal/platform/ provisioning/steps.go), deliberately: reconciling the registered set against the stored one is indistinguishable from undoing an administrator’s considered removal of a permission. So an organization provisioned before this release keeps exactly the grants it has, and these six never arrive:

  • pettycash.float.manage — open, top up, close out and administer floats;
  • pettycash.float.disburse — pay a claim or a spend out of a float;
  • pettycash.transition — move a spend, top-up or close-out along its workflow;
  • pettycash.spend.review — decide another member’s spend;
  • pettycash.read — read one’s own float, spends and movements;
  • pettycash.read_all — read every float in the organization.

What breaks if ignored: every /v1/pettycash/… route answers 403 for every member of that organization, including the Owner, however many times provisioning is re-run — which reads as a bug in the feature rather than as a missing grant.

What to do, per existing organization, after re-provisioning (step 1) and as a superuser or a role with BYPASSRLS — the postgres role in DATABASE_MIGRATE_URL; role_permission is under FORCE ROW LEVEL SECURITY, so as erp_app the statement below silently grants nothing:

INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, k
FROM role r,
unnest(ARRAY['pettycash.float.manage','pettycash.float.disburse',
'pettycash.transition','pettycash.spend.review',
'pettycash.read','pettycash.read_all']) AS k
WHERE r.org_id = '<org uuid>' AND r.system AND r.name = 'Owner'
ON CONFLICT (role_id, permission_key) DO NOTHING;

Owner is the safe default because a new organization’s Owner holds every registered key. Whether a customer’s other roles should hold any of these is that customer’s decision, not the deploy’s — pettycash.float.manage and pettycash.float.disburse are the tin’s custody, and pettycash.read_all sees every holder’s float. Grant those to a named role deliberately, the same way any other permission is granted, and record why.

Confirm, as the same role: the query below must return 6.

SELECT count(*) FROM role_permission rp JOIN role r ON r.id = rp.role_id
WHERE rp.org_id = '<org uuid>' AND r.name = 'Owner' AND rp.permission_key LIKE 'pettycash.%';

  • Existing organizations get the three advance workflows by re-running provisioning (POST /v1/org/provision, or orgadmin provision): step 60 publishes advance, advance_topup and advance_refund at v1 for any organization that has no version of them. New organizations get them at creation. No new environment variable.
  • Key rotation: migrate reseal-payout-details now also reseals advance settlement snapshots (advance_settlement), and --check exits 0 only when those are done too. The procedure in Plan 5’s section is unchanged; the claims-table check has an advance twin: SELECT count(*) FROM advance_settlement WHERE jsonb_path_exists(snapshot, '$.fields[*].sealed.kid ? (@ == "<old kid>")'); → 0.
  • Acquittal reminders are not sent. GET /v1/advances/reminders lists overdue acquittals and who should be told; nothing emails or notifies anyone yet (Plan 7 Ruling A28).

Wave 3 base — migrations apply out of order

Section titled “Wave 3 base — migrations apply out of order”

migrate up now runs goose with allow-missing. Plans own migration number ranges (Plan 3 00030–, Plan 4 00050–, Plan 5 00070–, Plan 6 00090–, Plan 9 00110–, Plan 7 00130–, Plan 8 00150–; the wave-3 base took 00034 and 00054) and merge in whatever order they finish. So a database already at 00090 must still apply a newly merged 00034. Without the option goose refuses with found N missing migrations before current version, and the deploy stops.

What it means for an operator: nothing changes in the command. migrate up applies every migration the database has not recorded, whatever its number. migrate down rolls back the most recently applied migration, which after an out-of-order merge need not be the highest-numbered one; check migrate status before rolling back. Do not use migrate down-to N after an out-of-order apply: it stops as soon as the most recently applied version is at or below N, so once 00034 has been applied after 00090, migrate down-to 60 is a silent no-op although 00090 is above 60. Roll back with repeated migrate down, checking status between steps (RUNBOOK §5.1). Allow-missing also applies, without complaint, a file someone numbered into another plan’s range by mistake — review migration numbers against the ranges above.

What it means for a migration author: every migration must commute with every higher-numbered one. It may run before them on a fresh database and after them on a deployed one. Do not redefine, drop or rename another range’s objects (a function, a trigger, a constraint) without checking every migration that touches the same object in both orders. For example, 00054 replaces 00050’s account_immutable_guard, which is safe only because no later migration redefines that function. A backfill must not read tables a higher-numbered migration creates.


Wave 3 base — new workflow kinds reach existing organizations only by re-provisioning

Section titled “Wave 3 base — new workflow kinds reach existing organizations only by re-provisioning”

The base makes seeded workflow graphs a registry (wave-3 contract §1.3): each plan registers its own kinds, and provisioning step 60 (workflow_definitions) seeds any registered kind that has no version yet. Nothing runs that step for existing organizations on deploy. A new organization gets every kind from orgadmin create-org; an existing one gets a new kind only when provisioning is re-run for it.

What breaks if ignored: after Plan 7 or 8 deploys, starting an advance or a petty-cash spend in any existing organization fails (workflow.Start → ErrNoDefinition), and GET /v1/org/readiness shows step 60 missing, until each organization is repaired.

What to do, per existing organization, after any release that registers a workflow kind or adds a provisioning step: POST /v1/org/provision (as a holder of identity.org.administer; no body needed for an organization provisioned before), or migrate provision --org <uuid> --owner-member <uuid> with DATABASE_URL. There is no all-organizations batch. Details in RUNBOOK §6.2. Plans 7 and 8 must repeat this step in their own deploy notes.


Claim receipts, e-Invoices and settlement proofs are stored as objects, not database rows. Five things an operator has to do or know.

1. OBJECT_STORE_ENDPOINT, OBJECT_STORE_BUCKET, OBJECT_STORE_ACCESS_KEY,

Section titled “1. OBJECT_STORE_ENDPOINT, OBJECT_STORE_BUCKET, OBJECT_STORE_ACCESS_KEY,”

OBJECT_STORE_SECRET_KEY are REQUIRED

The api container refuses to boot without all four. OBJECT_STORE_REGION defaults to us-east-1 (MinIO’s own default and ignored by MinIO itself); there is no default endpoint, deliberately — an S3 SDK’s own default points at AWS’s global endpoint, and defaulting to it would silently send a Malaysian deployment’s documents offshore the day somebody forgets to set it. .env.example ships OBJECT_STORE_SECRET_KEY as a placeholder that is refused at boot; set the store’s real secret.

2. Run MinIO in the same VPS/datacenter; enable versioning and object lock

Section titled “2. Run MinIO in the same VPS/datacenter; enable versioning and object lock”

Stand up MinIO (or another S3-compatible store) on the same VPS or the same datacenter as this service — see item 3. Create the bucket named by OBJECT_STORE_BUCKET and, before anything is uploaded to it, enable versioning and object lock in compliance mode with an 8-year retention period (seven years from the end of the year of assessment; see RUNBOOK §3 and §1.1 for why compliance, not governance). That is what stops a document from ever being erased or overwritten under duress or by mistake, which ITA 1967 s.82A requires for business records. Create it before the first start: api and worker refuse to start against a missing bucket rather than create an unlocked one. Leave OBJECT_STORE_CREATE_BUCKET empty in production.

3. Back the bucket up only to storage in Malaysia

Section titled “3. Back the bucket up only to storage in Malaysia”

ITA 1967 s.82(8): records stay in Malaysia. That includes this bucket, its volumes and every backup of it — no offsite or cross-border backup target, however convenient.

4. The store’s endpoint must be reachable from users’ browsers

Section titled “4. The store’s endpoint must be reachable from users’ browsers”

Uploads go straight from the browser to the object store (bytes never transit the API). That means OBJECT_STORE_ENDPOINT must be reachable from users’ browsers, behind the same TLS-terminating proxy as the rest of this deployment, and the store’s CORS configuration must allow PUT from PUBLIC_BASE_URL with headers Content-Type and x-amz-checksum-sha256.

OCR is an interface only in this release: no provider is built in, and OCR_PROVIDER accepts only an empty value or none — anything else refuses to boot. Leave it unset.

Rotating the payout-details key: the reseal command (completes Plan 6 §2)

Section titled “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:

Terminal window
# 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

--org <id> limits the walk to one organization; an id that names no organization is an error (non-zero exit), never a silent “done”. DATABASE_MIGRATE_URL is required either way.

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.


1. PAYOUT_DETAILS_KEY is now REQUIRED by the api container

Section titled “1. PAYOUT_DETAILS_KEY is now REQUIRED by the api container”

What it is. The AES-256 key that encrypts members’ saved payout details — account numbers, DuitNow and e-wallet IDs, payee names — and the masked values inside every claim’s settlement snapshot (Malaysia PDPA 2010, security principle). Format kid:base64secret, the secret exactly 32 bytes:

Terminal window
PAYOUT_DETAILS_KEY=p1:$(openssl rand -base64 32) # generate once per environment

The api container refuses to boot without it, with a malformed or short key, or with the .env.example placeholder. The worker reads it too from Phase 5 H1 (see that entry); migrate and orgadmin do not.

Back it up separately from the database. A database dump alone yields no account number; that holds only while the key is not stored beside the dump. Losing the key loses every saved payout detail (members re-enter them) and every masked value in every claim snapshot, which the law requires kept seven years.

Rotation is one active key plus at most one previous key (Ruling P32). Never reuse a kid.

  1. Generate a new key with a new kid. Set PAYOUT_DETAILS_KEY=<new kid>:<new secret> and PAYOUT_DETAILS_KEY_PREVIOUS=<old kid>:<old secret>. Deploy. New writes use the new key; everything sealed under the old one still opens.
  2. Re-encrypt everything under the old kid: members’ profiles (payout.ResealProfiles, per organization) and claim snapshots (payout.ResealSnapshot, on Plan 5’s claims). The operator command that runs both across every organization ships with Plan 5. Until it exists, do not remove PAYOUT_DETAILS_KEY_PREVIOUS and do not rotate again.
  3. Confirm nothing remains under the old kid, as the migration role: SELECT count(*) FROM member_payout_profile WHERE details_kid = '<old kid>'; → 0 (and Plan 5’s equivalent for claims). Only then clear PAYOUT_DETAILS_KEY_PREVIOUS.

Dropping a previous key before step 3 makes every value still sealed under it unreadable (ErrUnknownKey) — for claim snapshots, a statutory record.

3. Departed members’ saved payout details are purged

Section titled “3. Departed members’ saved payout details are purged”

When a member is deactivated, their saved payout profiles are deleted and each deletion is audited with masked values only (Ruling P33). Claim snapshots are not touched. No deactivation path exists yet; the purge runs from the one that is built later.


Four things. The first stops the containers from starting until it is done.

What it is. The address this deployment is reachable at from a browser — the origin (plus an optional path prefix) a user types to reach the app. Invitation links are built from it: <PUBLIC_BASE_URL>/…?token=….

Who reads it. Both producers of invitation links:

  • the api container, for POST /v1/org/invitations, which returns the link to the administrator who issued it;
  • orgadmin create-org (below), which prints the new Owner’s link.

The api and worker containers refuse to boot without it, and so does orgadmin. Both long-running binaries read configuration through the same loader, so the worker is refused too even though it builds no links. The value is validated at start-up: it must be an absolute http or https URL with a host and no query string or fragment. A trailing slash is accepted and removed.

This is deliberate. No mailer exists yet: an invitation link is handed to a person who passes it on, and its token exists nowhere else. A wrong base URL would produce a link that fails days later, as a colleague who could not join, with nothing to re-deliver. A container that will not start is the cheaper place to find out.

Terminal window
# Correct — the public origin, behind your TLS-terminating reverse proxy
PUBLIC_BASE_URL=https://erp.example.com.my
# Also correct — the app mounted under a path
PUBLIC_BASE_URL=https://example.com.my/erp
# Wrong — refused at boot
PUBLIC_BASE_URL=erp.example.com.my # no scheme
PUBLIC_BASE_URL=https://erp.example.com.my/?x=1 # query string
# Wrong — accepted, but produces links nobody outside the VPS can open
PUBLIC_BASE_URL=http://localhost:3000
PUBLIC_BASE_URL=http://api:3000 # the Compose service name

Use the address the user’s browser sees, not the one the proxy uses to reach the container.

2. TRUSTED_PROXIES — set it to your reverse proxy

Section titled “2. TRUSTED_PROXIES — set it to your reverse proxy”

Not new in this release, but it now matters for one more thing, so it is repeated here.

Default: empty, which means trust nobody. That is the safe default. The client address is then the TCP peer of the connection, and X-Forwarded-For, X-Real-IP and an inbound X-Request-Id are all ignored — a caller cannot choose the address recorded against them or the request id written into the audit trail.

What you lose while it is empty behind a reverse proxy:

  • Every client has the proxy’s address. Session records, login records and the audit trail record the proxy, not the user.
  • Upstream trace correlation is dropped. A request id minted by your proxy is replaced by one minted in-process, so the proxy’s logs and the app’s cannot be joined on it.
  • The throttles collapse into one bucket. Login throttling and — new in this release — the invitation-accept throttle (POST /v1/auth/invitations/accept) are keyed on the client address. Behind a proxy with TRUSTED_PROXIES empty, every client shares the proxy’s address and therefore one counter: a few failed attempts from anyone slow down every invitee — and every login — in the deployment.

Fix: name your proxy, and nothing else — comma-separated addresses or CIDR ranges.

Terminal window
# Docker Compose on a VPS: the proxy's container network
TRUSTED_PROXIES=172.18.0.0/16

Do not set it wider than your proxy. A trusted peer may author the client address; trusting everything lets any caller bypass the throttles by changing one header per request.

There is no public sign-up, on purpose: every organization is a tax record holder with a seven-year retention obligation, and an anonymously created tenant is that obligation with no known counterparty. Onboarding is operator-provisioned with the new orgadmin binary (cmd/orgadmin):

Terminal window
orgadmin create-org \
-name "Contoh Sdn Bhd" -country MY -currency MYR -fiscal-year-start 2026-01-01 \
-owner-email owner@contoh.com.my -owner-name "Nur Aisyah binti Ahmad"
  • It needs DATABASE_URL (the application role, not the migration role — its writes go through row-level security like the api’s) and PUBLIC_BASE_URL.
  • It runs in one transaction: it either creates the organization, company profile, roles, Owner membership and invitation, or writes nothing and prints how far provisioning got.
  • On success it prints two lines on stdout — organization <uuid> and invitation <link> — and notes on stderr.
  • The link is a credential. Deliver it to the Owner over a channel you would trust with a password. The Owner sets their own password by accepting it; no operator ever handles a customer’s password.
  • Deliver it promptly. The link expires after seven days (the exact time is printed). There is no command yet to re-issue an Owner’s invitation, and re-running create-org does not repair one: organization names are not unique, so it creates a second organization. An expired Owner invitation is currently an engineering task.
  • Build it with go build -o bin/orgadmin ./cmd/orgadmin and run it on a host that can reach the database. (make build does not build it yet.)

4. Migration 00021_tenant_foreign_keys can FAIL — check first

Section titled “4. Migration 00021_tenant_foreign_keys can FAIL — check first”

00021 rewrites every foreign key between two organization-scoped tables to carry org_id on both sides, so a row in one tenant can no longer reference a row in another. If the database already contains such a cross-tenant reference, adding the constraint fails and the migration aborts. That is the correct outcome — the row is a tenant breach to investigate, not data to migrate around — but find out before the deploy window, not during it. goose runs the migration in one transaction, so a failed 00021 leaves the database at version 20 with nothing half-applied. Nothing in the api checks the schema version, so it would still start — without the cross-tenant protection 00021 adds. Deal with the row and re-run the migration before serving traffic.

Run the queries below as a superuser or a role with BYPASSRLS — the postgres role in DATABASE_MIGRATE_URL in .env.example. Owning the tables is not enough: they are under FORCE ROW LEVEL SECURITY, which applies to the owner too. As any other role, row-level security hides other tenants’ rows and every query returns nothing, which would look like a clean bill of health. So first establish that you can see across tenants:

SELECT count(*) FROM organization; -- must equal the number of organizations you host

As the application role this returns 0, and so does every query below.

Then each query below should return zero rows; any row returned is a reference 00021 will reject.

Tables that existed before Plan 2b:

SELECT 'organization_member.party_id' AS fk, c.id, c.org_id, p.org_id AS target_org
FROM organization_member c JOIN party p ON p.id = c.party_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'role_permission.role_id', c.id, c.org_id, p.org_id
FROM role_permission c JOIN role p ON p.id = c.role_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'member_role.member_id', c.id, c.org_id, p.org_id
FROM member_role c JOIN organization_member p ON p.id = c.member_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'member_role.role_id', c.id, c.org_id, p.org_id
FROM member_role c JOIN role p ON p.id = c.role_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'activity_log.actor_member_id', c.id, c.org_id, p.org_id
FROM activity_log c JOIN organization_member p ON p.id = c.actor_member_id WHERE p.org_id <> c.org_id;

Tables added earlier in Plan 2b (00018, 00019). They do not exist on a database coming straight from Plan 2 — goose applies 00018–00021 in one run, and new tables start empty — so run these only if the database already has org_unit and invitation:

SELECT 'org_unit.parent_id' AS fk, c.id, c.org_id, p.org_id AS target_org
FROM org_unit c JOIN org_unit p ON p.id = c.parent_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'org_unit.unit_head_position_id', c.id, c.org_id, p.org_id
FROM org_unit c JOIN org_position p ON p.id = c.unit_head_position_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'org_position.org_unit_id', c.id, c.org_id, p.org_id
FROM org_position c JOIN org_unit p ON p.id = c.org_unit_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'org_position.reports_to_position_id', c.id, c.org_id, p.org_id
FROM org_position c JOIN org_position p ON p.id = c.reports_to_position_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'member_position.member_id', c.id, c.org_id, p.org_id
FROM member_position c JOIN organization_member p ON p.id = c.member_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'member_position.position_id', c.id, c.org_id, p.org_id
FROM member_position c JOIN org_position p ON p.id = c.position_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'organization_member.manager_override_id', c.id, c.org_id, p.org_id
FROM organization_member c JOIN organization_member p ON p.id = c.manager_override_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'invitation.role_id', c.id, c.org_id, p.org_id
FROM invitation c JOIN role p ON p.id = c.role_id WHERE p.org_id <> c.org_id
UNION ALL
SELECT 'invitation.invited_by_member_id', c.id, c.org_id, p.org_id
FROM invitation c JOIN organization_member p ON p.id = c.invited_by_member_id WHERE p.org_id <> c.org_id;

The list mirrors the constraints 00021 rewrites; if the two ever disagree, the migration file is the source of truth. Do not “fix” a returned row by updating its org_id until you know how it came to exist — under Malaysian record-keeping rules the investigation is part of the record.

1. Migration 00052_accounting_ledger revokes TEMPORARY — re-apply it after any restore

Section titled “1. Migration 00052_accounting_ledger revokes TEMPORARY — re-apply it after any restore”

00052 runs REVOKE TEMPORARY ON DATABASE <db> FROM PUBLIC, so the application role (erp_app) cannot create temporary tables. It is defence in depth for the ledger: the ledger’s guard functions pin their search_path and schema-qualify every table, and the revoke additionally means no session can create a TEMP table named like a ledger table at all. The migration fails (rather than warning) if erp_app can still create temporary objects afterwards — run it as the database owner.

This is a database-level privilege. It lives on the database object, not in any schema, so it is not carried by:

  • pg_dump / pg_restore without --create — restoring into a freshly created database leaves that database with the default privileges, which grant TEMPORARY to PUBLIC;
  • CREATE DATABASE … TEMPLATE … — a copy starts with default database privileges too.

After any restore or clone, re-apply it and verify:

-- as the database owner, connected to the restored database
REVOKE TEMPORARY ON DATABASE <db> FROM PUBLIC;
-- must return false
SELECT has_database_privilege('erp_app', current_database(), 'TEMPORARY');

(Re-running migration 00052’s DO block has the same effect, including the check.) If the query still returns true, some other grant — to erp_app directly or to a role it belongs to — is giving it the privilege; revoke that too.

2. BNM exchange-rate sync (BNM_RATES_URL, worker)

Section titled “2. BNM exchange-rate sync (BNM_RATES_URL, worker)”

When BNM_RATES_URL is set, the worker fetches Bank Negara Malaysia’s published rates once a day into fx_reference_rate — the rates every foreign-currency posting converts at. Production should set it (.env.example has the value).

  • Unset or empty: the sync is disabled and the worker logs BNM_RATES_URL not set; exchange-rate sync disabled. Nothing else fails at startup, but a posting in a foreign currency then has no reference rate for its date (a rate older than 7 days is refused as stale) and is refused with a 422 naming the missing rate. Someone with accounting.fx.manage must record a manual override for each date with POST /v1/accounting/exchange-rates. Acceptable in dev or staging; in production it means every foreign-currency claim waits on an override.
  • Only https on a bnm.gov.my host (bnm.gov.my or a subdomain, written in lower case) is accepted: rates are part of the tax record and come from the official Malaysian source only.
  • An invalid value fails worker startup (config: BNM_RATES_URL must be …, exit non-zero) rather than silently disabling the sync — a mistyped host must not look like a deliberate “off”. Fix the value, or unset it to disable the sync on purpose.

3. Database guards that can refuse a manual SQL fix

Section titled “3. Database guards that can refuse a manual SQL fix”

The accounting migrations (00050–00053) enforce in the database, not only in the application, what history depends on. An operator repairing data by hand as erp_app (or as any role, since triggers run for the owner too) will be refused, with a named constraint, when trying to:

  • change a fiscal year’s or period’s dates, a period’s year or number, move a locked period to any other status, reopen a closed year, or reopen a period of a closed year (fiscal_year_immutable, fiscal_period_immutable, fiscal_period_locked, fiscal_year_closed, fiscal_period_year_closed);
  • change an account’s type or place in the tree, a journal’s code or sequence key, or a tax code’s rate (account_immutable, journal_immutable, tax_code_immutable).

These are one-way doors by design (Rulings R17, R18, R21, R23, R27): the correction is a new record or a reversal, not an edit. A genuine emergency fix needs the owner to disable the trigger deliberately (ALTER TABLE … DISABLE TRIGGER …), in a reviewed change, and re-enable it in the same transaction.

4. Exchange-rate overrides: “newest” is the database’s write order

Section titled “4. Exchange-rate overrides: “newest” is the database’s write order”

exchange_rate carries a database-assigned created_seq; the newest override for a (currency pair, date) is the one written last, whatever its created_at says. The app servers’ clocks no longer matter. When correcting an override, record the correct rate as a new override — it wins because it is written after the old one.

5. Audit trail: every close records the chain head — restore audit_log whole

Section titled “5. Audit trail: every close records the chain head — restore audit_log whole”

Every period close, reopen and year close stores the organization’s newest audit row (close_log.audit_seq, audit_row_hash). The audit_chain reconciliation check, which runs before every close, requires each recorded row to still exist unchanged, so an audit_log cut short at the end — newest rows missing — refuses the next close with a finding naming the close whose head is gone.

Operationally: never restore audit_log from an older backup than the rest of the database, and never prune it. A restore that loses recent audit rows but keeps the close_log rows that recorded them will block period closes for that organization until the missing rows are restored.

Public API and webhooks (migrations 00960-00979)

Section titled “Public API and webhooks (migrations 00960-00979)”

Design and plan: docs/superpowers/specs/2026-10-03-public-api-webhooks-design.md, docs/superpowers/plans/2026-10-03-public-api-webhooks.md.

  • Before deploying: set WEBHOOK_SECRET_KEY (and nothing in _PREVIOUS) for the api and the worker; both refuse to start without it. Generate with echo "w1:$(openssl rand -base64 32)"; back it up beside PAYOUT_DETAILS_KEY (RUNBOOK 4.2). deploy/vps/setup.sh writes one for a new stack; an existing .env needs the line added by hand.

  • The worker service now serves a fourth queue, webhook:2 (compose sets WORKER_QUEUES: maintenance:1,einvoice:1,operator:1,webhook:2; worker-reports stays reports:2). A stack that overrides WORKER_QUEUES itself must add it, or deliveries queue and never send: cmd/worker’s TestEveryQueueIsServedByAComposeWorker checks the compose file.

  • New environment: APP_ENV (default production), WEBHOOK_BLOCKED_CIDRS (our own public addresses; set it to the VPS’s public IP so a delivery can never be aimed at Grafana, Postgres or other services on the same host), WEBHOOK_ALLOW_INSECURE_DEV (honoured only when APP_ENV=development), and the optional API_RATE_* / API_AUTH_FAIL_PER_IP_PER_MIN limits (.env.example). Set REDIS_URL so the limit buckets are shared across api instances.

  • Grafana alert rules to create (not yet created on the VPS: this is a checklist, not a record):

    Metric Rule
    erp_webhook_deliveries_dead rising for 1 h, or above 0 for 24 h
    erp_webhook_oldest_pending_seconds above 900 for 10 min (a stuck queue or a worker down)
    erp_webhook_endpoints_disabled any increase (the organization is also notified in the app)
    erp_webhook_attempts_total{outcome="blocked_address"} above 0: someone registered a hostile URL (RUNBOOK 8.3)
    erp_webhook_attempt_seconds informational

    The gauges are set by the reconciler on the instance that serves the maintenance queue; the counters and histogram by every instance serving webhook. Scrape both under the existing erp-worker job. No metric carries an organization, endpoint or key id.

  • Alert rules, prepared (2026-10-04), not applied: deploy/grafana/alerting/erp-webhooks.yaml holds five rules in Grafana file-provisioning format (dead letters rising 1 h; dead letters above 0 for 24 h; oldest pending above 900 s for 10 min; endpoint disabled; blocked_address above 0). To apply, on the VPS: copy the file to Grafana’s provisioning directory alerting/ (under /srv/grafana; the exact mount was not determinable from this repository), set PROMETHEUS_DS_UID in the Grafana container’s environment to the existing Prometheus datasource UID (Connections, Data sources, the UID is in the URL), restart Grafana. Verify: Alerting, Alert rules shows folder ERP, group erp-webhooks, five rules in state Normal; Prometheus has the series (erp_webhook_oldest_pending_seconds returns one value; the Prometheus scrape of both workers is in deploy/vps/README.md step 5). To prove a rule can fire, temporarily edit erp-webhook-queue-stuck’s threshold to -1 in Grafana’s UI (not the file), watch it go Firing and the Telegram message arrive, then revert. Rules carry team=erp; they route through the existing notification policy, which this repository does not define: confirm it sends to Telegram. Not covered: the spec’s erp_api_requests_total, erp_api_rate_limited_total and erp_api_key_auth_failures_total are not exported by any code (only the five webhook metrics are), so there is no rule for the 429 ratio or key guessing yet. Thresholds the spec does not state are marked # OWNER: threshold in the file.

  • Worker egress policy (not applied here: a deployment change). The in-process guard refuses loopback, private, link-local, metadata, carrier-grade NAT, multicast and reserved ranges, and WEBHOOK_BLOCKED_CIDRS, at every attempt. Defence in depth is to deny the same ranges from the worker container’s network: for example, drop outbound traffic from the erp network to 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 and 100.64.0.0/10 except to Postgres, Redis and MinIO by name, and to the VPS’s own public address. Verify with a webhook to a name resolving to the VPS’s public IP: the attempt must be recorded blocked_address. Prepared, not applied: deploy/vps/egress-policy.sh and the README section “Worker egress policy” in deploy/vps/README.md (print mode by default; the public IP is deliberately left reachable because the worker uses the object store through it).

  • Retention: webhook deliveries 30 days when succeeded and 90 when failed or dead, events 30 days and while a delivery names them, retired signing secrets at once, API idempotency answers 24 hours (all pruned by the maintenance queue). Keys, endpoints, rotations and replays live in audit_log under its retention.

  • Generated files (api.gen.go, schema.d.ts, api/public-openapi.yaml) conflict when this branch merges with another that touched api/openapi.yaml: re-run make generate. oapi-codegen.yaml now sets skip-prune: true so the webhook payload schemas (referenced from no operation) are generated.

  • Down-migrations: 00960-00969 drop the new tables; nothing outside them depends on them except audit_log_credential, which names api_key.

Plan 12 — Document numbering (migrations 01100-01101)

Section titled “Plan 12 — Document numbering (migrations 01100-01101)”
  • 01100 creates numbering_format; 01101 grants org.numbering.manage to every existing Owner and bumps perm_version, so each live session refreshes once. cmd/api, cmd/worker, cmd/migrate and cmd/orgadmin all link the numbering module, so a new organization’s Owner is seeded with the key whichever binary creates it.
  • No data changes: an organization that configures nothing keeps its exact numbers, and every existing counter (number_sequence) carries on. Sales and purchase documents, payments, batches, remittances and tax-return payments default to the fiscal-year layout they already had (INV-2027-0001 is the fiscal year’s end year); claims, groups, advances, petty cash, trade documents and fixed assets default to the calendar year.
  • Not customizable, by design: payroll runs, petty-cash float codes and the opening-balance kinds. They are issued through the same function but registered fixed; payroll’s number is also checked by 00900 constraints, which a future change would have to relax.
  • org.numbering.manage can change journal entry layouts. It is granted to Owner only; grant it to others deliberately.
  • Debit notes (DN, SDN) are registered as fiscal-basis scopes beside invoices and credit notes.
  • The Malay and Chinese strings for the screen are in docs/review/*.csv and are not yet reviewed by a native speaker.

After migrating, re-provision every organization (POST /v1/org/provision) so it gains the customer_debit_note and supplier_debit_note workflow kinds (a new seeded kind reaches an existing organization only that way; see “Wave 3 base — new workflow kinds reach existing organizations only by re-provisioning”). Until then an organization cannot create a debit note. Re-provision before turning e-invoicing on: setting.go salesVoidExits needs the customer_debit_note definition.

There is no per-kind e-invoicing switch. An in-scope organization queues every issued AR debit note as MyInvois type 03 (including one without an original), and 03/13 are not sandbox-validated (ROADMAP item 11a). In-scope organizations should not issue debit notes until 11a closes; raise an extra invoice instead.

Aging filters, invoice analysis and supplier statement (2026-10-05 plan)

Section titled “Aging filters, invoice analysis and supplier statement (2026-10-05 plan)”
  • Migrations 01080-01081: 01080 widens the export CHECK (three new reports); 01081 gives existing organizations’ Owner and Finance roles the new receivables.analysis.read key. A new organization’s roles get it from provisioning. Custom roles that should see the invoice analysis need the key added by hand; until then the tab is absent and the API answers 403.
  • No provisioning step, no re-seed. The public API’s AP aging default is unchanged; the web asks for scheme=extended. The public OpenAPI gains optional q, from, to, sort, order on the sales document list (additive).
  • Down-migrations: 01080 restores the previous CHECK list (fails if an export of a new report exists: delete or keep the migration); 01081 removes the key from Owner and Finance.
  • Not measured at scale: the aging, analysis and register queries have not been timed on an organization of 100k journal lines. They are set-based; measure before adding an index.