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.
List search, sort and filter
Section titled “List search, sort and filter”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=falsenow means inactive only. It used to mean “all” onGET /payables/suppliers,/receivables/customers,/inventory/itemsand/inventory/locations. Absent, or bothactive=true&active=false, lists all. An emptyactive=is ignored. -
activeis validated. Any value other thantrueorfalse(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, whereactiveis new. -
qsemantics 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’sAccept-Languagelocale else English, type, unit and category). Aqover 100 characters is now 400invalid_queryon 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),typeandcategory_id(items) accept the parameter repeated (any of). A single value works as before (statuson the float register is new); an empty value is ignored.class_idonGET /assetsis repeatable too. -
New filter parameters besides the ranges:
GET /assets/classesactiveandmethod,GET /hr/vacancieskind(unknown kind 422). -
GET /assetstotals 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
qalso 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 anothersort, or a sorted cursor sent withoutsort); 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=rolesandrole_idneedorg.roles.manage,sort=positionsneedsorg.structure.read(400invalid_sort/ 403 otherwise). With no parameter the response is unchanged. -
e-Invoice submissions:
issued_from/issued_toand the issued date inqare Kuala Lumpur calendar dates. The web screen now pages with Load more (it showed the newest 50). -
Table exports follow their list.
GET …/exportfor suppliers, customers, purchase invoices, the eight trade document lists and journal entries takes the sameqand filters as its list (and the samesort, except journal entries, which export oldest first whatever the list’s sort), so the file holds what the screen shows across every page. Theactive=falseand repeatablestatechanges 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).
CRM — erase personal data (anonymise)
Section titled “CRM — erase personal data (anonymise)”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.anonymisesee 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_appstill 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, ormigrate provision --org ... --owner-member ...): stepworkflow_definitionspublishes the seededcrm_leadandcrm_opportunitygraphs. Until then every lead and opportunity route that needs a definition (lists, the pipeline’s stages, creating, converting) answers 409workflow_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_reporterfor 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/provisionormigrate provision): stepworkflow_definitionspublishespayroll_runandpayslip; steppayroll_accounts(order 84) creates2180 Salaries payable(payroll_payable),2190 Statutory deductions payable,6210 Salaries and wagesand seeds the payroll setting with the last two. Until then creating a run answers 409workflow_missingorpayroll_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.readholders (Owner only by default). Payslip-bearing payment batches are hidden from members withoutpayroll.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 apayroll.run.readholder (GET /payables/batches, batch ids) and the batch candidates when they include payslips each write one id-onlypayment_batchread 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,salaryorpayroll_runrows:erp_apphas no DELETE on them (00900, enforced by the retention drift test),salaryandpayslip_lineare append-only andpayslipandpayroll_runare 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.writeorpayroll.item.manageholder 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-runmake generate. The clientOrgModule.keyenum gainedassetsandpayroll. - 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’sTestEveryBinaryLinksTheSameModulesnow pins it. Keys for existing organizations come from00802and00824(they also bumpperm_version); keys for new organizations come from thehrmodule’sinit(). - 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
00822a primary assignment beyond a position’s approved headcount is refused (member_position_within_headcount, HTTP 409over_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.Assignnow takes the seatFOR 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/provisionormigrate provision --org … --owner-member …). Until then requesting a vacancy answers 409workflow_missing. - The
committedsemantic is Phase 4 O1’s00700. This lane’s copy (00820) was dropped when Phase 4 merged first; H2 relies on00700and nothing in00821+ names00820. workflow.CommitmentKinds(internal/platform/workflow/model.go) gainsvacancy(H3 addsleave_request), beside Phase 4’s four trade kinds.vacancy_fixedcompares 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 touchedapi/openapi.yaml: resolve by re-runningmake 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.
Phase 5 H3 — leave
Section titled “Phase 5 H3 — leave”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.
00842widens themodule_install_key_knownCHECK, which fixes an H1 defect. The CHECK did not listhr, so H1’shrmodule could not be switched on or off at all. It now knowshrandleavebeside Phase 4’sinventory,procurement,salesandassets(00713,00772;00842sorts after them and replaces their list). Its Down deletes thehrandleaveinstall rows before restoring00772’s list, so an organization that had switched either off finds it on again after a rollback.00803and00842were edited after first commit; no repair migration is needed. The merge of Phase 4 intophase-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/mainwent straight frome0525f8bto4553ca14, which already had the edit;origin/phase-5-hr-designwas last pushed before either file existed), the dev databaseerp-dev-pgwas rebuilt after the merge and holds the full lists, and the VPS test stack was never brought up (erp.mcommsg.websitedoes not resolve). If a database that ran the earlier text does turn up,00903already rebuildsmodule_install_key_knownin full, butimport_job_kind_knownmust be re-added with00803’s current list by hand.- Re-run provisioning for every existing organization (
POST /v1/org/provision, ormigrate provision --org ... --owner-member ...): theleave_requestworkflow is a seeded graph, and until it is published a leave request failsno published definition. Readiness shows theworkflow_definitionsstep 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_grantsjob grants only once an active paid leave type has a rule set in force; theleavemodule 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. Migration00843addsleave_request.recorded_byand 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
00801copied have no hire date until HR records the hire (H1). - The worker must run both jobs.
hr_leave_grantsgrants 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_reroutelogs 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, whichplatform/hrregisters frominit(). A binary that does not linkplatform/hrwould not know who is on leave; thecmd/apiandcmd/workerwiring 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 readsPAYOUT_DETAILS_KEY_PREVIOUSduring a rotation), the same values as the api:hr_apply_leavingsdeactivates leavers, and deactivation purges their saved payout profiles, which needs the keyring. A worker without it refuses to start. The VPSerp.envandscripts/dev-stack.shalready 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 theemployeesimport, 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_noFROM organization_member mWHERE 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_nois read-only from00801: a script that updates it now fails withorganization_member_employee_no_read_only. Change numbers on the employee record. -
The worker must run
hr_apply_leavings(hourly, registered incmd/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’serp_hr_leavings_failinggauge (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.Deactivatecallspayout.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 touchedapi/openapi.yaml: resolve by re-runningmake generate, never by hand. -
Import kinds and Phase 4 O1/O4:
00803sorts after00714and00773and replaces theirimport_job_kind_knownlist, so it lists Phase 4’sitemsandfixed_assetas well asemployees. -
Identity numbers persist in
import_job.file_bytes. Anemployeesimport that carries an identity column keeps the uploaded file, identity numbers included;import_jobis append-only. -
Down-migrations refuse once H1 has been used:
00803while anemployeesimport is recorded (import_jobis 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.
00771the five fixed-asset tables;00772switches on the optional moduleassetsand grants its five keys to every existing Owner and Finance role (perm_versionbumps, so members pick them up at their next token refresh);00773thefixed_assetimport kind. There is no00770: the plan’s copy of theeffectsemantic (its Task 1) was dropped because lane O2’s00720had already landed it. - Migration order.
00772sorts before00773but touches nothing00773does. A fresh or normal deploy applies them in order. A database that somehow applied00773before00772(for example a branch build run against a shared database) needsgoose up -allow-missingonce. - Re-run provisioning (
POST /v1/org/provision, once per organization, after migrating): it seeds theasset_disposalworkflow. Until then creating a disposal answers “no published definition”. - Deploy the worker,
orgadminandmigratewith the api.cmd/workerregisters theassets_depreciationworker and its periodic job and carries the disposal’s posting rule;cmd/orgadminandcmd/migratelink 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_depreciationon themaintenancequeue, 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 askippedrun 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_glcompares 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.
Phase 4 O3 — procurement and sales
Section titled “Phase 4 O3 — procurement and sales”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.
00742grants the Owner every new key, Finance the eighteen trade keys, Employee the requisition keys andinventory.item.read, Approver requisition read and transition, and bumpsperm_version.- Deploy the worker with the api.
cmd/workernow 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:
00740while a receipt was posted under the sole-owner exception,00743while an order or quotation layout is saved. Roll the code back, not these migrations. 00742’s Down removesinventory.item.readfrom 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.
Phase 4 O2 — stock ledger and costing
Section titled “Phase 4 O2 — stock ledger and costing”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 moduleinventory; 2170 GRNI, control kindgrni; 5200 COGS; 5300 purchase price variance; 5400 Stock adjustment) andinventory_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_valuationintegrity check (RUNBOOK §7.3); an old worker never runs that check. 00727grants the Owner role the four stock keys andorg.profile.manage, and bumpsperm_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_installrow means on).procurementandsaleshave no screens until O3;inventoryshows Items and Locations to Owners and Finance. An organization that should not see them switches them off in Settings → Modules;procurementandsalesmust go off beforeinventory. 00713grants the Owner and Finance roles both item keys and bumpsperm_version, so members’ tokens are reissued with them. It also widensmodule_install_key_knownto the three new modules.- Down-migrations refuse once O1 has been used:
00700while any published workflow state iscommitted,00712while an item custom field is defined,00713while one of the three modules has amodule_installrow,00714while anitemsimport 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’sregistry_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_statesgrant (403self_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_idFROM workflow_gate_grant gJOIN organization_member m ON m.org_id = g.org_id AND m.activeWHERE m.id = g.grantee_member_idOR EXISTS (SELECT 1 FROM member_position pWHERE p.org_id = g.org_id AND p.member_id = m.id AND p.position_id = g.grantee_position_idAND p.withdrawn_at IS NULL AND p.valid_from <= current_dateAND (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_idFROM workflow_instance iJOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_keyWHERE 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) > 1AND EXISTS (SELECT 1 FROM holder h WHERE h.org_id = i.org_id AND h.definition_id = i.definition_idAND (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_idAND (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_requiredwhen blank). The audit row carriessole_owner_exceptionand the reason; an engine decision also hasauthority_via = 'sole_owner_exception'.00470adds the flag columnsparty_supplier.bank_verified_sole_owner,member_payout_profile.verified_sole_owner,payment_batch.approved_sole_owner,budget_version.approved_sole_ownerandpayment_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.
00470Down restores the old batch-file trigger, drops the five flag columns, and re-addspayment_batch_approver_differs,member_payout_profile_verifier_differsandparty_supplier_bank_verifier_differsas validated CHECKs (budget_version_preparer_not_approverstays 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 andworkflow_decisionrows flaggedsole_owner_exceptionstay (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 andworkflow_decision.authority_viasay 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_amountHAVING 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 orbothcode on thesst_payableaccount (SST gives no input-tax credit, so a purchase never touches 2140), or a non-withholding code onwht_payable. Existing ones still post; retire each (PATCHactive: false) and create a correct one:SELECT t.org_id, t.code, t.usage, a.code AS account, a.control_kindFROM tax_code t JOIN account a ON a.org_id = t.org_id AND a.id = t.account_idWHERE (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, sentencebase_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_whtFROM 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_onis frozen when its payment is saved, so existing deductions keep the day-later date and the register’slateflag reads it; re-saving a draft payment rewrites its deductions with the new date. - X7 — migration
00490addstax_code.standard_code: the country-pack withholding code a withholding code derives from (its payment type —W109-INTinterest,W109-ROYroyalty, …), fixed with the rate. The API now requires it on a withholding code (wht_standard_codewhen 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_rateFROM tax_code t LEFT JOIN tax_code s ON s.org_id = t.org_id AND s.code = t.standard_codeWHERE t.usage = 'withholding' AND t.treaty_reference IS NULLAND (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 accountposted_after_lock.00502records 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 compareposted_atwithlocked_at. - New key
accounting.period.close_unreconciled(00500), granted to existing Owners (and to new Owners with every key);perm_versionbumps. 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(00501for existing Finance roles, provisioning for new ones;perm_versionbumps). 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:
00502Down drops the snapshot column;00501then00500Down remove the keys; the code must roll back with them.
Phase 2 completion — release overview
Section titled “Phase 2 completion — release overview”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:
00430while a workflow state namessst_payable/wht_payable;00432while a 0% (treaty) withholding deduction exists;00443once a decision rests on the sole-owner exception;00451while a batch has a claim or advance line;00460once abatchauto-match exists;00461once 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);00463while an export row is abank_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_profilein 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/IssueTimestay 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 accountsst_payable(the tax registration’s account; with no registration, the single account the active positive-rate sales codes name) and its WHT payable accountwht_payable(the single account the active withholding codes name).account_ledger_guardis 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 showscontrol_kind_unmappedin readiness. The supported fix (00433): a member withaccounting.account.managesets 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 anaccountupdate; 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_payableaccount. An organization whose existing withholding codes name several accounts keeps the others as plain liabilities: point future withholding at thewht_payableaccount 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 409adjustment_account_missinguntil 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 409prior_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, withPOST /v1/accounting/entries{"opening": true, …}(no web form yet): it needsaccounting.opening_balance.post(00436grants 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) addstax_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
00430clears 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) addstax_code.treaty_referenceand 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 onlyreports.*loses those exports. -
00435re-createswht_remittance_line_guardso 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).
Phase 2 completion — C3 e-Invoice
Section titled “Phase 2 completion — C3 e-Invoice”Migrations 00440–00444, no provisioning:
00440: an e-Invoice’s statutory link (submission_uid,uuid,long_id,validation_url,validated_at) is write-once (theeinvoice_submission_frozentrigger).00441:purchase_invoice.cancelled_atis write-once; a sales document’s firstcancelled_atmust come with its move to a cancelled state (a deferred constraint trigger). A rawUPDATEof either, outside the app, is now refused.00442: the recorded MyInvois portal check (Ruling O-3c) oneinvoice_submission.00443:workflow_decision.authority_viaalso takessole_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 carryverified_by/verified_at. Every existing profile starts unverified, so before an organization’s first batch of claims or advances a holder ofpayables.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.
Phase 2c banking and budgets (C5)
Section titled “Phase 2c banking and budgets (C5)”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 rulebatchis allowed onbank_match.rule. Its Down restores the oldbank_match_rule_knownCHECK and therefore fails once anyrule = 'batch'match exists.00461—bank_reconciliation_unlock(append-only, RLS).bank_locked_throughignores unlocked locks, andbank_reconciliation_onceis 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_throughreverts), so a period edited after its unlock silently reads as locked under the stale snapshot. The unlock events survive only inaudit_log. Do not roll back past00461once an unlock has been recorded.00462— existing Owners gainbanking.reconciliation.unlock;perm_versionbumps so live sessions refresh once. Finance does not gain it.00463—report_export_report_knownis the ten reports00404listed plusbank_reconciliation; the Down restores00404’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).
Phase 2 — release overview
Section titled “Phase 2 — release overview”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 gainpayables.*; Finance gains it withoutverify_bank, plusaccounting.ledger.read. - AR
00313— Owner and Finance gainreceivables.*. - Payment batches
00331— Owners gainpayables.batch.read/.manage/.approve; Finance gainsreadandmanage. - WHT
00342— Owner and Finance gainpayables.wht.read,.create,.transition. - Bank reconciliation
00353— Owners gain all sixbanking.*keys; Finance gains five (notbanking.reconciliation.lock). - Budgets
00371— Owners gainaccounting.budget.manageand.approve; Finance gainsaccounting.budget.manage. - MyInvois
00382— Owners gaineinvoice.read,.submission.manage,.settings.manage; Finance gainsreadand.submission.manage. - SST-02
00402— Owner and Finance gain the fivetaxreturns.*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.
Phase 2 payment batches
Section titled “Phase 2 payment batches”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).
Phase 2 MyInvois
Section titled “Phase 2 MyInvois”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.pemopenssl pkcs12 -in cert.p12 -nocerts -nodes | openssl pkcs8 -topk8 -nocrypt -out key.pemDelete 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.
- Login as intermediary
onbehalfofa sandbox TIN. - Submit and validate one document of each type
01,02,11,12. - Submit one deliberately invalid document and confirm LHDN’s own errors are what is shown.
- Submit one signed version
1.1document and confirm it validates (signature shape,documentHashhex). - Cancel one document within 72 hours.
- Confirm the PDF’s QR code opens the document in the sandbox portal.
- 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. - 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 thatschemeIDisNAand that quantities are encoded to 10 decimals. 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) andtotalPagesshape 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/tokenanswering 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.
SST-02 return generation
Section titled “SST-02 return generation”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.
Phase 2 WHT
Section titled “Phase 2 WHT”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).
Phase 2 AR
Section titled “Phase 2 AR”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.
Phase 2 budgets
Section titled “Phase 2 budgets”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.
Phase 2 bank reconciliation
Section titled “Phase 2 bank reconciliation”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 newbin/operatorconnects as;erp_ops_owner(NOLOGIN, BYPASSRLS) owns the reviewedopsfunctions. Creating a BYPASSRLS role needs a superuserDATABASE_MIGRATE_URL; a CREATEROLE-only migration role fails at00233. -
Set
erp_operator’s password. The migration gives it the dev passworderp_operator:ALTER ROLE erp_operator PASSWORD '…'and put it inOPERATOR_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_ADDRmust name a loopback or private-network address (:8090and0.0.0.0:8090are refused). Never publish it through the public reverse proxy. It needsTOTP_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, queuemaintenance, every 24 h, not on start) and writes pass/fail per organization toops.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 needsPUBLIC_BASE_URLandTOTP_ENCRYPTION_KEYfor “create organization” (the owner’s invitation link is sealed with it); without them that action fails withmisconfigured. 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 stillrunning30 minutes after a worker claimed it (the worker died) is closed as failed,abandoned, and not run again (migration00241): check the organization before asking for it again. -
Suspension (
organization.status, migration00240): a suspended organization’s members cannot sign in or refresh (403org-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.exampleand RUNBOOK §1.1. -
Service-health dashboard (V2, progress item I10): import
deploy/grafana/erp-service-health.jsoninto Grafana (Dashboards → New → Import) and pick the Prometheus data source. It expects the two scrape jobs to be named exactlyerp-apianderp-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-apistatic_configs: [{ targets: ["<api private host>:9464"] }] # API_METRICS_ADDR- job_name: erp-workerstatic_configs: [{ targets: ["<worker private host>:9465"] }] # WORKER_METRICS_ADDRinternal/app/metrics_test.gofails 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_rowsandprocess_resident_memory_bytes; scrapeworker-reportsunder the sameerp-workerjob (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 runsworker(maintenance, einvoice, operator) andworker-reports(reports). Deploy both together: a stack that setsWORKER_QUEUESon one instance and not the other has every queue served twice or not at all. The instance servingmaintenancealone runs the session prune, the exchange-rate sync and theerp_river_jobsgauge; River’s periodic jobs are registered on every instance and enqueued once by the elected leader.
Phase 2 AP
Section titled “Phase 2 AP”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
00270addsnotification. It is operational (erp_app holds DELETE); its Down drops the table and every notification with it, which loses nothing of record. - Migration
00271addsnotification_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
maintenanceRiver 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
notificationsand 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
00255addsorganization_member.photo_document_idand admitsimage/webpas 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_urlonGET /v1/meandGET /v1/org/membersis 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 withfetch, so the object store must answer CORSGETfrom the web origin, as it already does for uploads. - WebP is accepted for every document, receipts included.
Plan 11 — approval by project
Section titled “Plan 11 — approval by project”No new container, variable or job. Run the migrations as usual.
- Migration
00190adds three tables (analytic account approvers, an instance’s shares, sign-offs) and theaccount_approvergrant kind. Its Down refuses while any published grant isaccount_approver. Published versions are never deleted, so once a version with the rule has been published,00190cannot be rolled back: plan a restore from backup instead of a down migration. workflow.definition.publishnow also governs who approves each analytic account (P14). Holders ofaccounting.account.managecan create projects but not choose their approvers. Review who holdsworkflow.definition.publishbefore 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_approvermarker; 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_sharerows and adds asharesrow 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
00191is additive: it addsmin_amountand 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.AssignApproveris replaced bySetApproverCell(the whole cell, replacing whatever is live) andEndApprovernow 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.
1. Compose
Section titled “1. Compose”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.
2. TRUSTED_PROXIES now has two hops
Section titled “2. TRUSTED_PROXIES now has two hops”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:
TRUSTED_PROXIES=172.18.0.0/16 # docker network inspect <project>_default → IPAM.Config.SubnetLeft 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-internalhttp://minio:9000produces URLs nobody outside the VPS can open. Put the store behind your TLS proxy (for examplehttps://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; seeweb/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
PUTandGETfrom the app’s origin, because the browser calls it cross-origin. For MinIO:mc anonymousis not it. SetMINIO_API_CORS_ALLOW_ORIGIN=https://erp.example.com.myon 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.
4. PUBLIC_BASE_URL and the refresh cookie
Section titled “4. PUBLIC_BASE_URL and the refresh cookie”PUBLIC_BASE_URLis 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/acceptpage.- The refresh cookie is
Path=/v1/auth; Secure; HttpOnly; SameSite=Lax, host-only. It only works when the app and/v1share an origin and that origin is HTTPS. Serving the app on one host and the API on another, or mounting/v1under 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.
5. Security headers
Section titled “5. Security headers”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.managefrom the Owner role (owner_role_manage_required); - a change that would take
org.roles.manageaway from the person making it (self_lockout).
Every change writes audit_log in the same transaction, with the before and after sets:
rolerows:created,renamedandpermissions_set;organization_memberrows: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.
Plan 9 — reports, exports and River
Section titled “Plan 9 — reports, exports and River”- River’s schema ships as goose migrations
00110–00116; runmigrate up(the privileged migrate job) before starting api or worker. Never run River’s ownriver migrate-upin production — goose owns the schema. - The worker now needs the four
OBJECT_STORE_*settings, like the api, and refuses to boot without them. - The worker polls (
PollOnly), so it works behind pgbouncer in transaction mode; expect up to a second before an export starts. - 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. - River deletes finished job rows after 24 h; the record of an export is
report_exportandaudit_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.%';Plan 7 — advances
Section titled “Plan 7 — advances”- Existing organizations get the three advance workflows by re-running provisioning
(
POST /v1/org/provision, ororgadmin provision): step 60 publishesadvance,advance_topupandadvance_refundat 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-detailsnow also reseals advance settlement snapshots (advance_settlement), and--checkexits 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/reminderslists 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.
Plan 5 — expense claims and documents
Section titled “Plan 5 — expense claims and documents”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.
5. OCR_PROVIDER stays empty
Section titled “5. OCR_PROVIDER stays empty”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:
# 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.
Plan 6 — payout channels
Section titled “Plan 6 — payout channels”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:
PAYOUT_DETAILS_KEY=p1:$(openssl rand -base64 32) # generate once per environmentThe 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.
2. Rotating the key
Section titled “2. Rotating the key”Rotation is one active key plus at most one previous key (Ruling P32). Never reuse a kid.
- Generate a new key with a new kid. Set
PAYOUT_DETAILS_KEY=<new kid>:<new secret>andPAYOUT_DETAILS_KEY_PREVIOUS=<old kid>:<old secret>. Deploy. New writes use the new key; everything sealed under the old one still opens. - 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 removePAYOUT_DETAILS_KEY_PREVIOUSand do not rotate again. - 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 clearPAYOUT_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.
Plan 2b — org structure and onboarding
Section titled “Plan 2b — org structure and onboarding”Four things. The first stops the containers from starting until it is done.
1. PUBLIC_BASE_URL is now REQUIRED
Section titled “1. PUBLIC_BASE_URL is now REQUIRED”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.
# Correct — the public origin, behind your TLS-terminating reverse proxyPUBLIC_BASE_URL=https://erp.example.com.my
# Also correct — the app mounted under a pathPUBLIC_BASE_URL=https://example.com.my/erp
# Wrong — refused at bootPUBLIC_BASE_URL=erp.example.com.my # no schemePUBLIC_BASE_URL=https://erp.example.com.my/?x=1 # query string# Wrong — accepted, but produces links nobody outside the VPS can openPUBLIC_BASE_URL=http://localhost:3000PUBLIC_BASE_URL=http://api:3000 # the Compose service nameUse 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 withTRUSTED_PROXIESempty, 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.
# Docker Compose on a VPS: the proxy's container networkTRUSTED_PROXIES=172.18.0.0/16Do 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.
3. New operator step: orgadmin create-org
Section titled “3. New operator step: orgadmin create-org”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):
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) andPUBLIC_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>andinvitation <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-orgdoes 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/orgadminand run it on a host that can reach the database. (make builddoes 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 hostAs 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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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_idUNION ALLSELECT '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.
Plan 4 — accounting core
Section titled “Plan 4 — accounting core”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_restorewithout--create— restoring into a freshly created database leaves that database with the default privileges, which grantTEMPORARYtoPUBLIC;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 databaseREVOKE TEMPORARY ON DATABASE <db> FROM PUBLIC;-- must return falseSELECT 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 withaccounting.fx.managemust record a manual override for each date withPOST /v1/accounting/exchange-rates. Acceptable in dev or staging; in production it means every foreign-currency claim waits on an override. - Only
httpson abnm.gov.myhost (bnm.gov.myor 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
lockedperiod to any other status, reopen aclosedyear, 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 withecho "w1:$(openssl rand -base64 32)"; back it up besidePAYOUT_DETAILS_KEY(RUNBOOK 4.2).deploy/vps/setup.shwrites one for a new stack; an existing.envneeds the line added by hand. -
The
workerservice now serves a fourth queue,webhook:2(compose setsWORKER_QUEUES: maintenance:1,einvoice:1,operator:1,webhook:2;worker-reportsstaysreports:2). A stack that overridesWORKER_QUEUESitself must add it, or deliveries queue and never send:cmd/worker’sTestEveryQueueIsServedByAComposeWorkerchecks the compose file. -
New environment:
APP_ENV(defaultproduction),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 whenAPP_ENV=development), and the optionalAPI_RATE_*/API_AUTH_FAIL_PER_IP_PER_MINlimits (.env.example). SetREDIS_URLso 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_deadrising for 1 h, or above 0 for 24 h erp_webhook_oldest_pending_secondsabove 900 for 10 min (a stuck queue or a worker down) erp_webhook_endpoints_disabledany 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_secondsinformational The gauges are set by the reconciler on the instance that serves the
maintenancequeue; the counters and histogram by every instance servingwebhook. Scrape both under the existingerp-workerjob. No metric carries an organization, endpoint or key id. -
Alert rules, prepared (2026-10-04), not applied:
deploy/grafana/alerting/erp-webhooks.yamlholds 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_addressabove 0). To apply, on the VPS: copy the file to Grafana’s provisioning directoryalerting/(under/srv/grafana; the exact mount was not determinable from this repository), setPROMETHEUS_DS_UIDin 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 folderERP, grouperp-webhooks, five rules in state Normal; Prometheus has the series (erp_webhook_oldest_pending_secondsreturns one value; the Prometheus scrape of both workers is indeploy/vps/README.mdstep 5). To prove a rule can fire, temporarily editerp-webhook-queue-stuck’s threshold to-1in Grafana’s UI (not the file), watch it go Firing and the Telegram message arrive, then revert. Rules carryteam=erp; they route through the existing notification policy, which this repository does not define: confirm it sends to Telegram. Not covered: the spec’serp_api_requests_total,erp_api_rate_limited_totalanderp_api_key_auth_failures_totalare 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: thresholdin 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 theerpnetwork to10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16and100.64.0.0/10except 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 recordedblocked_address. Prepared, not applied:deploy/vps/egress-policy.shand the README section “Worker egress policy” indeploy/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_logunder its retention. -
Generated files (
api.gen.go,schema.d.ts,api/public-openapi.yaml) conflict when this branch merges with another that touchedapi/openapi.yaml: re-runmake generate.oapi-codegen.yamlnow setsskip-prune: trueso the webhook payload schemas (referenced from no operation) are generated. -
Down-migrations:
00960-00969drop the new tables; nothing outside them depends on them exceptaudit_log_credential, which namesapi_key.
Plan 12 — Document numbering (migrations 01100-01101)
Section titled “Plan 12 — Document numbering (migrations 01100-01101)”01100createsnumbering_format;01101grantsorg.numbering.manageto every existing Owner and bumpsperm_version, so each live session refreshes once.cmd/api,cmd/worker,cmd/migrateandcmd/orgadminall 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-0001is 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
00900constraints, which a future change would have to relax. org.numbering.managecan 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/*.csvand are not yet reviewed by a native speaker.
Debit notes (migration 01070)
Section titled “Debit notes (migration 01070)”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:01080widens the export CHECK (three new reports);01081gives existing organizations’ Owner and Finance roles the newreceivables.analysis.readkey. 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 optionalq,from,to,sort,orderon the sales document list (additive). - Down-migrations:
01080restores the previous CHECK list (fails if an export of a new report exists: delete or keep the migration);01081removes 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.