Skip to content

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.
  • Migration range: 01020-01039 (this plan uses 01020 and 01021; 01022 only if D8 had picked a new key, and it did not). Task 2 extends internal/kernel/db/migrationranges_test.go in the same change as the first migration.
  • Contract first: api/openapi.yaml is edited before the handlers; make generate leaves 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, ENABLE and FORCE ROW LEVEL SECURITY, policy org_isolation, composite tenant foreign keys on (org_id, ...).
  • Every constraint is named; every function pins SET search_path = pg_catalog, public, pg_temp and 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 no wiring_test.go change. (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 in kernel/audit/audit.go is 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 first audit.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 + segment under 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 be prefix + parent.code + segment when 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 MoveAccount those disagree (a moved node keeps its code, so a child created later has code segments != depth + 1). The insert trigger therefore checks: root, code = prefix || sep || local_code and one segment; child, code = parent.code || sep || segment and local_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.Segment is required and Code must be empty; without one, Code is required and Segment must be empty. SeedKey defaults to Code in 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_code and seed_key are not CHECKed to 80 (they are shorter by construction); account_alias_shape stays 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 reporting aggregates, recorded as open (spec 15 item 3).
  • SC-9 Legacy accounts keep code_template_id NULL, local_code = code, seed_key = code, and are exempt from the conformance trigger.

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 reporting rolls up, the petty-cash float path.
  • Verify: the findings note below; the concurrency test fails against today’s unguarded schema (there is no MoveAccount yet, 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.go has 38 ParentCode entries; 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_code appears only in budget CSV keys and the import preview); exports and the GL print a.code at 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.go builds the float account code as 1530.PCF-2026-0001 and 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 through AccountInput; it also builds the code from a numbering scope.
  • Onboarding (item 7): onboarding.CreateOrganization is driven by cmd/orgadmin flags; there is no screen. The company short code is a new Input.CompanyCode and a --company-code flag. 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.go carries its own accountCode regex (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 beyond chart.go, web zod/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); UPDATE and DELETE refused after an INSERT succeeded; 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); backfill local_code = code, seed_key = code; UNIQUE (org_id, seed_key) WHERE seed_key IS NOT NULL.
  • account_code_format widened to 80 characters.
  • Pre-flight that raises, naming the offending org ids, if any row has depth > 5; then account_depth_range becomes 0..5.
  • account_immutable_guard rewritten: type, owner_module, code, local_code, seed_key, code_template_id fixed; parent_id, path, depth may 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 from SET code = 'X4000' to a name edit.
  • Verify: go test -p 2 ./internal/platform/accounting/chart/....

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 stored code_regex is what the trigger uses; a test pins the two together).
  • CreateAccount takes Segment and SeedKey (SC-1, SC-2, SC-5); MaxDepth = 6; typed errors ErrTooDeep (message: six levels, five sub-accounts), ErrCycle, ErrLegacyParent, ErrCodeFull, ErrBadSegment.
  • AccountPatch.Code is deleted; a reflection guard asserts the field set is non-empty and has no Code.
  • MoveAccount (SC-8): new advisory-lock class, same checks as orgstruct.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/....

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

  • SeedChart sets SeedKey from the pack literal and the segment from the same literal in template mode; every non-test literal lookup moves to GetAccountBySeedKey.
  • Static guard: walks internal for GetAccountByCode( outside _test.go and *test support 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 2 for each touched package.

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.

Files: internal/platform/imports/chart.go, tests

  • Template-aware validation, preview violations by line, column and rule, optional legacy_code column becomes an account_alias row; legacy mode unchanged but with the 80-character regex. A guard test asserts the importer’s regex and the CHECK agree.

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; createAccount takes segment or code; updateAccount loses code (422 on send); Account gains local_code, code_template_id, seed_key.
  • Handlers behind httpx.Protected with accounting.account.manage (reads accounting.ledger.read); the route-authorization drift test stays green.
  • Verify: handler tests for 403, 422, 409.

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.

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.

D8 reuses accounting.account.manage. No 01022.

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.

Tick only verified items; docs/LOCK_ORDER.md row for the new lock; the audit.go registry; record what stays open (spec section 15).

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.

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 ends ErrTreeChanged, not ErrTooDeep) 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 (required company_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_code is an optional column; an account_alias row is written for it. maps_to rows keep the old code in code (32-character alias shape).
  • Task 10 is a note only: local_code is on account and 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 account rows 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)”
  1. 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).
  2. The petty-cash float (spec 3). It does not pass a flat code through AccountInput; it builds 1530.PCF-2026-0001 from a numbering scope, which holds letters and the separator. Template mode takes the next numeric segment (AutoSegment, Template.NextSegment).
  3. “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.
  4. dry_run “returning the violations for pending imports” (spec 5). Imports are validated and committed inside one request, so nothing is pending; dry_run validates the template and returns warnings and an example code.
  5. A deferred trigger’s NEW is 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.
  6. parent_id, path and depth become mutable at the statement level (4.3). An UPDATE of parent_id alone is no longer refused when issued; it fails at commit with account_tree_coherent.
  7. Silent: a child under a legacy parent (SC-2), a child under a parent of an older template version (SC-1), what seed_key does when a legacy organization later sets a template and the pack seeds again (a second 2105 collides on account_seed_key_once; ErrDuplicateSeedKey), and the maps_to rows of an import (their code is the old system’s code, so it keeps the 32-character alias shape).
  8. Fixtures: the number of non-test literal lookups was 14 (the spec said about 15). Four raw-SQL test fixtures had incoherent path/depth and failed the new commit trigger; two payables tests renamed a code to simulate an old organization and now delete the account instead.
  • 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 ErrTreeChanged instead of ErrTooDeep); commit trigger removed; conformance trigger removed; immutability code term removed; a planted GetAccountByCode literal; template onboarding call removed; regex builder altered; a Code field added to AccountPatch; the policy renamed on account_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 2 run did not finish inside two hours, so no single run was green.