Consolidation runs and inter-company eliminations 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: An owner organization produces an immutable, reproducible consolidated position for one of its groups at one date: members are read through allowlisted, consent-checked, audited cross-organization functions; translated to the presentation currency with rates frozen into the run; inter-company balances, investments, dividends and unrealized profit are eliminated into owner-side tables that are never a ledger; NCI and associates are attributed; closing checks decide whether the run is provisional or final-capable; a second person approves. Plus the account-codes pre-fill (“Apply suggestions”) that the structured account codes build left out.
Architecture: a new platform/consolidation package (kernel + platform imports only, no accounting Poster, no journal writes). Owner tables carry the owner’s org_id with org_isolation. Run tables are insert-only. Member books are read only through new SECURITY DEFINER functions modelled on 00921’s group_read_allowed, which they call or share an internal helper with, returning aggregates only. One new small primitive in platform/accounting/fx (Average). Rules are deterministic Go code registered by kind, pinned by a golden file and a rule_version.
Tech Stack: Go, pgx v5, shopspring decimal via money, Postgres (goose), gin, oapi-codegen, React + TanStack Router/Query + i18next, vitest + msw.
Spec: docs/superpowers/specs/2026-10-03-consolidation-runs-design.md (owner decisions D1-D16 in its sections 13 and 17, all the recommended options; D5 per entity, exclusion ships first; section 18 amendment: the balances read function returns local_code). Companion specs: 2026-10-03-group-register-design.md (built, platform/group, 00920-00922), 2026-10-03-structured-account-codes-design.md (section 11).
Base (updated 2026-10-04): main now holds the structured account codes (account.local_code, seed_key, the template, 80-character codes; migrations 01020-01021) and the public API and webhooks (00960-00969). This plan was written on the account codes branch before it merged; it is now executed on main. The group register (platform/group, 00920-00922) is on main too. Task numbering and file lists below are kept as written; where the build did something other than the plan, the Execution record at the end says so.
- G-A (blocks Tasks 6 and 7 only in their labels and in any output shown outside the owner’s team): the adviser confirms spec section 14 (MFRS 10, 3, 121, 127, 128, 112, Companies Act 2016, retention, MPERS). D16 says: before the first run is shown outside the owner’s team. Until then every output carries the “management consolidation” banner (spec 11), a banner on the output, not a mode. No build task waits for it.
- G-B: the register’s G1 (Companies Act s.4 wording) and G2 (retention period) stay open and are inherited unchanged. Runs and inputs use the same provisional seven-year working figure as the register’s retention grants.
- G-C: the structured account codes branch is merged before this branch merges. Met (2026-10-04): it is on main.
- G-D: owner ruling on the items in “Spec problems” below before the task that cites each (CR-1 before Task 4, CR-4 before Task 2).
Global Constraints
Section titled “Global Constraints”- Migration range:
00940-00959. Task 2 adds{940, 959, "Phase 6 consolidation runs (2026-10-03-consolidation-runs-design.md)"}tointernal/kernel/db/migrationranges_test.goin the same change as00940(done). Files:00940tables andcompany_groupcolumns,00941tag and counterparty map,00942read functions,00943permission grants. The1020-1039and960-979ranges are other plans’;940-959is free on main. - Contract first:
api/openapi.yamlandconstants.yamlbefore handlers;make generateleaves the tree clean; generated files (internal/gen/api/api.gen.go,web/src/lib/api/schema.d.ts, the vocab outputs) are never edited by hand. - A float never touches money, rates or percentages, in any layer, including the average-rate computation.
NUMERIC(19,4)money,NUMERIC(19,10)rates (asexchange_rate),NUMERIC(9,6)percentages; strings on the wire;money.Amount/money.Rate/ decimal in Go. The rounding mode is one named constant, recorded on the run. - Every business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, policyorg_isolation, composite tenant FKs(org_id, ...). The one cross-tenant reference (party_group_counterparty.counterparty_org_idtoorganization) is declared the waygroup_entity_member_org_fkis and is accepted bytenantfk_test.go’s rule. - Every constraint is named; every function pins
SET search_path = pg_catalog, public, pg_tempand schema-qualifies reads (searchpath_test.go,constraintnames_test.go). - Every
SECURITY DEFINERfunction is allowlisted ininternal/kernel/db/securitydefiner_test.gowith a reason,REVOKE ALL ... FROM PUBLIC,GRANT EXECUTE ... TO erp_app. Each has a refusal test and a presence test (see Task 4). - Append-only run and input tables are listed in the
appendOnlyset ofinternal/kernel/db/retention_test.go(the test fails for any table no list names).REVOKE UPDATE, DELETE FROM erp_appplus a refusing trigger. - Vocabulary values are strings:
kind,basis, class, stage, treatment, check name, event type, rate method, rounding mode. Closed sets are CHECKs mirrored inconstants.yamland pinned by a test. - Every user-facing name is a translation row (reporting line names especially) or an en/ms/zh locale entry; permission keys get en/ms/zh team labels (
TestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocale). Statutory headings:msis statutory,zhis UI only. - No handler reads an organization from a request header. The owner is the session org; a member is named in the body or path and validated by the read function against the group.
- Every route is behind
httpx.Authorizeor inapi/public_routes.txt; none go in the latter. - Layering:
platform/consolidationimportskernelandplatformpackages only (group,accounting/fx,accounting/charttypes,reporting/export,audit); nomodules/import; nothing imports it exceptmodules/consolidation. - Advisory locks: none new. Run creation takes the
company_grouprowFOR UPDATEfirst (serialises runs and the retained-earnings roll-forward lookup), then reads, then writes,audit.Writelast (class 1). Recorded indocs/LOCK_ORDER.mdin Task 13. - Drift tests: presence before absence; each watched failing once (break, 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: one package at a time with
go test -p 1(Docker is fragile on this VM);go test -p 1 ./...and the web suite once, at the end. Eachgo testinvocation leaves its Postgres container running; remove the leftovers (docker ps -q --filter ancestor=postgres:17-alpine | xargs docker rm -f) between runs or the VM runs out of memory. - Commits: one per task, 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)”- CR-1 Refusals must survive the request transaction. Spec 2 and 3 say a refused read “raises”, “is audited in the member’s chain”, and “the run is not created”. A raised exception rolls back the audit insert in the same transaction (this is why
00921returns a boolean). Ruling: the Go service first callsgroup.ReadAllowed(existing, audits allowed or refused, never raises) for every member and returns a refusal result (HTTP 200 withstatus: "refused"and a list of reasons by member and account) rather than an error, so the transaction commits and the audit rows persist; no run row is stored. The data functions re-verify and raise (defence in depth; they are never reached on a refusal). Task 1 must confirm howhttpxtreats a handler error (rollback?) before this is relied on. Needs owner confirmation of the 200-with-refusal shape. - CR-2 Two audit rows per allowed member read, not one. Spec 12 test 3 says “one new row”. The existing check already writes one; the data function writes a second carrying the row count (spec 3.4). Ruling: two rows by design (
group_read_allowed, thengroup_member_balancesread with row count); the test asserts exactly that pair andaudit.VerifyChain. The alternative (an internal non-auditing check helper so there is one row) saves a row but loses the explicit allowed/refused record pairing; not taken. A shared internal helpergroup_cross_read_auditis still the single place that holds the hashed bytes (spec 3.4), and it is itself allowlisted and revoked from PUBLIC anderp_app. - CR-3 The caller must own the group and the member must be in it, inside the data function.
group_read_allowedchecks only the grant (it does not check that the caller owns the group or that the member is an entity of it). Every new function therefore verifiescompany_group.org_id = callerandgroup_entity.(group_id, member_org_id)itself, before reading, and raises on failure (tests in Task 4). - CR-4 Input tables are insert-only; “ended” and “approved” are rows, not updates. Spec 7.7 says inputs are “append-only with end dates (never edited)” and approved by a second person, which cannot both hold if ending or approving is an
UPDATE. Ruling:REVOKE UPDATE, DELETEon every input table; a row is superseded by inserting a newer row for the same key with a latereffective_from(newesteffective_from <= as_of, ties bycreated_at); removing is a tombstone row (active = false); approval is a row in oneconsolidation_input_approval(input_kind, input_id, approved_by, approved_at)table whose trigger looks the input’screated_byup per kind and refuses equality (the register’s rule, GR-5). Derivations ignore an input with no approval row. Needs owner confirmation (differs from the register’s UPDATE-basedApproveAssessment). - CR-5
parent_entity_id(D1) andic_tolerance(D13) are columns oncompany_groupadded in00940(nullableparent_entity_idwith the composite FK(org_id, group_id, parent_entity_id)togroup_entity;ic_tolerance NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK >= 0), both set through a newgroup.UpdateGroupSettingsthat audits. A run copies both. Ifparent_entity_idis null the parent is the entity whosemember_org_idis the owner; if neither exists the run refuses naming the group. - CR-6 D5 per entity.
group_entity_treatment (group, entity, treatment, rationale, created_by)withtreatment IN ('exclude','import'), approved through CR-4. v1 accepts onlyexcludein the service; choosingimportis recorded (the owner’s choice, as spec 17 asks) but the run lists the entity as a blocker, “import not yet available”, never as silently omitted. Whether import ships in v1 is the spec 17 open question: this plan does not build it; it is a follow-up task (a CSV trial-balance import into a run-input table, reusingplatform/imports) needing its own spec section and an owner ruling. - CR-7 Statuses derived.
consolidation_run_event.type IN ('approved','superseded','difference_accepted'); the list and read APIs compute status in one query. A CHECK-equivalent trigger refuses anapprovedevent when the creator equals the approver, when anyconsolidation_run_checkrow of that run failed, or when the run’sfinal_capableis false.final_capableandprovisional_reasonsare stored on the run at creation (they are a computed fact of that run, not a mutable status). - CR-8
rule_versionis one integer per rule set, a constant inplatform/consolidationbumped by hand; the golden file stores the version beside the schedule. The determinism test (spec 12.8) fails if the schedule changes while the constant does not, by comparing against a checked-inrule_versionin the golden file’s header. - CR-9 Fiscal period gate. “Provisional” per member means any
fiscal_periodending on or beforetois stillopen; “closed or locked” is read inside the function and returned as a boolean per member (not as period rows). - CR-10 Equity history source. The retained-earnings result per prior year comes from
group_member_equity_movements’ second result set (per prior fiscal year result, base currency). If the member’s own closing entries (system_role = retained_earnings, journalkindof the year-end close) are not distinguishable in the lines the function reads, Task 1 records it and the translation task refuses members whose prior years are unclosed. - CR-11 Rounding. All translation uses decimal multiply then one rounding to 4 places, half-even (
money’s existing convention, confirmed in Task 1), recorded on the run asrounding_mode. The translation reserve check tolerance is0.0001 x (number of translated lines), recorded on the run. - CR-12 Output exports use
reporting/exportandrenderunchanged; a consolidation statement is shaped into the existing report row types by an adapter inplatform/consolidation, not by extendingreporting. - CR-13 Migration
00941editsaccount.account_immutable_guard(rewritten in01021) fixes onlytype,owner_module,code,local_code,seed_key,code_template_id;consolidation_tagis editable byaccounting.chart.managethroughUpdateAccount. Task 3 re-reads the guard on the merged base before writing the migration.
Spec problems needing an owner ruling
Section titled “Spec problems needing an owner ruling”- Refusals and audit rows (CR-1). Spec 3 and 2.3 require both a raise/refuse and a persisted audit row; a raise cannot keep the row. Ruled as a 200 refusal result.
- One audit row (spec 12.3) versus two (spec 3.2 + 3.4). Ruled two (CR-2).
- Inputs “append-only with end dates” and “approved” (spec 7.7, 6.3, 6.6) cannot be done with
UPDATE REVOKED(CR-4). - Spec 15 says “Tasks are independent after Task 1”. They are not: 6 needs 2, 4 and 5; 7 needs 6; 8 needs 7; 10 needs 8; the plan’s dependency graph below corrects it.
group_read_allowedneither checks group ownership nor membership (CR-3); the spec assumes it does (“also confirms … in the same call”).- Spec 5.2:
Resolverefuses a reference rate older than seven days (MaxStaleness) per day, soAverageover a long period with a published-rate gap (the feed publishes weekdays) is fine for weekends but refuses a period with a longer gap even when the run date itself has a rate. Behaviour recorded in the Task 5 tests; ruling needed only if the owner wants a longer staleness for averages. - Spec 7.1 “excluding the member’s own year-end closing entries” is described for an arbitrary
from;reporting.TrialBalancehasExcludeClosingbut its interaction with a mid-period closing is unread (spec 14 item 9). The read function reimplements the exclusion in SQL; Task 1 states what it found, and the member-contribution output says which basis it used. - Spec 6.5: ordered pair matching and “A’s receivable against B’s payable” do not say how a three-way mismatch (A and B agree, C unmapped) is reported; ruled by the golden fixture in Task 7: unmatched legs become
ic_differenceper pair, never netted across pairs. - D5 “import ships in v1 or follows” (spec 17): left to the owner; not planned here (CR-6).
Dependency and parallelism map
Section titled “Dependency and parallelism map”T1 ──► T2 ──┬─► T3 ──┐ ├─► T4 ──┤ └─► T5 ──┴─► T6 ──► T7 ──► T8 ──► T10 ──► T11 ──► T12 ──► T13 └──► T14 (needs T3, T4, T6, T10 for its API; service part can start after T6)T9 (00943) needs T2 only for the key names; best run just before T10| Tasks | Parallel? | Shared files (serialise edits) |
|---|---|---|
| T3 and T4 and T5 (after T2) | Yes, three disjoint areas | internal/kernel/db/securitydefiner_test.go (T4 only), constants.yaml (T3 only), internal/kernel/db/retention_test.go (T2 only) |
| T6, T7 | No, T7 extends T6’s types | platform/consolidation/* |
| T9 with T6/T7/T8 | Yes, disjoint (a migration and label files) | web/src/lib/i18n/locales/{en,ms,zh}/team.json (T9 and T11 both touch; T9 first), internal/modules/consolidation/permissions.go |
| T10 and T14’s API | No, one openapi.yaml |
api/openapi.yaml, internal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts |
| T11 screens | Yes among themselves after T10 (input screens, run screens, member-side screens) | web/src/lib/i18n/locales/*/consolidation.json (one author at a time per file), nav entry |
| T12 | after everything | retention_test.go, tenantfk_test.go, securitydefiner_test.go read only |
Shared files in one list: api/openapi.yaml (T3 field consolidation_tag on Account and the counterparty routes, T10, T14), constants.yaml (T3 tag vocabulary, T2/T8 closed vocabularies: kinds, checks, events, methods), internal/kernel/db/securitydefiner_test.go (T4), internal/kernel/db/retention_test.go and migrationranges_test.go (T2), web/.../locales/*/team.json (T9/T11), docs/ROADMAP.md (T13 only, plus the pointer added with this plan).
Task 1 - Audit and fixtures
Section titled “Task 1 - Audit and fixtures”Files: this plan (findings note below), internal/platform/consolidation/consolidationtest/ (fixtures, no schema yet)
- Resolve spec section 14 items 6, 7, 8, 9 by reading, and the open facts this plan added: (a) does each posting rule set
party_idon the lines that matter: receivables (read in the spec), payables (supplier_party_idis on documents; check the posting step), expenses, petty cash, bank, payroll; (b)journal_entry.posted_seqexists (00052,GENERATED ALWAYS AS IDENTITY; confirm it is on the entry and whether the watermark can usemax(posted_seq)plus a count and a balance hash); (c) which journalkindmarks a year-end close, howreporting.movementsexcludes it, and what a mid-period closing does to anExcludeClosingread from an arbitraryfrom; (d)journal_line.currencyand base amounts for “native currency flag”; (e) whether the Malaysian pack’s seeded charts share codes across orgs now that structured codes put a prefix on each (they sharelocal_code; confirm in the merged chart tests); (f) howhttpxtreats a handler error with respect to the request transaction (CR-1); (g)money’s rounding convention (CR-11); (h) thegroup.ReadAllowedcall path and whether a refusal is an error today. - Build
consolidationtest: a builder for the spec’s fixture (three or more orgs on one DB: owner and domestic subsidiary, a foreign (USD or SGD) subsidiary, a 70/30 NCI case, an associate at 25%, a UPI case, an unmatched balance), re-usingaccountingtestandgrouphelpers, with grants made and a group register populated. No schema is added. - Verify: findings note written here; the fixture compiles. No test is run that needs a container.
Findings note: to be written by the executing agent. At plan time (read, not run): journal_entry.posted_seq is GENERATED ALWAYS AS IDENTITY in 00052; reporting.TrialBalance has ExcludeClosing; group.ReadAllowed (grant.go:123) calls group_read_allowed and returns a boolean; only receivables posting was read for party_id (spec 14 item 6 stays open). journal_line has party_id, base_debit, base_credit, currency, account_id and a composite FK to party.
Task 2 - Migration 00940: tables and constraints
Section titled “Task 2 - Migration 00940: tables and constraints”Files: db/migrations/00940_consolidation_runs.sql, internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go, internal/platform/consolidation/migration00940_test.go, constants.yaml (closed vocabularies; with make generate)
- Range line declared (Global Constraints).
company_group:parent_entity_id,ic_tolerance(CR-5).- Run tables (spec 7.7):
consolidation_run(group,period_from,as_of,presentation_currency,basis,rule_version,rounding_mode,average_method,ic_tolerance,final_capable,provisional_reasonsJSONB,result_checksum,created_by,created_at),consolidation_run_member(entity, member org,treatment, voting and ownership percent, grant id or null for the owner, fiscal year end, period state, watermark fields),consolidation_run_rate,consolidation_run_balance(member x code xlocal_codex reporting line, native and translated, class),consolidation_entry,consolidation_entry_line(with source references),consolidation_run_result,consolidation_run_check,consolidation_run_event. All composite FKs to the run on(org_id, run_id). - Input tables (CR-4, insert-only):
group_reporting_line,group_reporting_line_name(translation rows by locale),group_account_map,acquisition_record,associate_input,adjustment_input(policyandupi),group_entity_treatment,opening_position, andconsolidation_input_approval. - Triggers: a refusing
UPDATE/DELETEtrigger on every run and input table; a deferred constraint trigger that everyconsolidation_entrybalances (debit = credit over its lines); the approval trigger (CR-4); the event trigger (CR-7). Named CHECKs for every closed vocabulary. PercentagesNUMERIC(9,6)in[0,100], moneyNUMERIC(19,4), ratesNUMERIC(19,10)> 0. - RLS,
FORCE,org_isolationon all;REVOKE UPDATE, DELETE FROM erp_app; all added to theappendOnlylist inretention_test.go. - Tests (each watched failing once): tenant isolation across two owners (presence: owner A reads its own rows first);
UPDATEandDELETErefused forerp_appafter anINSERTby the same role succeeded, for each table; an unbalanced entry is refused at commit (presence: a balanced one commits); creator-equals-approver refused (presence: a different approver accepted); approving a run with a failed check refused; Down migration restores the prior state; the range test sees the new range;rlscoverage_test,constraintnames_test,tenantfk_test,retention_teststay green. - Verify:
go test -p 1 ./internal/kernel/db/... ./internal/platform/consolidation/....
Task 3 - Migration 00941: consolidation tag and the counterparty map
Section titled “Task 3 - Migration 00941: consolidation tag and the counterparty map”Files: db/migrations/00941_consolidation_tag_counterparty.sql, constants.yaml, internal/platform/accounting/chart/{account.go,...} (tag field), internal/platform/consolidation/counterparty.go, api/openapi.yaml (the Account field and the counterparty routes; shared file), web/src/features/accounting/* (tag field on the account form), tests
account.consolidation_tag(nullable, named CHECK over the 12 tags of spec 6.1, inconstants.yamlasconsolidationTagsand pinned by a test the waycontrol_kindsis,chart/account_test.go:130); editable byaccounting.chart.manage; not acontrol_kind.party_group_counterparty (org_id member, party_id, counterparty_org_id, group_id, active, effective_from, created_by, created_at), insert-only (CR-4), RLS member-owned, composite FK toparty (org_id, id); the cross-tenant FK toorganization; permissiongroup.intercompany.manage(registered here, labelled in Task 9).- Registration-number suggestion (D4):
consolidation.SuggestCounterpartiesreturns candidates fromparty.registration_noandparty.tinagainst the group’s entities; it never writes. - Tests (each watched failing once): vocabulary CHECK matches
constants.yaml; the tag can be set and cleared by an account edit while code stays immutable (presence: the code edit is refused); a party maps to an org (presence) and a duplicate active mapping is refused; the member sees only its own map; suggestions return a match and write nothing (row count unchanged). - Verify:
go test -p 2 ./internal/platform/accounting/chart/... ./internal/platform/consolidation/..., thenmake generateclean.
Task 4 - Migration 00942: cross-org read functions (highest risk; review first)
Section titled “Task 4 - Migration 00942: cross-org read functions (highest risk; review first)”Files: db/migrations/00942_group_member_reads.sql, internal/kernel/db/securitydefiner_test.go, internal/platform/consolidation/reads.go, internal/platform/consolidation/migration00942_test.go
- Functions (spec 3 plus the amendment):
group_member_balances(returnscode,local_code,type,system_role,consolidation_tag, a native-currency flag, opening, movement and closing base debit and credit, and a per-memberprovisionalflag; no account names),group_member_equity_movements,group_member_ic_balances,group_member_watermark,group_member_ledger_lines(ledger_readonly). AllSECURITY DEFINER, pinnedsearch_path,REVOKE ALL FROM PUBLIC,GRANT EXECUTE TO erp_app. - Internal helper
group_cross_read_auditwrites the member-chain row (function name, group, scope, period, row count) with the exactrowHashSQLbytes in one place (00921contains the pattern); the helper isSECURITY DEFINER, revoked from PUBLIC anderp_app, allowlisted. Every function verifies grant scope (CR-3: caller owns the group, member in the group) and raises on failure; the audit row and the read are one transaction. - Allowlist: add all six names (five public functions and the helper) to
securityDefinerswith a reason each. - Go wrappers in
reads.gocallgroup.ReadAllowedfirst (CR-1, CR-2). - Tests (each watched failing once; presence first): with a live grant,
group_member_balancesequals the member’s ownreporting.TrialBalanceover the same range (presence: non-empty); then absence: no grant, revoked grant, abalances_readgrant refusinggroup_member_ledger_lines(while the same grant acceptsgroup_member_balances), a caller that does not own the group, a member not in the group, an unknown scope; each returns no rows.local_codematchesaccount.local_codefor template and legacy accounts. Audit: the pair of rows lands in the member’s chain andaudit.VerifyChainpasses (watch it fail by hashing a different byte string); a refusal leaves exactly the existing refusal row. The allowlist test,searchpath_test, the PUBLIC test and “no function returns a name or memo column” (a result-column guard asserting a non-zero column count first) pass. - Verify:
go test -p 1 ./internal/kernel/db/... ./internal/platform/consolidation/....
Task 5 - platform/accounting/fx: Average
Section titled “Task 5 - platform/accounting/fx: Average”Files: internal/platform/accounting/fx/average.go, average_test.go
Average(ctx, tx, from, to, rangeStart, rangeEnd)per spec 5.2 (D3): unweighted mean of the resolved daily rate for every calendar day,Resolve’s earlier-day fallback and override handling, decimal only, fixed rounding, method name constantunweighted_daily_mean;*NoRateErrornaming the currency and range.- Tests: a hand-computed mean over a fixture with weekend gaps (presence: the daily rates exist and differ), an override in the range is honoured, identical currencies return one, a missing currency refuses naming it, a gap longer than
MaxStalenessrefuses (spec problem 6), determinism, and a static check that the file imports nofloatoperation (the money guard sees it: non-zero candidates first). - Verify:
go test -p 2 ./internal/platform/accounting/fx/....
Task 6 - platform/consolidation: scope, mapping, translation, aggregation
Section titled “Task 6 - platform/consolidation: scope, mapping, translation, aggregation”Files: internal/platform/consolidation/{scope,mapping,translate,aggregate,types,errors,doc}.go, tests
ResolveScope(spec 2, D1, CR-5, CR-6): consumesgroup.RelationshipsAt, classifies by approved assessment first, presumption second; refusesrequires_judgement, off-platform controlled entities with no approvedexclude,importchoice (CR-6), unconsented members (by name, via CR-1), ownership change inside the period (fromgroup.HoldingHistory, D6 refusal with dates), multi-class NCI structure (D6).CheckMapping(spec 7.1, D14): a non-zero balance with no current approved map row refuses naming member and code; inputs are read through “newesteffective_from <= as_of, not tombstoned, approved” (CR-4).Translate(spec 5): rate classes fromtypeandsystem_role; rates resolved in the owner’s session and frozen intoconsolidation_run_rate; closing, average (Task 5), historical (equity movements by day); translation reserve as the balancing figure and the analytic computation (CR-11 tolerance); a refusal naming currency and range onNoRateError.Aggregate: sums mapped lines; checks debits equal credits at every stage.- Tests: scope table row by row (each classification with its adjacent valid case), every refusal (spec 12.11: unmapped, unconsented, ownership change, off-platform without exclusion, missing rate, each with its adjacent valid input accepted), identity translation at 1, the reserve check on the fixture and a deliberate rate error making it fail, rates frozen (a later override changes
fx.Resolve, not the stored rates). - Verify:
go test -p 2 ./internal/platform/consolidation/....
Task 7 - Elimination rules
Section titled “Task 7 - Elimination rules”Files: internal/platform/consolidation/{rules,rule_investment,rule_icbalance,rule_icincome,rule_dividend,rule_upi,rule_difference,rule_nci,rule_associate,rule_policy}.go, testdata/schedule.golden, tests · Gate G-A for any output shown outside the team
- One file per
kind(spec 6.4), registered bykindin a closed set, in the spec 7.2 order. Inputs:acquisition_record(goodwill, bargain purchase refused to a person, D8),adjustment_input(policy and UPI),associate_input. NCI per subsidiary bottom-up from effective ownership (spec 7.4); associates as one line (7.5); differences classifiedrateorunexplainedagainst the group tolerance (6.5). - Golden test (spec 12.8): a foreign subsidiary, 30% NCI, an associate, a UPI and an unmatched balance; the file header carries
rule_version(CR-8). - Tests: each rule’s zero-sum on the fixture,
ic_*lines non-zero in aggregate before and net to the recorded differences after (presence then absence, spec 12.7); determinism (same inputs, same checksum twice); static guard: the package names no workflow state or semantic role (nosst_test.gostyle, non-zero files and candidates first, a planted reference fails it); static guard: no write tojournal_entryorjournal_line, no import of theaccountingPoster (non-zero files and SQL strings first; plant one write and watch it fail). - Verify:
go test -p 2 ./internal/platform/consolidation/....
Task 8 - Run creation, checks, events, approval, staleness
Section titled “Task 8 - Run creation, checks, events, approval, staleness”Files: internal/platform/consolidation/{run,checks,events,staleness,store}.go, tests
CreateRun(spec 7.2 pipeline in one transaction; group rowFOR UPDATEfirst; refusal as a result per CR-1; stores raw balances, mapped balances, rates, entries, results, checks,final_capable,provisional_reasons, checksum, and the audit row);ApproveRun,SupersedeRun,AcceptDifference(each an event, CR-7);CheckStaleness(livegroup_member_watermarkagainst the stored one, never alters the run).- Closing checks (spec 7.6): every stage balances, balance sheet balances, reserve equals analytic, each
ic_*pair nets to zero apart from differences, retained earnings roll-forward against the previous final run (first run: approvedopening_position, D12), ownership shares sum to 100%, scope complete. - Tests: provisional versus final-capable cases one by one (each of the spec 8.4 conditions flipped alone; the run flips only for that one), approval by the creator refused (presence: another user accepted), approval of a run with a failed check refused, a superseding event, staleness detected after a back-dated member entry (presence: the live watermark equals the stored one before the entry), a run’s rows unchanged after a later override.
- Verify:
go test -p 2 ./internal/platform/consolidation/....
Task 9 - Migration 00943: permissions
Section titled “Task 9 - Migration 00943: permissions”Files: db/migrations/00943_consolidation_owner_grants.sql, internal/modules/consolidation/permissions.go, wiring_test.go lists, web/src/lib/i18n/locales/{en,ms,zh}/team.json (shared)
- Keys
consolidation.run.read,.create,.approve,consolidation.input.manage,consolidation.input.approve, andgroup.intercompany.manage(member side), Owner-only by default; the migration grants them to existing Owners and bumpsperm_version, modelled on00922(and its Down). - Tests: the label drift test (three locales); a fresh Owner gets every key; a non-Owner gets none.
- Verify:
go test -p 2 ./internal/modules/... ./internal/platform/provisioning/....
Task 10 - API
Section titled “Task 10 - API”Files: api/openapi.yaml (shared, first), then make generate, internal/modules/consolidation/*, internal/app/routes_test.go expectations
- Routes: group settings (parent, tolerance), reporting lines (with names by locale), account map, suggestions (read-only, Task 14), acquisition, associate, adjustments, entity treatment, opening position, input approval; run create (returns a result or a refusal, CR-1), list, get, outputs (trial balance, financial position, profit or loss and OCI, member contribution, elimination schedule), export, events (approve, supersede, accept difference), staleness; the member-side counterparty map and “who read my figures” (built from the member’s own audit rows).
- Every handler behind
httpx.Authorizewith the Task 9 keys; no header-derived org. - Tests: 403 without each key; 422 refusals with the field; a creator cannot approve their own run; a run is not recomputed on read (a spy on
fx.Resolveis never called while displaying, spec 5.2). - Verify:
go test -p 2 ./internal/modules/consolidation/... ./internal/app/...;make generateleaves the tree clean.
Task 11 - Web
Section titled “Task 11 - Web”Files: web/src/features/consolidation/*, web/src/features/group/* (member-side screens), nav, web/src/lib/i18n/locales/{en,ms,zh}/consolidation.json
- Screens: group chart and mapping (with the “Apply suggestions” button from Task 14), inputs (acquisition, UPI, policy, exclusion or import choice, opening position, input approvals), run list and create with the refusal list rendered by member and account, the five outputs with a persistent provisional or final label and the management consolidation banner (until G-A), the elimination schedule with differences classified, staleness warning, member-side counterparty map and “who read my figures” list; en/ms/zh, phone width.
- Verify: vitest, typecheck, lint, prettier. A manual en/ms/zh, phone-width and dialog-focus pass on a seeded stack including data-dependent screens is part of this task and is not claimed unless done.
Task 12 - Drift tests collected
Section titled “Task 12 - Drift tests collected”Files: internal/platform/consolidation/drift_test.go (the static ones), assertions added in the earlier tasks’ tests
- Spec section 12’s sixteen items are each a test above or here: 1 tenant isolation (T2), 2 cross-org reads (T4), 3 audit chain (T4), 4 append-only (T2), 5 never in a ledger (T7), 6 balance (T2), 7 zero-sum (T7), 8 determinism (T7), 9 rates frozen (T6), 10 reserve (T6), 11 refusals (T6), 12 tags not names (T7), 13 tag vocabulary (T3), 14 no float: the money guard sees the new package (non-zero candidate count first), 15 routes, permissions and labels (T9, T10), 16 migration range (T2). Each is watched failing once and the failure message recorded in the commit message.
- Verify:
go test -p 2 ./...and the web suite once, on a clean Docker.
Task 13 - Roadmap and docs
Section titled “Task 13 - Roadmap and docs”- Tick only verified items in
docs/ROADMAP.md, add the group-row lock order todocs/LOCK_ORDER.md, record what stays open (spec 14, the open questions above), and record which spec problems were ruled.
Task 14 - Account-codes pre-fill (“Apply suggestions”)
Section titled “Task 14 - Account-codes pre-fill (“Apply suggestions”)”The structured account codes build deliberately left this out (its roadmap Task 10; its spec section 11, D11). local_code is on account and in group_member_balances (Task 4).
Files: internal/platform/consolidation/suggest.go, suggest_test.go, api/openapi.yaml (shared, with Task 10), internal/modules/consolidation/suggest_handlers.go, web/src/features/consolidation/ApplySuggestions.tsx, locale files
Suggest(group, member, as_of)reads the member’s accounts throughgroup_member_balances(audited, consented; accounts with no movement and zero balance are not returned and need no mapping) and the parent’s own chart (owner session) for accounts that already have a current approvedgroup_account_maprow. A suggestion pairs a member account with a parent account bylocal_codeplustype(never a string compare of full codes: prefixes differ by design). An account with no match, a type mismatch or more than one candidate stays unmapped and is listed with the reason. Read-only: it writes nothing.ApplySuggestions(ids): the only place suggestions become rows, called by an explicit “Apply suggestions” click that lists what will be written; each becomes an ordinary dated, audited, insert-onlygroup_account_maprow (CR-4), awaiting second-person approval like any mapping. No automatic apply anywhere (spec D11 alternative rejected); a static test asserts the apply function has exactly one caller, the handler.- Tests: a match on
local_codeandtypeis suggested across entities whose prefixes differ (presence); a type mismatch, no match and an ambiguous match are not suggested; callingSuggestleavesgroup_account_mapunchanged (row count before and after); apply writes the rows and they are unapproved until approved; a legacy account (local_code = code) matches a template account whoselocal_codeequals it; the worked example (a company sold to another group maps with no recoding) as a test. - Verify:
go test -p 2 ./internal/platform/consolidation/..., handler tests, vitest.
What could not be verified
Section titled “What could not be verified”- Nothing was run. No Go test, no migration and no container (Docker overloaded, per the task). Every statement about existing code was read, not executed.
- The accounting and legal content of the spec is the spec’s own unverified list (its section 14); this plan adds none and verifies none.
- Facts left for Task 1: whether payables, expenses, petty cash, bank and payroll postings set
party_id; how a mid-period closing entry behaves underExcludeClosingfrom an arbitraryfrom; which journal marker identifies a year-end close; howhttpxtreats a handler error (CR-1);money’s rounding mode (CR-11); whether the seeded charts really sharelocal_codeacross orgs. account_immutable_guard’s final form on the structured-codes branch was read only as its plan describes it (CR-13); Task 3 re-reads the migration01021on the merged base.- The structured account codes branch is itself unmerged and unreviewed by me; its owner confirmations (SC-1, SC-2, optional Go-level onboarding) are still open and this plan assumes them as written.
- Parallel-task file lists are from reading the tree; a task that finds another file shared with a sibling must serialise on it.
Not in this plan
Section titled “Not in this plan”Importing off-platform trial balances (CR-6), part-year consolidation, step acquisitions, loss of control, translation reserve recycling, preference and multi-class NCI, joint ventures, the net-investment exception, deferred tax on eliminations, automatic detection of unrealized profit or inter-company transactions, cash flow and changes in equity statements, notes and disclosures, SSM or XBRL filing, any basis other than mfrs10, comparatives by re-running prior periods, any change to the journal tables, the posting engine or workflow semantics, and the other Phase 6 items.
Execution record (2026-10-04)
Section titled “Execution record (2026-10-04)”Built on branch worktree-agent-adf5524a3a3b7b582, commits on top of main 4ff55eb8. Nothing merged or pushed.
Task 1 findings (read, and exercised by the tests):
- (a)
party_idon lines: the payables, receivables, expenses, advances, petty cash and payroll posting rules all setPartyIDon the lines that matter (payables/posting.go,receivables/posting.go,expenses/posting.go,advances/posting_*.go,pettycash/*_posting.go,payroll/posting.go); banking’s journal and manual entries carry one only if the person picks it. Open design problem, needs an owner ruling: an AR or AP document posts to the organization’s singlear/apcontrol account, which cannot be a separate “due from group” account. Tagging the control account makes every external customer’s line “unidentified”; leaving it untagged makes a mapped group customer “untagged”. Either way such a run stays provisional. The spec’s tag-on-account model fits inter-company accounts kept by manual journal; for module documents it needs either a party-level rule (deriveic_receivable/ic_payablefrom the control kind when the party maps to a group counterparty) or a separate account per document. RULED 2026-10-05 (option 1) and BUILT: read-side derivation ofic_receivable/ic_payablefrom thear/apcontrol kind plus a party mapped to a group counterparty, ingroup_member_ic_balances(migration00944); spec 6.2.1. No schema, posting-rule or ledger change; unmapped member-side parties remain the manual fallback. Unmapped-party warning (ruling 1B, built on branchconsolidation-unmapped-party-warning, migration00945, spec 6.2.1): a run counts a member’sar/apparties with a balance and no active mapping (an ended mapping counts as unmapped, consistent with the derivation: migration00946, owner decision 2026-10-05, test subtest “a party whose mapping ended is counted” watched failing against the00945behaviour) and records it as the advisory checkunmapped_ar_ap_parties(present only when above zero; always passes; not a provisional reason). Verified byTestARunCountsUnmappedPartiesWithABalanceAndStaysFinalCapable(watched failing with the check disabled and with the mapped-party exclusion removed) and a vitest. Verified byTestICBalancesDeriveTheTagFromTheControlKindAndAMappedParty(watched failing with the AR branch, the AP branch, the mapped-party condition and the control-kind condition each disabled). - (b)
journal_entry.posted_seqisGENERATED ALWAYS AS IDENTITY; the watermark ismax(posted_seq), the entry count and a sha256 over per-account base balances, all to the date. - (c) A year-end close is
journal_entry.semantic = 'year_end_close';reporting.movementsleaves it out withExcludeClosing. The read function reimplements that: movement overfrom..toexcludes closing entries; opening (beforefrom) keeps them; an equity row for a closing entry beforefromis kept at its fiscal year’s average, one inside the period is left out together with the P&L it closed. - (d)
journal_line.currencyanddebit/creditare the transaction view,base_debit/base_creditthe functional; the IC read returns a single-currency transaction view per account and counterparty. - (e) Seeded charts share
local_codeacross organizations; the pre-fill matches onlocal_codeplus type. - (f)
httpxrolls the request transaction back on a handler error, so a refusal that must keep its audit rows is a 200 result, as CR-1 ruled. - (g)
moneyrounds half away from zero (one rounding to four places), not half-even: CR-11 is corrected,rounding_modeishalf_away_from_zero. - (h)
group.ReadAllowednever raises and audits allowed and refused alike.
Where the build differs from the plan (each deliberate):
- CR-2: the data functions write ONE audit row each (function, scope, period, rows returned) after a non-auditing, raising grant check; the consent pre-check
group_read_allowedwrites its own row (allowed or refused). Per member per run: onegroup_read_allowedrow and four function rows. - The owner reads its OWN books through the same functions (member = caller needs no grant and writes no audit row), instead of through
reporting.TrialBalance, so every member has one shape. group_member_watermarkalso returns the grant id (the owner cannot see the grant row) and the base currency.- Inputs are keyed
(seq, effective_from)whereseqis a database identity column, never the application clock (a fixed clock in tests madecreated_atties). - Reporting lines are not approval-gated (structure, not figures); account maps, treatments, acquisitions, associate inputs, adjustments and the opening position are.
consolidation_run_ratehas an extra classtransaction(the rate used to restate a foreign-currency balance when classifying a difference).- No
policy_adjustmententries: an approved policy adjustment appears asconsolidation_run_balancerows ofrow_kind = 'policy_adjustment', applied before translation (an entry would double count it). The kind stays in the vocabulary. - Associates take their figures from the approved
associate_inputonly; an on-platform associate’s own ledger is not read. - The pre-fill’s counterpart suggestion by registration number (D4) is not built: the member cannot see the owner’s group entities, and the owner cannot see the member’s parties, so it would need a further cross-organization function.
- The roll-forward check separates only the PARENT’s own direct equity movements (see
rollForward), and the translation reserve is shown as a closing balance (no opening reserve is held). - NCI is computed per subsidiary from effective ownership on its own books, not as a bottom-up roll of consolidated sub-subsidiaries.
Unverified accounting and legal content (spec section 14, unchanged) plus the figures this build chose: the NCI formula, goodwill held in the subsidiary’s currency and retranslated at closing with the difference in the translation reserve, the elimination of pre-acquisition equity against share capital first, a dividend’s expected share on a chain as the ratio of effective ownerships, the 0.01 rate-classification tolerance. All carry “unverified” in their code comments.