Structured account codes Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
Goal: Every organization’s chart carries codes of the form [world]-[country]-[company]-[account]-[sub]... from a per-organization, versioned template (separator and segment lengths are choices; the depth cap of five sub-accounts is not). A code never changes after creation. Accounts can be re-parented without touching their code. Provisioning lookups by literal code move to a seed_key so a structured code does not break seeding.
Architecture: a new append-only table account_code_template (one row per version) in platform/accounting/chart; three new columns on account (local_code, seed_key, code_template_id); the depth CHECK narrows to 0..5; account_immutable_guard is rewritten (code fixed for everyone; parent, path, depth may change); a deferred constraint trigger keeps path and depth coherent so concurrent moves cannot both commit; a BEFORE INSERT trigger checks a template account’s code against the template’s regex. chart.MoveAccount is modelled on orgstruct.MoveUnit under a new advisory-lock class. Orgs with no template row are in legacy mode and behave as today (apart from immutability and the 80-character limit).
Tech Stack: Go, pgx v5, Postgres (goose), gin, oapi-codegen, React + TanStack Router/Query + i18next, vitest + msw.
Spec: docs/superpowers/specs/2026-10-03-structured-account-codes-design.md (owner decisions D1-D14 recorded in its section 18, all the recommended options).
None block the build. Two items from the spec stay open and are recorded, not guessed:
- D3 world code. The owner took the recommendation: a literal chosen in the template (default
WW), no data source. If the owner later says it stands for something else, only the template settings screen changes. - D4 platform-wide uniqueness of the company segment is not assumed (spec section 17). v1 does not enforce it.
Global Constraints
Section titled “Global Constraints”- Migration range:
01020-01039(this plan uses01020and01021;01022only if D8 had picked a new key, and it did not). Task 2 extendsinternal/kernel/db/migrationranges_test.goin the same change as the first migration. - Contract first:
api/openapi.yamlis edited before the handlers;make generateleaves the tree clean; generated files (internal/gen/api/api.gen.go,web/src/lib/api/schema.d.ts) are never hand-edited. - Every business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, policyorg_isolation, composite tenant foreign keys on(org_id, ...). - Every constraint is named; every function pins
SET search_path = pg_catalog, public, pg_tempand schema-qualifies reads. - A float never touches money. Nothing here carries money.
- Every user-facing name is a translation row. Codes are identifiers and are never translated. UI strings (segment labels, errors) are en/ms/zh locale entries.
- Permissions: D8 reuses
accounting.account.manage, so there is no new key, no new label, no grant migration and nowiring_test.gochange. (If one is added, the label drift test and the two wiring lists come with it.) - Advisory locks take the two-argument form with a
lockClass*constant; the registry inkernel/audit/audit.gois re-read at build time to take the next free class, and updated in the same change. Class acquisition order: the new class before the firstaudit.Write. - Drift tests: presence before absence; each watched failing once (break the protection, read the message, restore). A guard that walks the tree asserts it read a non-zero number of files and found a non-zero number of candidates first.
- Tests: iterate on touched packages; run
go test -p 2 ./...and the web suite once at the end; on a Postgres crash (“recovery mode”, “unexpected EOF”) rerun the failed packages one at a time with-p 1. - Commits: one per task, ending with the attribution line the session specifies. Merging and pushing are the owner’s.
Rulings (where the spec is silent or its parts disagree)
Section titled “Rulings (where the spec is silent or its parts disagree)”- SC-1 Children use the parent’s template version. A child’s code is
parent.code + separator + segmentunder the version its parent was built under; a root uses the current version. Reason: a new version may change the separator or lengths, and mixing two versions in one code would never match either regex. Consequence: a new version does not change the lengths under existing parents. Needs owner confirmation. - SC-2 A template account needs a conforming parent. In an organization with a current template, a new account under a legacy (non-conforming) parent is refused (
ErrLegacyParent, 422); a new root is fine. Reason: a code cannot beprefix + parent.code + segmentwhen the parent has no prefix, and the spec does not say. Owner confirmed 2026-10-04. - SC-3 Conformance checks the shape of the code against its parent, not “segment count = depth + 1”. The spec states both “code is the parent’s code plus one segment” (4.5) and “segment count = depth + 1” (4.5, drift test 4). After
MoveAccountthose disagree (a moved node keeps its code, so a child created later hascode segments != depth + 1). The insert trigger therefore checks: root,code = prefix || sep || local_codeand one segment; child,code = parent.code || sep || segmentandlocal_code = parent.local_code || sep || segment; and each segment against the level at its position. This is a spec error, recorded in the progress note. - SC-4 A code with six segments cannot take a child even when depth allows it after a move up, because the template has no seventh level (
ErrCodeFull). - SC-5 Template mode and legacy mode are exclusive per call. With a current template,
AccountInput.Segmentis required andCodemust be empty; without one,Codeis required andSegmentmust be empty.SeedKeydefaults toCodein legacy mode, so existing orgs and test fixtures seed and repair exactly as before. - SC-6 “Current” template is the highest version whose
effective_from <= clock.Now()date. Effective dates in the future are allowed. - SC-7 The 80-character limit applies to
code;local_codeandseed_keyare not CHECKed to 80 (they are shorter by construction);account_alias_shapestays at 32. - SC-8 Move after posting (D6). Allowed, audited, with a reason, for any account that is not control, system-role or module-owned; refused when the new parent has postings. A move changes no journal line and no code; whether it restates closed-period roll-ups depends on how
reportingaggregates, recorded as open (spec 15 item 3). - SC-9 Legacy accounts keep
code_template_idNULL,local_code = code,seed_key = code, and are exempt from the conformance trigger.
Task 1 - Audit and the concurrency test
Section titled “Task 1 - Audit and the concurrency test”Files: this plan (findings note, below), internal/platform/accounting/chart/move_concurrency_test.go (written first against today’s schema)
- Resolve spec section 15 items that can be resolved by reading: the Malaysian pack’s code lengths and depths, every place a code is copied into a snapshot, the import preview, the onboarding/provisioning order, how web validates a code, how
reportingrolls up, the petty-cash float path. - Verify: the findings note below; the concurrency test fails against today’s unguarded schema (there is no
MoveAccountyet, so it first fails to compile; it is completed in Task 4 and is red against a schema without the guards).
Findings note (Task 1, read 2026-10-03 on this branch):
- Pack (spec 15 item 9):
countrypack/my/my.gohas 38ParentCodeentries; every account literal read is exactly 4 digits and the deepest chain is depth 2 (1000>1500>1510). It fits the default template (4-digit segments) with room. - Snapshots (item 4): no migration copies an account code into a snapshot column (
account_codeappears only in budget CSV keys and the import preview); exports and the GL printa.codeat read time. A 44-character default code is not a stored-width problem; PDF and CSV column widths were not inspected (open). - Reporting (item 3): the GL and movements reports scope by
a.path LIKE prefix || '%'. A move after posting does change which accounts roll up under a header, retroactively for closed periods. SC-8 stands as allowed and audited; the owner may want the alternative of D6 (refuse while a closed period is touched). - Petty cash float (item 5):
pettycash/float.gobuilds the float account code as1530.PCF-2026-0001and creates it at runtime. That text contains the separator and letters, so it cannot be a template segment. Template mode needs a generated numeric segment:chart.NextSegment(parent)(next free number at the level’s minimum length), used by the float. Spec error: the spec thought the float passed a flat code throughAccountInput; it also builds the code from a numbering scope. - Onboarding (item 7):
onboarding.CreateOrganizationis driven bycmd/orgadminflags; there is no screen. The company short code is a newInput.CompanyCodeand a--company-codeflag. When given, template version 1 is written right after the company profile, before provisioning. When empty (tests, existing callers) the organization is in legacy mode. The spec’s “the chart step refuses without a template” would break every fixture the spec itself wants to keep in legacy mode (4.7); this plan makes the CLI flag required and keeps the Go API optional. Needs owner confirmation. - Imports (item 5):
imports/chart.gocarries its ownaccountCoderegex (32). Moves to 80 with the CHECK; the template-aware preview is Task 7. - Spec error (SC-3): “code is parent’s code plus one segment” and “segment count = depth + 1” contradict after a move; see SC-3.
- Not resolved by reading (left open): whether any deployed database has
depth > 5(the migration pre-flight is the answer), PDF/CSV column widths for 44-character codes, the full import preview path beyondchart.go, webzod/CSV validators beyond the editor and budget CSV.
Task 2 - Migration 01020: the template table
Section titled “Task 2 - Migration 01020: the template table”Files: db/migrations/01020_account_code_template.sql, internal/kernel/db/migrationranges_test.go, internal/platform/accounting/chart/migration01020_test.go
account_code_template:id,org_id,version,effective_from,separator(-,.,_),world,country,company(the prefix literals),levels(JSONB, at most 6 entries of{charset, min_len, max_len}),code_regex,reason,created_by,created_at;UNIQUE (org_id, version),UNIQUE (org_id, id); RLS, FORCE,org_isolation;REVOKE UPDATE, DELETE FROM erp_app.- A trigger refuses a new version whose prefix differs from version 1 and a version number that is not previous + 1.
- Declare
{1020, 1039, "Phase 6 structured account codes (2026-10-03-structured-account-codes-design.md)"}in the range test. - Tests (watched failing once): tenant isolation (org B sees none of org A’s versions; presence: org A sees its own);
UPDATEandDELETErefused after anINSERTsucceeded; prefix change refused, with an unchanged-prefix new version accepted; the every-table-is-isolated test sees the new table; Down migration restores the prior state. - Verify:
go test -p 2 ./internal/kernel/db/... ./internal/platform/accounting/chart/....
Task 3 - Migration 01021: account columns, depth cap, guards
Section titled “Task 3 - Migration 01021: account columns, depth cap, guards”Files: db/migrations/01021_account_structured_codes.sql, internal/platform/accounting/chart/migration01021_test.go, internal/platform/accounting/chart/immutable_test.go
- Columns
local_code,seed_key,code_template_id(composite FK to the template); backfilllocal_code = code,seed_key = code;UNIQUE (org_id, seed_key) WHERE seed_key IS NOT NULL. account_code_formatwidened to 80 characters.- Pre-flight that raises, naming the offending org ids, if any row has
depth > 5; thenaccount_depth_rangebecomes0..5. account_immutable_guardrewritten:type,owner_module,code,local_code,seed_key,code_template_idfixed;parent_id,path,depthmay change.- A deferred constraint trigger: for each changed row,
path = parent.path || id || '/'(or'/' || id || '/'),depth = parent.depth + 1(0 for a root),depth <= 5, the id appears once in its path. - A BEFORE INSERT conformance trigger (SC-3).
- Tests (each watched failing once): code immutability for a template account and a legacy account on
erp_app(presence: a name edit commits); depth 5 inserts and depth 6 is refused (watch it fail by raising the CHECK to 9); the conformance cases of the spec’s test 4 inserted directly; coherence trigger refuses an incoherent path; 80 vs 81 characters; the Down migration. immutable_test.go’s “account parent” allowed edit moves fromSET code = 'X4000'to a name edit.- Verify:
go test -p 2 ./internal/platform/accounting/chart/....
Task 4 - chart service
Section titled “Task 4 - chart service”Files: internal/platform/accounting/chart/{template,code,move}.go, account.go, errors.go, internal/kernel/audit/audit.go (registry comment), tests
SetTemplate,CurrentTemplate,ListTemplates; a code composer and a regex builder (Go builds, the storedcode_regexis what the trigger uses; a test pins the two together).CreateAccounttakesSegmentandSeedKey(SC-1, SC-2, SC-5);MaxDepth = 6; typed errorsErrTooDeep(message: six levels, five sub-accounts),ErrCycle,ErrLegacyParent,ErrCodeFull,ErrBadSegment.AccountPatch.Codeis deleted; a reflection guard asserts the field set is non-empty and has noCode.MoveAccount(SC-8): new advisory-lock class, same checks asorgstruct.MoveUnit, plus type, control/system-role/owned refusals, reason, one audit row.GetAccountBySeedKey.- Tests (each watched failing once): cycle (presence: a leaf under a sibling moves), depth (presence: a subtree landing at depth 5), concurrent re-parenting (each guard independently live), composer and regex builder agree.
- Verify:
go test -p 2 ./internal/platform/accounting/chart/....
Task 5 - Seed-key conversion
Section titled “Task 5 - Seed-key conversion”Files: countrypack/seed.go, pettycash/float.go, payables/{step,withholding_step}.go, inventory/{step,reason}.go, imports/migration.go, expenses/step.go, payroll/step.go, tests, a static guard
SeedChartsetsSeedKeyfrom the pack literal and the segment from the same literal in template mode; every non-test literal lookup moves toGetAccountBySeedKey.- Static guard: walks
internalforGetAccountByCode(outside_test.goand*testsupport packages; asserts non-zero files and non-zero candidates (the definition and its own tests), allowlists nothing else; a planted literal is watched failing. - Verify:
go test -p 2for each touched package.
Task 6 - Provisioning
Section titled “Task 6 - Provisioning”Files: internal/platform/provisioning, the chart provisioning step, internal/platform/onboarding (as the audit finds)
- A new org gets template version 1 before the chart step; the chart step refuses without it; the company short code is asked at onboarding.
- Tests: the row exists (presence) and the chart step refuses without it.
Task 7 - Imports
Section titled “Task 7 - Imports”Files: internal/platform/imports/chart.go, tests
- Template-aware validation, preview violations by line, column and rule, optional
legacy_codecolumn becomes anaccount_aliasrow; legacy mode unchanged but with the 80-character regex. A guard test asserts the importer’s regex and the CHECK agree.
Task 8 - API
Section titled “Task 8 - API”Files: api/openapi.yaml, then make generate, internal/modules/accounting/*, internal/app/routes_test.go expectations
GET/POST /accounting/code-template,POST /accounting/code-template/check,POST /accounting/accounts/{id}/move;createAccounttakessegmentorcode;updateAccountlosescode(422 on send);Accountgainslocal_code,code_template_id,seed_key.- Handlers behind
httpx.Protectedwithaccounting.account.manage(readsaccounting.ledger.read); the route-authorization drift test stays green. - Verify: handler tests for 403, 422, 409.
Task 9 - Web
Section titled “Task 9 - Web”Files: web/src/features/accounting/*, web/src/lib/i18n/locales/{en,ms,zh}/*, nav
- Template settings screen; account form with read-only prefix and parent code, segment input, validator from the check endpoint (no second copy of the rules); edit no longer sends or shows an editable code; Move action with a parent picker that refuses at the cap; en/ms/zh; phone width.
- Verify: vitest, typecheck, lint.
Task 10 - Consolidation hook (not built)
Section titled “Task 10 - Consolidation hook (not built)”The consolidation spec’s local_code amendment is already made. The “Apply suggestions” service and screen belong to the consolidation build. local_code on account (Task 3) is built here.
Task 11 - Permission (not needed)
Section titled “Task 11 - Permission (not needed)”D8 reuses accounting.account.manage. No 01022.
Task 12 - Drift tests collected
Section titled “Task 12 - Drift tests collected”Each of spec section 12’s eleven items is either a test above or recorded as not applicable (item 11). Each watched failing once. Full go test -p 2 ./... once, and the web suite.
Task 13 - Roadmap and docs
Section titled “Task 13 - Roadmap and docs”Tick only verified items; docs/LOCK_ORDER.md row for the new lock; the audit.go registry; record what stays open (spec section 15).
Not in this plan
Section titled “Not in this plan”A recode tool; editing a code for any reason; the group-chart pre-fill (belongs to the consolidation build); a settable cap; an empty or multi-character separator; parsing a code at read time; changing the GL cursor or any report’s sort; platform-wide uniqueness of the company segment.
Execution record
Section titled “Execution record”Built on branch worktree-agent-a420c02be7237c57c, one commit per task except where noted. Rulings SC-1 to SC-9 above were taken while building; the owner confirmed SC-1, SC-2 and keeping the Go-level onboarding API optional on 2026-10-04 (the Task 6 flag decision stands as built).
- Tasks 3 and 4 share a commit. The migration’s columns and guards and the service that writes them could not be committed apart without a red tree.
- Task 1’s concurrency test was written with Task 4 (it needs
MoveAccount); it was watched failing with the lock removed (the second move then endsErrTreeChanged, notErrTooDeep) and, separately, with the commit trigger removed (TestTheCommitTriggerStopsAWriterThatForgotTheLock). - Task 6 as built:
onboarding.Input.CompanyCode,orgadmin create-org -company-code(required), the operator console’s create-organization form and action (requiredcompany_code). Go callers that pass no company code stay in legacy mode, which every pre-existing fixture relies on. The spec’s “the chart step refuses without a template” is not built (see the findings note). - Task 7 as built:
legacy_codeis an optional column; anaccount_aliasrow is written for it.maps_torows keep the old code incode(32-character alias shape). - Task 10 is a note only:
local_codeis onaccountand in the API; the group-chart pre-fill belongs to the consolidation build. - Task 11 was not needed (D8 reuses
accounting.account.manage). - Raw fixtures: four tests that inserted
accountrows with incoherent paths (/1521/at depth 1 with no parent) now write coherent ones; the commit trigger refuses the old shape by design.
Where the spec was wrong or silent (found while building)
Section titled “Where the spec was wrong or silent (found while building)”- Segment count against depth (spec 4.5 and drift test 4). “Code is the parent’s code plus one segment” and “segment count = depth + 1” cannot both hold once an account has moved. The trigger checks the first (SC-3).
- The petty-cash float (spec 3). It does not pass a flat code through
AccountInput; it builds1530.PCF-2026-0001from a numbering scope, which holds letters and the separator. Template mode takes the next numeric segment (AutoSegment,Template.NextSegment). - “The chart step refuses without a template” (drift test 8) contradicts 4.7 (legacy fixtures keep working). Built as: the CLI and the operator console require a company code; the Go API does not.
dry_run“returning the violations for pending imports” (spec 5). Imports are validated and committed inside one request, so nothing is pending;dry_runvalidates the template and returns warnings and an example code.- A deferred trigger’s
NEWis the tuple of the statement that queued it. The coherence trigger described in 4.3 would refuse a subtree move whose root row is updated twice; it reads the row as it stands at commit instead. parent_id,pathanddepthbecome mutable at the statement level (4.3). An UPDATE ofparent_idalone is no longer refused when issued; it fails at commit withaccount_tree_coherent.- Silent: a child under a legacy parent (SC-2), a child under a parent of an older template version (SC-1), what
seed_keydoes when a legacy organization later sets a template and the pack seeds again (a second2105collides onaccount_seed_key_once;ErrDuplicateSeedKey), and themaps_torows of an import (theircodeis the old system’s code, so it keeps the 32-character alias shape). - Fixtures: the number of non-test literal lookups was 14 (the spec said about 15). Four raw-SQL test fixtures had incoherent
path/depthand failed the new commit trigger; two payables tests renamed a code to simulate an old organization and now delete the account instead.
Verification
Section titled “Verification”- Every guard of spec section 12 was watched failing once: depth CHECK raised to 9; cycle check removed; advisory lock removed (the second move ends
ErrTreeChangedinstead ofErrTooDeep); commit trigger removed; conformance trigger removed; immutabilitycodeterm removed; a plantedGetAccountByCodeliteral; template onboarding call removed; regex builder altered; aCodefield added toAccountPatch; the policy renamed onaccount_code_template(TestEveryTableIsTenantIsolated); the importer’s limit narrowed to 32; the range declaration removed. Item 11 (labels) does not apply: no new permission key. - Web: vitest 822 passing, typecheck, eslint and prettier clean. Not verified: the screens in a browser at phone width, and
web/e2e. - Go: the full suite was run with
go test -p 2 ./...and the Docker daemon collapsed under it (130+ idle Postgres containers from other sessions on a 4 GB VM: “recovery mode”, “unexpected EOF”, “not yet accepting connections”). The failed packages were rerun one at a time with-p 1(up to three attempts each) and every one of the 138 packages passed in the end (einvoice, liveness, photo and s3store only on a second pass, after the daemon had recovered). The first-p 2run did not finish inside two hours, so no single run was green.