Skip to content

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).
  • Migration range: 00940-00959. Task 2 adds {940, 959, "Phase 6 consolidation runs (2026-10-03-consolidation-runs-design.md)"} to internal/kernel/db/migrationranges_test.go in the same change as 00940 (done). Files: 00940 tables and company_group columns, 00941 tag and counterparty map, 00942 read functions, 00943 permission grants. The 1020-1039 and 960-979 ranges are other plans’; 940-959 is free on main.
  • Contract first: api/openapi.yaml and constants.yaml before handlers; make generate leaves 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 (as exchange_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, ENABLE and FORCE ROW LEVEL SECURITY, policy org_isolation, composite tenant FKs (org_id, ...). The one cross-tenant reference (party_group_counterparty.counterparty_org_id to organization) is declared the way group_entity_member_org_fk is and is accepted by tenantfk_test.go’s rule.
  • Every constraint is named; every function pins SET search_path = pg_catalog, public, pg_temp and schema-qualifies reads (searchpath_test.go, constraintnames_test.go).
  • Every SECURITY DEFINER function is allowlisted in internal/kernel/db/securitydefiner_test.go with 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 appendOnly set of internal/kernel/db/retention_test.go (the test fails for any table no list names). REVOKE UPDATE, DELETE FROM erp_app plus 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 in constants.yaml and 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: ms is statutory, zh is 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.Authorize or in api/public_routes.txt; none go in the latter.
  • Layering: platform/consolidation imports kernel and platform packages only (group, accounting/fx, accounting/chart types, reporting/export, audit); no modules/ import; nothing imports it except modules/consolidation.
  • Advisory locks: none new. Run creation takes the company_group row FOR UPDATE first (serialises runs and the retained-earnings roll-forward lookup), then reads, then writes, audit.Write last (class 1). Recorded in docs/LOCK_ORDER.md in 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. Each go test invocation 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 00921 returns a boolean). Ruling: the Go service first calls group.ReadAllowed (existing, audits allowed or refused, never raises) for every member and returns a refusal result (HTTP 200 with status: "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 how httpx treats 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, then group_member_balances read with row count); the test asserts exactly that pair and audit.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 helper group_cross_read_audit is still the single place that holds the hashed bytes (spec 3.4), and it is itself allowlisted and revoked from PUBLIC and erp_app.
  • CR-3 The caller must own the group and the member must be in it, inside the data function. group_read_allowed checks 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 verifies company_group.org_id = caller and group_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, DELETE on every input table; a row is superseded by inserting a newer row for the same key with a later effective_from (newest effective_from <= as_of, ties by created_at); removing is a tombstone row (active = false); approval is a row in one consolidation_input_approval(input_kind, input_id, approved_by, approved_at) table whose trigger looks the input’s created_by up 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-based ApproveAssessment).
  • CR-5 parent_entity_id (D1) and ic_tolerance (D13) are columns on company_group added in 00940 (nullable parent_entity_id with the composite FK (org_id, group_id, parent_entity_id) to group_entity; ic_tolerance NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK >= 0), both set through a new group.UpdateGroupSettings that audits. A run copies both. If parent_entity_id is null the parent is the entity whose member_org_id is 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) with treatment IN ('exclude','import'), approved through CR-4. v1 accepts only exclude in the service; choosing import is 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, reusing platform/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 an approved event when the creator equals the approver, when any consolidation_run_check row of that run failed, or when the run’s final_capable is false. final_capable and provisional_reasons are stored on the run at creation (they are a computed fact of that run, not a mutable status).
  • CR-8 rule_version is one integer per rule set, a constant in platform/consolidation bumped 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-in rule_version in the golden file’s header.
  • CR-9 Fiscal period gate. “Provisional” per member means any fiscal_period ending on or before to is still open; “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, journal kind of 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 as rounding_mode. The translation reserve check tolerance is 0.0001 x (number of translated lines), recorded on the run.
  • CR-12 Output exports use reporting/export and render unchanged; a consolidation statement is shaped into the existing report row types by an adapter in platform/consolidation, not by extending reporting.
  • CR-13 Migration 00941 edits account. account_immutable_guard (rewritten in 01021) fixes only type, owner_module, code, local_code, seed_key, code_template_id; consolidation_tag is editable by accounting.chart.manage through UpdateAccount. Task 3 re-reads the guard on the merged base before writing the migration.
  1. 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.
  2. One audit row (spec 12.3) versus two (spec 3.2 + 3.4). Ruled two (CR-2).
  3. Inputs “append-only with end dates” and “approved” (spec 7.7, 6.3, 6.6) cannot be done with UPDATE REVOKED (CR-4).
  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.
  5. group_read_allowed neither checks group ownership nor membership (CR-3); the spec assumes it does (“also confirms … in the same call”).
  6. Spec 5.2: Resolve refuses a reference rate older than seven days (MaxStaleness) per day, so Average over 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.
  7. Spec 7.1 “excluding the member’s own year-end closing entries” is described for an arbitrary from; reporting.TrialBalance has ExcludeClosing but 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.
  8. 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_difference per pair, never netted across pairs.
  9. D5 “import ships in v1 or follows” (spec 17): left to the owner; not planned here (CR-6).
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).

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_id on the lines that matter: receivables (read in the spec), payables (supplier_party_id is on documents; check the posting step), expenses, petty cash, bank, payroll; (b) journal_entry.posted_seq exists (00052, GENERATED ALWAYS AS IDENTITY; confirm it is on the entry and whether the watermark can use max(posted_seq) plus a count and a balance hash); (c) which journal kind marks a year-end close, how reporting.movements excludes it, and what a mid-period closing does to an ExcludeClosing read from an arbitrary from; (d) journal_line.currency and 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 share local_code; confirm in the merged chart tests); (f) how httpx treats a handler error with respect to the request transaction (CR-1); (g) money’s rounding convention (CR-11); (h) the group.ReadAllowed call 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-using accountingtest and group helpers, 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_reasons JSONB, 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 x local_code x 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 (policy and upi), group_entity_treatment, opening_position, and consolidation_input_approval.
  • Triggers: a refusing UPDATE/DELETE trigger on every run and input table; a deferred constraint trigger that every consolidation_entry balances (debit = credit over its lines); the approval trigger (CR-4); the event trigger (CR-7). Named CHECKs for every closed vocabulary. Percentages NUMERIC(9,6) in [0,100], money NUMERIC(19,4), rates NUMERIC(19,10) > 0.
  • RLS, FORCE, org_isolation on all; REVOKE UPDATE, DELETE FROM erp_app; all added to the appendOnly list in retention_test.go.
  • Tests (each watched failing once): tenant isolation across two owners (presence: owner A reads its own rows first); UPDATE and DELETE refused for erp_app after an INSERT by 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_test stay 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, in constants.yaml as consolidationTags and pinned by a test the way control_kinds is, chart/account_test.go:130); editable by accounting.chart.manage; not a control_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 to party (org_id, id); the cross-tenant FK to organization; permission group.intercompany.manage (registered here, labelled in Task 9).
  • Registration-number suggestion (D4): consolidation.SuggestCounterparties returns candidates from party.registration_no and party.tin against 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/..., then make generate clean.

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 (returns code, local_code, type, system_role, consolidation_tag, a native-currency flag, opening, movement and closing base debit and credit, and a per-member provisional flag; no account names), group_member_equity_movements, group_member_ic_balances, group_member_watermark, group_member_ledger_lines (ledger_read only). All SECURITY DEFINER, pinned search_path, REVOKE ALL FROM PUBLIC, GRANT EXECUTE TO erp_app.
  • Internal helper group_cross_read_audit writes the member-chain row (function name, group, scope, period, row count) with the exact rowHashSQL bytes in one place (00921 contains the pattern); the helper is SECURITY DEFINER, revoked from PUBLIC and erp_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 securityDefiners with a reason each.
  • Go wrappers in reads.go call group.ReadAllowed first (CR-1, CR-2).
  • Tests (each watched failing once; presence first): with a live grant, group_member_balances equals the member’s own reporting.TrialBalance over the same range (presence: non-empty); then absence: no grant, revoked grant, a balances_read grant refusing group_member_ledger_lines (while the same grant accepts group_member_balances), a caller that does not own the group, a member not in the group, an unknown scope; each returns no rows. local_code matches account.local_code for template and legacy accounts. Audit: the pair of rows lands in the member’s chain and audit.VerifyChain passes (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/....

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 constant unweighted_daily_mean; *NoRateError naming 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 MaxStaleness refuses (spec problem 6), determinism, and a static check that the file imports no float operation (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): consumes group.RelationshipsAt, classifies by approved assessment first, presumption second; refuses requires_judgement, off-platform controlled entities with no approved exclude, import choice (CR-6), unconsented members (by name, via CR-1), ownership change inside the period (from group.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 “newest effective_from <= as_of, not tombstoned, approved” (CR-4).
  • Translate (spec 5): rate classes from type and system_role; rates resolved in the owner’s session and frozen into consolidation_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 on NoRateError.
  • 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/....

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 by kind in 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 classified rate or unexplained against 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.go style, non-zero files and candidates first, a planted reference fails it); static guard: no write to journal_entry or journal_line, no import of the accounting Poster (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 row FOR UPDATE first; 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 (live group_member_watermark against 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: approved opening_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/....

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, and group.intercompany.manage (member side), Owner-only by default; the migration grants them to existing Owners and bumps perm_version, modelled on 00922 (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/....

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.Authorize with 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.Resolve is never called while displaying, spec 5.2).
  • Verify: go test -p 2 ./internal/modules/consolidation/... ./internal/app/...; make generate leaves the tree clean.

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.

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.
  • Tick only verified items in docs/ROADMAP.md, add the group-row lock order to docs/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 through group_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 approved group_account_map row. A suggestion pairs a member account with a parent account by local_code plus type (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-only group_account_map row (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_code and type is suggested across entities whose prefixes differ (presence); a type mismatch, no match and an ambiguous match are not suggested; calling Suggest leaves group_account_map unchanged (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 whose local_code equals 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.
  • 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 under ExcludeClosing from an arbitrary from; which journal marker identifies a year-end close; how httpx treats a handler error (CR-1); money’s rounding mode (CR-11); whether the seeded charts really share local_code across 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 migration 01021 on 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.

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.

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_id on lines: the payables, receivables, expenses, advances, petty cash and payroll posting rules all set PartyID on 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 single ar / ap control 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 (derive ic_receivable / ic_payable from 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 of ic_receivable / ic_payable from the ar / ap control kind plus a party mapped to a group counterparty, in group_member_ic_balances (migration 00944); 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 branch consolidation-unmapped-party-warning, migration 00945, spec 6.2.1): a run counts a member’s ar/ap parties with a balance and no active mapping (an ended mapping counts as unmapped, consistent with the derivation: migration 00946, owner decision 2026-10-05, test subtest “a party whose mapping ended is counted” watched failing against the 00945 behaviour) and records it as the advisory check unmapped_ar_ap_parties (present only when above zero; always passes; not a provisional reason). Verified by TestARunCountsUnmappedPartiesWithABalanceAndStaysFinalCapable (watched failing with the check disabled and with the mapped-party exclusion removed) and a vitest. Verified by TestICBalancesDeriveTheTagFromTheControlKindAndAMappedParty (watched failing with the AR branch, the AP branch, the mapped-party condition and the control-kind condition each disabled).
  • (b) journal_entry.posted_seq is GENERATED ALWAYS AS IDENTITY; the watermark is max(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.movements leaves it out with ExcludeClosing. The read function reimplements that: movement over from..to excludes closing entries; opening (before from) keeps them; an equity row for a closing entry before from is kept at its fiscal year’s average, one inside the period is left out together with the P&L it closed.
  • (d) journal_line.currency and debit/credit are the transaction view, base_debit/base_credit the functional; the IC read returns a single-currency transaction view per account and counterparty.
  • (e) Seeded charts share local_code across organizations; the pre-fill matches on local_code plus type.
  • (f) httpx rolls 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) money rounds half away from zero (one rounding to four places), not half-even: CR-11 is corrected, rounding_mode is half_away_from_zero.
  • (h) group.ReadAllowed never 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_allowed writes its own row (allowed or refused). Per member per run: one group_read_allowed row 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_watermark also returns the grant id (the owner cannot see the grant row) and the base currency.
  • Inputs are keyed (seq, effective_from) where seq is a database identity column, never the application clock (a fixed clock in tests made created_at ties).
  • Reporting lines are not approval-gated (structure, not figures); account maps, treatments, acquisitions, associate inputs, adjustments and the opening position are.
  • consolidation_run_rate has an extra class transaction (the rate used to restate a foreign-currency balance when classifying a difference).
  • No policy_adjustment entries: an approved policy adjustment appears as consolidation_run_balance rows of row_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_input only; 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.