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.
Decided before the ledger held data
Section titled “Decided before the ledger held data”Closed 2026-09-20, between the Phase 0 merge and Plan 2. Rationale in §17 of the spec.
- Multi-entity / consolidation —
org_idis the posting dimension. Noentity_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)
Phase 0 — Foundation
Section titled “Phase 0 — Foundation”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 LOCALper transaction (§4.1) - Drift test — every table has
org_id, forced RLS and anorg_isolationpolicy - 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 thatAuthenticate()placed on the request context, and nothing else.TestNothingReadsAnOrgFromAHeaderininternal/kernel/layeringscansinternal,cmdandapi—.goand.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
NUMERICcolumn or a monetary API field must configure a sqlc and oapi-codegen type override ontomoney.Amount. No override exists today because nothing monetary exists yet. Without one, a monetary column generates asfloat64— 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/v3requires it, and CI derives its version fromgo.modrather than restating one. — spec §2 and the Phase 0 plan amended;go.modsaysgo 1.26.0, CI usesgo-version-file: 'go.mod' - Two tables carry a nullable
org_idby declaration —expense_categoryandtranslation— recorded with justifications indb/org_id_nullable.txt, andkernel/db/rlscoverage_test.gofails on any undeclared one. A global registry row belongs to no tenant. Any new registry follows the00005_registry.sqlpattern, including itsWITH CHECKclause.
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_PROVIDERis 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 bykernel/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.
- Spec amended (§17.3 item 3, §11A.2); statutory rows undeletable by the app role
(
-
mslocale is statutory — record-bearing free text restricted toen/ms;zhis a UI locale only. Done for expense claims (expenses.CheckRecordTextplus named CHECKs in00072; category names required inenandms). Being done onphase1-wave3:msadded tolocale.Supported(the workflow validator refusedmslabels 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_hashchain onaudit_log, with a verification pass CI proves can fail. — Plan 2 Task 1:00007,VerifyChain;chain_test.goproves an altered, excised or tampered row fails;audit_chainreconcile check before every close;migrate verify-audit - Claim lines carry the tax record —
supplier_tin,einvoice_uuid,document_basis, andemployee_named_document_allowedon 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).
Phase 1 — Expense and accounting core
Section titled “Phase 1 — Expense and accounting core”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'))
- Users, organizations, members, sessions (refresh rotation + reuse detection) (§4.4)
—
- 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.mdDesign:2026-09-21-org-structure-and-onboarding-design.mdDeploy:DEPLOY_NOTES.md—PUBLIC_BASE_URLis now required- Org units, positions,
reports_totree, 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.
- Org units, positions,
- 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.
superiorand 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.Eventcannot say “past payout”, so an upheld dispute after payment reached the Poster asrejected+PastAccrual. Resolved onphase1-wave3(wave-3 contract §1.1, the same item as “Reversal once past payout” below):posting.Event.PastPayoutplusworkflow_instance.payout_open(migration00034) make a reject or cancel past a payout reverse it, and validator rule 5 now demandsreversesthere. Claims were already safe onmain: their payout requires an unreversed accrual, and Plan 4 R7 mirrors every unreversed leg, soTestAnUpheldDisputeAfterPayoutReversesBothLegsproves the bank pays once. The gap was payout-first and cash-only documents (Plans 7 and 8).phase1-wave3is merged. - Caller constraint for every consumer: call
workflow.Transitionbefore anyaudit.Write(includingworkflow.Start) in the same transaction when the move can post — a Poster’s advisory lock must precede audit class 1 (lockClassAuditChain,internal/kernel/audit); seeTransition’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/referenceand/overrides(server-side filter by currency and date, sort, offset paging with a total) and the/accounting/exchange-ratespage 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_URLis set; nightly reconciliation is deferred to River (Ruling R14)
- Sync runs from the worker when
- 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
- Channel definitions,
- 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_onlyposting 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_openbackfill (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_firstordering- Three workflow kinds (
advance,advance_topup,advance_refund, contract §2.1, §8.1); an acquittal of zero returns the advance throughreturned(Ruling A4) - The acquittal carries the §6.7 tax record, validated by
taxrecord(Ruling A30)
- Three workflow kinds (
- 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/remindersand theoverdueblock give the escalation level (claimant, then manager, Ruling A28); delivery waits for River (Plan 9), a mailer, and tenant enumeration (contract §4)
- Deferred:
advance_offsetclaim lines; paying an advance from the tin
- Acquittal decision table; signed variance fan-out;
- Plan 8 — Petty cash (§8)
Plan:
2026-09-22-plan-8-petty-cash.md(19 tasks)- Float,
cash_balance+committed,float_movementledger (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_closeoutworkflow kind (Ruling PC3) - Replenishment restores every approved disbursement: VERIFIED spends and claims settled from the tin (Ruling PC20)
- Close-out is its own
- 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)
- Float,
- 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 inops.check_result); moving the BNM sync and Plan 5’s idempotency pruner is still open (Ruling R30)
- River lands here: schema vendored into goose (
- Trial balance, P&L, balance sheet, GL detail — one query layer in
- 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 newwebcontainer fronts the api;TRUSTED_PROXIESand the object store’s public origin change- Shell, org switcher, i18n (
en/ms/zh) —msis statutory (§11A.3); the line saiden/zhuntil 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-cashto_act,/advances/summary, close-out list - Global search (Figma batch 2, audit 2.2) —
GET /searchbehindsearch.use, held by every role (00268Owners,00269all other roles, provisioning for new ones); each group is its own list’s visibility, from the owning module’scontract.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)
- Seams added for the UI:
- 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, migration00255. Organization logo (audit row 1.10) not built - End to end: four Playwright flows against the dev stack (
pnpm -C web e2e, CI jobe2e) — claim round trip, petty-cash spend round trip, workflow draft violation then publish, trial-balance CSV export
- Shell, org switcher, i18n (
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(atableIdopts 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, keytable.<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— migration00190;workflow.definition.publishnow 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 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
- 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
projectaxis 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), migration01120- 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)
-
01120analytic_account.chargeable_by(NULL= any flow, no backfill);analytic.CheckChargeable, wired at claim line save and submission;ledger.Postunchecked - API:
chargeable_byon analytic accounts;/expenses/analytic-optionsreturns only expense-chargeable accounts - Web: “Charged by” on the project dialog; en/ms/zh
- Other flows:
CheckChargeableat purchase, sales, trade, asset, manual-journal and bank-statement-journal entry points, plus the drift guard over theanalytic.Plan(call sites;ledger.Postand accrual re-posts stay unchecked - Members-only projects:
01121analytic_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 atledger.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:
01124claim_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 callsGET /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:
01122project_unit; a project is open to everyone in a listed department or beneath it (a stored rule, never copied intoproject_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/claimsand/expenses/claims/exporttakecategory_id(any live line),claimant_id,from/to(created_at, inclusive, organization timezone) andmin_total/max_total(claim total), on every scope includingto_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, likeqandgroup_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:
01123narrow carve-out inexpense_claim_line_draft_guard(onlyshares),workflow.ReviseSharesgate (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:
01133second narrow carve-out inexpense_claim_line_draft_guard(app.line_category_edit, category_id only);POST /expenses/claims/{id}/lines/{lineId}/category(expenses.claim.transition); authority isworkflow.MayEditBeforeAccrual(not the reporter, and an allowed edge into an Accrual-meaning state); audit actionchange_category; a per-line category picker in the claim drawer. ms/zh strings need review - Project codes:
projectnumbering scope (defaultPRJ-{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_idfilter andproject/multiple_projectson 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 withaccounting.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 withaccounting.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 currentproject_idbecause 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 viaaccounting.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. Flowspetty_cash_spend/advance_acquittalare checked like expense claims (invalid,not_chargeable,not_memberon the holder / claimant; a zero acquittal with a project isnone_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 withaccounting.ledger.read, options filtered by flow),CODE · Namein 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 withoutaccounting.ledger.readcannot 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
- Piece 1 (plan
- Not in scope: customer link and CRM (PU7,
project_profilewhen 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.mdPlan:2026-09-28-operator-console.md(backend B1–B6, then frontend F1–F4), migrations00230–00249- Stage 0 decisions D1–D5: D1 (the
SECURITY DEFINERexception, §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,
/metricswith 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
- Stage 0 decisions D1–D5: D1 (the
- AP: supplier role, purchase invoices, credit notes, aging, payment allocation
Design:
2026-09-26-accounts-payable-design.md(D1–D12), migrations00300–00306Plan: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) behindreports.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— Changed (D1): the audited sole-owner exception (O-3a) now covers supplier-payment approval and supplier bank-detail verification for a one-member organization; anall_statesgrant could still allow a self-approved paymentall_statesgrant 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 visibilityand 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
- Sub-ledger = control per supplier is a drift test (
- AR: customer role, sales invoices, credit notes, receipts, aging, statements
Design:
2026-09-27-accounts-receivable-design.md(D1–D20), migrations00310–00314Plan: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)
- Sub-ledger = control per customer is a drift test (
- Debit notes — AR
customer_debit_noteand APsupplier_debit_noteDesign:2026-10-05-debit-notes-design.md, migration01070Plan:2026-10-05-debit-notes.mdDeploy: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
03and13— 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), migrations01080–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
- 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) —
- Payment batches → one bank file (DuitNow bulk / GIRO)
Design:
2026-09-27-payment-batches-design.md(D1–D14), migrations00330–00332Plan: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); itsline_nois 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
- Supplier payments, and (C4,
- MyInvois: submission, API validation, status polling, retry
- Self-billed e-Invoice on the AP side
Design:
2026-09-27-myinvois-design.md(D1–D22), migrations00380–00382Plan: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, MSIC00000, SST/IDsNA - 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_URLon loopback hosts only Single-Owner orgs cannot self-issue a sales invoice (— fixed in the final review (Ruling EINV-F2): submission routing skips a target-gated reversal of what was posted (a void), so the OWNER-onlynot_routable)issued → voidedexit 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, decisionauthority_via = sole_owner_exception, audit row flaggedsole_owner_exception; the member count is re-read under theorganizationrow 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): sendNAfor 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_lhdnorcancelled_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 bothcancelled_atitems (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’sDetailscalls 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_atis not write-once though the e-Invoice Retry gate reads it (the sales trigger00410is the model);00410stops 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 codesnot_draft,inactiveandvoidedare generic names with feature-specific sentences
- SST-02 return generation
Design:
2026-09-27-sst-02-design.md(D1–D16), migrations00400–00404Plan:2026-09-27-sst-02.md(10 tasks, Rulings SST-R1–SST-R14) Deploy:DEPLOY_NOTES.md— re-provision every organization (tax_return_paymentworkflow 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.Todayincompany_profile.timezone, resolved once per transaction;TodayUTConly where allowlisted (internal/kernel/clock/todayutc_drift_test.go) (final review I3) 2140 has no control kind— ruled O-7: 2140 issst_payable, 2160wht_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)
- Business date is the organization’s day (O-1, Phase 2c lane C1):
- 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)
- O-4 done: the periods list computes
- WHT, CP37 (CP38 → Phase 5 payroll, spec D13)
Design:
2026-09-27-withholding-tax-design.md(D1–D14), migrations00340–00342Plan: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_remittancekind)- 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;
latefollows it - D3 X3/X5/X7: a purchase code cannot name
sst_payable, only a withholding code nameswht_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 statusexempt
- Tax is withheld on the supplier payment (gross settled, net paid,
- Bank statement import (MT940 / CSV), auto-match, reconciliation
Design:
2026-09-27-bank-reconciliation-design.md(D1–D16), migrations00350–00354Plan:2026-09-27-bank-reconciliation.md(13 tasks, Rulings B1–B18) Deploy:DEPLOY_NOTES.md— existing Owners/Finance gainbanking.*; 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’sbatchrule 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): reportbank_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);ErrNotInOrderwording; voided and second-account filters tested;ItemTablekey;FuzzParseMT940/FuzzParseCSV;bank_statement_chainrequiresstarts_onafter the previousends_on; CSV strips onlyRMor an ISO code, reads(RM 100.00), padded dates and own-side DR/CR markers;lockBankAccountlocks 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
ErrDifferenceretry 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 (thebank_reconciliation_difference_zeroCHECK only compares the stored figures). Upgrade: compute the D11 figures in one statement (a CTE) for a single READ COMMITTED snapshot
- Reconciliation difference is provably only the go-live gap (property test in
- 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 onbank_statement_linewith a SQL guard, effective closing carried through the chain, reconciliation and lock,banking.AddLineandbanking.Preview,added_lineson 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, indocs/review/sheets)
- Budgets per account / period / axis, budget-vs-actual
Design:
2026-09-27-budgets-design.md(B1–B14), migrations00370–00372Plan:2026-09-27-budgets.md(10 tasks, Rulings R1–R21) Deploy:DEPLOY_NOTES.md— migrations only; existing Owners gainaccounting.budget.*, Financeaccounting.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 refusalbudget_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.
- Versions: original then revisions; approval freezes (a lock under
Needs an outside authority
Section titled “Needs an outside authority”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.
- 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. - 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.
- 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.
- 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.
- 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.
- 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_earlierflag shown on the register and in the payment drawer. - 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.
- 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.
- 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.
- Self-billed foreign-supplier defaults (MyInvois) — sandbox-validate one self-billed document
using
EI00000000030, MSIC00000andNAfor missing identifiers. Until then: the defaults stay as coded. - 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 (type03) with aBillingReferenceto a valid original and one without, and one self-billed supplier debit note (type13), against LHDN’s current guideline: whether03/13takeBillingReferenceat 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 filesdebit_note.jsonandself_billed_debit_note.jsonare the encoder’s own output, and an organization that cannot wait issues an extra charge as a new invoice (the documented workaround). - 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.
- B2C/foreign buyer TINs (MyInvois, review I4) — sandbox-validate a B2C document naming
EI00000000010with ID and phone sent asNA, checked against LHDN guideline Appendix 1. Until then: an ID document and phone are required for every buyer. - 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.
- Employment Act leave figures (HR, H3) — UNVERIFIED. An HR or legal adviser confirms, against the
current Employment Act 1955, the seeded
leave_statutory_floorfigures (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. Thehr_leave_grantsjob grants only after an administrator creates an active paid leave type with a rule set in force (theleavemodule 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.
Phase 3 — Platform customization
Section titled “Phase 3 — Platform customization”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_formatandIssue(all call sites converted),/org/numberingbehindorg.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
maina393d49a (2026-10-06): all 43internal/platformdirectories with their subpackages, and every othercmd/andinternal/package (57) passes, run one package at a time because the 4 GB Docker VM cannot take the parallel run. No singlego 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 (01132frees it fromjournal_immutable_guard); itssequence_keystays 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_schemapattern — 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’scf.<key>columns), filtered withfield.<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 byplatform/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.mdC1 (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 answermodule_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 reportcustomwith 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), migrations00600–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): throughCreateCustomer/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 onemigration_periodentry; 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 inarchived_documentunder their old numbers with the customer or supplier (or the old name), never posted; searched at/v1/imports/archive/salesand/purchasesbehind 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_entrieskind,01131): one row per line, rows sharingentrypost as one manual entry throughledger.Post; needsaccounting.journal.postas well asimports.run; “Import from Excel or CSV” link on the manual journal page. Not verified: real-browser check; ms and zh strings unreviewed
- M1 import framework: CSV and XLSX, column mapping, validate as a dry run then commit
all or nothing,
Phase 4 — Operations
Section titled “Phase 4 — Operations”- O1 — document chain (
platform/trade,00700–00709) and item master (platform/inventory,00710–00719): thecommittedsemantic; 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 theitemsimport; modulesinventory,procurement,sales(plandocs/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:
effectsemantic,grni,stock_valuationintegrity check; web UI for stock is a later lane, R-O2-19; plandocs/superpowers/plans/2026-09-30-phase-4-o2-stock-costing.md) - Fixed assets — operations design
2026-09-30-operations-design.md§O4, plan2026-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 thefixed_assetopening import, never posting; a monthly depreciation run (one manual entry, sourcedepreciation_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 theasset_disposalworkflow, depreciated through the disposal month and posted on O2’seffect; reports (register, schedule forecast, roll-forward, register vs ledger); theasset_register_glintegrity check (nightly, and before every close); moduleassets. Book depreciation only (OD-4)
Phase 5 — People
Section titled “Phase 5 — People”- 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–00919reserved) 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_runworkflow (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
payslipworkflow 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
statutoryline kind does not have GET /payroll/item-typesneedspayroll.item.manage, so asalary.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.readholders (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
listBatchesand payrolllistRunsstill show per-person pay topayroll.run.readholders without an audit row (a single-payslip batch or one-employee run totals are one person’s pay); aggregates are the owner’s call.getBatchandbatchCandidatesnow 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.keyenum gainedassetsandpayroll - 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_nomoved fromorganization_member(read-only there) - Append-only lifecycle; status derived by
employee_spell; identity number behindhr.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)
- Employee keyed to the employee party (HD-1);
- 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}/eventswithkind: notice_withdrawn, the Withdraw notice button in the employee drawer. Refused 409employee_statuswith 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 publishedplatform/hrread (hr.NoticeLastDay,NoticeLastDayOfMember) called by the two modules, which already sit aboveplatform; 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 ano_primary_positiongap 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
ErrAlreadyMemberor 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 byTestIdentityDoesNotImportHR), gated byorg.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.
- Withdrawing a notice:
- 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/obligationsneeds the newhr.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—usableOverrideskips overrides whose granter no longer qualifies - The sole-owner override’s “still one member” re-check is Go only; no DB backstop —
00804makes the guard trigger require exactly one active member GET /obligationsexposes advance references and the float cash balance to anyhr.employee.readholder — owner decided: HR and Finance only — built, see aboveimports.runalone can create and hire employees (only the identity number needshr.employee.sensitive) — owner decided: imports cannot create employees. Built: the employees import needshr.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 /identitywith{}clears the identity document (already 400,TestAnEmptyIdentityBodyIsRefusedNotAClear);?status=is not validated — now 422
- Obligation reads (open advances, float) are unlocked (Ruling H1-6 defers it) — owner decided: HR and Finance read. Built:
- H2 grades, salary bands, headcount, vacancies (
platform/hr,00821–00824; thecommittedsemantic is Phase 4 O1’s00700) 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 linkshr(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)
vacancyworkflow ending incommitted; 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 byhr.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 existingVacancyDrawer. No home-page card and no notification for a vacancy (notifications carry money kinds only)
- Assumptions: the queue is the pending vacancies the caller did not request and holds an allowed
non-reporter exit on (the engine’s answer,
- H2 follow-up: a transfer fills a vacancy (
00825)- Assumptions:
vacancy_idon atransferredevent, counted like a hire’s; the vacancy must be for the seat the person moves into (409vacancy_other_seat,employee_event_vacancy_seat), else the samevacancy_not_openguard 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)
- Assumptions:
- 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:— it links every module now;cmd/workerdid not linkhr,identityand seven other modulesTestEveryBinaryLinksTheSameModulescoversworker -
— 74 keys and 9 module headings labelled in en/ms/zh;hr.*permission labels are missing inteam.json; the gap is wider (54 keys and 8 module headings unlabelled) and needs a drift testTestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocaleguards it -
A retired grade renders blank in the headcount page’s grade Select— shown as “CODE (Retired)” -
— error branch addedVacancyDrawershows “Loading…” forever ifuseVacancyerrors -
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.;VacancyDrawerCancel does not clearformErrorgrade 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,00881and provisioning; audited with a reason). Earlier note:GrantHeadcountOverrideallowsActor == MemberID(an Owner granting an override for their own seat, requested by HR); should the beneficiary be allowed to grant? requested_byon an override is whatever the caller asserts (low risk: the Owner also holdshr.headcount.manage)Assignnow serialises every assignment to a seat, acting ones includedvacancy_fixedcompares 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-suppliedheadcount_override_idon INSERT skips the sole-owner re-count (no Go writes it)SetPositionGradereads the grade without a lock (a concurrent retire can slip through); no cross-org grade teststale_state/workflow_missingare generic codes with vacancy-specific sentences; the route-coverage guard does not check theRequireswording; the report’sSeatCounthas no band figuresheadcount.goreports a random field when both member fields are unknown (map loop)
-
- H1 employee record and lifecycle (
- 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: escalateresolves past it (H1, Ruling H1-10) - H3 leave (
platform/hrleave_*.go,modules/leave,00841-00842;00840deliberately 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 theleave_requestworkflow; 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_requestworkflow ending incommitted: 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.readonly; the reason is kept out of the audit chain - HD-2:
workflow.Unavailable, registered byhr— an approver on approved leave is passed over by theon_vacancy: escalatewalk, recorded in the decision log; nobody above available: stays, flagged, never refused; dailyhr_leave_reroutelogs 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_knownlisted only eight keys, so thehrmodule could not be switched off or on;00842widens it
- H3 follow-ups built (
00843):- HR recording leave on someone’s behalf:
POST /leave/requestswithemployee_id, forhr.leave.manage(403manage_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(migration00843) 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 (409recording_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_reroutepublisheshr.leave_reroutedfor each move that handed the step on, and the bell tells the new approvers once (awaiting_action,record_kindleave_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
- HR recording leave on someone’s behalf:
- 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 onhr.leave.requestinstead ofdocuments.document.upload, which nobody was granted. Smallest design consistent with how documents are bound: the same declare, presign andcompleteflow, with a fixeddocument.purpose(general|leave_certificate, migration00880).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 callsLockForLeave. Payout proofs (expense and advance settlement) refuse one too. Dedup is per purpose. Reads are unchanged (the request’s own rule), soexpenses.claim.read_allstill cannot open it and the reason stays out of the audit chain. Tests:TestALeaveCertificateUploadIsNarrow,TestClaimReadAllOpensClaimDocumentsOnly. The leave dialog now shows the uploader to everyhr.leave.requestholder -
Leave-request routing is not shown—LeaveRequestView.routing(contract first), only while pending and only in responses toCanReadLeavereaders: the resolved approvers’ names andpassed_over(away,note);GET /leave/requests/:idis 404 to everyone else. Open: the admin routing tab still shows raw workflow kind and state keys and member ids -
— any ofGET /leave/typesandGET /leave/holidaysrequirehr.leave.requestrequest,read_allormanage(checked in the handler; the two routes are declared[no-permission]inapi/public_routes.txt, sincehttpx.Protectedtakes one key) - Privacy (fixed):
GET /documents/:idlet anexpenses.claim.read_allholder 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) -
— 74 keys and 9 module headings labelled in en/ms/zh;hr.*permission keys and thehrmodule heading have no labels in the team screen (raw keys shown forhr.employee.*; 54 keys and 8 module headings lack en/ms/zh labels); add them with a drift testTestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocaleguards it -
Back-dated rule sets are allowed— owner decided: forbidden. A rule set cannot start before today in the organization’s time zone (422field=effective_from rule=past, translated; the date picker hasmintoday). 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— owner decided: they cannot. Refused in Go (422hr.leave.adjustholder can adjust their own balancefield=employee_id rule=self, translated), compared by person, not login; backstop triggerleave_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): 422field=bands rule=statutory_floorwithfloor_kind,floor_daysandfloor_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.InTxcarriescompany_profile.timezone, O-1); the claim was stale. Now pinned byTestTheGrantJobRollsTheYearInTheOrganizationsZone(20:00 UTC on 31 December is 1 January in Kuala Lumpur) ReroutedOnperformance ceiling: aGetand aLoadper pending instance and oneOnLeavequery per resolved member (about 15-30 round trips per pending instance, noLoadcache by definition); fine at SME size; the daily job shares one 15-minute timeout across organizations; itsanyonepre-check ignores half days- Other deferred minors, the unambiguous ones:
leave_typenow locksFOR NO KEY UPDATE;Resolved.Awaywas already sorted before it is persisted (resolve.go); the holidays tab has a zh name input;RequestDrawerstyles the approving edge by the target’s semantic (to_semantic), not the state nameapproved; the carry-forward expiry no longer truncates 1.5 to 1 (the screen refuses it) - Other deferred minors: the escalate walk over
levels_upandunit_headis tested (TestTheWalkPassesOverApproversOnLeaveUpTheTree); the report range cap is 366 inclusive days (was 367);ownerApproverErrorcounts only a grant on theapprovedstep (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/srcrestores it)
-
- Lifecycle effects on approval routing: a leaver’s seat ends and
Phase 6 — Scale and intelligence
Section titled “Phase 6 — Scale and intelligence”-
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;
tscandgo vetclean. 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; briefsuperpowers/briefs/2026-10-04-public-api-counsel-questions.md); tax agent and counsel (AI spec section 6; briefsuperpowers/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 indocs/review/, nothing signed off).
- Merged: inter-company derivation from control accounts (
-
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-beprisma/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 branchweb-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 rawmfrs10had 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: B1DELETErevoked oncompany_groupandgroup_entity(00923); B2 the group name is a translation row (en required, ms/zh optional; 00925),group_entity.legal_namestays a plain column; B3 a database trigger lets a holding change only by ending an open row once (00924); B4grantee_org_idkept 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 anar/apcontrol account whose party maps to a group counterparty is read asic_receivable/ic_payable(migration00944, 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 ofar/apparties with a non-zero balance that are not mapped as group counterparties (an ended mapping counts as unmapped, consistent with the derivation: migration00946, owner decision 2026-10-05), as the advisory checkunmapped_ar_ap_parties(migration00945, 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-equityoutput 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, migration00947, second-person approval) compared by account code with the books a run read; advisory checkaudited_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, migration00949): 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-flowoutput and export; inputcash_flow_class, migration00948): 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 (
notesoutput 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, migration00950): 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, migration00951; treatmentimport): 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) failsretained_earnings_rollforwardand shows the gap as other movements; the engine does not carry them forward by itself
- 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
- 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_companyon customers and suppliers,is_group_transactionon 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 filtergroup=only|noneon 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/layeringTestEveryTenantQueryCarriesAnExplicitOrgPredicatefails onGroupPartySQLuntil 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 inapi/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
- Owner ruling 2026-10-05 (option 1), BUILT on branch
- Two stages: a group register first (members, dated shareholdings, beneficial owners,
consent grants from each member org), then consolidation runs. Reference:
-
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.goguard) - Task 2:
WORKER_QUEUES, a reports-onlyworker-reportsin compose, periodic jobs tick once - Tasks 3-7: renderer seam, service, client and shadow mode, deployment, cutover. Not approved
- Design:
-
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 (provisionaldraft-1), Grafana rules created on the VPS, the worker egress policy. Built since: the operator console’s per-organization suspend action (migration00962, 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 briefsuperpowers/briefs/2026-10-04-public-api-counsel-questions.md(not sent to anyone); Grafana rulesdeploy/grafana/alerting/erp-webhooks.yaml(not provisioned, YAML syntax checked only, never loaded into Grafana; the threeerp_api_*metrics the spec lists are not exported, so no rule for them); worker egress policydeploy/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)
- Design:
-
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(migrations00990-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; reusesreports.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.Validateor 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(newGET /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; nowgrid-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 throughexpenses.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 asanalytic_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, triggerscheduled, one run per ledger state) andinsight_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 underINSIGHT_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_logis read only byaudit.VerifyChain,cmd/migrateverify-audit, and two internal joins; the report exports include no audit report), so there is nothing to add them to; theflags_shownrows 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 inanalyse.go); administrator-editable rule parameters (constants in code, versionedclaimflags/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)
- Design spec:
-
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 inplatform/attachments, instead of a table per consumer- Core:
01060table (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_idson each create input and view,POST …/:id/attachmentsper family, openapi first - Web:
AttachmentsFieldin each create dialog (create only; never sent on the update),AttachmentsSection(thumbnails, add-only upload viaPOST …/attachments) in each drawer and in a dialog reopened on a saved draft;documentsstrings en/ms/zh, ms/zh rows appended todocs/review/*-review.csv(need native review).default: []dropped from the sixattachment_idsinputs inopenapi.yamlso the generated type is optional. Verified by vitest (create-vs-update body watched failing once), tsc, lint; no browser pass
- Core:
-
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:
01020template table (RLS, append-only, prefix fixed for life), range declared -
Tasks 3-4:
01021columns, 80-character limit, depth cap 0..5, code immutable for every account, commit-time coherence trigger, insert conformance trigger;charttemplates, composer,MoveAccount(lock class 4), no code edits. Every guard watched failing once -
Task 5:
seed_keyand the 14 literal lookups moved to it; static guard (seedkey_guard_test.go) -
Task 6:
orgadmin create-org -company-codeand 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_codealias) -
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 toseed_keyandsegment) were not run -
Task 10: the group-chart pre-fill (“Apply suggestions”) is built in the consolidation stage 2 (
consolidation.Suggest/ApplySuggestions, matched onlocal_codeplus 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 permissionaccounting.code_template.company, Owner only), migration01134,chart.ChangePrefix; web section on the code template screen (en/ms/zh). The permission key and path keep the namecompany. Every template account’s code is rewritten withlocal_codeuntouched; legacy accounts keep theirs; old codes live in the audit row and the earlier template version (no alias:account_aliasneeds 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 templatekind,flat(migration01180, 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 fromparent_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 (triggeraccount_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 (1100becomes110000, still found by seed key1100); 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 indocs/review/*-review.csvneed 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 (regionis the columnworld); a part needs the ones after it (country needs company, region needs both; CHECKaccount_code_template_prefix_chain, mirrored inchart.ValidateTemplate). A flat code with a prefix is prefix + separator + number (MY-ACME-110001,local_codeis the number); a structured template with no prefix starts at its first segment. Which parts are on is fixed for life (triggeraccount_code_template_prefix_parts_fixed); their values change throughchart.ChangePrefix, which now refuses a template with no prefix or an attempt to switch a part on (ErrNoPrefix, wasErrFlatNoPrefix).account_code_prefix()is the one SQL definition of the prefix and separator, used by the conformance trigger and the code guard. OnboardingInput.CodePrefixandorgadmin 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 indocs/review/*-review.csvneed 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_levelson 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 andDEMOSUBadopts 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
- Migration
-
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 withoutSAMPLE_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.shseeds once, creates the subsidiary org itself, data on named Docker volumes; sample journals keyed bysequence_key(the code became editable andSALis nowSI). 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)
- Opt-in
-
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 inx-sort-keyson each operation), and named filters on each list endpoint;DataTableserver mode with URL state. Spec:2026-10-06-list-search-sort-filter-design.md; plan:2026-10-06-list-search-sort-filter.md. Branchlist-search-sort-filter, integrated with main’s table export and project link on branchintegrate-list-query(2026-10-12), not merged tomain. The interim browser-side search (committed 2026-10-06) is removed:DataTablesearchText/onSearchChangeanduseLoadAllWhileSearchingare 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 testinternal/app/listsort_test.go(every converted operation declaresq,sortandx-sort-keys; over HTTP it requests only the first sort key, and the other keys are covered by comparing the allowed keys named in theinvalid_sortrefusal withx-sort-keys; a q of 100 runes is accepted and 101 is 400invalid_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.bankNoneand the shared filter strings) best effort, need native review (rows added todocs/review/*.csvat 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.50open/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=falsenow means inactive only (was “all”) on suppliers, customers, items and locations;qon 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_idrepeatable; 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 withoutpayroll.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’stableIdargument is unused (one list per page); sortValuefunctions 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/decimalReinto one helper with one 422 shape (payables, receivables, assets, trade, pettycash and einvoice differ); date-range parameter names are not uniform (due_fromvs sort keydue_on,document_date_fromvs sort keydate) and are public now; batch theHoldingsOfreads in the members directory (one query per position underqorsort=positions); text-sort collation: tests run onpostgres:17-alpine(musl) while production may run glibc, so text order there is unverified; trade routes keepas nevercasts on a shared helper
- 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
-
Table export with user-chosen columns — shared
platform/tableexportregistry,ExportDialog, private/shareable presets, CSV and XLSX; first batch claims, journal entries, petty cash, parties; plusaxis.<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:
renderPlain mode,platform/tableexportregistry,platform/exportpreset(migration01190),modules/exportsroutes gated byexports.use, claims kind with columns, headings and format,ExportDialogwith presets, claims Export button.exports.useis granted to every existing role (01191) and to new organizations (EveryRolePermissions), assearch.usewas - Journal import:
axis.<key>dimension columns - Trial balance: signed
balancecolumn 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), kindjournal_entrieswith the import’s columns plusaxis.<key>per axis (headed by their keys so the file imports back; identical only for manual base-currency entries, the import ignores currency andreversedis a flag, the reversal link is not restored) and optionalbase_debit,base_credit,currency,source_kind,reversed; a split line’s axis cell isCODE:60;CODE2:40and 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 andExportDialogon Journal entries (the report export stays). Round trip into a fresh organization tested (incl. a split line and a reversal); drift testinternal/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.lobwins 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’sscopeandfloat_idwith the same visibility, one row per spend, newest first), kindpetty_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 withExportDialogon the petty cash records bar, shown withpettycash.readandexports.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), kindscustomersandsupplierssharingtableexport.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 toverify_bank). Export button withExportDialogon both lists, shown with the read key andexports.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 tripinternal/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 (
projectin 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/exportandGET <family>/exportfor 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). Kindssales_invoices,purchase_invoicesand 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,Multiplefor several, empty for none;tableexport.SetProjectoveranalytic.ProjectsOf) andproject_nameare optional columns, off by default as PL2 says. Export button withExportDialogon the three list pages, shown with the read key andexports.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.goTestEveryExportKindHasAListPageThatOpensItsDialogmaps 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, partiesgroup), 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’sq,sortand 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 serversearch=through the list view’s search box (DataTablelostsearchText); opening a record keeps it. No browser check - Go tests per kind: defaults with a known row, 403 after a 200, the cap (
tableexport.MaxRowsis 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.jsonexport.title,journal.jsonlist.exportLines/list.exportTitle,pettycash.jsonfilters.exportSpends*,receivables.jsoncustomers.export*andinvoices.export*,payables.jsonsuppliers.export*andinvoices.export*,trade.jsonexport/exportTitle(rows indocs/review/*.csv) - Reversing-entry column on journal import: optional
reversing_dateon an entry’s first row posts the entry’s reversal on that day throughledger.Reverse(the reversal route’s default description andaccounting.journal.postkey), 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 isreversing_datein any case (the import screen also auto-maps “Reversing date”); no other alias is built. Result countreversalswhen any,imports.jsoncounts.reversalsin en/ms/zh (ms/zh rows indocs/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)
- Foundation:
-
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 inapi/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, migration01172) 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 branchcrm-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_profilelink (PU7), email sync, campaigns
- Phase 1: schema
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); permissionorg.setup.complete, Owner only (01200) - Part 1:
onboarding.Inputprofile fields optional;orgadmin create-org, the console form and thecreate_orgjob 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-Matchversioned, 64 KiB cap),POST /v1/setup/completereads the saved draft and re-validates it (per-field errors) - Pending lock: default-deny
httpx.RequireSetup(403setup_required), allowlistsetupExemptininternal/app/setupgate.gowith a drift test, only “complete” cached in process,GET /v1/setup/status,setup_stateon/v1/me - Jobs skip pending orgs (
ops.org_ids()), the console flags themsetup 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 inonboarding/hub.goand 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_pendinggauge (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.Getcallers outside request paths (the job loops all readops.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 anorg_isolationpolicy. - 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.