Structured account codes — design (Phase 6, chart-of-accounts identity)
Status: DRAFT for owner sign-off. Nothing is built. No code, no migrations, no tests are in this change. Migration range proposed: 01020-01039 (Section 14). Roadmap: a Phase 6 line pointing here. It rests on the architecture spec (one org = one legal entity, §17.1 / §18.2 item 5) and feeds consolidation runs, decision D14 (the owner maps member accounts by code).
Reading note. Every statement about the current code was read in this worktree on 2026-10-03 (file and line named where it matters). Anything not read, or not confirmed against a running database, is marked (U) inline and collected in Section 15. Where the owner’s intent is ambiguous I say so rather than choose silently (the “world code”, Section 4.1).
1. The request, and what is already decided
Section titled “1. The request, and what is already decided”Account codes should follow [world]-[country]-[company]-[account]-[sub]-[sub]..., or be
customizable per organization, with a maximum of 5 nested sub-accounts.
Decided by the owner (not reopened here):
- It applies to every organization’s chart, not only the group chart.
- Customization is a per-org template: choose the segments, the separator and the lengths. The default is world-country-company-account-sub… . The depth cap of 5 sub-accounts is a hard constraint, not a setting.
- The entity prefix
[world]-[country]-[company]identifies the entity itself, never its owner or parent. A group restructuring or a sale therefore never changes any code. - A code never changes after creation. Moving an account changes
parent_idonly; the UI draws the tree fromparent_id, never by parsing the code. - The consolidation group chart uses the same template, the prefix identifying the entity. A default pre-fills the group map from the parent’s own chart where codes match; exceptions show as unmapped.
2. What exists today (read, not assumed)
Section titled “2. What exists today (read, not assumed)”| Fact | Where |
|---|---|
account.code TEXT, CHECK account_code_format ^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$ (32 chars), UNIQUE (org_id, code), composite FK (org_id, parent_id) |
00050_accounting_reference.sql |
The tree is already a materialized path (path of UUIDs) plus a stored depth, CHECK depth BETWEEN 0 AND 9 (ten levels, chart.MaxDepth = 10) |
00050, chart/account.go |
account_immutable_guard (trigger) refuses any change to type, parent_id, path, depth, owner_module |
00054_account_owner_module.sql; pinned by chart/immutable_test.go (“account parent”) |
chart.UpdateAccount can patch Code (AccountPatch.Code), and the web editor sends it (AccountEditor.tsx line 84). A code is therefore mutable today, including on a posted account: account_ledger_guard fixes only control kind, currency and postability |
account.go, 00433 |
immutable_test.go uses a code edit (SET code = 'X4000') as its example of an allowed edit |
chart/immutable_test.go |
CreateAccount reads the parent, derives path and depth, flips the parent to non-postable, audits both |
account.go |
There is no re-parent operation for accounts. The pattern exists for org units: orgstruct.MoveUnit takes advisory-lock class 2, refuses a cycle by “does my id appear in the proposed parent’s path”, checks the deepest descendant against the cap, rewrites the subtree in one statement, writes one audit row |
orgstruct/unit.go, orgstruct/tree.go |
Advisory lock classes are a registry in kernel/audit/audit.go (1 audit chain, 2 org tree, 3 operator audit); a drift test refuses the one-argument form |
audit.go, lockconvention_test.go |
company_profile holds country_code (^[A-Z]{2}$, one-way ISO 3166-1 alpha-2 shape, not checked against the list (U)), registration_no (free text, nullable) |
00011_company_profile.sql |
| There is no “world” datum anywhere in the schema | grep of migrations |
Permission accounting.account.manage exists and is already labelled en/ms/zh (team.json); the label drift test is internal/app/permissionlabels_test.go |
constants.yaml, team.json |
3. Every place a code is read or written, and what each needs
Section titled “3. Every place a code is read or written, and what each needs”Writes (all go through chart.CreateAccount / UpdateAccount):
| Site | Today | Needs |
|---|---|---|
countrypack.SeedChart (seed.go) |
writes the pack’s literal code ("1530") with ParentCode |
build the structured code from the template; set seed_key (4.5) |
imports/chart.go (saveChart) |
own copy of the code regex (accountCode, 32 chars, comment says it mirrors the CHECK); maps_to alias path via account_alias |
template-aware validation; preview lists violations (Section 9) |
chart.CreateAccount / AccountInput.Code |
caller supplies the whole code | template mode: caller supplies a segment, the service composes the code |
chart.UpdateAccount / AccountPatch.Code, PATCH /accounting/accounts/{id} (updateAccount, “Rename, recode, re-map or retire”) |
recode allowed | remove recode; DB refuses (Section 7) |
AccountEditor.tsx |
free text code, sent on change | prefix read-only, segment input, validator; no recode on edit |
pettycash float creation (float.go, FloatHeaderCode) creates a float’s own account at runtime |
a flat code | composed through the same service call; (U) whether it passes a literal code or goes through AccountInput only: read, not traced end to end |
Reads by literal code (the dangerous set; each looks an account up by a hard-coded string):
| Site | Literal |
|---|---|
pettycash/float.go:40,231 |
FloatHeaderCode = "1530" |
payables/step.go:26,58 |
tradePayablesCode = "2105" |
inventory/reason.go:149,173,182 |
adjustmentAccountCode = "5400"; inventory/step.go:70,110 (account and parent codes) |
imports/migration.go:291-321 |
clearingCode = "3900", clearingParent = "3000" |
expenses/step.go:117, expenses/seed_my.go |
seed.accountCode per expense category |
countrypack/seed.go:135,197 |
DefaultAccountCode (journals), AccountCode (tax codes) |
tests and *test/env.go helpers (GetAccountByCode(..., "1530"), "6110", …) |
many |
These are provisioning-time and migration-time lookups, not posting-time lookups. The posting path
resolves by control_kind (chart.ResolveControl) or system_role (bySystemRole), both by column, never
by code. So the claim “posting rules are unaffected” is true for posting, false for seeding: with a
structured code, GetAccountByCode("2105") no longer finds the account in a new org. Section 4.5 is the fix.
Reads that sort or print by code (ORDER BY a.code, no change of meaning, one hazard):
reporting/generalledger.go:93,115 (also a keyset cursor on (a.code, a.id, ...)),
closing/balances.go:47,92, budget/budget.go:262, banking/account.go:118, banking/closegate.go:83,
inventory/integrity.go:57, reporting/export/project.go:916 (prints code name), budget/csv.go
(CSV key account_code), the web search (tree.ts:24) and list screens. Hazard: with variable-length
segments …-10 sorts before …-2. The fix is a default template of fixed-length segments (4.2), not a
change of sort (changing the GL cursor key would invalidate in-flight cursors).
Reads by id (unchanged): journal lines, tax codes (account_id), journals (default_account_id),
bank_account, account_alias (account_id), the posting engine. None stores a code copy (U): I read
00052’s journal_line columns only far enough to see account_id, not every downstream snapshot table.
4. Design
Section titled “4. Design”4.1 Where the template lives, and what a segment is
Section titled “4.1 Where the template lives, and what a segment is”Recommended: an org-level, versioned, append-only table account_code_template (business table: org_id,
ENABLE + FORCE ROW LEVEL SECURITY, policy org_isolation, REVOKE UPDATE, DELETE from erp_app, the
account_alias shape). One row per version:
| Column | Notes |
|---|---|
id, org_id, version (per-org, 1..n), effective_from (DATE), created_by, created_at, reason |
A new version is a new row; “current” is the highest version whose effective_from <= today. |
separator |
one of -, ., _ (a closed vocabulary of strings; a separator may not appear in any segment’s charset). Empty separator is refused in v1 (it needs fixed lengths everywhere and makes the display ambiguous). |
prefix |
the three entity segments, stored as resolved literals: world, country, company, each with charset and length. Frozen text, not a live join (below). |
levels |
the account-path segments, levels[0] = account, levels[1..5] = sub-account; each {charset, min_len, max_len}. At most 6 entries (the hard cap, 4.3). |
code_regex |
the anchored regex built by Go from the above; a trigger checks a new account’s code against it. The drift test pins builder and regex together. |
Prefix segments are fixed per org for the life of the entity (they identify it): a template version may change the account-path segments, separator and lengths, never the prefix values (the trigger refuses a new version whose prefix differs from version 1). Prefix and account-path segments are different in kind, so they are modelled as two lists, not one.
- country: stored as ISO 3166-1 alpha-2, validated when the template is set against
company_profile.country_code(which exists) and a list of codes (U: no ISO list is in the repo; the plan adds one with a test, as the other reference lists do). It is copied, not joined: editing the company profile’s country later must not change an account code (the owner’s immutability rule). Alpha-3 is not offered. - company: a short code the owner chooses,
[A-Z0-9]{1,8}, unique across the platform (U: see D4). Notregistration_no: that field is nullable, free text, may contain-, and a re-registration would change it. - world: I could not determine what the owner means by it. There is no datum for it. Candidates:
(a) a constant for the whole platform deployment, (b) a region/continent code, (c) a “top of the
hierarchy” marker so group charts of different groups never collide. Recommendation: a literal chosen in
the template (
[A-Z0-9]{1,4}, defaultWW), with no data source, until the owner says what it stands for (D3).
The template is locked once an account is created under it in the sense that matters: an existing
version is never edited (append-only), and a new version never rewrites an old code. It is not
locked by “has the org posted entries”: since codes never change, a template change cannot disturb
posted history, so a posting-based lock would only add a way to be stuck. A new version needs a reason
and is audited (account_code_template, action set).
4.2 Default template
Section titled “4.2 Default template”WW - country - company - account - sub - sub … with every path segment fixed-length digits,
length 4, separator -. Fixed length keeps ORDER BY code equal to tree order (Section 3 hazard) and
the Malaysian pack’s literals (1000, 1100, 1530; all 4-digit in the 65 entries I counted, (U)
not every entry read) fit exactly. Worst case length: 2+2+8 prefix, 6 path segments of 4, 8 separators = 44
characters. The template validator warns (does not refuse) on variable-length segments, naming the
sort consequence.
Example: Acme Sdn Bhd, company code ACME: account WW-MY-ACME-1000, its child WW-MY-ACME-1000-1100,
leaf WW-MY-ACME-1000-1100-1110.
4.3 Depth cap of 5 sub-accounts, in the database and the service
Section titled “4.3 Depth cap of 5 sub-accounts, in the database and the service”A chart node is the account (depth 0) plus at most 5 nested sub-accounts: depth 0..5. (Assumption:
the account level is not counted among the “5 sub-accounts”; D2.) Today the CHECK is 0..9 and
MaxDepth = 10.
- Stored column, not a trigger count.
depthalready exists and is already derived from the parent in Go. Replaceaccount_depth_rangewithCHECK (depth BETWEEN 0 AND 5);chart.MaxDepthbecomes 6 (same zero-indexed convention and the same off-by-one warning inaccount.go). - Existing data. The migration first runs a pre-flight that fails with the offending org ids if any row
has
depth > 5(I expect none: the Malaysian pack goes to depth 3 (U); custom charts unread). It does not weaken the cap to fit them. - Re-parenting (new
chart.MoveAccount, modelled onorgstruct.MoveUnit): take a new advisory-lock class (the registry’s next free number; (U) other Phase 6 specs may also claim one, so the number is taken at build time, after the registry is re-read) keyed onhashtext(org_id), acquired before the firstaudit.Writeper the acquisition-order rule; read both nodes; refuse a cycle by path containment; refuse whendeepest_descendant_depth + delta > 5; refuse a differenttype, a control/system-role parent (theCreateAccountrules); rewrite parent, path and depth of the whole subtree in one statement; one audit row (move, before/after path and depth, rows affected). - Database backstop for concurrency.
account_immutable_guardis rewritten:typeandowner_modulestay fixed;parent_id,path,depthmay change. A deferred constraint trigger checks, at commit, every changed row:path = parent.path || id || '/'(or'/' || id || '/'for a root),depth = parent.depth + 1(0 for a root),depth <= 5, and the row’s id appears once in its path (cycle). Two concurrent moves that each pass against their own snapshot cannot both commit: the second commit re-reads the first’s committed rows and fails coherence. The advisory lock gives the clean, serialised result; the trigger is what makes it safe if some future writer forgets the lock. (U) that this holds underREAD COMMITTEDis my reasoning, not a run test; the concurrency drift test (Section 12, item 3) is what settles it, and Task 1 writes it first. - Service refusals return typed errors (
ErrTooDeep,ErrCycle) so the screen names the rule; the CHECK and trigger are the backstop and are never the normal path (theMaxDepthcomment inaccount.gorecords why).
4.4 Code immutability
Section titled “4.4 Code immutability”The guard gains NEW.code IS DISTINCT FROM OLD.code → refuse (account_immutable), for every
account, legacy included (D5). AccountPatch.Code is deleted; PATCH /accounting/accounts/{id}
rejects a code field with 422. This is a behavioural change (AGENTS.md §3.6): today a code can be
corrected after the fact, even on a posted account. A mistyped legacy code is fixed by retiring the account
and creating another (or an alias), never by editing. immutable_test.go’s “allowed edit” example
(SET code = 'X4000') must change to a still-allowed field (name), as it already does for the type case.
4.5 How the tree and the code relate, and seeding
Section titled “4.5 How the tree and the code relate, and seeding”Code at creation is prefix || sep || ancestors' segments || sep || own segment; the parent’s code plus one
segment. After a move the code no longer starts with the new parent’s code, and that is fine: nothing
reads structure out of the code. The conformance check (code vs code_regex, segment count = depth + 1)
runs at insert only.
Two columns make the rest work, both written once at creation and immutable:
local_code TEXT: the path part without the entity prefix (1000-1100-1110). It exists so that consolidation can match accounts across entities whose prefixes differ (Section 11) without parsing a code at read time. The insert trigger verifiescode = prefix_literal || sep || local_code. For legacy accountslocal_code = code.seed_key TEXT NULL: the country pack’s literal (2105), set bySeedChartandUNIQUE (org_id, seed_key) WHERE NOT NULL. Every literal lookup in Section 3 changes fromGetAccountByCode(literal)toGetAccountBySeedKey(literal). Migration backfillsseed_key = codefor existing accounts, which keeps every existing org’s seeding and repair runs working unchanged. Without this the template would silently break provisioning for every new org, the single biggest risk in this spec.
account.code_template_id (nullable, FK to the template version, immutable) records which version a code
was built under; NULL means a legacy account.
4.6 The 32-character limit
Section titled “4.6 The 32-character limit”Widen account_code_format to ^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$: 80 characters. A widening cannot
reject an existing row, needs no rewrite, and the column is already TEXT. 80 covers the template
validator’s own ceiling (it refuses a template whose worst case exceeds 80) with margin over the default’s
44. Other 32-character copies of the rule that must move with it: imports/chart.go (accountCode),
account_alias_shape (kept at 32: an alias is an old system’s code; widening it is not needed),
and any web zod or CSV rule (U: not all of web read). A guard test asserts the Go regex and the
CHECK agree. Print layouts (PDF column widths, CSV consumers, bank/e-Invoice fields that carry a
code) may truncate or wrap 44-character codes (U: not read; Task 1 audits the exports).
4.7 Existing accounts
Section titled “4.7 Existing accounts”Recommended: existing codes stay valid; the template applies to accounts created after it is set. A
legacy account has code_template_id NULL, is exempt from the conformance trigger, keeps local_code = code and seed_key = code. An org with no template row is in legacy mode, behaving exactly as today
(this is also what lets the existing test fixtures keep their "1530"-style codes). New orgs always get a
template at provisioning (a drift test asserts it; the company short code is asked at onboarding (U):
I did not read the onboarding flow). An audited, optional migration tool that re-codes
existing accounts is not in v1, and would contradict decision 4 (a code never changes); if ever built it
would mint new accounts and aliases, never edit a code.
4.8 Uniqueness
Section titled “4.8 Uniqueness”Unchanged: UNIQUE (org_id, code) over the full code (account_org_id_code_key is already mapped to
ErrDuplicateCode). Added: UNIQUE (org_id, local_code) is not added, because a legacy local_code
could theoretically collide with a structured one across template versions (U); uniqueness of code
suffices.
4.9 Re-parenting after posting
Section titled “4.9 Re-parenting after posting”Recommended: allowed, audited, with a reason, for any account that is not a control, system-role or
module-owned account, and refused when the new parent has postings (the existing
account_ledger_guard already refuses making a posted account a header). Moving changes no journal line
and no code. It does change roll-up reports (anything grouping by path or parent) for closed
periods retroactively (U: I did not read how reporting aggregates by hierarchy; if it uses path this
is real). Alternative: refuse a move while the account or any descendant has postings in a closed period
(D6). owner_module accounts (petty cash floats) stay immovable by the existing rule.
5. API (api/openapi.yaml first)
Section titled “5. API (api/openapi.yaml first)”Handlers implement the generated interface, so each change below is first an edit to the contract:
GET /accounting/code-template: the current template and its history.accounting.ledger.read.POST /accounting/code-template: set a new version (separator,prefix,levels,reason). Also adry_runflag returning the violations a change would cause for pending imports.accounting.account.manage(D8).POST /accounting/code-template/check:{parent_id, segment}→ composed code and violations; the single source for the web validator (no second copy of the rules in TypeScript).POST /accounting/accounts/{id}/move:{parent_id | null, reason}; 422 on cycle, depth, type or control parent; 409 on a postings conflict.createAccount: body takessegment(template mode) orcode(legacy mode); the 422 text for depth changes from “ten levels” to “six levels (five sub-accounts)”.updateAccount:codeis removed from the body; sending it is a 422.Accountschema: addlocal_code,code_template_id(nullable),seed_key(nullable, read-only).
The generated TypeScript client (web/src/lib/api/schema.d.ts) regenerates from the same file; the
generated-code drift test covers it.
6. Names, permissions, audit
Section titled “6. Names, permissions, audit”- Translation rows. Account names stay translation rows (
entity_kind 'account', fieldname). Codes are identifiers, not names, and are never translated. A template segment’s label in the settings screen (“Company”, “Sub-account 1”) is UI text in the three locale files, not data. - Permissions. Reuse
accounting.account.manage(it already governs creating accounts and already has en/ms/zh labels, so the label drift test needs nothing). If the owner wants a second pair of hands for an identity-affecting change, a new key (for exampleaccounting.codetemplate.manage) would need: the key inconstants.yaml, labels inen/ms/zh team.json, grants to existing Owners in a migration (as00922did), and the twowiring_test.golists incmd/orgadminandcmd/migrate(D8). - Audit. Every template version (
account_code_template,set), every move (account,move, one row for the whole subtree), and the rejected-recode attempt is not audited (it is a refusal, not a write). Each usesaudit.Writein the same transaction.
7. Imports
Section titled “7. Imports”An import into an org with a template must produce conforming codes. Recommended: the chart file’s code
column must be the full conforming code, preview (the existing validation pass, (U) read only in
part) lists each violation by line and column with the rule broken (charset, length, segment count,
wrong prefix, parent missing); a new optional legacy_code column becomes an account_alias row, reusing
the alias mechanism that maps_to already uses, so the old system’s trial balance import (which names
accounts by alias) still resolves. This is the mapping step; no second mechanism. In legacy mode the
importer behaves as today with the 80-character regex. Opening-balance and movement imports name accounts
by alias or code and are unchanged (U: balances.go read only to the alias query).
8. Web
Section titled “8. Web”- Template settings screen (under Accounting, gated
accounting.account.manage): segments list with charset and length per level, separator, a live example code, the 80-character check, the fixed-length sort warning, and version history. Prefix fields read-only after version 1. - Account form: the prefix and the parent’s code are shown read-only; the user types only the new
segment; the composed code and violations come from
POST /accounting/code-template/check. On edit, the code field is read-only (today’sacc-codeinput is removed from the patch). Legacy mode keeps the free field. - Tree view is already built from
parent_idin pre-order (tree.tsbuildRows); it needs no change beyond a Move action (a parent picker dialog showing the resulting depth and refusing at the cap). - en / ms / zh strings for all of it; phone width. Search by code keeps working (
tree.tssubstring match).
9. Posting rules and reports, verified
Section titled “9. Posting rules and reports, verified”Posting resolves by control_kind and system_role, by column (ResolveControl, bySystemRole); journals,
tax codes, bank accounts and aliases hold account_id. None parses or compares a code. The exceptions are
the seeding and migration lookups of Section 3, fixed by seed_key. Reports print and sort by code
and are unaffected in meaning; the fixed-length default keeps their order sensible; a legacy/structured
mix sorts by raw text and is only cosmetically odd. Accounting reacts to semantic tags, never names, so
nothing in accounting/ changes.
10. Uniqueness, immutability and the template are three separate guards
Section titled “10. Uniqueness, immutability and the template are three separate guards”| Guard | Enforced by |
|---|---|
| Code unique per org | existing UNIQUE (org_id, code) |
| Code never changes | account_immutable_guard (rewritten) |
| New code conforms to the template in force | BEFORE INSERT trigger reads the template row; Go builds the code from the same segments |
| Depth <= 5, no cycle, path coherent | CHECK depth <= 5 + deferred constraint trigger + advisory lock |
11. Consolidation, and a worked restructuring
Section titled “11. Consolidation, and a worked restructuring”Prefix = the entity, not its owner. consolidation-runs D14 maps member accounts by code onto the owner’s
group chart. Because a member’s code carries its own prefix and never changes, a mapping row stays true
for as long as the account exists, whoever owns the member.
The group chart. group_reporting_line.code follows the same template with the owner entity’s prefix
(the group chart is the owner’s). (U) The register has no “owner entity prefix” notion beyond the
owner org’s own template; the plan reads it from the owner’s current template version.
Default pre-fill. Matching “where codes match” cannot be a string compare of full codes: a subsidiary’s
prefix differs from the parent’s by definition. It matches on local_code plus type between a member
account and the parent’s own account that has a group reporting line. The UI offers “Apply suggestions”,
which writes ordinary, dated, audited group_account_map rows; accounts with no match, a type mismatch or
more than one candidate stay unmapped (and an unmapped account with a balance refuses the run, as
D14 already says). This needs the consolidation cross-org read function to return local_code beside
code: an amendment to that spec (U: not yet made).
Worked example: a company is sold to another group.
- Group A owns entities
WW-MY-ALFA(parent) andWW-MY-BETA. BETA’s codes areWW-MY-BETA-1000.... - A’s run for FY2026 stored its scope and BETA’s mapping rows (
WW-MY-BETA-4100→ A’s line “Revenue”). - In 2027 BETA is sold to Group B. No BETA code changes: the prefix names BETA, not A. BETA’s journal history, aliases, exports and audit trail are untouched.
- A’s closing statement for the part-year is out of scope (consolidation spec §16), but A’s old runs stay readable and reproducible: the mapping rows were dated and the run is immutable; BETA’s codes are exactly what they were.
- Group B’s owner
WW-MY-GAMAcreates its group chart, then “Apply suggestions”: BETA’slocal_code4100matches GAMA’s own4100revenue, so the same BETA codes map onto B’s lines with no recoding; exceptions show as unmapped. - Counter-case, the rejected design (prefix = owner): BETA’s sale would require recoding every BETA account, voiding every alias, export and consolidation map that cites a code.
12. Drift tests this adds
Section titled “12. Drift tests this adds”Each is watched failing once (break the protection, read the message, restore) and establishes the presence before the absence:
- Depth cap. Presence: an account at depth 5 inserts and a subtree move landing leaves at depth 5 commits. Absence: depth 6 on insert, and a move putting a descendant at 6, are refused. Watch it fail by raising the CHECK to 9.
- Cycle. Presence: a move of a leaf under a sibling commits. Absence: moving a node under its own descendant is refused. Watch it fail by removing the path-containment term from the trigger.
- Concurrent re-parenting. Two transactions each valid alone that together exceed the cap: presence, each commits alone on a fresh fixture; absence, run together, the second fails. Watch it fail by removing both the advisory lock and the deferred trigger (it must fail with only one removed too, to prove each guard is independently live).
- Template conformance. Presence: a code built by the Go composer under the default template inserts.
Absence: a wrong prefix, wrong charset, wrong segment length, wrong separator, a segment count that
disagrees with depth, are each refused by the trigger when inserted directly (bypassing Go). Watch it
fail by dropping the trigger. Also: the Go regex builder equals the stored
code_regexfor a fixture set (the same pin ascontrol_kindvsconstants.yaml). - Code immutability. Presence: a name edit on the same row commits. Absence: a code edit is refused for
a template account and a legacy account, on
erp_app. Watch it fail by removing thecodeterm. AlsoAccountPatchhas noCodefield (a reflection guard, with a non-zero field count asserted first). - Tenant isolation of the template table. The existing every-table-is-isolated test must see the
new table (assert a non-zero candidate count first); org B sees zero of org A’s versions;
UPDATEandDELETErefused forerp_appafter anINSERTsucceeded. Watch it fail by dropping the policy. - No literal-code lookup left. A static guard walks
internalforGetAccountByCode(with a string literal argument outside tests and asserts it read a non-zero number of files and found the known allowlisted (post-migration: zero) call sites; it fails if a planted literal is added. - New orgs get a template. Provisioning a new org creates version 1 (presence: the row exists; the chart step refuses without it).
- Limit agreement. The Go regex and
account_code_formataccept and refuse the same sample set, including 80 vs 81 characters. - Migration range. New migrations fall in 1020-1039; the range test is extended (Section 14).
- Label drift only if D8 picks a new permission key.
13. Owner decisions (recommended option first)
Section titled “13. Owner decisions (recommended option first)”| # | Decision | Recommended | Alternative |
|---|---|---|---|
| D1 | Template location and lock. | Org-level append-only versioned table; prefix fixed for life; not locked by postings (codes never change). | A single mutable settings row (loses history); lock once the org has posted. |
| D2 | Depth meaning of “5 nested sub-accounts”. | Account at depth 0 plus 5 sub-levels: depth 0..5. | Depth 0..4 (the account counts as one of the five). Changes the CHECK and the default. |
| D3 | What is the world code? | A literal in the template (default WW), no data source, until you say what it stands for. |
A platform-wide constant, or a region code; both need a data source I cannot find. |
| D4 | Company segment. | Owner-chosen short code [A-Z0-9]{1,8}, asked at onboarding. Uniqueness across the platform: a unique index on a platform table is an architecture question (one org = one tenant) and is not assumed; v1 relies on the code only being unique within consolidating groups, checked at group join. |
Derive from the company name or registration number (unstable). |
| D5 | Code immutability for legacy accounts. | Immutable for every account from the migration (a behaviour change: recode disappears). | Immutable only for template accounts; legacy recode stays (two rules). |
| D6 | Re-parent after posting. | Allowed, audited, with reason, not for control/system/module accounts. | Refuse while the subtree has postings in a closed period. |
| D7 | Existing accounts. | Keep codes; template applies to accounts created after it is set; no migration tool in v1. | Audited re-code tool in v1 (contradicts “code never changes”). |
| D8 | Who sets the template. | Reuse accounting.account.manage. |
New accounting.codetemplate.manage (en/ms/zh labels, Owner grants, wiring tests). |
| D9 | The 32-character limit. | 80 characters. | 64 (tighter, still fits the default’s 44). |
| D10 | Country segment. | ISO 3166-1 alpha-2, copied from the company profile at set time. | Alpha-3. |
| D11 | Group chart prefix. | The owner entity’s prefix; pre-fill matches member and parent local_code plus type, applied by an explicit “Apply suggestions” click. |
Pre-fill writes mappings automatically (no review step). |
| D12 | Import of a chart into a template org. | Full conforming code required; preview lists violations; old code kept as an alias via legacy_code. |
The importer composes codes from a segment column. |
| D13 | Seeding lookups. | Add seed_key; change the ~15 literal lookups to it; backfill from code. |
Make the Malaysian pack write structured codes and teach each module the new literal (more change, same result). |
| D14 | Separator. | One of -, ., _; no empty separator in v1. |
Free-form single character, or none with fixed lengths. |
14. Migration range
Section titled “14. Migration range”Proposed 01020-01039. Declared today (internal/kernel/db/migrationranges_test.go): up to
{920, 939} (group register) and the roadmap/spec ranges 940-959 (consolidation), 960-979 (public API),
980-989 (extraction), 990-1019 (AI) are proposed in merged specs but not yet in the test (U: I read
the test file and the specs, not the other branches that will add them). The task that adds the first
migration extends the table with {1020, 1039, "Phase 6 structured account codes (2026-10-03-structured-account-codes-design.md)"}; the regex ^(\d{5})_ accepts 01020.
Sketch: 01020 template table + RLS + append-only; 01021 account columns (local_code, seed_key,
code_template_id), backfill, widened CHECK, depth pre-flight and CHECK 0..5, rewritten guard, deferred
coherence trigger, insert-conformance trigger; 01022 provisioning/permission data if D8 needs it.
15. What I could not verify
Section titled “15. What I could not verify”- Whether any production or staging database holds an account with
depth > 5or a code over 32 (none can exceed 32 today). The pre-flight exists for this; the data was not read. - That the deferred constraint trigger plus advisory lock prevents two concurrent over-cap moves under
READ COMMITTED. This is reasoning fromMoveUnit’s own comments; no concurrency test was run. - How
platform/reportingrolls up by hierarchy (path, parent or neither); this decides whether a move after posting restates closed-period reports. - Every place a code is copied into a snapshot (exports, e-Invoice, payout files, PDF layouts), and whether a 44-character code fits them.
- The full import preview path, the opening-balance import and the petty-cash float creation path (read only in part, Section 3 and 7).
- All web files that validate or format a code (
zod, CSV, budget grid); only the editor and the tree were read. - The onboarding and provisioning order (whether the company short code can be asked before the chart
step), and whether
company_profile.country_codeis checked against the ISO list. - The meaning of “world code” (D3).
- That all 65 Malaysian-pack codes are exactly 4 digits and that none is deeper than depth 3: I
read the parent/child list, not every
Codeliteral’s length. - That other Phase 6 specs do not also claim the next advisory-lock class or a migration range that overlaps 1020-1039.
- Whether the consolidation cross-org read function can return
local_codewithout weakening its privilege model (the spec defines that function; this one needs an amendment to it).
16. Task-plan outline (after sign-off)
Section titled “16. Task-plan outline (after sign-off)”Migration range 01020-01039. Task 1 gates the rest.
- Audit and fixtures. Resolve Section 15 items 1, 3, 4, 5, 6, 7, 9: query real depths and lengths, find every code snapshot, read the import preview and onboarding. Write the concurrency test (it should fail against today’s unguarded schema). Output: a findings note in the plan.
01020template table, RLS,FORCE,org_isolation, append-only privileges, range declared and the range test extended.01021account columns, backfill (local_code,seed_key), widened CHECK, depth pre-flight and CHECK, rewritten immutability guard, coherence and conformance triggers.chart:TemplateSet/Get, the code composer and regex builder,MoveAccount(lock class from the registry), dropAccountPatch.Code,MaxDepth = 6, typed errors, audit.- Seed-key conversion:
SeedChartsets it; each literal lookup in Section 3 moves toGetAccountBySeedKey; the static guard (test 7) goes red then green. - Provisioning: new orgs get template v1; onboarding asks the company short code.
- Imports: template-aware validation, preview violations,
legacy_codealias. - API in
api/openapi.yamlfirst, handlers, regenerated client. - Web: settings screen, account form, move dialog; en / ms / zh; phone width.
- Consolidation hook: amend that spec’s read function to return
local_code; the “Apply suggestions” service and screen (lands with the consolidation tasks, not before). 01022(only if D8 chooses a new key) permission, labels, grants, wiring tests.- Drift tests of Section 12 collected, each watched failing once; the full suite with
-p 2once before commit. - Roadmap and docs: tick items; record what stays open (Section 15).
17. Not in scope
Section titled “17. Not in scope”An automatic or bulk re-coding tool for existing accounts; editing a code for any reason; the code
carrying or implying ownership, group or hierarchy beyond the entity prefix; a template per account type
or per country pack; more than 5 sub-accounts or a settable cap; an empty or multi-character separator;
parsing a code at read time to build the tree; changing the GL keyset cursor or any report’s sort;
mapping legacy aliases beyond the existing account_alias; consolidation runs themselves (that spec); any
change to the posting engine, journals, tax codes or workflow semantics; translating codes; platform-wide
uniqueness of the company segment (D4); changing account_alias_shape beyond keeping it at 32.
18. Owner decisions recorded (2026-10-03)
Section titled “18. Owner decisions recorded (2026-10-03)”D1-D14: the recommended option in each row of the owner-decisions table. In particular: depth is the
account level plus 5 sub-levels (0 to 5); the world segment is a literal set in the template
(default WW); codes are immutable after creation, legacy accounts included; the code limit is 80;
existing codes are kept and there is no recode tool in v1; seeded lookups move to seed_key;
the group chart pre-fill is applied only by an explicit “Apply suggestions” click.
Pending amendment (not yet made): the consolidation spec’s cross-org read function must return
local_code.