Skip to content

Roadmap checklist

The running state of the build. Tick items as they land. Anything not ticked is not done, whatever a branch name says.

Source of truth for why each item exists: docs/superpowers/specs/2026-09-20-erp-architecture-design.md. Sections cited below are that document’s. Operating a deployment: RUNBOOK.md and DEPLOY_NOTES.md.


Closed 2026-09-20, between the Phase 0 merge and Plan 2. Rationale in §17 of the spec.

  • Multi-entity / consolidation — org_id is the posting dimension. No entity_id. One org = one legal entity; a group onboards as several orgs and consolidation is a Phase 2+ reporting concern. (§17.1)
  • Base currency scope — per organization, on the company profile. (§17.2)
  • Tenant data retention and deletion — export, then anonymize in place. Never hard delete; the append-only ledger keeps no sanctioned deletion path. (§17.3)
  • Delegation and thresholds — the delegate acts on the delegator’s thresholds, and the decision log records both. (§17.4)

Plan: 2026-09-20-phase-0-foundation.md

  • Repo scaffolding, Makefile, CI, AGENTS.md
  • Postgres test harness (testcontainers) and goose runner
  • kernel/clock — one definition of today, UTC (§20.1.2); the organization’s day since O-1 (clock.Today)
  • kernel/money — decimal; rejects JSON numbers (§6.5)
  • kernel/errors — RFC 7807 and Postgres mapping (§20.1.5)
  • kernel/tenancy + db.InTx — forced RLS, SET LOCAL per transaction (§4.1)
  • Drift test — every table has org_id, forced RLS and an org_isolation policy
  • Drift test — every env var appears in .env.example
  • kernel/audit — append-only, commits with the change (§4.5)
  • kernel/eventbus — buffered, dispatched only after commit
  • platform/numbering — locked sequence row, not a Postgres sequence (§20.1.3)
  • platform/registry — the org-configurable lookup primitive (§20.1.4)
  • platform/i18n — translation rows, per-row locale fallback (§20.1.6)
  • Codegen: sqlc, OpenAPI, shared vocabulary (§20.1.9)
  • HTTP edge: middleware chain, router, entrypoints, access log (§20.1.7)

Carried out of Phase 0 — read before Plan 1 or Plan 2.

  • CLOSED (Plan 2 Task 12) — Tenant() no longer reads an organization from a header. The Phase 0 placeholder trusted an unauthenticated header, so any caller could assert any org. Tenant() now reads the organization out of the verified access token that Authenticate() placed on the request context, and nothing else. TestNothingReadsAnOrgFromAHeader in internal/kernel/layering scans internal, cmd and api — .go and .yaml — and fails the build if any file reintroduces an org header. Routes serving real tenant data may now be registered under /v1.
  • The first task adding a NUMERIC column or a monetary API field must configure a sqlc and oapi-codegen type override onto money.Amount. No override exists today because nothing monetary exists yet. Without one, a monetary column generates as float64 — the defect the money invariant exists to prevent, landing silently. — done in Plan 4 Task 18 (Money → money.Amount, Rate → money.Rate)
  • Amend §Global Constraints: the Go floor is 1.26, not 1.23. pressly/goose/v3 requires it, and CI derives its version from go.mod rather than restating one. — spec §2 and the Phase 0 plan amended; go.mod says go 1.26.0, CI uses go-version-file: 'go.mod'
  • Two tables carry a nullable org_id by declaration — expense_category and translation — recorded with justifications in db/org_id_nullable.txt, and kernel/db/rlscoverage_test.go fails on any undeclared one. A global registry row belongs to no tenant. Any new registry follows the 00005_registry.sql pattern, including its WITH CHECK clause.

Statutory compliance — a standing constraint

Section titled “Statutory compliance — a standing constraint”

Malaysian audit compliance is checked at design time, not added at the end. The binding requirements and their statutory references are §11A of the spec. Not yet reviewed by a tax agent — that must happen before any paying customer’s data lands.

  • Records stay in Malaysia (ITA s.82(8)) — VPS, volumes, object store and every backup in a Malaysian datacenter; no offshore processor touches record data, including OCR. A deployment fact, not a code path: checked at provisioning against RUNBOOK §1 (VPS, Postgres volume, DB backups and WAL archives, bucket and its backups, OCR off). The code backs it where it can: no default object-store endpoint, and any OCR_PROVIDER is refused at boot.
  • Seven-year retention outranks erasure (ITA s.82A) — anonymization suspends until a row’s retention clock expires.
    • Spec amended (§17.3 item 3, §11A.2); statutory rows undeletable by the app role (00015_retention_grants, enforced by kernel/db/retention_test.go)
    • Retention-gated anonymization path and its §11A.7 test (“no anonymization path may run against a row inside its retention window”) — built with tenant erasure / export (§17.3). No erasure path exists yet, so nothing can breach it today.
  • ms locale is statutory — record-bearing free text restricted to en / ms; zh is a UI locale only. Done for expense claims (expenses.CheckRecordText plus named CHECKs in 00072; category names required in en and ms). Being done on phase1-wave3: ms added to locale.Supported (the workflow validator refused ms labels without it) and the en/ms record-text check applied to journal text and party names. Tick when the base merges.
  • Audit trail is tamper-evident — prev_hash / row_hash chain on audit_log, with a verification pass CI proves can fail. — Plan 2 Task 1: 00007, VerifyChain; chain_test.go proves an altered, excised or tampered row fails; audit_chain reconcile check before every close; migrate verify-audit
  • Claim lines carry the tax record — supplier_tin, einvoice_uuid, document_basis, and employee_named_document_allowed on the category. Phase 1, because identifiers cannot be backfilled onto settled documents. — Plan 5 (Tasks 9, 11, 13)
  • e-Invoice submission stays Phase 2 — the issuing obligation is a sales-side concern Phase 1 does not build. Inbound capture is Phase 1 (claim lines, above).

Nine plans, in this order. 2 and 2b before 3, because grants bind to positions and the country pack needs the company profile. 3 and 4 before 5, because building claims first is how the logic ends up inside claims again. (§16)

Status (2026-09-24): Plans 2, 2b, 3, 4, 5, 6, 8 and 10 and the Wave 3 base are done. Phase 1 closes when Plan 7’s reminder delivery (waits for a mailer) and Plan 9’s aged-advances and float-register reports land.

  • Plan 2 — Identity Plan: 2026-09-20-plan-2-identity.md (17 tasks) Session and auth designed in full: 2026-09-20-session-and-auth-design.md
    • Users, organizations, members, sessions (refresh rotation + reuse detection) (§4.4) — 00008, 00016; replay revokes the family (session_test.go)
    • Delete X-Org-Id — org becomes a signed token claim; closes the Phase 0 blocker
    • Company profile — country, base currency, fiscal year start, TIN/SST/MSIC (§4.6) — 00011; created at provisioning. No company-profile edit API yet
    • Per-org customizable roles and module-registered permissions (§4.2) — 00010, member.RegisterPermission; five system roles seeded. No role editor API yet
    • Party model, employee role only (§18.2 item 3) — 00009 (kind IN ('employee'))
  • Plan 2b — Org structure (split per spec §16; grants bind to positions, so this precedes Plan 3) Plan: 2026-09-21-plan-2b-org-structure.md Design: 2026-09-21-org-structure-and-onboarding-design.md Deploy: DEPLOY_NOTES.md — PUBLIC_BASE_URL is now required
    • Org units, positions, reports_to tree, member assignments (§4.7)
    • Activity log — the user-facing feed (§20.1.7)
    • Organization provisioning — ordered, idempotent, API-repairable, one code path with the CLI backfill, plus the readiness endpoint (§20.2)
    • Organization creation and invitations landed here, not in Plan 2. Nothing in Plan 2 created an organization — it authenticated members of organizations that had to exist already — and positions have nothing to attach to without people in them. So orgadmin create-org (operator-provisioned; there is no public sign-up) and the invitation issue / accept / revoke flow shipped with this plan.
  • Plan 3 — Workflow engine Plan: 2026-09-21-plan-3-workflow-engine.md (12 tasks) Existing organizations get the seeded flow by re-running provisioning (POST /v1/org/provision); step 60 is new. See RUNBOOK §6.2.
    • Definitions, states, edges, grants, version pinning (§5.2)
    • Gates: reporter / target / source / advance (§5.3)
    • Thresholds on grants, floors only, tiling (§5.4)
    • Grantee kinds incl. superior and its resolution chain (§5.4)
    • Semantic roles as the ledger contract (§5.5)
    • Publish-time validator, all seven rules (§5.7)
    • Delegation, escalation, dispute, immutable decision log (§5.8)
    • Seed the korre default flow as v1 (§6.3)
    • Seam follow-up (Ruling 17): posting.Event cannot say “past payout”, so an upheld dispute after payment reached the Poster as rejected + PastAccrual. Resolved on phase1-wave3 (wave-3 contract §1.1, the same item as “Reversal once past payout” below): posting.Event.PastPayout plus workflow_instance.payout_open (migration 00034) make a reject or cancel past a payout reverse it, and validator rule 5 now demands reverses there. Claims were already safe on main: their payout requires an unreversed accrual, and Plan 4 R7 mirrors every unreversed leg, so TestAnUpheldDisputeAfterPayoutReversesBothLegs proves the bank pays once. The gap was payout-first and cash-only documents (Plans 7 and 8). phase1-wave3 is merged.
    • Caller constraint for every consumer: call workflow.Transition before any audit.Write (including workflow.Start) in the same transaction when the move can post — a Poster’s advisory lock must precede audit class 1 (lockClassAuditChain, internal/kernel/audit); see Transition’s doc comment.
    • Deferred: role sets and quorum (§5.8) — Ruling 9.
  • Plan 4 — Accounting core Plan: 2026-09-21-plan-4-accounting-core.md (21 tasks)
    • Journals (§10.2)
    • Chart of accounts with control kinds (§10.1)
    • Analytic axes and ratio distributions (§10.2)
    • Journal entries, balanced by deferred trigger, append-only (§10.2)
    • Posting engine keyed on (kind, semantic), same transaction (§10.3)
    • Fiscal periods and year-end close (§10.4)
    • FX: frozen base amounts, gain/loss account, Bank Negara sync (§10.5)
    • Exchange rates page (added 2026-10-07, not planned in the spec): GET /accounting/exchange-rates/reference and /overrides (server-side filter by currency and date, sort, offset paging with a total) and the /accounting/exchange-rates page show Bank Negara’s stored rates and the organization’s overrides. Go test and vitest, each watched failing; not opened in a browser; ms and zh strings need native review
      • Sync runs from the worker when BNM_RATES_URL is set; nightly reconciliation is deferred to River (Ruling R14)
    • Reconciliation set (§10.6)
    • Malaysia country pack: CoA, journals, SST codes (§11)
  • Plan 5 — Expense claims Plan: 2026-09-21-plan-5-expense-claims.md (21 tasks)
    • Claim header + lines, four line types (§6.1, §6.2)
    • Date-effective rates, never edited in place (§6.2)
    • Claim groups and references (§20.1.3)
    • Documents: presigned upload, thumbnails, OCR as suggestion only (§6.6)
      • OCR ships as an interface with a no-op default; the provider is the operator’s choice and must process in Malaysia (Ruling R35)
      • Virus scanning deferred (Ruling R32)
    • Final-review hardening: claim/line identity and document evidence frozen by trigger (00074, 00075); non-viewers get 404 on every claim route (R21); inclusive tax rounded once (money.Amount.MulDiv)
    • Possible duplicate receipts across claimants flagged to approvers and recorded in each transition’s audit row, never blocked (00076)
  • Plan 6 — Payout channels (§9) Plan: 2026-09-21-plan-6-payout-channels.md (9 tasks)
    • Channel definitions, field_schema, GL binding
    • Seven Malaysia seeds; member payout profiles (encrypted at rest, purged on deactivation); settlement snapshot
  • Wave 3 base — seam changes for Plans 7 and 8 (branch phase1-wave3, merged) Contract: 2026-09-22-phase1-wave3-contracts.md
    • Reversal once past payout — closes Plan 3 Ruling 17 (§1.1)
    • cash_only posting order for pure cash movements (§1.2)
    • Seeded-graph registry, so each plan seeds its own kinds (§1.3)
    • Cross-consumer document binding: one record per receipt, read access for every consumer’s approvers (§1.4)
    • Module-owned accounts refuse manual journal lines (float accounts) (§1.5)
    • Document routes open to every consumer (documents.document.upload)
    • payout_open backfill (00055)
  • Plan 7 — Advances (§7) — merged; open only for reminder delivery Plan: 2026-09-22-plan-7-advances.md (16 tasks)
    • Acquittal decision table; signed variance fan-out; payout_first ordering
      • Three workflow kinds (advance, advance_topup, advance_refund, contract §2.1, §8.1); an acquittal of zero returns the advance through returned (Ruling A4)
      • The acquittal carries the §6.7 tax record, validated by taxrecord (Ruling A30)
    • Acquittal reminders
      • Due dates, overdue list and escalation level ship with Plan 7; delivery is deferred until River (Plan 9) and a mailer exist (wave-3 contract §4)
      • Computed, not delivered: GET /v1/advances/reminders and the overdue block give the escalation level (claimant, then manager, Ruling A28); delivery waits for River (Plan 9), a mailer, and tenant enumeration (contract §4)
    • Deferred: advance_offset claim lines; paying an advance from the tin
  • Plan 8 — Petty cash (§8) Plan: 2026-09-22-plan-8-petty-cash.md (19 tasks)
    • Float, cash_balance + committed, float_movement ledger (00150)
    • Two-phase spend, three reviewer outcomes, void semantics by origin
    • float_topup, replenishment, close-out with refund proof
      • Close-out is its own float_closeout workflow kind (Ruling PC3)
      • Replenishment restores every approved disbursement: VERIFIED spends and claims settled from the tin (Ruling PC20)
    • Claims settled from the tin credit the paying holder’s float (Plan 6 Ruling P6)
      • Disputes over what a closed float paid cannot be upheld in the system (Ruling PC33)
  • Plan 9 — Reports (§10.7) Plan: 2026-09-21-plan-9-reports.md (17 tasks)
    • Trial balance, P&L, balance sheet, GL detail — one query layer in platform/reporting, ledger figures only, one read-only snapshot per report
    • Aged advances (after Plan 7) · float register (after Plan 8) — specified in the plan’s Deferred until Plans 7/8 section. Not built as reports: the float register exists as a screen over the petty-cash API (Plan 10 Task 26), and advance aging only as Plan 7’s overdue list
    • Export to CSV / XLSX / PDF via River
      • River lands here: schema vendored into goose (00110–00116), kernel/jobs, a poll-only worker. Nightly reconciliation now runs on it (ops_reconcile, operator console B5, results in ops.check_result); moving the BNM sync and Plan 5’s idempotency pruner is still open (Ruling R30)
  • Plan 10 — Frontend (§13) Plan: 2026-09-23-plan-10-frontend.md (28 tasks) Design: 2026-09-23-plan-10-frontend-design.md (rulings F1–F21) Deploy: DEPLOY_NOTES.md — a new web container fronts the api; TRUSTED_PROXIES and the object store’s public origin change
    • Shell, org switcher, i18n (en / ms / zh) — ms is statutory (§11A.3); the line said en / zh until F2
    • Claims list and detail from the Figma design
    • Workflow-driven UI: status chips, filters and action buttons all from the API
    • Screens missing from Figma, in priority order (§13.5) — workflow definition editor first, then payout channels, chart of accounts, journal entries, fiscal periods and close, reports and exports, advance acquittal, float register
      • Seams added for the UI: /me, /org/members, roles API (org.roles.manage, 00170), claim list filters and summary, petty-cash to_act, /advances/summary, close-out list
      • Global search (Figma batch 2, audit 2.2) — GET /search behind search.use, held by every role (00268 Owners, 00269 all other roles, provisioning for new ones); each group is its own list’s visibility, from the owning module’s contract.go; pages and people client-side; ⌘K (design). No trigram index yet
      • Notifications (header bell; Figma batch 2, audit 2.4) — 00270–00271, in-app only, 90-day prune: waits for you, your request approved / paid / rejected / returned, comments; polled (design)
      • Notification settings and the Telegram bot — per-member kind × channel preferences (bell locked for the asks and returns a member must see), quiet hours, language; a one-time deep link connects a member’s Telegram; the outbox and its own River queue send with Telegram’s rate limits, a digest for a backlog, and blocked-bot revocation; Approve / Reject from the chat run the same engine move as the web (claims only; Reject collects a reason); optional shared department chats; org switch and show-amount switch. Migration 01140; TELEGRAM_* env, off by default; go run ./cmd/telegram-webhook set (design). Not verified: a real bot against api.telegram.org (the proofs ran against a local stand-in for the Bot API); ms and zh strings are unreviewed; admin notice when a group removes the bot; sharding across bots
      • Claim comments (F3; Figma batch 2, audit 8.8) — 00250, thread in the claim drawer; edits kept in audit_log, deletes soft (design)
    • Figma alignment, user chip: profile photo (Settings → Profile) and job title from the primary seat — design: 2026-09-26-profile-photo-job-title-design.md, migration 00255. Organization logo (audit row 1.10) not built
    • End to end: four Playwright flows against the dev stack (pnpm -C web e2e, CI job e2e) — claim round trip, petty-cash spend round trip, workflow draft violation then publish, trial-balance CSV export

Phase 2 — Payables, receivables and MY compliance

Section titled “Phase 2 — Payables, receivables and MY compliance”

Makes it an accounting system rather than a claims system. (§19)

  • Customizable table columns — land before AP/AR, which are mostly tables. Built once in the shared web/src/components/ui/DataTable.tsx (a tableId opts a table in; columns are filtered and reordered before TanStack sees them), so every list screen gets it, not per screen.
    • Each user can show, hide and reorder columns per table, and reset to the default
    • A table declares which columns are always shown (the identifier, the amount) and which are hidden by default, so adding optional columns does not crowd the default view
    • Column names in the picker come from the same translation keys as the headers
    • The below-768 px card view is unaffected
    • Decided: the choice persists per browser (localStorage, key table.<tableId>); per user on the server, which follows them across devices but needs a preferences table and endpoint, only if users ask
  • Approval by project — an approval step can be decided by the approver of the project a claim is charged to, alongside the org-chart rules. Built on analytic axes, so the same rule works for cost centre or a customer’s own axis, not only project. One workflow per organization still; adding a project means assigning its approver, not building another workflow. Design first: a claim’s lines can split across projects Design: 2026-09-24-project-approval-design.md (P1–P14) Plan: 2026-09-24-plan-11-project-approval.md (14 tasks, then revision 2: RT1–RT6) Deploy: DEPLOY_NOTES.md — migration 00190; workflow.definition.publish now also governs who approves each project
    • Design spec
    • Plan and build
      • Revision 2: each project chooses each step’s approver (anyone a grant can name); unassigned, the step’s own other grants are its default. Projects × steps grid at /accounting/approvers
      • End to end: a claim split across two projects waits at “Reviewed” for each project’s approver, then at “Approved” both shares fall to the step’s default, who signs both in one click (web/e2e/project-approval.spec.ts)
    • Revision 3: amount bands per project step — a cell is a list of bands (spec P16), chosen by the project’s share of the claim; any one of that band’s approvers may sign (migration 00191)
  • Project usage across modules — which flows may charge a project. A project is already an analytic account on the project axis and every module already takes shares, so this is one column and one gate, not a new master. Common claim projects (INT-ADMIN, INT-TRAVEL) are ordinary project accounts open to claims only. Design: 2026-10-06-project-usage-design.md (PU1–PU9), migration 01120
    • Design spec (draft 2026-10-06, checked against the code)
    • Owner questions briefed, built to their defaults meanwhile (Q1 mandatory on claims vs axis-wide is_required, Q2 retired project and approved unposted claims, Q3 on hold, Q4 member-only customer projects)
    • Plan (not written: built straight from the spec)
    • 01120 analytic_account.chargeable_by (NULL = any flow, no backfill); analytic.CheckChargeable, wired at claim line save and submission; ledger.Post unchecked
    • API: chargeable_by on analytic accounts; /expenses/analytic-options returns only expense-chargeable accounts
    • Web: “Charged by” on the project dialog; en/ms/zh
    • Other flows: CheckChargeable at purchase, sales, trade, asset, manual-journal and bank-statement-journal entry points, plus the drift guard over the analytic.Plan( call sites; ledger.Post and accrual re-posts stay unchecked
    • Members-only projects: 01121 analytic_account.members_only + project_member; invite up to 500 per call, keyset-paged list, index-only “my projects” read; gated at claim line save and submission (analytic.CheckMember), not at ledger.Post; claim form offers a members-only project only to its members; Projects dialog “Who can see this project”; en/ms/zh. Not built: picker filtering outside expense claims
    • Claim export layout: 01124 claim_export_layout; Manage > Document layouts has an Expense claim export tab where each column can be switched on or off, reordered and given its own heading (any language); GET/PUT /org/claim-export-layout (org.layouts.manage); the Expense claims list Export button calls GET /expenses/claims/export, which applies the list’s own tab, status, search, project and month filters across every page (up to 20,000 claims) and writes one CSV row per claim line in that layout. 22 columns to choose from (date, claim no., applicant, department, project, type, category, GL coding, description, supplier, currency, amount, base amount, tax, status, approver, submitted, remarks, year, week, month, title); en/ms/zh. Not built: .xlsx output (CSV opens in Excel), a date range filter, running balances
    • Project departments: 01122 project_unit; a project is open to everyone in a listed department or beneath it (a stored rule, never copied into project_member); GET/PUT …/analytic-accounts/:id/units; Departments picker in the project dialog; en/ms/zh. Spec: 2026-10-06-project-access-design.md
    • Expense claims Filter panel: GET /expenses/claims and /expenses/claims/export take category_id (any live line), claimant_id, from/to (created_at, inclusive, organization timezone) and min_total/max_total (claim total), on every scope including to_act; the list’s Filter button opens a panel for them (claimant picker only off My claims) and shows a count badge. The summary cards ignore them, like q and group_id, so “of N” is dropped while one is set. Not built: department filter (the shown department is the claimant’s seat on the submission day, so a current-seat filter would disagree with the column)
    • Approver sets the project on a pending claim line: 01123 narrow carve-out in expense_claim_line_draft_guard (only shares), workflow.ReviseShares gate (pending, not final approval, nothing signed, not the claimant), POST …/lines/:lineId/project, “Set project” in the claim drawer; en/ms/zh
    • Finance refiles a claim line’s category before the accrual: 01133 second narrow carve-out in expense_claim_line_draft_guard (app.line_category_edit, category_id only); POST /expenses/claims/{id}/lines/{lineId}/category (expenses.claim.transition); authority is workflow.MayEditBeforeAccrual (not the reporter, and an allowed edge into an Accrual-meaning state); audit action change_category; a per-line category picker in the claim drawer. ms/zh strings need review
    • Project codes: project numbering scope (default PRJ-{YYYY}-{SEQ:4}) issues a code when the dialog’s code is blank; the Document numbering page is now Code customization (en/ms/zh)
    • Project link on documents (design 2026-10-10-project-link-design.md, PL1–PL7): the project a sales, purchase or quote document is for, derived from its lines’ shares (no new column)
      • Piece 1 (plan 2026-10-10-project-link-lists.md): project_id filter and project / multiple_projects on the sales invoice, purchase invoice and trade (quote, order, …) lists; Project column (hidden by default) and filter on the three web lists, filter shown only with accounting.ledger.read; en/ms/zh. Not done: browser check, archived projects can’t be picked in the filter (active projects only; the API accepts any project id)
      • Piece 2 (plan 2026-10-10-project-link-picker.md): one project picker in the sales invoice, purchase invoice and trade (quote, order, sales order, purchase requisition, …) dialogs that sets every line that can carry a project to 100%; other-axis shares kept, added lines inherit it, shown only with accounting.ledger.read. Lines that cannot carry one are left alone: a purchase line matched to a receipt (posts to GRNI, a liability) and, on the invoice dialogs, a line on an account type the project axis does not apply to (the trade dialog has no account per line, so the server still refuses a line it posts to a non-income/expense account). Verified by component tests only; browser check not done (needs the API stack and a login, not available to the agent)
      • Piece 3 (plan 2026-10-10-project-link-crm.md): a CRM opportunity can name its project (01192, project_id / project; shown in the drawer and on the pipeline card; edits always send the current project_id because the PUT replaces the record); “Create quote” opens /sales/quotations/new?project=<id>, which prefills the project on the new quote’s lines once, only if it is an active project, never over a project the lines already carry, and not when started from a source. No stored opportunity↔quote link. Limitation: the API does not enforce project membership on opportunities (membership applies to claim lines only), so the picker offers every active project the caller can read via accounting.ledger.read. Verified by component tests only; browser check not done (needs the API stack and a login, not available to the agent). ms/zh strings need review
      • Piece 4 (plan 2026-10-10-project-link-cash.md, spec PL6 amended): a petty cash spend (01193) and an advance acquittal (01194) can name one project, carried as a 100% share on the expense line only (cash and control legs carry none; rows with no project post exactly as before). The advance itself carries no project by design: it posts to balance-sheet accounts the project axis refuses, and the expense is booked at acquittal. Flows petty_cash_spend / advance_acquittal are checked like expense claims (invalid, not_chargeable, not_member on the holder / claimant; a zero acquittal with a project is none_exists) and can be ticked on a project’s “Charged by” list. Web: a single-value Project field on the record-spend dialog and the acquittal form (only with accounting.ledger.read, options filtered by flow), CODE · Name in the spend drawer and the acquittal panel; re-recording an acquittal (a PUT) carries its current project even when the field is hidden. The web has no spend edit form, so a spend’s project is set at creation only (the API’s PATCH can change it on a draft). Limitations: the spend’s chargeable and members-only checks run at save only, not at record; the acquittal’s run at save and again at decision, so an administrator narrowing, retiring or removing a member from the project after the acquittal is saved blocks the decision until it is re-recorded or the project reopened (a claimant without accounting.ledger.read cannot change the project on a re-record, so an administrator must reopen it). Refusals reach ms/zh users as the generic validation message (English detail only), same as other field-rule refusals. Verified by component and Go tests only; browser check not done. ms/zh flow labels need review
    • Not in scope: customer link and CRM (PU7, project_profile when CRM exists), billable re-invoicing (PU8), per-project membership, an on-hold state, timesheet and the other modules that do not exist yet
  • Operator console — the provider’s view of every organization: health, jobs, integrity checks, compliance counts and operator actions. Counts and pass/fail only, never record contents (PDPA). Depends on no Phase 2 item, so it can run alongside. Design: 2026-09-25-operator-console-design.md (Stages 0–5) Progress: 2026-09-25-operator-console-progress.md Plan: 2026-09-28-operator-console.md (backend B1–B6, then frontend F1–F4), migrations 00230–00249
    • Stage 0 decisions D1–D5: D1 (the SECURITY DEFINER exception, §3.3), D2, D4 and D5 decided 2026-09-28; D3 (the private network path) open. D1 still to be shown to the tax agent / auditor
    • Groundwork: version stamp, /metrics with its label drift test, object lock compliance mode
    • Build: Stages 0–4, B1–B6 and F1–F4 (PR #8), migrations 00230–00241
    • Stage 5 support access (A7) and A6 tenant export
  • AP: supplier role, purchase invoices, credit notes, aging, payment allocation Design: 2026-09-26-accounts-payable-design.md (D1–D12), migrations 00300–00306 Plan: 2026-09-26-phase-2-ap.md (19 tasks, Rulings P1–P48) Deploy: DEPLOY_NOTES.md — re-provision every organization (Trade payables 2105, MY purchase tax codes, three workflow kinds)
    • Sub-ledger = control per supplier is a drift test (internal/platform/payables/subledger_test.go)
    • End to end: supplier → invoice → approve → pay → aging shows zero (web/e2e/payables.spec.ts)
    • AP aging is a report (ap_aging, CSV/XLSX/PDF export, 00306) behind reports.read
    • Void refuses while allocations exist: reverse credit-note offsets / void payments first (Ruling P24, amends D11’s “or reversed in the same action”)
    • Open decisions (owner’s call, not defects) — resolved 2026-09-29 (owner confirmations, 2026-09-29-phase-2-owner-confirmations.md):
      • Two-step void acceptable, or add a combined action — Confirmed: each reversal stays its own audited event; a combined action would hide payment voids inside an invoice void.
      • Ruled O-12 (Phase 2 completion C4): an approved purchase invoice posts on its invoice date; its period closed, on the first open date after it, the entry saying why
      • Single-Owner orgs cannot approve their own payment or verify their own bank change; an administrator’s all_states grant could still allow a self-approved payment — Changed (D1): the audited sole-owner exception (O-3a) now covers supplier-payment approval and supplier bank-detail verification for a one-member organization; an all_states grant no longer lets the preparer approve their own supplier payment, WHT remittance or SST payment.
      • SST-02 line identification (see the SST-02 note below); may a purchase code name 2140 — Confirmed: v1 needs no purchase-line snapshot until imported services are computed from purchases. Changed (D3): a purchase tax code can no longer name the SST payable account (2140/sst_payable).
      • AP aging visibility and the same for every Phase 2 report — ruled O-9: an export and its download also need the owning module’s read key (reports.OwnerReadPermission; an unmapped report fails a test). The on-screen report routes need the same key (fix round 1 I-4).
      • Credit-note offset and its reversal need no approval — Confirmed: both documents are already approved and posted; an offset moves no cash and no ledger line, and it is audited.
      • Ruled O-11/O-12 (C4): an inactive supplier or customer blocks new documents; payments, receipts, credit-note offsets and allocations against its open items are allowed
    • Deferred minors (C4): line errors beside the line; the supplier list reads the bank registry once; layering guard G-1 no longer reads error strings as SQL
  • AR: customer role, sales invoices, credit notes, receipts, aging, statements Design: 2026-09-27-accounts-receivable-design.md (D1–D20), migrations 00310–00314 Plan: 2026-09-27-accounts-receivable.md (14 tasks, Rulings R1–R23) Deploy: DEPLOY_NOTES.md — re-provision every organization (three AR workflow kinds)
    • Sub-ledger = control per customer is a drift test (internal/platform/receivables/subledger_test.go)
    • Issued documents are frozen by trigger; corrections are credit notes (the MyInvois Ruling)
    • The export CHECK accepts every report is a drift test (reporting/export/reportcheck_internal_test.go)
    • End to end: customer → invoice issued → receipt → aging zero → statement (web/e2e/receivables.spec.ts)
    • Open decisions (owner’s call) — resolved 2026-09-29: the 13 lines of the spec’s For the owner to confirm, annotated in place (12 confirmed as built, 1 already ruled O-9)
  • Debit notes — AR customer_debit_note and AP supplier_debit_note Design: 2026-10-05-debit-notes-design.md, migration 01070 Plan: 2026-10-05-debit-notes.md Deploy: DEPLOY_NOTES.md — re-provision every organization (two new workflow kinds)
    • No opening debit note: an old open debit note is migrated as an opening invoice, a settled one goes to the archive under its own kind; the AP debit note has no reason field
    • Native-speaker review of the new Malay and Chinese strings
    • Open decision (adviser): sandbox-validate MyInvois types 03 and 13 — item 11a below
  • Aging filters, invoice analysis and supplier statement Plan: 2026-10-05-aging-filters-invoice-analysis-supplier-statement.md (10 tasks, Rulings A1–A14), migrations 01080–01081
    • Owner questions briefed (A3 bucket edges, A6 month-end counting, A7 credit notes and collected basis, A14 sort vs keyset, A8 who holds the permission) — briefs/2026-10-05-aging-owner-questions.md, not sent; built on the plan’s defaults
    • AR and AP aging: filter by customer/supplier and by amount; totals follow the filter
    • AP aging: six buckets (scheme=extended), Outstanding and Overdue totals, group by due year; AR and the public API unchanged
    • Invoice analysis report: issued, collected for the month, accumulated, collection rate; receivables.analysis.read
    • Invoice export defaults to the current financial year
    • Supplier statement: AP mirror of the customer statement, tie-out to the sub-ledger, export with owner key
    • Sales invoice list verified for search, filter, sort and paging
    • Web: aging matrix and filters, analysis view, supplier statement; en/ms/zh
    • Not in scope: counting age from the first day of the next month (A6) until the owner rules
    • Not measured: the aging queries on an organization of 100k lines (Task 9); the rest is covered by tests
  • Payment batches → one bank file (DuitNow bulk / GIRO) Design: 2026-09-27-payment-batches-design.md (D1–D14), migrations 00330–00332 Plan: 2026-09-27-payment-batches.md (9 tasks, Rulings B1–B16) Deploy: DEPLOY_NOTES.md — migrations only; no provisioning step
    • Supplier payments, and (C4, 00450–00452) approved expense claims’ and advances’ payouts; the approver must be able to approve every line under its own grants; marking paid moves each line through its existing payout edge (no new journal path). An employee’s bank details must be verified by a second person (payables.supplier.verify_bank) and are sealed into the line at approval; every payout’s bank memo carries the batch reference (Ruling B15)
    • v1’s one file format is generic_csv_v1 (this project’s layout, not a bank’s); its line_no is the batch line’s own number, gaps and all
    • An approved batch holds its invoices (final review I1): approval refuses an invoice that is voided or over-allocated; while approved, a void, credit-note offset or individual payout that would eat into it is refused 409 invoice_in_batch, so mark paid cannot fail after the bank paid
    • End to end: submitted payment and approved claim → batch → approve → download (hash checked) → mark paid → both paid, aging shows zero (web/e2e/payment-batch.spec.ts)
    • Open decisions (owner’s call) — resolved 2026-09-29: the ten lines of the spec’s For the owner to confirm, annotated in place (6 confirmed, 1 already ruled C4, 1 changed — D2 now posts cash on the payment/batch-value date, not the mark-paid date); the bank file format and bank identifiers need the customer’s bank — see “Needs an outside authority” below
    • Follow-up: claim and advance payouts in a batch (§19 “200 claims”) — C4
    • Owner decision (C4 review M7, accepted): a batched claim’s or advance’s settlement carries no transfer slip even when its channel asks one of a single payout. The proof is the approved batch (preparer ≠ approver, DB-enforced), its stored file and SHA-256, the member who marked it paid, the batch reference on the settlement and the bank memo, and the bank statement line reconciliation matches to the batch (C5)
    • Follow-up: a bank-specific format once a customer supplies its bank’s specification
  • MyInvois: submission, API validation, status polling, retry
  • Self-billed e-Invoice on the AP side Design: 2026-09-27-myinvois-design.md (D1–D22), migrations 00380–00382 Plan: 2026-09-27-myinvois.md (12 tasks) Deploy: DEPLOY_NOTES.md — re-provision every organization (sales workflow v2, step 78)
    • Tested against a local fake only — the DEPLOY_NOTES sandbox checklist must pass before go-live
    • Open decisions (owner’s call) — resolved 2026-09-29: annotated in place in the spec’s For the owner to confirm (9 confirmed as built, 4 need an outside authority; B2C/foreign buyer TINs, review I4 below, need one too) — see “Needs an outside authority” below
      • Intermediary login only, not per-organization taxpayer login
      • Signing key as a worker-only file, unsigned version 1.0 when unconfigured
      • Documents issued before “in scope” are never submitted automatically
      • Invalid or failed documents are voided and reissued under a new number
      • Cancellation needs the OWNER seat and a reason; after 72 hours only a credit note
      • Self-billed e-Invoices submit at approval of the purchase invoice, not at payment
      • A self-billed foreign supplier with no TIN uses EI00000000030, MSIC 00000, SST/IDs NA
      • B2C sales get individual e-Invoices; no consolidated monthly e-Invoice in v1
      • A credit note for a pre-mandate invoice references it by number with UUID NA
      • Customer receipts and advance payments are not e-Invoiced
      • The in-scope switch is Owner-only by default
      • Cancellation reason and void reason are the same text
      • A local fake is accepted as MYINVOIS_API_URL on loopback hosts only
      • Single-Owner orgs cannot self-issue a sales invoice (not_routable) — fixed in the final review (Ruling EINV-F2): submission routing skips a target-gated reversal of what was posted (a void), so the OWNER-only issued → voided exit no longer blocks “Issue invoice”. With two or more active members the Owner still cannot void (so cannot cancel the e-Invoice of) an invoice they prepared themselves — the claimant rule
      • A one-person in-scope organization has no in-app correction — Ruling O-3a (Phase 2c C3): with exactly one active member it voids its own sales document and issues its own correcting credit note as the sole-owner exception: reason mandatory, decision authority_via = sole_owner_exception, audit row flagged sole_owner_exception; the member count is re-read under the organization row lock a member’s INSERT contends with
      • Ruling EINV-F2 also means a petty-cash custodian with no superior (e.g. the Owner) now records a spend that nobody can rule “not reimbursable”; before, recording was refused. Accept, or flag it again on the chart (re-review F-a)
      • B2C and foreign buyers on the general TINs (EI00000000010/…20): send NA for a missing ID and phone (unverified reading of LHDN’s guideline), or keep requiring an ID document as today (review I4)
      • Whether a document whose e-Invoice outcome is unknown or duplicated may be voided without a recorded portal check (review M5) — Ruling O-3c (Phase 2c C3): refused (portal_check_required) until a portal check (who, when, not_at_lhdn or cancelled_at_lhdn) is recorded, once, audited (POST /einvoice/submissions/{id}/portal-check)
      • Ruling O-2 (Phase 2c C3): a self-billed purchase invoice is not paid, individually or by batch approval, until its self-billed e-Invoice is valid (self_billed_einvoice_not_valid); mark paid is not re-checked (the bank already paid)
      • Owner’s note (C3 review minor 4, accepted as is): because mark paid is not re-checked, a self-billed e-Invoice cancelled at LHDN between a batch’s approval and its mark paid is paid without any flag. A later change could note it on the batch line (not a refusal)
      • Ruling O-3f (Phase 2c C3): every MyInvois call is paced at the design’s recorded SDK limits per endpoint, 429 holds the endpoint for its Retry-After; submit’s limit is not recorded, so it is MYINVOIS_SUBMIT_RPM (default 12) — confirm it in the sandbox
    • Deferred: consolidated e-Invoices, debit/refund notes, inbound e-Invoices, taxpayer-mode login
    • Deferred minors below fixed in Phase 2c C3: M2 (a last look a minute after the window closes), M3 (00440, audited), M6 (paced by O-3f), M7 (at the switch, and at publish of a sales graph while in scope), F-b, and both cancelled_at items (00441). The generic refusal-code names stay (Phase 2c “Not changed”)
    • Deferred minors (final review): window watch stops before a late LHDN cancel near 72 h and the UI then hides Cancel (M2); statutory link (uuid, long_id, …) not write-once and its history not audited (M3); window watch’s Details calls exceed 125 RPM at ~1,000 documents/day — a scale ceiling for the sandbox checklist (M6); switching in scope does not check that both sales graphs have a void exit (M7); workflow validation does not refuse the reverses-accrual flag on an edge before any posting state, so an administrator could take a target-gated rejection out of submission routing (re-review F-b)
    • Deferred minors (Phase 2 final review): purchase_invoice.cancelled_at is not write-once though the e-Invoice Retry gate reads it (the sales trigger 00410 is the model); 00410 stops a change or clear but not a wrong first write on an issued invoice (a deferred constraint trigger, or SST reading the workflow state); refusal codes not_draft, inactive and voided are generic names with feature-specific sentences
  • SST-02 return generation Design: 2026-09-27-sst-02-design.md (D1–D16), migrations 00400–00404 Plan: 2026-09-27-sst-02.md (10 tasks, Rulings SST-R1–SST-R14) Deploy: DEPLOY_NOTES.md — re-provision every organization (tax_return_payment workflow kind)
    • Figures come from AR’s per-line tax snapshot; the purchase side is not read (spec D6), so the note below is answered for v1: no purchase-line snapshot until imported services are computed
    • Service tax on payment basis with the 12-month rule (spec D5); a filed period that changes is flagged stale
    • No MySST upload format: a figures sheet (CSV/XLSX/PDF) for manual entry (spec D12)
    • Reconciliation with 2140 is a drift test (internal/platform/taxreturn/reconcile_test.go)
    • End to end: registration → live draft → finalise → export → pay → approve (web/e2e/tax-returns.spec.ts)
    • Open decisions (owner’s call) — resolved 2026-09-29: the 13 lines of the spec’s For the owner to confirm, annotated in place (10 confirmed, 2 changed — D3 refuses a purchase code naming 2140 and requires base currency MYR); SST-02 form references still need a tax agent — see “Needs an outside authority” below
      • Business date is the organization’s day (O-1, Phase 2c lane C1): clock.Today in company_profile.timezone, resolved once per transaction; TodayUTC only where allowlisted (internal/kernel/clock/todayutc_drift_test.go) (final review I3)
      • 2140 has no control kind — ruled O-7: 2140 is sst_payable, 2160 wht_payable (00430); manual journals and journal-from-line refuse both
      • Imported-services SST posts on finalise (00434: Dr the adjustment’s expense account / Cr 2140; an amendment reverses and reposts). Go-live balances on 2140/2160 enter as one opening manual entry per account (opening: true; API only, no web form yet)
      • Amending a return finalised before deploy with imported services: Finalise refuses (prior_manual_booking_unreversed) until the caller confirms the pre-deploy manual journal on 2140 is reversed; the confirmation is on the audit row (Phase 2c final review I-1)
    • Deferred (Phase 2c final review M-6): a web form for the go-live opening balance on 2140/2160 (API only today)
      • O-4 done: the periods list computes stale; a superseded/discarded sheet says “not the filed version” on every page; the return files the registration’s own single number (00431)
  • WHT, CP37 (CP38 → Phase 5 payroll, spec D13) Design: 2026-09-27-withholding-tax-design.md (D1–D14), migrations 00340–00342 Plan: 2026-09-27-withholding-tax.md (9 tasks, Rulings W1–W16) Deploy: DEPLOY_NOTES.md — re-provision every organization (account 2160, six withholding codes, wht_remittance kind)
    • Tax is withheld on the supplier payment (gross settled, net paid, Cr 2160); a separately approved remittance per section pays LHDN and records its receipt number
    • 2160 = open deductions is a drift test (internal/platform/payables/withholding_drift_test.go)
    • Withholding register (wht_register, CSV/XLSX/PDF) is the CP37 listing, with late and credited-earlier flags
    • End to end: supplier → invoice → pay with WHT → remit → register shows remitted (web/e2e/withholding.spec.ts)
    • Open decisions (owner’s call) — resolved 2026-09-29: annotated in place in the spec’s For the owner to confirm (3 confirmed — rounding among them, 4 changed — D3 X6/X7 below and D2 X8’s gross approval amount; rates/sections/forms, “paid or credited” timing and taxable base still need a tax agent — see “Needs an outside authority” below)
    • D3 X6 (owner confirmations 2026-09-29): a remittance is due the day before the same date next month (clamped) — unverified, stricter reading until a tax agent confirms; late follows it
    • D3 X3/X5/X7: a purchase code cannot name sst_payable, only a withholding code names wht_payable; an SST registration, a deduction and a remittance need base MYR; a withholding code below its section’s standard rate names its treaty
    • O-5 done: no void once LHDN’s receipt number is recorded (receipt_recorded_no_void); a 0% treaty code with its treaty reference required (00432), deduction status exempt
  • Bank statement import (MT940 / CSV), auto-match, reconciliation Design: 2026-09-27-bank-reconciliation-design.md (D1–D16), migrations 00350–00354 Plan: 2026-09-27-bank-reconciliation.md (13 tasks, Rulings B1–B18) Deploy: DEPLOY_NOTES.md — existing Owners/Finance gain banking.*; nothing to provision
    • Reconciliation difference is provably only the go-live gap (property test in internal/platform/banking)
    • End to end: link → import MT940 → auto-match → journal the charge → owner locks (web/e2e/banking.spec.ts)
    • Open decisions (owner’s call) — resolved 2026-09-29: the eleven lines of the spec’s For the owner to confirm, annotated in place (6 confirmed, 2 already ruled — Phase 2c C5, 1 changed — D4 X9 reconciliation-lock segregation, 1 owner ruling — BR-11, answered in the owner-confirmations spec’s Outcome section); one line (untested Malaysian bank files) needs an outside authority — see “Needs an outside authority” below
    • Owner ruling O-6 (Phase 2c, migration 00460): auto-match’s batch rule matches one bank debit to a whole paid payment batch (what the bank paid, its bank account, the mark-paid date window), finding the batch’s lines by the reference its payout memo carries, never by line kind
    • Audited unlock (Phase 2c, 00461/00462): banking.reconciliation.unlock (Owner), the latest live lock only, a mandatory reason; the lock’s own record is kept and the date can be locked again as a new one
    • Reconciliation export (Phase 2c, 00463): report bank_reconciliation, CSV/XLSX/PDF of a locked reconciliation’s stored snapshot
    • Deferred minors (Phase 2c): the error-mapping list tested; filename*; Lock recomputes a difference a concurrent post skewed; a model-based property test (exact list membership; statement-only contras, n:m, n:1, journal-kind groups, void and re-import); ErrNotInOrder wording; voided and second-account filters tested; ItemTable key; FuzzParseMT940 / FuzzParseCSV; bank_statement_chain requires starts_on after the previous ends_on; CSV strips only RM or an ISO code, reads (RM 100.00), padded dates and own-side DR/CR markers; lockBankAccount locks alone, then re-reads
    • Deferred: rules that post journals automatically; bank feeds; a “bank reconciled” period-close check; showing an unlocked lock’s history on screen (it is in the database and the audit log)
    • Deferred minor (Phase 2c review M3): Lock’s ErrDifference retry is bounded, not one snapshot. The book balance and the outstanding items are read in separate statements, so a concurrent post whose skew cancels a real non-zero difference can still lock (the bank_reconciliation_difference_zero CHECK only compares the stored figures). Upgrade: compute the D11 figures in one statement (a CTE) for a single READ COMMITTED snapshot
  • Bank statements: import preview and manual lines Design: 2026-10-06-bank-manual-lines-and-import-preview-design.md (M1-M9); plan: 2026-10-06-bank-manual-lines-and-import-preview.md (7 tasks)
    • Preview before commit (add lines, no delete); manual lines on the latest statement via effective closing
    • Migration 01110. Built: manual lines on bank_statement_line with a SQL guard, effective closing carried through the chain, reconciliation and lock, banking.AddLine and banking.Preview, added_lines on import, preview and add-line routes, preview and add-line dialogs, D11 property and model tests with manual lines. Not verified: real-browser check of the preview/add-line dialogs; ms and zh strings (unreviewed, in docs/review/ sheets)
  • Budgets per account / period / axis, budget-vs-actual Design: 2026-09-27-budgets-design.md (B1–B14), migrations 00370–00372 Plan: 2026-09-27-budgets.md (10 tasks, Rulings R1–R21) Deploy: DEPLOY_NOTES.md — migrations only; existing Owners gain accounting.budget.*, Finance accounting.budget.manage
    • Versions: original then revisions; approval freezes (a lock under accounting.budget.approve, not a workflow kind)
    • Actuals are reporting.Actuals — the P&L’s figures, pinned by a test
    • End to end: budget → grid → approve → post → budget vs actual (web/e2e/budgets.spec.ts)
    • Owner rulings (O-8, Phase 2c, 00464): no preparer — the version’s creator or any member who saved or imported its cells — approves it (coded refusal budget_self_approval); budgets are kept forever; actuals stay by posting date
    • Deferred: spend checks against budget at claim/invoice time; balance-sheet and multi-axis budgets; unbudgeted spend per analytic account on an axis budget; original-vs-revised in one report
    • Deferred (Phase 2c final review M-6): the web shows Approve to a member who prepared or edited the version; the server refuses it (budget_self_approval), the button does not hide
    • Owner decision (Phase 2c final review M-4): under O-8 a one-member organization cannot approve any budget version (the sole-owner exception O-3a covers only AR void and credit notes) — Changed (2026-09-29, D1): the audited sole-owner exception now also covers budget approval for a one-member organization.

From the owner-confirmations pass (2026-09-29). Every line here has a system behaviour that stands until the named authority confirms otherwise; none of them block using the system today.

  1. Bank file format (payment batches) — the customer’s bank (Maybank2u Biz, CIMB BizChannel, etc.) must supply its DuitNow bulk / IBG upload specification. Until then: generic_csv_v1, labelled “not a bank format” in the download dialog.
  2. Bank identifiers (payment batches) — the same bank supplies its beneficiary-bank code table (PayNet participant codes / BIC). Until then: the file names the bank by registry key and name only.
  3. SST-02 form part references — a licensed tax agent maps every field to the current SST-02 (and SST-02A) form. Until then: every export keeps the “(unverified)” stamp.
  4. WHT rates and sections — a tax agent confirms each seeded rate against ITA 1967, the current LHDN WHT pages and any 2025+ budget changes. Until then: the codes stay editable data, and the register carries a “rates unverified” note.
  5. WHT form letters (CP37/CP37A/CP37D/CP37F) — the same tax-agent pass as item 4. Until then: only the labels are at risk; no behaviour depends on them.
  6. WHT “paid or credited” timing — a tax agent’s written reading of whether LHDN treats crediting to the payee’s books as crediting for s.107A/109/109B/109F. Until then: withholding happens at payment, with a credited_earlier flag shown on the register and in the payment drawer.
  7. WHT taxable base — a tax agent rules whether SST and reimbursements are excluded from the withholding base. Until then: the clerk enters the base, defaulting to the gross settled including SST, with the SST portion shown beside it.
  8. Untested Malaysian bank statement files (bank reconciliation) — an anonymised MT940 and CSV export from Maybank, CIMB, Public Bank, RHB and Hong Leong, added as parser fixtures. Until then: the import screen marks MT940 “untested with your bank”; CSV mapping is the fallback.
  9. MyInvois signing key / certificate — confirm in the LHDN sandbox and SDK FAQ that unsigned version 1.0 is still accepted and until when, and obtain the provider’s digital certificate (MCMC-licensed CA) before go-live. Until then: a boot warning, and a DEPLOY_NOTES sandbox checklist item.
  10. Self-billed foreign-supplier defaults (MyInvois) — sandbox-validate one self-billed document using EI00000000030, MSIC 00000 and NA for missing identifiers. Until then: the defaults stay as coded.
  11. Credit note for a pre-mandate invoice (MyInvois) — sandbox-validate a credit note that references such an invoice with UUID NA. Until then: it stays as coded. 11a. Debit notes 03 and 13 (MyInvois) — UNVERIFIED. The adviser sandbox-validates one customer debit note (type 03) with a BillingReference to a valid original and one without, and one self-billed supplier debit note (type 13), against LHDN’s current guideline: whether 03/13 take BillingReference at all, and that a debit note with an original waits for the original’s UUID as a credit note does (MyInvois D12). Until then: the encoder emits them as written, the golden files debit_note.json and self_billed_debit_note.json are the encoder’s own output, and an organization that cannot wait issues an extra charge as a new invoice (the documented workaround).
  12. Customer receipts and advance payments (MyInvois / AR) — a tax agent confirms against the current LHDN e-Invoice Specific Guideline whether an advance payment received requires its own e-Invoice. Until then: receipts and advances are not e-Invoiced, with a warning shown when a receipt leaves money on account in an in-scope organization.
  13. B2C/foreign buyer TINs (MyInvois, review I4) — sandbox-validate a B2C document naming EI00000000010 with ID and phone sent as NA, checked against LHDN guideline Appendix 1. Until then: an ID document and phone are required for every buyer.
  14. Capital allowance (ITA 1967 Schedule 3) — the tax agent computes initial and annual allowances, balancing adjustments on disposal and the qualifying-expenditure classes (OD-4). Until then: the system keeps book depreciation only (MFRS 116), the fixed asset register and its disposals give the agent cost, in-service and disposal dates and proceeds, and no tax computation depends on it.
  15. Employment Act leave figures (HR, H3) — UNVERIFIED. An HR or legal adviser confirms, against the current Employment Act 1955, the seeded leave_statutory_floor figures (annual 8/12/16 and sick 14/18/22 by under 2 / 2-5 / over 5 years of service; hospitalisation 60; maternity 98 consecutive days; paternity 7 consecutive days, recalled from the 2022 amendments), and the service-month rules they build on (hr.ServiceMonths). Open points: the first-year proration rounding (attributed to s.60E(1A), unconfirmed); whether the 60 hospitalisation days include the sick days rather than add to them; paternity eligibility conditions (not modelled); which employees are covered (some provisions apply only below a wage threshold); whether the service band moves mid-year; retention of employment and leave records; and whether unused annual leave carried over or lost on leaving has statutory treatment. Until then: nothing is seeded per organization. The hr_leave_grants job grants only after an administrator creates an active paid leave type with a rule set in force (the leave module switch does not matter, Ruling H3-18), so grants CAN be made from the unverified statutory floors once rules are entered. The floors bind as seeded; a correction is a migration. A pending record (no hire date) grants none.

What lets a reseller deploy without engineering. (§19)

  • Customizable document numbering — each organization sets its own number layout per document type (claims, groups, advances, petty cash, journals; invoices when they exist), with a reset rule and a one-time starting number for migration. Allocation stays gap-free (§20.1.3); journals keep a fiscal-year reset. Design: 2026-09-25-document-numbering-design.md (N1–N14) Plan: 2026-09-25-plan-12-document-numbering.md (6 tasks)
    • Design spec
    • Plan written
    • Build, Tasks 1-5: pattern language and scope registry, numbering_format and Issue (all call sites converted), /org/numbering behind org.numbering.manage, the Document numbering screen, checked in a browser at 390 and 1280 px, light and dark, en and ms
    • End-to-end spec web/e2e/numbering.spec.ts (passes against a fresh stack)
    • Go suite on merged main a393d49a (2026-10-06): all 43 internal/platform directories with their subpackages, and every other cmd/ and internal/ package (57) passes, run one package at a time because the 4 GB Docker VM cannot take the parallel run. No single go test ./... was run. Debit notes are registered (DN, SDN) after merging with that work
    • Linguistic review of the ms/zh numbering strings (2026-10-06, by Claude, not a native speaker): document-type names aligned to the repo’s existing vocabulary (apungan, belian, Penerimaan barang, Pemulangan, 请购单), “restarts:” wording fixed, two awkward Malay sentences rewritten. “Tahun fiskal” kept (16 uses vs 5 for “tahun kewangan”)
    • Native-speaker sign-off on the ms/zh strings (docs/review/*.csv)
    • Journals page (Accounting → Journals, web/src/features/accounting/journals): list, create with a code of the organization’s choosing (SI, SO, …), edit code and name, retire / reactivate. A journal’s code is editable (01132 frees it from journal_immutable_guard); its sequence_key stays fixed, so a rename keeps the counter and never touches an issued entry number. The future prefix of entry numbers is still set under Code customization. Unit-tested, Go suites for chart, accounting module, imports and numbering pass; not yet looked at in a browser. ms/zh strings (journals.json) not native-reviewed
  • User-defined fields, reusing the field_schema pattern — design C2 (00675–00676): text, number, date, yes/no and choice fields with en/ms/zh labels on customers, suppliers, sales and purchase invoices, defined at Manage → Custom fields (org.custom_fields.manage); values checked by each entity’s own save (screen, API and the customer/supplier import’s cf.<key> columns), filtered with field.<key>=, shown on the drawers and in the lists’ CSV download; a field is retired, never deleted
  • Report and document layout designer — design C4 (00693–00694): per document kind (invoices and credit notes, customer statements, supplier payment advices, claim vouchers) the optional header lines and columns, their order, the title, English / Malay / both labels, footer notes and the logo; validated configuration rendered by platform/printing, never a template, with the statutory lines (tax identities, totals, tax, e-Invoice QR) printed under every layout; previewed on a sample at Manage → Document layouts (org.layouts.manage). New PDFs: statement, payment advice, claim voucher
  • Per-tenant module install with declared dependencies — design 2026-09-29-platform-customization-design.md C1 (00670–00671): eight optional modules (claims, advances, petty cash, payables, receivables, banking, e-Invoice, tax returns) switched at Manage → Modules; off means no one holds its permissions (member.Permissions), its routes answer module_not_installed, the nav hides it and the e-Invoice sweep skips it; data kept
  • Custom report builder on platform/reporting — design C3 (00685–00686): account balances over a period with an account subtree, an analytic account, the columns (opening, debit, credit, movement, closing) and a roll-up depth, on the trial balance’s engine (a drift test holds them equal); saved private or shared (saved_report), and exported to CSV/XLSX/PDF as report custom with its definition inline. Reports → Custom reports
  • Import / migration tooling — CoA, parties, opening balances, historical documents. A go-to-market requirement, not a convenience. Design: 2026-09-29-import-tooling-design.md (rulings I-1–I-10), migrations 00600–00669
    • M1 import framework: CSV and XLSX, column mapping, validate as a dry run then commit all or nothing, import_job (00600), imports.run (00601), /v1/imports. Saved mappings moved to M7, their only user
    • M2 chart of accounts: create, map onto an existing account (account_alias, 00610) or leave unchanged; parents in any order
    • M3 customers and suppliers (00620): through CreateCustomer / CreateSupplier, the screen’s defaults; a supplier’s bank account arrives unverified; a TIN or registration number another party has is a warning, not an error
    • M4 opening trial balance and period movements (00630): the trial balance posts as one opening entry at S, each period before go-live as one migration_period entry; receivables and payables go to Migration clearing (3900, provisioning step 79); cut-over date, finish (clearing must be zero) and reopen at /v1/imports/migration; a period does not close while an unfinished migration holds a clearing balance
    • M5 opening documents (00640): each unpaid invoice, unapplied credit note or unallocated receipt at the cut-over enters issued as an ordinary document (is_opening, the old number, OB- references), posts its control against Migration clearing on C, ages from its original dates and is never submitted to MyInvois; a supplier prepayment comes in as an opening credit note
    • M6 historical archive (00650): settled documents from before go-live, one row per line, kept read-only in archived_document under their old numbers with the customer or supplier (or the old name), never posted; searched at /v1/imports/archive/sales and /purchases behind each side’s invoice read key; scans attach through the documents store (00651, one record per scan, read with the side’s invoice key)
    • M7 web (00660): Import page (pick a kind, download its template, upload CSV or XLSX, map columns or pick a saved mapping, see every bad line, import only after a clean check), Migration checklist (cut-over date, steps, live clearing balance, finish and reopen), Archive page and each customer’s and supplier’s archived documents; saved mappings (import_mapping) and /v1/imports/kinds, /headers, /mappings; the Owner attaches scans in the archived document’s dialog
    • Manual journal entries in bulk (journal_entries kind, 01131): one row per line, rows sharing entry post as one manual entry through ledger.Post; needs accounting.journal.post as well as imports.run; “Import from Excel or CSV” link on the manual journal page. Not verified: real-browser check; ms and zh strings unreviewed

  • O1 — document chain (platform/trade, 00700–00709) and item master (platform/inventory, 00710–00719): the committed semantic; requisition, order, quotation and sales-order workflows; copy-forward, open quantity and close, guarded by the database; the invoice-line link; items, units, categories and locations with screens, custom fields and the items import; modules inventory, procurement, sales (plan docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md)
  • Procurement: PR → PO → GRN → purchase invoice, on a generalized document chain (O3: receipts only against an order, the order’s approver kept off its receipt by the database, the three-way match at approval with an audited Owner override, GRNI cleared with price variance to P&L and realised FX, purchase returns and supplier credit notes, purchase-order PDF, open orders and GRNI aging; plan docs/superpowers/plans/2026-09-30-phase-4-o3-procurement-sales.md)
  • Sales: quotation → SO → DO → invoice, on the same chain (O3: quotation validity and PDF, deliveries at cost, invoiced ≤ delivered − invoiced, sales returns and customer credit notes, open sales orders with backorders, delivered not invoiced; MyInvois unchanged)
  • Inventory: items, multi-UOM, multi-location, transfers, FIFO and weighted average costing, serial/batch. Costing posts through the posting engine. (O1 + O2: effect semantic, grni, stock_valuation integrity check; web UI for stock is a later lane, R-O2-19; plan docs/superpowers/plans/2026-09-30-phase-4-o2-stock-costing.md)
  • Fixed assets — operations design 2026-09-30-operations-design.md §O4, plan 2026-09-30-phase-4-o4-fixed-assets.md (00771–00773): asset classes (four accounts, straight line or reducing balance, fixed once created); assets registered from a posted purchase invoice line, a manual journal line or the fixed_asset opening import, never posting; a monthly depreciation run (one manual entry, source depreciation_run, unique per period, caught up after a late registration or a closed month, which is skipped and flagged) run daily by River for the previous month and by hand at Accounting → Depreciation; disposals and write-offs through the asset_disposal workflow, depreciated through the disposal month and posted on O2’s effect; reports (register, schedule forecast, roll-forward, register vs ledger); the asset_register_gl integrity check (nightly, and before every close); module assets. Book depreciation only (OD-4)

  • Payroll P1: pay items, payslips, bank files (statutory lines manual). Owner decided 2026-10-03: P2 (EPF / SOCSO / EIS / PCB) and P3 (EA forms, CP38, statutory files) are outside Phase 5, waiting on an adviser and official figures; the sign-offs below stay open
    • Split into P1 core, P2 statutory, P3 filings
    • P1 core (platform/payroll, modules/payroll, 00900–00903; 00904–00919 reserved) Not for real payroll until P2’s statutory lines: EPF, SOCSO, EIS and PCB are entered by hand in P1. Design: 2026-10-01-payroll-p1-design.md (PD-1–PD-6) Plan: 2026-10-01-phase-5-payroll-p1.md (13 tasks, Rulings PD-7–PD-30) Deploy: DEPLOY_NOTES.md — re-provision for the two workflow kinds and the payroll accounts; settings before the first run Not yet exercised on a running stack: the manual en/ms/zh, phone-width and dialog-focus pass
      • Append-only salaries; pay item types (names as translation rows, tag fixed by kind); recurring items ended once
      • The monthly run: calendar-day proration per salary row, unpaid leave from approved H3 leave at a configurable divisor, one-off, manual statutory and adjustment inputs; every recalculation a new generation, nothing deleted
      • payroll_run workflow (accrual-only): preparer never approves (engine and database), sole-owner exception; void reverses
      • Posting by tag: salary expense / deductions payable / payroll_payable (new control kind); per payslip, party = employee
      • Each payslip a payslip workflow instance and a payment-batch source (C4): verified details sealed, one bank line per payslip
      • “My payslips” for every member; salary and others’ payslip reads audited
      • Drift tests: frozen payslips (watched failing); preparer ≠ approver; tenant isolation; retention lists; module CHECK = catalogue
    • P1 follow-ups (decisions and gaps, none blocking):
      • Department split of salary expense: no org-unit → analytic mapping exists (PD-19)
      • Employees without a login are left out of a run (PD-8); a rehire inside one month pays the current spell only
      • A one-member organization cannot batch-pay the Owner’s own payslip (PD-29)
      • Void is refused while a payslip sits in a draft batch, stricter than the spec (PD-22)
      • Holiday changes after a leave request can make a month’s unpaid days differ from the request’s stored days (PD-11)
      • Statutory lines are employee shares only; P2’s employer contributions need a debit side the statutory line kind does not have
      • GET /payroll/item-types needs payroll.item.manage, so a salary.read-only holder sees item types unnamed
      • “My payslips” stays in the sidebar when the payroll module is off (the shell has no module flag)
      • Accepted P1 limit: the accrual posts gross, deductions and net per named employee, so anyone with ledger read sees each person’s salary; bank reconciliation shows each payout amount. Owner decision: aggregate the expense and deductions lines per run (the per-payslip net line stays)
      • Run detail shows basic pay to payroll.run.read holders (Owner-only by default); each detail read and each recurring-item read writes an id-only audit row
      • Access-trail gap to decide before real payroll: payables listBatches and payroll listRuns still show per-person pay to payroll.run.read holders without an audit row (a single-payslip batch or one-employee run totals are one person’s pay); aggregates are the owner’s call. getBatch and batchCandidates now write an id-only audit row when they show payslips
      • Switching payroll off leaves existing payslip batches unreadable and unapprovable until it is re-enabled
      • Client OrgModule.key enum gained assets and payroll
      • Unpaid-leave divisor (final review I-3, PROVISIONAL pending adviser / Employment Act confirmation): by default the month’s working days under the leave working calendar (week less public holidays), so a whole month of unpaid leave deducts exactly the basic; a fixed 1–31 (e.g. 26) remains a setting; calendar days as a moving divisor were dropped. In that default the basic of a partial month (starter, leaver, salary change) is also prorated by working days (divisor review I-1, PROVISIONAL), so worked days are paid in full and a fully unpaid span nets zero; a fixed divisor keeps calendar-day proration; recurring items stay calendar-prorated
      • Owner to confirm (spec): the unpaid-leave divisor above, proration basis, statutory lines manual or zero in P1, the retention period, whether an approver may approve a run with their own payslip
    • Open before real payroll (owner/adviser actions), all NOT DONE:
      • DEFERRED by the owner (2026-10-03), outside Phase 5: adviser signs off the working-day divisor and the proration rule against the Employment Act (provisional; spec owner points 1-2)
      • DEFERRED by the owner (2026-10-03), outside Phase 5; P1 ships with statutory lines manual. P2: EPF / SOCSO / EIS / PCB calculation needs official rate figures and the PCB method from an adviser / LHDN. P3: EA forms, CP38, statutory files
      • Owner confirms the statutory retention period — confirmed: 7 years, and backups outlive it (2026-10-03)
      • Per-person pay in the ledger and bank reconciliation — owner confirmed: the P1 ruling stands (Owner-only payroll roles, reads audited) (2026-10-03)
    • CP38 directions: deduct from salary and remit with PCB (deferred from the Phase 2 WHT item, spec D13)
  • HR layer deferred from Phase 1: job grades, salary bands, headcount, vacancies Design: 2026-09-30-hr-layer-design.md (HD-1–HD-8), lanes H1–H3
    • H1 employee record and lifecycle (platform/hr, 00800–00803) Plan: 2026-09-30-phase-5-h1-employee-lifecycle.md (10 tasks, Rulings H1-1–H1-17) Deploy: DEPLOY_NOTES.md — member numbers copied as pending records; the hourly leavings job Not yet exercised on a running stack: the manual en/ms/zh, phone-width and dialog-focus pass (Task 9 step 9)
      • Employee keyed to the employee party (HD-1); employee_no moved from organization_member (read-only there)
      • Append-only lifecycle; status derived by employee_spell; identity number behind hr.employee.sensitive
      • A last day is refused while an advance or float is open; an Owner who is not the requester overrides (HD-6)
      • Hourly job: seats end the day after the last day, login ends, approvals escalate past the vacant seat
      • Drift tests: one employee row per employee party; HD-6 refusal and override (internal/platform/hr)
    • H1 follow-ups (all built or decided):
      • Withdrawing a notice: notice_withdrawn (00860, a seventh event kind; the spec named six, Ruling H1-2 left this open) cancels the notice in force, so the person is active with no last day and notice can be given again. POST /hr/employees/{id}/events with kind: notice_withdrawn, the Withdraw notice button in the employee drawer. Refused 409 employee_status with no notice or once the last day has passed (the hourly job owns a passed day, Ruling H1-9)
      • A new advance or float after notice: DECIDED by the owner, warn only. It is allowed, and the advance read (AdvanceView.holder_last_day) and the float open/get (PettyCashFloat.holder_last_day, not in lists) carry the holder’s last day while they are on notice; the advance drawer shows it as a banner (the web never opens or reads a single float, so the float field is API only). Smallest seam: a published platform/hr read (hr.NoticeLastDay, NoticeLastDayOfMember) called by the two modules, which already sit above platform; no refusal, no new cross-package coupling, no migration. The HD-6 refusal at the last day is unchanged
      • A leaver’s own pending documents (Ruling H1-11): DECIDED by the owner, HR handles them; the engine is unchanged. GET /hr/employees/{id}/stranded-documents (hr.employee.read) lists the person’s pending documents whose current step resolves to a no_primary_position gap today (workflow.StrandedReportedBy, reusing the step resolution the reroute job uses); the employee drawer shows the list for a leaver, en/ms/zh. It only surfaces: re-pointing or rejecting a document is still done by the people who hold that power (no re-point action is built)
      • Rehire restoring login — owner decision: reactivate the existing membership (keeps the member, party and employee row; an invitation cannot, it returns ErrAlreadyMember or would create a new party). POST /hr/employees/{id}/restore-login (member.Reactivate, hr.RestoreLogin; served by modules/hr, because linking platform/hr into modules/identity breaks readiness there, guarded by TestIdentityDoesNotImportHR), gated by org.members.invite; refused for the caller’s own member (self_reactivation), for a login already active, and unless the employee is active or on notice (not_rehired). Every role the member held is deleted, not kept: an administrator re-grants roles afterwards. Audited (reactivate, with the roles dropped). A rehire alone still does not restore login (Ruling H1-16); “Restore login” on the employee drawer is the second step.
    • H1 follow-ups from review (decisions and gaps, none blocking):
      • Obligation reads (open advances, float) are unlocked (Ruling H1-6 defers it) — owner decided: HR and Finance read. Built: GET /hr/employees/:id/obligations needs the new hr.employee.obligations (Owner, Admin, Finance; 00881)
      • The last administrator is refused even when their last day is in the future — only a day already past is refused at recording; a future one is refused by the job when it applies
      • An override stays usable after its granter is deactivated or loses hr.employee.lifecycle_override — usableOverride skips overrides whose granter no longer qualifies
      • The sole-owner override’s “still one member” re-check is Go only; no DB backstop — 00804 makes the guard trigger require exactly one active member
      • GET /obligations exposes advance references and the float cash balance to any hr.employee.read holder — owner decided: HR and Finance only — built, see above
      • imports.run alone can create and hire employees (only the identity number needs hr.employee.sensitive) — owner decided: imports cannot create employees. Built: the employees import needs hr.employee.manage; each row is reported on its line otherwise
      • A persistently failing leaving is logged hourly and raises no alert — the worker exports erp_hr_leavings_failing (failures in the last run); the Grafana rule (above 0 for 3 hours) is still to be created on the VPS
      • PUT /identity with {} clears the identity document (already 400, TestAnEmptyIdentityBodyIsRefusedNotAClear); ?status= is not validated — now 422
    • H2 grades, salary bands, headcount, vacancies (platform/hr, 00821–00824; the committed semantic is Phase 4 O1’s 00700) Plan: 2026-09-30-phase-5-h2-grades-headcount.md (11 tasks, Rulings H2-1–H2-18) Deploy: DEPLOY_NOTES.md — headcount backfilled from what seats hold; re-provision for the vacancy workflow; the worker links hr (done) Not yet exercised on a running stack: the manual en/ms/zh, phone-width and dialog-focus pass (Task 10)
      • Job grades named as translation rows, retired not deleted; effective-dated bands, overlaps refused
      • The grade and the approved headcount belong to the seat (org_position); filled = primary holders
      • Over-headcount refused by trigger; an Owner who is not the requester overrides, used once (HD-4)
      • vacancy workflow ending in committed; a new vacancy’s approval raises headcount once; hires fill it
      • Reports: headcount per unit and position; open vacancies and their age
      • Drift tests: over-headcount refused and overridden, acting not counted; raise once, replacement none (internal/platform/hr)
    • H2 follow-up: vacancies in the approvals inbox (GET /hr/vacancies?scope=awaiting, migration none)
      • Assumptions: the queue is the pending vacancies the caller did not request and holds an allowed non-reporter exit on (the engine’s answer, AwaitingLeave’s shape, not an approver index); gated by hr.vacancy.read, which also now shows the Approvals nav entry. A row has no amount (an em dash) and its seat stands in for a reference; the detail is the existing VacancyDrawer. No home-page card and no notification for a vacancy (notifications carry money kinds only)
    • H2 follow-up: a transfer fills a vacancy (00825)
      • Assumptions: vacancy_id on a transferred event, counted like a hire’s; the vacancy must be for the seat the person moves into (409 vacancy_other_seat, employee_event_vacancy_seat), else the same vacancy_not_open guard as a hire. The transfer dialog offers only the chosen seat’s open vacancies. A transfer does not free the vacancy of the seat the person left (a replacement is still raised by hand)
    • H2 follow-ups: applicant tracking, offer letters, succession, a headcount budget in RM, promotions (spec “Out of H2”, deliberately not built)
    • Not yet exercised on a running stack: the approvals inbox vacancy row and the transfer dialog’s vacancy picker (covered by jsdom tests only); the manual en/ms/zh and phone-width pass
    • H2 follow-ups from review (decisions and gaps, none blocking except the first):
      • Merge precondition: cmd/worker did not link hr, identity and seven other modules — it links every module now; TestEveryBinaryLinksTheSameModules covers worker
      • hr.* permission labels are missing in team.json; the gap is wider (54 keys and 8 module headings unlabelled) and needs a drift test — 74 keys and 9 module headings labelled in en/ms/zh; TestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocale guards it
      • A retired grade renders blank in the headcount page’s grade Select — shown as “CODE (Retired)”
      • VacancyDrawer shows “Loading…” forever if useVacancy errors — error branch added
      • The headcount page saves headcount then grade sequentially — still two calls (no combined endpoint); the dialog now says the headcount was saved but the grade was not, and a retry does not resend the headcount. VacancyDrawer Cancel does not clear formError; grade band labels say “(RM)” — they use the organization’s base currency
      • Owner decided: Owner and Admin may grant a headcount override (built: the Admin role holds hr.headcount.override, 00881 and provisioning; audited with a reason). Earlier note: GrantHeadcountOverride allows Actor == MemberID (an Owner granting an override for their own seat, requested by HR); should the beneficiary be allowed to grant?
      • requested_by on an override is whatever the caller asserts (low risk: the Owner also holds hr.headcount.manage)
      • Assign now serialises every assignment to a seat, acting ones included
      • vacancy_fixed compares values, so a rewrite to the same value passes; an approval still raises the headcount of a seat deactivated after the vacancy was created; the headcount trigger is correct only under READ COMMITTED and its UPDATE arms are untested; a caller-supplied headcount_override_id on INSERT skips the sole-owner re-count (no Go writes it)
      • SetPositionGrade reads the grade without a lock (a concurrent retire can slip through); no cross-org grade test
      • stale_state / workflow_missing are generic codes with vacancy-specific sentences; the route-coverage guard does not check the Requires wording; the report’s SeatCount has no band figures
      • headcount.go reports a random field when both member fields are unknown (map loop)
  • Leave and claims interplay (owner decided: nothing new); lifecycle effects on approval routing
    • Lifecycle effects on approval routing: a leaver’s seat ends and on_vacancy: escalate resolves past it (H1, Ruling H1-10)
    • H3 leave (platform/hr leave_*.go, modules/leave, 00841-00842; 00840 deliberately unused here) Plan: 2026-09-30-phase-5-h3-leave.md (12 tasks, Rulings H3-1-H3-20) Deploy: DEPLOY_NOTES.md — re-provision for the leave_request workflow; no types or holidays seeded; the two daily jobs Not yet exercised on a running stack: the manual en/ms/zh, phone-width and dialog-focus pass
      • Leave types (names as translation rows) over a seeded statutory floor the database enforces against the highest floor inside each band (HD-3); effective-dated rule sets that never rewrite a past grant (HD-8)
      • Append-only leave_ledger (grant, take, reverse, carry_forward, expire, adjust); a balance is the sum
      • Daily hr_leave_grants: idempotent per employee, type and year; first-year annual prorated by whole months; year-end lapse and optional carry-forward with expiry
      • Adjustments with a reason, audited, never below the year’s floor on a statutory type (HD-7)
      • leave_request workflow ending in committed: approval takes, cancelling before the first day reverses; paid types never go negative; an OWNER-seat holder’s own leave goes to another OWNER-seat holder, the sole-owner exception, or an administrator-configured grant (Ruling H3-20), never to the requester (database-enforced)
      • Working days from the organization’s week and the public holidays it enters (not seeded; warned when a year has none); half days; maternity and paternity in calendar days
      • Medical certificates visible to the requester, the approver, HR and hr.leave.medical.read only; the reason is kept out of the audit chain
      • HD-2: workflow.Unavailable, registered by hr — an approver on approved leave is passed over by the on_vacancy: escalate walk, recorded in the decision log; nobody above available: stays, flagged, never refused; daily hr_leave_reroute logs each move once a day
      • Reports: balances per employee, leave taken by type and period, the team calendar
      • Drift tests: floor anywhere in a band; grant job twice grants once; a rule change does not alter a past grant; beyond the balance refused, cancelling restores it; approver on leave escalates and is logged; balance = sum of the ledger; module catalogue equals the database CHECK
      • Fixed here, an H1 defect: module_install_key_known listed only eight keys, so the hr module could not be switched off or on; 00842 widens it
    • H3 follow-ups built (00843):
      • HR recording leave on someone’s behalf: POST /leave/requests with employee_id, for hr.leave.manage (403 manage_required). It records a draft: the employee stays the requester and the workflow’s reporter, so the approver is resolved from their seat and only they submit, withdraw or cancel; recorded_by (migration 00843) and the audit actor name HR. An attachment may be one the employee or the recorder uploaded. An employee with no active login cannot have one recorded (409 recording_needs_login)
      • A starter set of leave types, an explicit administrator action (POST /leave/types/starter, the Types tab): annual, sick, hospitalisation, maternity, paternity and unpaid, named in en/ms/zh, whole days only, no entitlement rules (so nobody is granted anything until rules are set; the floor still binds them). Idempotent by code. Still nothing is seeded on its own (Ruling H3-16)
      • Notifying the approver a step was handed to while someone is on leave: the daily hr_leave_reroute publishes hr.leave_rerouted for each move that handed the step on, and the bell tells the new approvers once (awaiting_action, record_kind leave_request, opens the leave drawer). Moves that stayed with the person on leave tell nobody. The bell still does not follow a leave request’s own moves
    • Claims interplay: undefined by the specs. The architecture spec (section 19, Phase 5) names “leave and claims interplay” in one line and the HR layer spec gives it no section; its only concrete claims rules are built (open claims of a leaver still settle, H1-9; an approver on leave is passed over for every workflow, including expense claims, H3-14). Nothing else is specified: no rule links a claim to leave dates (for example refusing or flagging a claim dated inside approved leave, or a mileage claim on a leave day) and none links the leave balance to claims. Options if wanted: (a) flag, never refuse, a claim line dated on a day of the claimant’s approved leave; (b) nothing, since a claim is for money spent, not days worked. Say which and it becomes a spec section first. DECIDED by the owner: nothing new. The existing rules suffice (a leaver’s open claims settle, H1-9; an approver on leave is passed over, H3-14). No claim/leave link is built. The parent stays open only for the H3 owner-decision items below
    • H3 follow-ups left open (all decided):
      • HR submitting a request it recorded — owner decided: no change. HR records a draft; only the employee submits (the reporter gate is the employee’s, and a submit by HR would need the decision log to name HR as the actor on the employee’s step, a workflow change). No separation-of-duties question arises
      • Account-approver sign-off steps and leave (Ruling H3-14) — owner decided: no. Leave keeps its own routing
      • A leave year other than the calendar year — owner decided: calendar year only for now
    • H3 follow-ups from review (decisions and gaps, none blocking unless marked):
      • A default Employee cannot attach a medical certificate — owner decided: a narrow leave-only upload path (option b), POST /documents/leave-certificate (contract first), gated on hr.leave.request instead of documents.document.upload, which nobody was granted. Smallest design consistent with how documents are bound: the same declare, presign and complete flow, with a fixed document.purpose (general | leave_certificate, migration 00880). documents.Lock, which every other consumer binds through (claim line, advance, petty-cash spend and close-out, AP invoice, photo, archive), answers not-found for a certificate; only the leave request’s check calls LockForLeave. Payout proofs (expense and advance settlement) refuse one too. Dedup is per purpose. Reads are unchanged (the request’s own rule), so expenses.claim.read_all still cannot open it and the reason stays out of the audit chain. Tests: TestALeaveCertificateUploadIsNarrow, TestClaimReadAllOpensClaimDocumentsOnly. The leave dialog now shows the uploader to every hr.leave.request holder
      • Leave-request routing is not shown — LeaveRequestView.routing (contract first), only while pending and only in responses to CanReadLeave readers: the resolved approvers’ names and passed_over (away, note); GET /leave/requests/:id is 404 to everyone else. Open: the admin routing tab still shows raw workflow kind and state keys and member ids
      • GET /leave/types and GET /leave/holidays require hr.leave.request — any of request, read_all or manage (checked in the handler; the two routes are declared [no-permission] in api/public_routes.txt, since httpx.Protected takes one key)
      • Privacy (fixed): GET /documents/:id let an expenses.claim.read_all holder open any document by id, including a colleague’s medical certificate. Fixed: read_all is applied inside the expense-claim binder, so it opens only documents a claim actively holds (TestClaimReadAllOpensClaimDocumentsOnly)
      • hr.* permission keys and the hr module heading have no labels in the team screen (raw keys shown for hr.employee.*; 54 keys and 8 module headings lack en/ms/zh labels); add them with a drift test — 74 keys and 9 module headings labelled in en/ms/zh; TestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocale guards it
      • Back-dated rule sets are allowed — owner decided: forbidden. A rule set cannot start before today in the organization’s time zone (422 field=effective_from rule=past, translated; the date picker has min today). Rule sets already recorded are not rewritten. The test fixtures set older sets on a clock pinned to that day (hrtest.Env.Rules), as an organization’s older sets would be
      • An hr.leave.adjust holder can adjust their own balance — owner decided: they cannot. Refused in Go (422 field=employee_id rule=self, translated), compared by person, not login; backstop trigger leave_adjust_not_self (00880). Note this binds a one-member organization’s Owner too: with nobody else to adjust it, their balance cannot be adjusted by anyone. Still as decided: a negative adjustment can take a paid non-statutory balance below zero (the floor guards statutory types only, HD-7)
      • A half day of leave does not make an approver unavailable — owner decided: no change. Only approved full-day leave does (Ruling H3-14)
      • The below-floor refusal is database-enforced only — a rule set is pre-checked in Go (hr.BelowFloorError): 422 field=bands rule=statutory_floor with floor_kind, floor_days and floor_from_month, which the screen words in en/ms/zh. The trigger stays the backstop. The adjustment refusal (below_statutory_floor) was already a translated code
      • The carry-forward setting must exist before 1 January (the amount is fixed by the year’s first run); days held by a pending request at New Year neither lapse nor carry, and stay in the old year if that request is then rejected or withdrawn (the year-end expiry is written once); likewise at the carry-forward expiry. “Carried days used” means approvals recorded so far, not leave dates, which leans toward the employee (leave approved before the expiry but dated after it still uses them)
      • The worker rolls the year in the default time zone — it already rolls in each organization’s zone (db.InTx carries company_profile.timezone, O-1); the claim was stale. Now pinned by TestTheGrantJobRollsTheYearInTheOrganizationsZone (20:00 UTC on 31 December is 1 January in Kuala Lumpur)
      • ReroutedOn performance ceiling: a Get and a Load per pending instance and one OnLeave query per resolved member (about 15-30 round trips per pending instance, no Load cache by definition); fine at SME size; the daily job shares one 15-minute timeout across organizations; its anyone pre-check ignores half days
      • Other deferred minors, the unambiguous ones: leave_type now locks FOR NO KEY UPDATE; Resolved.Away was already sorted before it is persisted (resolve.go); the holidays tab has a zh name input; RequestDrawer styles the approving edge by the target’s semantic (to_semantic), not the state name approved; the carry-forward expiry no longer truncates 1.5 to 1 (the screen refuses it)
      • Other deferred minors: the escalate walk over levels_up and unit_head is tested (TestTheWalkPassesOverApproversOnLeaveUpTheTree); the report range cap is 366 inclusive days (was 367); ownerApproverError counts only a grant on the approved step (an owner-floor grant elsewhere reported “vacant” instead of “missing”); Balances() and the leave setting have tests of their own
      • Manual pass (2026-10-03, live local stack, empty demo organization): all seven HR and leave pages in en, ms and zh at 375px: no horizontal overflow, no unresolved translation keys; a dialog traps focus and fits the phone width. Not covered: screens that need data (employee drawer, request dialog, vacancy drawer), since the demo organization has no employees. Found, app-wide not Phase 5: closing a dialog with Escape does not return focus to the button that opened it (no dialog in web/src restores it)

  • Status 2026-10-05: the Phase 6 work below that says “branch, not merged” is now merged to local main (3dfd9c4c), NOT pushed. No gate or sign-off is ticked by this entry.

    • Merged: inter-company derivation from control accounts (00944), unmapped-party advisory (00945), an ended mapping counts as unmapped (00946); Stage 1 UI fixes (dialog focus, 40px controls, uncoded 422 never raw, ms “Persetujuan” for consent, zh declare wording); consolidation ms/zh run strings and tokens, stacked account maps, effective-from default, the accepted-differences card, one status badge per run state; Drawer/Dialog focus restore; claims wizard at 375px (class-only fix, not visually verified); report-search zh honesty; structured-code description and zh flag sentences (01040), ms “Ketepikan”; e2e selector fixes.
    • Verification record: the full Go suite needed a serial re-run of 20 packages that failed under parallel container load (all passed alone); web 900 tests; tsc and go vet clean. Browser passes done 2026-10-05 on seeded stacks.
    • Not verified: claims wizard at 375px after the fix; operator console at 375px; ms and zh on consolidation, flags and forecast after the fixes; light-theme contrast.
    • Gates still open, need people: adviser (Companies Act s.4, retention; brief superpowers/briefs/2026-10-05-consolidation-adviser-questions.md); counsel (public-API acknowledgement; brief superpowers/briefs/2026-10-04-public-api-counsel-questions.md); tax agent and counsel (AI spec section 6; brief superpowers/briefs/2026-10-05-ai-features-tax-agent-and-counsel.md); Grafana rules and the egress policy not applied (owner: leave the VPS alone for now); native ms/zh review (sheets in docs/review/, nothing signed off).
  • Multi-entity consolidation and inter-company eliminations

    • Two stages: a group register first (members, dated shareholdings, beneficial owners, consent grants from each member org), then consolidation runs. Reference: korre-pm-be prisma/migrations/20261119000000_company_groups — follow its shape (relationships derived from shareholdings, never stored), but give the register its own permissions and enforce the grantee rule in the schema, two gaps it has
    • Stage 1: BUILT (Tasks 1-6; branch phase6-group-register, full Go suite 116 packages and web 815 tests pass), not yet signed off. Still open: adviser confirms Companies Act 2016 s.4 and the retention period (until then the legal result shows only as presumed); manual pass done 2026-10-04 on a seeded stack (en/ms/zh, 375px for ms/zh, keyboard focus): no group-specific defects; fixed on branch web-group-ux-fixes (vitest only, not yet in a browser; ms and zh wording needs native review): dialog focus restored on close, controls 40px, uncoded 422 never shown raw on group screens, ms “Persetujuan” for consent, zh 申报 for declare (the raw mfrs10 had already been fixed); open: light theme, approving an assessment (needs a second member). Owner rulings made 2026-10-04 (brief Part B, recommendations accepted), built and MERGED to main: B1 DELETE revoked on company_group and group_entity (00923); B2 the group name is a translation row (en required, ms/zh optional; 00925), group_entity.legal_name stays a plain column; B3 a database trigger lets a holding change only by ending an open row once (00924); B4 grantee_org_id kept as is (the composite key already refuses a non-owner). Verified by Go tests (watched failing) and vitest only; no browser pass. The retention rulings stay provisional until the adviser answers. Spec decisions settled; plan written (2026-10-03-phase-6-group-register.md, 7 tasks). Gates: adviser confirms Companies Act 2016 s.4 and the accounting-records retention period. Spec: 2026-10-03-group-register-design.md. Not built
    • Stage 2: BUILT and MERGED to main 2026-10-04 (migrations 00940-00943, platform/consolidation, modules/consolidation, fx.Average, web; integrated with the AI features, serial full Go suite 130 packages and web 867 tests green on the integrated branch), accounting and legal content still unverified: not signed off by an adviser. Spec: 2026-10-03-consolidation-runs-design.md; plan and its execution record (deviations, Task 1 findings): 2026-10-03-consolidation-runs.md. Every guard in spec section 12 exists; each was watched failing once except the rates-frozen and translation-reserve ones (see the plan’s execution record). Every run is a management consolidation: the accounting and legal content is unverified (spec section 14) and the adviser has not confirmed it (D16).
      • Owner ruling 2026-10-05 (option 1), BUILT on branch consolidation-ic-from-control-accounts, not merged: inter-company items posted by AR and AP are identified on the read side only; a line on an ar/ap control account whose party maps to a group counterparty is read as ic_receivable/ic_payable (migration 00944, spec 6.2.1); no schema, posting-rule or ledger change; unmapped parties stay the manual fallback. Verified by a Go test (watched failing); no browser pass
      • Owner ruling 2026-10-05 (option 1B), BUILT on branch consolidation-unmapped-party-warning (on top of the above), not merged: a run reports a count of ar/ap parties with a non-zero balance that are not mapped as group counterparties (an ended mapping counts as unmapped, consistent with the derivation: migration 00946, owner decision 2026-10-05), as the advisory check unmapped_ar_ap_parties (migration 00945, spec 6.2.1); never provisional, never claims they are group parties; en/ms/zh strings need native review. Verified by a Go test and a vitest (both watched failing); no browser pass
      • Finance 11-step gap work (2026-10-07), pieces built one at a time; none is statutory-ready until C-E land. Accounting content unverified by the adviser.
        • A: consolidation-tag select on the account editor (edit only; options follow the account type; en/ms/zh need native review). Management fees, rent and service charges use ic_revenue/ic_cost; no new tag types. Vitest watched failing once; no browser pass
        • C: consolidated statement of changes in equity (changes-in-equity output and export; opening from the previous approved run, retained earnings otherwise from the opening position; columns with no opening show closing only; NCI dividends sit in other movements). Go tests (scenario run, column arithmetic watched failing), route and flow tests, vitest watched failing; no browser pass; ms/zh need native review; the dividend/direct-movement split reuses the roll-forward’s unverified derivation. Not tested: the previous-run opening path against a real chained run (no fixture exists)
        • F: audited trial balance per member (new input audited_position, migration 00947, second-person approval) compared by account code with the books a run read; advisory check audited_agreement (owner choice 2026-10-07: advisory, by account code), never provisional, present only when an approved position exists for the run date. A difference shows where an audit adjustment is missing from the books. Go tests (watched failing), route/drift guards, vitest (watched failing); no browser pass; ms/zh need native review. Not built: audit-adjustment detection beyond this diff (the system holds no notion of an audit adjustment); P&L accounts are compared on the basis the run read (the run’s period), which assumes the audited statements cover the same period
        • B: unrealised profit on assets other than stock (adjustment kind upi_asset, migration 00949): the owner states the gain still unrealised, direction and buyer; the engine eliminates it and, given a tax rate and two lines, posts the deferred tax on it (MFRS 112); upstream, the minority bears its share net of that tax. Go tests (watched failing). The owner works out what depreciation has realised (the engine does not); no browser pass for the new fields
        • D: consolidated statement of cash flows (cash-flow output and export; input cash_flow_class, migration 00948): indirect method from this run and the previous approved run, classes per reporting line approved by a second person, classes frozen by approval time so a stored run never changes. Says it cannot be derived without a previous approved run or a cash line, names lines with a movement and no class, and does not claim completeness unless the four parts add up to the movement in cash. The arithmetic is tested on a hand-worked year (watched failing); NOT tested against a real chained run (no fixture); assumes non-cash expenses have their counter-balance in an investing line
        • E: notes to the consolidated statements (notes output and export) generated from the run, with cross-checks (goodwill, NCI, ownership, schedule against the eliminations column, line by line). Accounting policies and other disclosures no run holds are the preparers’ to write and are not generated. Go test (watched failing), vitest
        • H: preference shares outside the group (input preference_nci, migration 00950): the owner states the preference dividend entitlement for the period; a run no longer refuses the subsidiary for its share classes and attributes that entitlement to the preference holders and the ordinary share of the rest to the ordinary holders. Assumes fixed-dividend, non-participating preference shares carried unchanged since acquisition and included in the acquisition record’s NCI (not verified by an adviser). Go test (watched failing)
        • G: off-platform subsidiary imported from its approved trial balance (input imported_trial_balance, migration 00951; treatment import): lines name their reporting line, balance to zero, presentation currency only (another currency is refused: import_currency_unsupported); the investment is found on the parent’s account the import names (used for that subsidiary alone, no party mapping); the acquisition record must state the net assets acquired; inter-company items cannot be identified, so the run is provisional (imported_member_intercompany) unless the owner states there are none. Go tests (watched failing); no browser pass. Not built: a foreign-currency import (needs historical equity rates)
        • I: step 2 reporting date: a member whose financial year ends on a different day from the parent’s makes the run provisional (member_period_misaligned; en/ms/zh strings need native review). Go test (watched failing). Not built: accounting-policy alignment (no record of a member’s policies exists), part-year consolidation
        • J: step 10 other comprehensive income: the translation reserve’s movement for the period and total comprehensive income are computed when the previous approved run ended the day before the period (otherwise still not computed); the movement is the whole group’s, the NCI’s part is not separated. Chained-run tests now exist (chained_test.go: first run approved, year closed, second run): reserve movement, changes in equity opening from the previous run, and a cash flow that reconciles to an independently computed movement in cash (all watched failing or tied to an independent figure). Finding: a second run that does not restate the first run’s consolidation adjustments (unrealised profit, associate result) fails retained_earnings_rollforward and shows the gap as other movements; the engine does not carry them forward by itself
      • Still open: no web field for account.consolidation_tag (the API takes it); import of an off-platform subsidiary’s trial balance (D5, exclusion only); associates read from approved inputs only; part-year consolidation, preference shares, deferred tax on eliminations, a cash flow statement; the pre-fill by registration number (D4); the statement headings for export are Go strings for en and ms, not translation rows; manual en/ms/zh, phone-width and focus pass on a seeded stack (vitest only); native-speaker review of the ms and zh text; the roll-forward check separates only the parent’s own direct equity movements
      • “Group transaction” badge (owner request 2026-10-07, not in the original plan): is_group_company on customers and suppliers, is_group_transaction on sales documents, purchase invoices, journal entries and journal lines, and a Group tag on those screens. Derived at read time from the member’s own counterparty map (consolidation.GroupParties, as at today); nothing is stored on a transaction, so it cannot disagree with what a run eliminates. Go tests (watched failing) in consolidation, receivables, payables and accounting; vitest; checked in the browser on the Alpha demo data. Also a server-side filter group=only|none on the five lists (customers, suppliers, sales invoices, purchase invoices, journal entries; consolidation.GroupPartySQL, proven by a test to agree with the badge), with a select on each page; checked in the browser on the journal entries page. Open: internal/kernel/layering TestEveryTenantQueryCarriesAnExplicitOrgPredicate fails on GroupPartySQL until the owner decides how to satisfy it. Not done: receipts and other schemas that carry the optional field stay false; the new fields also appear in api/public-openapi.yaml (additive; owner to confirm they should be public); ms/zh strings need native review; the flag is as at today, so an ended mapping stops showing on old transactions
  • Module extraction behind existing contracts (reporting and rendering first; the ledger never leaves)

    • Design: 2026-10-03-module-extraction-design.md — recommends deferring until measured triggers fire; renderer only, never the ledger (owner decisions D1-D11 recorded; Tasks 1 and 2 approved, Tasks 3-7 wait for a §9.2 trigger)
    • Task 1: render and export measurements (four histograms, dashboard row, metrics_test.go guard)
    • Task 2: WORKER_QUEUES, a reports-only worker-reports in compose, periodic jobs tick once
    • Tasks 3-7: renderer seam, service, client and shadow mode, deployment, cutover. Not approved
  • Public API and webhooks on the same OpenAPI contract

    • Design: 2026-10-03-public-api-webhooks-design.md (migrations 00960-00979; owner decisions D1-D13 recorded in its section 14)
    • Plan: 2026-10-03-public-api-webhooks.md. Gates still open: counsel on the acknowledgement wording (provisional draft-1), Grafana rules created on the VPS, the worker egress policy. Built since: the operator console’s per-organization suspend action (migration 00962, key authentication only; no browser pass on the console page)
    • A0 spike, K keyring move, A1 contract tooling, A2 API keys, A3 key administration, A4 idempotency and rate limits, A5 first public operations
    • W1 outbox and catalogue, W2 delivery, W3 webhook administration, W4 metrics, runbook and retention, X integration. Every guard in the plan’s break-it table was watched failing once. Not written: one test from a key-authenticated create to a verified delivery (the plan’s execution record says why)
    • Gates, not ticked: counsel’s wording of the acknowledgement, the Grafana alert rules created on the VPS, the worker egress policy applied
    • Prepared 2026-10-04 on branch phase6-ops-prep, not applied, not sent; the gates stay open: counsel brief superpowers/briefs/2026-10-04-public-api-counsel-questions.md (not sent to anyone); Grafana rules deploy/grafana/alerting/erp-webhooks.yaml (not provisioned, YAML syntax checked only, never loaded into Grafana; the three erp_api_* metrics the spec lists are not exported, so no rule for them); worker egress policy deploy/vps/egress-policy.sh (print mode by default, never run, untested against Docker)
    • Operator console: per-organization suspend/restore public API with a required reason, in the operator audit and shown on the organization page; keys get the generic 401, an administrator cannot clear it (webhook delivery not stopped: the spec is silent)
  • AI: anomaly detection on claims, spend forecasting, natural-language reports

    • Design spec: docs/superpowers/specs/2026-10-03-ai-features-design.md — advisory only, rules before models; owner decisions D1-D13 recorded (the recommended option in every row: Tier 0 only, no external model until counsel and a tax agent have read section 6)
    • Plan: 2026-10-04-phase-6-ai-features.md (migrations 00990-00992; execution record at its end)
    • Claim flags, backend: schema (insight_org_setting, insight_run, claim_flag, insight_feedback), rules R1-variant, R2, R3, R5, R6, R7 as pure decimal functions, service (read-only analysis, separate write path, supersede-never-edit), post-commit subscriber on a claim leaving draft, mode off/shadow/live per feature (off by default), INSIGHT_ENABLED. Guards G1, G2, G4, G6, G9, G10, G12, G14, G15, G16 each watched failing once
    • Claim flags, API: list flags on a claim (claim read check first, mode-aware), dismiss, confirm, feedback, settings; insight.flags.read, insight.flags.review, insight.settings.manage (Owner by default, a claimant holds none, D4); openapi, generated clients, en/ms/zh labels
    • Spend forecast, backend and API: seasonal-naive / trailing-mean by a fixed history-length rule, back-tested p10-p90 range (none under 6 back-test months), stored per series and ledger watermark, reads actuals through reporting; reuses reports.read (D10). Actuals only: commitments (open POs, approved-unpaid AP, approved-unposted claims) were not verified to be queryable by month, so none are included and the response says nothing about them
    • Natural-language report search, Tier 0: deterministic en/ms matcher over report names, periods and the organization’s account and analytic names; the only outputs are a proposal that passed export.Params.Validate or a clarification; runs nothing; audited by question hash, never the words. No model, no provider, no credentials
    • Web screens (en/ms/zh), all with advisory wording: flags with reasons and dismiss/confirm/feedback on the claim approval drawer (hidden without insight.flags.read, so never shown to a claimant), a reviewer’s flags list /insight/flags (new GET /insight/flags: newest first, capped at 200, each flag only if its claim passes the read check), settings /admin/insight (per-feature off/shadow/live), forecast view /reports/forecast, and the report-search box on the reports page (a preview naming report and period; confirm opens the ordinary report; it asks rather than guesses and says so when the report screen cannot apply a filter). Covered by the web suite; phone-width pass (375px, seeded dev stack, 2026-10-04): no horizontal overflow on /insight/flags, /admin/insight, /reports/forecast, /group, /group/grants, /accounting/accounts, /accounting/code-template; it found and fixed one overflow on /reports (the tab bar widened the page’s implicit grid; now grid-cols-1). Not exercised: flags on the claim drawer, report-search preview and the forecast result, because the demo org has no flags or posted history; en/ms/zh not eyeballed; Playwright specs not run
    • Flags shown at the decision are recorded on the audit trail (expense_claim / flags_shown: the open flag ids the decider could see, read before the move and written after it by the expenses transition handler through expenses.Deps.FlagsShown; none shown writes nothing; spec 1.4)
    • Leftovers (branch phase6-ai-leftovers, not merged): the forecast screen has an analytic-account filter (one account, sent as analytic_account_ids); two nightly River jobs on the maintenance queue, insight_forecast_nightly (the whole-chart series for every organization whose forecast mode is not off, as the system, trigger scheduled, one run per ledger state) and insight_flags_nightly (re-runs the rules over every claim with an open flag; an unchanged scheduled pass writes nothing, a changed one supersedes), both skipped under INSIGHT_ENABLED=false; GET /insight/evaluation/flags (review key) and a card under /admin/insight: precision per rule = confirmed / (confirmed + dismissed), with the sample size and the per-reason dismissal counts, no floor and no verdict (D6); a fixed synthetic en/ms question set scored for exact-match, clarification and invalid rates (a test, not a view)
    • Not built: flags in the audit-trail export (D12). Re-checked 2026-10-04: still no audit-trail export exists in the codebase (audit_log is read only by audit.VerifyChain, cmd/migrate verify-audit, and two internal joins; the report exports include no audit report), so there is nothing to add them to; the flags_shown rows are in the hash chain until one is built. Also not built, and why: recall against a seeded set and the forecast back-test table of the evaluation harness (spec 5.2 names the categories of seeded case but no contents or sizes, and says “per series class” without defining a class; with the v1 methods the comparison against the seasonal-naive baseline is vacuous, because the method already is seasonal-naive from 12 months and the naive cannot be computed below that); a minimum-sample prompt before moving to live (the spec sets no minimum); no peer-statistics table (statistics are derived on every analysis, so the nightly flag job recomputes them by re-analysing; a stored table is the upgrade named in analyse.go); administrator-editable rule parameters (constants in code, versioned claimflags/v1); rules R4, R8, R9 (skipped on purpose: R4 needs receipt timestamps nobody verified, R8 needs a per-category weekday-only setting and a calendar the spec leaves undefined, R9 is “only if shadow data shows it adds anything beyond R2”; D7 ships R1, R2, R3, R5, R6, R7); commitments in the forecast; a one-click “wrong translation” feedback on report search (no endpoint records it); Tier 1/2 models (blocked on D2/D3: provider, region, contract terms, credentials)
    • Open: counsel and a tax agent on spec section 6 (PDPA, 11A.1); the release bar to move an org from shadow to live is an administrator’s call (D6), no numeric floor was set; a flag’s evidence for R7 and R6 depends on free-text supplier names and has not been evaluated on real data (expect false positives, spec 2.6)
  • Record attachments (supporting documents on sales/purchase invoices, receipts, payments and trade-chain documents; added at creation, add-only). Not in the original plan; requested 2026-10-05. Decision: one generic link table record_attachment (01060) and one binder in platform/attachments, instead of a table per consumer

    • Core: 01060 table (RLS, no UPDATE/DELETE grant), platform/attachments (Attach/AttachAll/List, binder reading by the record kind’s read key); guard watched failing once
    • API: attachment_ids on each create input and view, POST …/:id/attachments per family, openapi first
    • Web: AttachmentsField in each create dialog (create only; never sent on the update), AttachmentsSection (thumbnails, add-only upload via POST …/attachments) in each drawer and in a dialog reopened on a saved draft; documents strings en/ms/zh, ms/zh rows appended to docs/review/*-review.csv (need native review). default: [] dropped from the six attachment_ids inputs in openapi.yaml so the generated type is optional. Verified by vitest (create-vs-update body watched failing once), tsc, lint; no browser pass
  • Structured account codes ([world]-[country]-[company]-[account]-[sub]..., per-org template, 5 sub-account cap)

    • Spec: 2026-10-03-structured-account-codes-design.md (migrations 01020-01039; owner decisions D1-D14 recorded, all the recommended options). Plan: 2026-10-03-structured-account-codes.md

    • Task 1: audit and findings note (pack literals, reporting rolls up by path, petty-cash float code, onboarding)

    • Task 2: 01020 template table (RLS, append-only, prefix fixed for life), range declared

    • Tasks 3-4: 01021 columns, 80-character limit, depth cap 0..5, code immutable for every account, commit-time coherence trigger, insert conformance trigger; chart templates, composer, MoveAccount (lock class 4), no code edits. Every guard watched failing once

    • Task 5: seed_key and the 14 literal lookups moved to it; static guard (seedkey_guard_test.go)

    • Task 6: orgadmin create-org -company-code and the operator console’s create form (both required); template v1 is written before the chart is seeded

    • Task 7: chart import in a template organization (full conforming code, violations by line and column, legacy_code alias)

    • Task 8: API (/accounting/code-template, .../check, .../accounts/{id}/move, segment, local_code, seed_key)

    • Task 9: web (template screen, account form with prefix, segment and validator, Move section, en/ms/zh). vitest and typecheck only: not driven in a browser at phone width, and the Playwright specs (web/e2e, moved to seed_key and segment) were not run

    • Task 10: the group-chart pre-fill (“Apply suggestions”) is built in the consolidation stage 2 (consolidation.Suggest / ApplySuggestions, matched on local_code plus type, never applied automatically; web in the group workspace). vitest only

    • Owner CONFIRMED 2026-10-04 (recommendations accepted): SC-1 (a child is built under its parent’s template version; REVERSED 2026-10-05 at the owner’s request: a child follows the version in force, same separator required, migration 01041), SC-2 (a template account cannot sit under a legacy parent) and keeping the Go-level onboarding API optional while the CLI and console require a company code. Still open: PDF and CSV column widths for 44-character codes; a move after posting restates closed-period roll-ups (reports group by path); the platform-wide uniqueness of the company code (D4, not enforced)

    • Editable entity prefix (2026-10-08, owner request; reverses “the prefix is fixed for life”: world, country and company are all editable): POST /accounting/code-template/company (own permission accounting.code_template.company, Owner only), migration 01134, chart.ChangePrefix; web section on the code template screen (en/ms/zh). The permission key and path keep the name company. Every template account’s code is rewritten with local_code untouched; legacy accounts keep theirs; old codes live in the audit row and the earlier template version (no alias: account_alias needs an import job). Refused while the organization is in a consolidation group (active access grant, a mapping of its accounts, or an imported trial balance naming its investment account), because consolidation holds account codes as text: making it follow aliases is the larger follow-up. Verified by Go tests (route-permission guard watched failing; group refusal paired with a presence check), vitest, and a live call on the dev stack (Demo is refused, it is in the sample group); the success path was not driven in a browser

    • Flat numeric codes (2026-10-09, owner request: 100000 Asset, 110000 Current Asset, 110001 Bank; not limited to six digits): a second template kind, flat (migration 01180, range 1180-1189). No prefix and no separator; one numeric level whose min and max digits are set per organization; the code is the local code and the tree comes from parent_id, so the digit blocks are a convention, not enforced (owner did not choose to enforce them). A legacy parent is allowed under a flat template. The kind is fixed for life (trigger account_code_template_kind_fixed), so the prefix change is refused on a flat chart. The Malaysia pack’s four-digit literals are padded on the right to the template width (1100 becomes 110000, still found by seed key 1100); a petty-cash float takes the next free code under its header (153001). Created at onboarding (Input.FlatCodeDigits, orgadmin create-org -flat-code-digits) or chosen once on the code template screen by an organization with no template. Go tests (conformance trigger watched failing once, paired with a presence insert; onboarding end to end), vitest, typecheck, lint; en/ms/zh strings, ms/zh rows in docs/review/*-review.csv need native review. Not built: (the create-org form no longer asks for a code: two-phase onboarding, the owner chooses it in the wizard); switching an existing structured chart to flat; the group standard chart on a flat organization (not exercised: adopting creates accounts from the standard’s local codes, so it should only work when they fit the flat width); Playwright specs and a browser pass were not run

    • Optional entity prefix, part by part, for both kinds (2026-10-09, owner request; migration 01181): the prefix is none, company, country + company, or region + country + company (region is the column world); a part needs the ones after it (country needs company, region needs both; CHECK account_code_template_prefix_chain, mirrored in chart.ValidateTemplate). A flat code with a prefix is prefix + separator + number (MY-ACME-110001, local_code is the number); a structured template with no prefix starts at its first segment. Which parts are on is fixed for life (trigger account_code_template_prefix_parts_fixed); their values change through chart.ChangePrefix, which now refuses a template with no prefix or an attempt to switch a part on (ErrNoPrefix, was ErrFlatNoPrefix). account_code_prefix() is the one SQL definition of the prefix and separator, used by the conformance trigger and the code guard. Onboarding Input.CodePrefix and orgadmin create-org -code-prefix none|company|country|region (default region, or none with -flat-code-digits). Web: a “Prefix in every code” select on the template screen; the account form, list and rename section follow the parts that are on; the world label is now “Region”. Go tests (the chain CHECK and the flat conformance check each watched failing once), vitest, typecheck, lint; ms/zh rows in docs/review/*-review.csv need native review. Not built: switching a part on or off after the first version; the operator console’s create form (still structured, full prefix); a browser pass

  • Group standard chart, locked core (added 2026-10-08 on request; design docs/superpowers/specs/2026-10-08-group-standard-chart-design.md)

    • Migration 01150, group_chart_standards() definer (allowlisted), lock trigger; chart.AdoptGroupStandard / GroupStandardStatus; standard_chart_levels on the group settings; GET /accounting/group-standard, POST .../adopt; web (owner’s settings tab, subsidiary page /accounting/group-standard, en/ms/zh); sample data sets 2 levels and DEMOSUB adopts it. Lock trigger watched failing once; the unrelated-organization read paired with a presence check; live on the dev stack (29 accounts locked, rename refused 422). Web: vitest only, no browser pass. The 422 for a locked rename carries the raw database text after the rule
    • Not built: the owner’s list of drifting subsidiaries, un-adopt, auto-push
  • Mock data for manual testing (not in the original spec; added on request 2026-10-05)

    • Opt-in tsx e2e/seed.ts --sample-data, public API only, idempotent, refuses a non-local host without SAMPLE_ALLOW_REMOTE=1; web/e2e/sample/ (finance, expenses, hr, operations, group, insight); README section
    • Verified on a fresh local stack (2026-10-05): full run from zero, rerun creates nothing; found and fixed structured account codes, a 1130 account clash (motor vehicles vs investment in subsidiary) and a renovation journal that failed asset_register_gl; with both orgs’ periods closed through 30 Jun a new H1 consolidation run was created and approved
    • Permanent local demo (2026-10-07): ERP_SAMPLE=1 scripts/dev-stack.sh seeds once, creates the subsidiary org itself, data on named Docker volumes; sample journals keyed by sequence_key (the code became editable and SAL is now SI). Verified by a live run on the dev stack: second run creates nothing. A fresh-volume run of the script itself was not done
    • Staging run, and a browser pass over the seeded screens (none done)

  • List search, sort and filter (server-side) — 27 list screens (payables, receivables, journal entries, trade documents and reports, inventory, assets, petty cash floats, banking, e-invoices, team, HR) over 30 endpoints. Shared kernel/listquery; q, sort (allowed keys in x-sort-keys on each operation), and named filters on each list endpoint; DataTable server mode with URL state. Spec: 2026-10-06-list-search-sort-filter-design.md; plan: 2026-10-06-list-search-sort-filter.md. Branch list-search-sort-filter, integrated with main’s table export and project link on branch integrate-list-query (2026-10-12), not merged to main. The interim browser-side search (committed 2026-10-06) is removed: DataTable searchText/onSearchChange and useLoadAllWhileSearching are deleted

    • Built and verified by tests only: Go tests against real Postgres per endpoint (presence then absence for search and each filter, every sort key both directions with ties and NULLs, 400 invalid_sort/invalid_query/invalid_cursor, other organization absent, permission hiding); for each keyset- or offset-paged list, tests that paging under a sort returns the unpaged order across page boundaries and that cursors are bound to their sort (unpaged lists have no paging to test); the central drift test internal/app/listsort_test.go (every converted operation declares q, sort and x-sort-keys; over HTTP it requests only the first sort key, and the other keys are covered by comparing the allowed keys named in the invalid_sort refusal with x-sort-keys; a q of 100 runes is accepted and 101 is 400 invalid_query; watched failing once); vitest per converted screen (params reach the request; typed search survives opening and closing a record)
    • Not verified: no real-browser pass at any width (phone layout only in jsdom); no Playwright spec run; ms/zh strings (ui.removeSort, accounts.active, suppliers.bankNone and the shared filter strings) best effort, need native review (rows added to docs/review/*.csv at the integration); no performance measurement (ILIKE '%x%' scans the organization’s rows, no index; Go-side lists load every row first); the date-range filters are tested over HTTP and from a URL, not by driving the date picker; the linked ?q=10.50 open/close variant is pinned only on the payables, receivables and journal-entry screens (the router change covers the rest, unpinned)
    • Behaviour changes (listed for API clients in DEPLOY_NOTES.md): active=false now means inactive only (was “all”) on suppliers, customers, items and locations; q on customers, suppliers, employees and items is now every-word over more columns (items: the name in the request’s locale only, no code prefix); state/status/type/category_id repeatable; e-invoices page with Load more instead of the newest 50, and the status Select became the column filter; float register tabs filter on the server; payment batches holding payslips are always excluded in SQL for a caller without payroll.run.read (full pages)
    • Deferred: translated state labels in free-text search (search matches the state key; fix by joining the translation row); base-currency amount sort/filter; a trigram index if a table grows; the AR sales-invoice list (its own sort/order/q) untouched; trade document screens still fetch one page of 200 (no Load more, as before); the “Backorder” tag on open-order reports is not searchable; useListView’s tableId argument is unused (one list per page); sort Value functions of localized or Go-side columns return nil (fine unpaged, need real values before a cursor is added); saved views, multi-column sort
    • Follow-ups (not done): hoist queryDates/queryAmounts/decimalRe into one helper with one 422 shape (payables, receivables, assets, trade, pettycash and einvoice differ); date-range parameter names are not uniform (due_from vs sort key due_on, document_date_from vs sort key date) and are public now; batch the HoldingsOf reads in the members directory (one query per position under q or sort=positions); text-sort collation: tests run on postgres:17-alpine (musl) while production may run glibc, so text order there is unverified; trade routes keep as never casts on a shared helper
  • Table export with user-chosen columns — shared platform/tableexport registry, ExportDialog, private/shareable presets, CSV and XLSX; first batch claims, journal entries, petty cash, parties; plus axis.<key> dimension columns on the journal import. Added 2026-10-09 on owner request (D365 journal uploads). Spec: 2026-10-09-table-export-design.md

    • Foundation: render Plain mode, platform/tableexport registry, platform/exportpreset (migration 01190), modules/exports routes gated by exports.use, claims kind with columns, headings and format, ExportDialog with presets, claims Export button. exports.use is granted to every existing role (01191) and to new organizations (EveryRolePermissions), as search.use was
    • Journal import: axis.<key> dimension columns
    • Trial balance: signed balance column and cent rounding
    • Rulings: the claims export cap counts lines (20,000), not claims; claims CSV now uses CRLF line endings
    • Journal-entries export: GET /accounting/entries/export (accounting.ledger.read, the list’s filters, one row per line, oldest first, cap 20,000 lines), kind journal_entries with the import’s columns plus axis.<key> per axis (headed by their keys so the file imports back; identical only for manual base-currency entries, the import ignores currency and reversed is a flag, the reversal link is not restored) and optional base_debit, base_credit, currency, source_kind, reversed; a split line’s axis cell is CODE:60;CODE2:40 and the journal import now accepts that form (must add up to 100, no code twice) and refuses a party code shared by a customer and a different supplier. “Export lines” button and ExportDialog on Journal entries (the report export stays). Round trip into a fresh organization tested (incl. a split line and a reversal); drift test internal/app/tableexport_test.go: every kind has a catalogue and a registered permission. Round trip covers a customer, a supplier and a tax code; no browser check
    • D365 headings (LOB, SUBLOB, DEPT …): no built-in preset (a shared preset with those headings does the same). The journal import now reads a bare header equal to an analytic axis key, any case, as that axis’s column (LOB = axis.lob; axis.lob wins if both are present; a fixed field name is never an axis; any other unknown column is ignored as before). Requires the org’s axis keys to match the D365 names; a per-axis alias is not built. Verified by Go tests only; ms/zh unaffected
    • Petty cash export: GET /pettycash/spends/export (pettycash.read, the list’s scope and float_id with the same visibility, one row per spend, newest first), kind petty_cash (reference, date, float code, holder, description, expense account code, amount, currency, project code, status, receipt flag; optional tax code, supplier, supplier TIN, e-Invoice UUID, document basis, review outcome). “Export spends” button with ExportDialog on the petty cash records bar, shown with pettycash.read and exports.use; the old client-side Export (top-ups and spends together) stays. Spends carry no category, base amount or tax amount, so those are not columns
    • Customers and suppliers export: GET /receivables/customers/export, GET /payables/suppliers/export (each list’s read key and filters: group, q, active, cf.<key>; up to 20,000, past the list’s 1000), kinds customers and suppliers sharing tableexport.PartyColumns/PartyRow, headed by the import’s keys (defaults = the old Download CSV’s columns incl. cf.<key>; optional kind, registration, SST (customers), address, country, group_company). No bank columns (the list shows them only to verify_bank). Export button with ExportDialog on both lists, shown with the read key and exports.use; the client-side Download CSV stays. A phone starting with + is written '+… by the formula guard; every import strips one leading apostrophe before a guard trigger (render.FormulaTriggers), so the customers file imports back with the phone unchanged (round trip internal/app/partyexport_test.go). Parties have no anonymisation path (CRM’s anonymise covers contacts, leads and person accounts only), so the test asserts the export shows what the list shows, not erased values
    • Claims project column: already there (project in the claim export layout); nothing added
    • Sales invoice, purchase invoice and trade document exports with the project column (PL2): GET /receivables/invoices/export, GET /payables/invoices/export and GET <family>/export for the eight trade families, each with its list’s read key and the list’s own filter parser (sales: kind, customer, project, state, currency, open, q, from/to, sort/order, group, cf.<key>; purchase: kind, supplier, project, state, due_before, currency, open, group, cf.<key>; trade: state, party, project, open), paging through the list loader to the cap (refuses past 20,000). Kinds sales_invoices, purchase_invoices and one per trade family keyed by its document kind (quotation, purchase_order, … : a kind has one read key and the families have six). One row per document: number, date, due date (Valid until on quotations), counterparty code and name, status, currency, total, base total; sales adds tax and customer reference, purchase adds supplier invoice no. (purchase and trade lists carry no tax total). project (the one project’s code, Multiple for several, empty for none; tableexport.SetProject over analytic.ProjectsOf) and project_name are optional columns, off by default as PL2 says. Export button with ExportDialog on the three list pages, shown with the read key and exports.use, sending the page’s server-side filters (sales: tab, search, applied filter form incl. project, sort; purchase: tab, group, project; trade: project). Go tests per kind (quotations for the trade suite, a read-key check for all eight), vitest for the three buttons; no browser check
    • Drift test: internal/app/tableexport_test.go TestEveryExportKindHasAListPageThatOpensItsDialog maps every kind to its list page and fails when a kind has no page or a page no longer opens <ExportDialog kind="…"> (both directions watched failing). It lists pages that have an export, not pages without one; the latter would need a list-page registry that does not exist
    • The exports apply the server-side filters only (petty cash scope/float_id, parties group), not the pages’ client-side search, month or state boxes
    • Integration with list search, sort and filter (integrate-list-query, 2026-10-12): the supplier, customer, purchase invoice, eight trade document and journal-entry exports read their list’s own filter parser, so they take the list’s q, sort and filters (the web sends the list view’s params; a pick list’s values repeated) and the file is what the screen shows across every page; journal entries stay oldest first whatever the list’s sort. Paging exports page through the list’s sort cursor under a sort (tableexport.PageRows, a var a test lowers). Go test per export (presence, then each filter; sort order; paging one row a page), vitest per page that the export URL carries the view; each watched failing. Not changed: the petty cash spend and sales invoice exports (their lists were not converted), the journal-entries report export (ExportMenu, journal/period/group only). CRM accounts and leads keep their server search= through the list view’s search box (DataTable lost searchText); opening a record keeps it. No browser check
    • Go tests per kind: defaults with a known row, 403 after a 200, the cap (tableexport.MaxRows is now a var a test lowers), a filter with presence first; XLSX money checked on petty cash. Vitest for the three buttons and dialogs; no browser check
    • Project link on sales, purchase and CRM (separate design)
    • Browser check of the dialog (not run); XLSX opened in Excel (not run)
    • ms/zh strings need native review: exports.json, team.json (exports, exports_use), claims.json export.title, journal.json list.exportLines/list.exportTitle, pettycash.json filters.exportSpends*, receivables.json customers.export* and invoices.export*, payables.json suppliers.export* and invoices.export*, trade.json export/exportTitle (rows in docs/review/*.csv)
    • Reversing-entry column on journal import: optional reversing_date on an entry’s first row posts the entry’s reversal on that day through ledger.Reverse (the reversal route’s default description and accounting.journal.post key), in the entry’s savepoint. Refused per line: not a date, not after the entry date, different on a later line of the entry, or in a closed, locked or missing fiscal period. D365’s “Revesing Entry” (sic) yes/no flag is ignored and is never an axis column: the date decides. The header is reversing_date in any case (the import screen also auto-maps “Reversing date”); no other alias is built. Result count reversals when any, imports.json counts.reversals in en/ms/zh (ms/zh rows in docs/review/*.csv, need native review). Export unchanged. Go tests only, each guard watched failing; no browser check
    • Backlog, not scheduled: project billing-lines import (Capcom fee template)
  • CRM v1 — core pipeline — leads, accounts, contacts, opportunities with workflow stages, activities. Added 2026-10-08 on owner request; not in the original architecture phases (spec §1 “Phase 3+ later”). BUILT on branch crm-v1, NOT merged. Spec: 2026-10-08-crm-design.md; plan: 2026-10-08-crm.md

    • Phase 1: schema 01170, platform/crm (graphs, accounts/contacts, opportunities, leads and conversion, activities). Go tests, each guard watched failing
    • Phase 2: module switch and 12 permission keys (01171), API in api/openapi.yaml, modules/crm, wiring in app/api/migrate/worker/orgadmin, receivables boundary test
    • Phase 3: accounts, leads and pipeline UI, en/ms/zh. Browser-checked on a throwaway stack (accounts, lead convert, Won, Lost with reason, en/ms/zh, 375px); drag and drop only via dispatched events
    • Final review fixes: owner/created_by FKs, convert needs the create keys, convert target found by tag, owner is the workflow reporter, coded refusals with translations, workflow_not_provisioned
    • ms/zh strings need native review (docs/review/*.csv)
    • Existing organizations: re-run provisioning to seed the CRM workflow definitions (docs/DEPLOY_NOTES.md); CRM is on by default (the platform has no default-off)
    • Retention and erasure of contact data (spec §11, CD-4, decided 2026-10-09): admin-only anonymise action (crm.data.anonymise, migration 01172) for contacts, leads and person accounts and their directly attached activities; row kept, placeholder name + anonymised_at, audit entry, no cascade, no DELETE grant. Built on branch crm-anonymise; no browser pass yet
    • UI follow-ups (2026-10-09): the board offers only allowed moves (single-opportunity view refetched on menu open or drag start), edit lead and opportunity, archive account, link an account to a receivables customer. Browser-checked on a throwaway stack (allowed moves only, Lost reason gate, edits, archive, link and already-linked message, en/ms/zh, 375px); real mouse drag untestable with the tool, synthetic drag events behaved correctly
    • Polish (2026-10-09): labels come from each instance’s own definition version, malformed-id codes translated, loose tests tightened, move-menu failure paths tested. Full Go run (163 packages) and web suite: only the failures that pre-exist on the base
    • Reassigning an owner does not change who may move the record (the workflow has no supported way to change an instance’s reporter). Decided 2026-10-09: keep the limitation (reporter fixed at creation)
    • Minor UX (2026-10-09): the reason dialog focuses its textarea on open; stage buttons are hidden while editing. Not built: re-linking a linked account to another customer (a receivables-boundary decision)
    • Opportunities of an erased person account: erasing the account blanks their names and activities and refuses new text under them (no separate opportunity action). The linked receivables party is outside CRM and is handled in receivables. Separately, the anonymise action added a 13th key, crm.data.anonymise (01172)
    • Not in scope: quotes (trade owns them), project_profile link (PU7), email sync, campaigns

Two-phase company onboarding (added 2026-10-09, owner request; branch two-phase-onboarding, not merged)

Section titled “Two-phase company onboarding (added 2026-10-09, owner request; branch two-phase-onboarding, not merged)”

Design: 2026-10-09-two-phase-onboarding-design.md. The operator creates a company with three fields (name, owner name, owner email); the owner finishes the rest in a first-login wizard. Setup pending = no company_profile row, so no new column and existing companies are unaffected. Migrations 01200-01203.

  • provisioning.RunSteps(keys...) (Part 1 runs “roles” alone); permission org.setup.complete, Owner only (01200)
  • Part 1: onboarding.Input profile fields optional; orgadmin create-org, the console form and the create_org job take three fields. Org, login, party, member, Owner role, invitation; no profile, template, chart, journals, tax codes, fiscal year. Go tests (presence then absence)
  • onboarding.CompleteSetup: one transaction under an org row lock, idempotent (ErrSetupComplete), writes profile and code template, runs the full provisioning, applies the module selection, deletes the draft; rollback leaves the org pending. Go tests incl. rollback
  • Server-side draft: setup_draft (01201), GET /v1/setup/sections, GET /v1/setup/draft, PUT /v1/setup/draft/{section} (lenient, If-Match versioned, 64 KiB cap), POST /v1/setup/complete reads the saved draft and re-validates it (per-field errors)
  • Pending lock: default-deny httpx.RequireSetup (403 setup_required), allowlist setupExempt in internal/app/setupgate.go with a drift test, only “complete” cached in process, GET /v1/setup/status, setup_state on /v1/me
  • Jobs skip pending orgs (ops.org_ids()), the console flags them setup pending (org_setup_pending, RUNBOOK 7.1; 01202)
  • Invitation reissue: invite.Reissue, orgadmin reissue-invite -org <id>
  • Web: redirect while pending, wizard (company, tax, code format, modules, review) with 5 s debounced autosave, Save draft button, conflict Reload/Keep mine, draft restore; en/ms/zh (ms/zh need native review). vitest (autosave watched failing once) and typecheck; no browser pass yet
  • Setup hub after Finish (Owner only): GET /v1/setup/hub, rows registered in onboarding/hub.go and computed from real data (team, org structure, bank, budgets, payroll, petty cash; module rows only when the module is on), page /getting-started, a nudge card on Home, never blocking
  • Hub rows still to add: opening balances, exchange rates, payout channels, inventory locations, layouts; widen the audience beyond the Owner (admin permission)
  • Console action to reissue an invitation (the CLI has it); pending-age alert and erp_orgs_setup_pending gauge (D4)
  • Browser pass at 375px and desktop in en/ms/zh; Playwright specs; ms/zh rows in docs/review/*-review.csv
  • Task 1 audit of company.Get callers outside request paths (the job loops all read ops.org_ids(), now filtered; the remaining callers are in request handlers, which the lock already guards)
  • Open: D1 owner-only completion (built as recommended), D2 DECIDED: server-side draft, 5 s autosave and manual save, D3 platform-wide company code uniqueness, D4 14-day pending alert, D5 no fiscal-year default (built: no default), D6 required sections (built: company, code format; tax optional), D7 optional personal welcome prompt for non-owner users

Invariants — never tick these off, they are permanent

Section titled “Invariants — never tick these off, they are permanent”

Re-read before any change that touches money, tenancy or the workflow.

  • A float never touches money, in any layer. Money crosses the wire as a string.
  • Every business table has org_id, forced RLS and an org_isolation policy.
  • Every request runs inside a transaction, including reads.
  • Vocabulary values are strings, never integers.
  • The ledger is append-only. Corrections are reversals, never mutations.
  • accounting/ reacts to semantic tags, never state names.
  • Modules never write journal lines; they register posting rules.
  • Nothing outside a module imports its internal/.
  • Every user-facing name is a translation row.
  • Approval grants are configured by an administrator; a claimant never picks their own approver.