Skip to content

Consolidation runs and inter-company eliminations — design (Phase 6, stage 2)

Status: DRAFT for owner sign-off. Nothing is built. Roadmap item: Phase 6, “Multi-entity consolidation and inter-company eliminations”, stage two. It rests on §17.1 / §18.2 item 5 (one org = one legal entity; consolidation is solved above the ledger) and consumes stage one, the group register (built: internal/platform/group, migrations 00920-00922, including the group_read_allowed SECURITY DEFINER function).

Reading note on accounting and legal content. Every statement below about MFRS 10, 3, 121, 127, 128, 112 or the Companies Act 2016 is from my recollection of the standards, not read from them in this session, and is marked (unverified). Section 14 collects them. Nothing here is a statutory figure; there are no thresholds, rates or section numbers asserted as fact beyond those already recorded in the register spec. An adviser confirms Section 14 before any run is shown as anything stronger than a management view.

A consolidation run is one owner-initiated, immutable computation of a group’s consolidated position at one date. Its parameters are fixed at creation and stored on the run:

Parameter Notes
group_id A company_group the calling organization owns.
period_from, as_of The group’s financial period; as_of is inclusive and is the reporting date.
presentation_currency Copied from company_group at creation. Not a free choice per run.
basis mfrs10 (the only value the group register allows today).
rule_version The version of the elimination rule set that produced it (Section 9).

A run is never edited. Fixing an input (a missing mapping, a late member entry, a corrected rate override) means a new run; the old one stays as evidence and is marked superseded by an appended event, not by an update (Section 8). This is the ledger’s append-only habit applied to reporting snapshots: what the owner showed an auditor or a bank last quarter can always be reproduced exactly, including the rates, the scope and the rule version it used.

A run is built in one transaction: it either exists complete with its checks recorded, or it does not exist (a refusal is returned to the caller and audited, not stored as a half run).

Scope is computed at as_of from the register, never typed in by the operator.

  1. Read group.RelationshipsAt(group, as_of) for the group’s parent entity (the entity whose member_org_id is the owner, or the entity the owner nominates on the group; the register does not yet name a parent, see decision D1).
  2. Classify every other entity by the approved assessment first, presumption second:
Register result at as_of Treatment Run status effect
mfrs_control = controlled (approved assessment) Full consolidation (MFRS 10) none
derived presumed_control, no assessment Full consolidation run is provisional and lists the entity as unassessed
requires_judgement on control Not consolidated; listed blocks a final run until assessed
mfrs_influence = significant_influence (assessed) or presumed Equity method (MFRS 128) presumed-only makes the run provisional
neither Not in the run none
  1. Consent per member. For every on-platform member being read, group_read_allowed(group, member, scope, as_of) must be true. A member with no live grant is refused by name (“member X has not granted balances_read”), the run is not created, and that refusal is itself audited in the member’s chain by the existing function. A revoked grant stops new reads; runs already produced stay readable (register spec decision 5, recommended yes).
  2. Off-platform controlled entities (member_org_id null) cannot be read. v1 does not import their trial balances: the run refuses unless the owner has recorded an approved exclusion (rationale, second-person approval) for that entity, which the run lists prominently and which makes it provisional (decision D5). Silently omitting a subsidiary is the failure this prevents.
  3. Ownership changes inside the period. If a controlled entity’s total ownership or voting changed between period_from and as_of (acquisition, disposal, step acquisition, dilution), v1 refuses that entity with the dates. Part-year consolidation, remeasurement and gain/loss on loss of control are MFRS 10/3 mechanics out of scope for v1 (decision D6).

Scope of read. The grant has two scopes today, ledger_read and balances_read (the latter also satisfied by the former, already built). The run needs only balances_read. ledger_read additionally lets the owner drill from an elimination difference to the member’s lines, which a group accountant needs to resolve a mismatch (Section 6.5). The two scopes are therefore different in what the read functions return, not just in name (Section 3).

The owner’s own books are a member like any other except that no grant is needed: the owner reads its own ledger in its own session, through platform/reporting’s existing trial balance and the same tag columns. The run records grant_id = null and “owner” for that row.

3. Reading member books across organizations

Section titled “3. Reading member books across organizations”

Every member’s ledger is behind FORCE ROW LEVEL SECURITY. The owner reads it only through allowlisted SECURITY DEFINER functions (the exception recorded as D1 in design §3.3 and used by group_read_allowed). Each new function follows the register spec §3 and the 00921 shape exactly:

  1. Takes (group, member, ...) as arguments. The grantee is the session tenant (app.current_org), never an argument and never from a header (existing drift test: no handler reads an organization from a request header).
  2. Confirms in the same call that the caller owns the group and that member is an entity of that group (group_entity.member_org_id), then performs the group_read_allowed check (which also writes the audit row). A refused check raises; no rows are returned on refusal.
  3. Returns only the rows the grant’s scope allows.
  4. Writes an audit row in the member’s chain, not only “allowed”: the function name, group, scope, period and row count returned. The hashed bytes must match audit.rowHashSQL (00921 already has the pattern; the new functions share one internal helper so the bytes exist in one place). Audit and read are one transaction: a read whose audit insert fails returns nothing.

Proposed functions (all in the owner’s migration range, all REVOKE ALL ... FROM PUBLIC, GRANT EXECUTE ... TO erp_app, all added to internal/kernel/db/securitydefiner_test.go’s allowlist):

Function Scope Returns
group_member_balances(group, member, from, to) balances_read Per account: code, local_code (the code without the entity prefix; equals code for a legacy account; see 2026-10-03-structured-account-codes-design.md), type, system_role, consolidation_tag (Section 6.1), native-currency flag, opening, movement and closing base-currency debit/credit, from journal_line.base_debit/base_credit. Also provisional (any fiscal_period ending on or before to still open). Account names are not returned, only codes and the type fields the rules need: the owner reads figures, not the member’s chart.
group_member_equity_movements(group, member, from, to) balances_read Equity-account net movement per entry date (no line detail, no memo, no party), so equity can be translated at the rate on the day it arose (Section 5.3).
group_member_ic_balances(group, member, from, to) balances_read For lines whose party_id the member has mapped to a group counterparty (Section 6.2): per tagged account and counterparty org, the opening, movement and closing base amounts, and the transaction currency and amount where the account is currency-restricted. Aggregates only.
group_member_watermark(group, member, to) balances_read The member ledger’s high-water mark at read time (Section 8.3).
group_member_ledger_lines(group, member, account_code, counterparty, from, to) ledger_read Line-level drill for inter-company differences. Not used by a run; used by the difference screen. Refuses balances_read grants, which is the test that shows the two scopes differ.

What these deliberately do not return: lines, memos, party identities (other than the mapped counterparty org), attachments, employee data, payroll, anything outside the trial balance and the mapped inter-company aggregates. This is data minimization by function shape, because the grant is consent to read figures for consolidation.

A grant is not date-limited by this design: while unrevoked it covers any period of that member (decision D9).

Decision: separate owner-side tables, never the ledger. Consolidation adjustments are journal-shaped (balanced debit/credit against reporting lines) but they are not transactions of any legal entity.

  • Posting them into a member’s ledger would change that company’s statutory accounts, tax computation and period-close reconciliation for the group’s convenience, and would require the member to consent to writes, which no grant provides.
  • Posting them into the owner’s ledger would mix group worksheet entries with the parent’s own entity accounts (its trial balance, its SST and WHT controls, its audit trail and its filings), make a re-run impossible without a reversal storm, and breaks the architecture’s decision that one org is one legal entity with a gapless numbered journal per fiscal year.
  • Consolidation entries need properties the ledger deliberately forbids or does not have: they reference member accounts and counterparties across organizations, they are regenerated wholesale for a new run, and they carry no document number, no fiscal-period gate and no posting rule.

So a run owns its own tables (Section 7), carrying the owner’s org_id with the standard org_isolation policy, so the existing tenant-isolation drift test covers them. Eliminations are stored as balanced entries with lines (a deferred balance constraint trigger, the same invariant as the ledger’s, but over a separate table) so the schedule is auditable as double entry, not as free-form adjustments. They are not journal_entry rows, do not use platform/numbering, and do not go through the posting engine (no PostingRule): that engine is the mechanism by which modules write to a ledger, and this is neither. A static drift test enforces the separation (Section 12).

5. Currency translation (MFRS 121) (unverified)

Section titled “5. Currency translation (MFRS 121) (unverified)”

Each member keeps its books in its own functional currency, which this platform stores as the organization’s base currency (§17.2, per organization on the company profile; journal_line has base_debit/base_credit in it). Translation is to the group’s presentation currency. A member whose base currency equals the presentation currency is translated at 1 (fx.SourceIdentity).

Class Items Rate
closing all assets and liabilities (including goodwill and fair-value adjustments of a foreign operation (unverified)) closing rate at as_of
average income and expenses average for the period (decision D3)
historical share capital, pre-acquisition reserves, and other equity accounts the rate on the day of each movement
balancing translation reserve difference, in OCI; never entered by hand

The class comes from the account’s type (and system_role for retained earnings), not from a per-line setting.

5.2 Existing rate sources, and what is missing

Section titled “5.2 Existing rate sources, and what is missing”

What exists, read in internal/platform/accounting/fx: fx_reference_rate (Bank Negara’s published rates, MYR per one unit, global, append-only, one session per day today, never edited) and exchange_rate (the owner’s append-only overrides, newest wins), resolved through fx.Resolve(from, to, day) via the MYR hub. Resolve refuses a reference rate older than seven days (MaxStaleness).

What is missing: there is no average-rate function and nothing returns a rate for a period. The run therefore needs one small addition, in platform/accounting/fx, not in the new package: Average(from, to, rangeStart, rangeEnd) = the unweighted arithmetic mean of the resolved daily reference rates for each calendar day in the period (a day with no published rate takes the latest earlier one, as Resolve does), with overrides honoured the same way. This is a stated approximation (MFRS 121 permits an average where rates do not fluctuate significantly (unverified)), recorded on the run with its method name so a different method can be added as a new method value, not a silent change. A currency with no usable rate for any day refuses the run, naming the currency and date range (*fx.NoRateError, which exists).

Rates are resolved in the owner’s session (so the owner’s overrides apply), and frozen into the run (consolidation_run_rate: currency pair, class, rate, source, as-of date or range, method). A later override or feed change does not alter a stored run. A run never calls fx.Resolve when it is displayed.

Equity at historical needs the date of each movement, which is why group_member_equity_movements exists. Retained earnings is the accumulation of each prior year’s result translated at that year’s average rate (not the year-end closing entry translated at the closing-entry date’s rate). The member’s closing entries are identified by system_role = retained_earnings, and the function returns prior years’ results separately from current-year movement.

The translation reserve for a member is the balancing figure needed to make the translated trial balance balance: translated net assets at closing rate, less translated equity (historical) and current-period result (average). To make it a check rather than a plug, the engine also computes the reserve movement analytically (opening net assets x (closing - opening rate), plus the period result x (closing - average rate), plus equity movements x (closing - rate at the day)) and the run is refused if the two differ by more than a rounding tolerance recorded on the run. The reserve is presented in OCI and in a separate equity component; the NCI share of it goes to NCI (Section 7.4). A reserve is recycled to profit or loss on disposal of a foreign operation (unverified), which is out of scope with disposals.

Exchange differences on an inter-company monetary item between entities with different functional currencies are not eliminated in consolidated profit or loss (unverified, MFRS 121 treatment of intra-group items, and the net-investment exception). The run compares inter-company balances in the presentation currency (Section 6.5); a residual caused only by rates is labelled rate in the schedule, not unexplained, and left in the result. Net-investment-in-a- foreign-operation exceptions are out of scope (decision D7).

6. Inter-company identification and elimination

Section titled “6. Inter-company identification and elimination”

6.1 Tagging: semantic tags on accounts, never on states

Section titled “6.1 Tagging: semantic tags on accounts, never on states”

accounting/ reacts to semantic tags, never state names; consolidation follows the rule. A new nullable column account.consolidation_tag, closed vocabulary of strings (migration, named CHECK, mirrored in constants.yaml the way control_kind is, and pinned by a test the same way):

ic_receivable, ic_payable, ic_revenue, ic_cost, ic_loan_asset, ic_loan_liability, ic_interest_income, ic_interest_expense, ic_dividend_income, ic_dividend_paid, ic_investment (cost of an investment in a group entity), ic_stock (holds goods bought from a group entity).

A tag says what kind of inter-company account this is, set by the member’s accountant on the account (permission accounting.chart.manage, existing). Workflow states and semantic roles are untouched: renaming “Reviewed” to “HOD Sign-off” cannot change a consolidation, because nothing here reads a state. It is not a control_kind (that vocabulary drives posting resolution; adding a consolidation concept to it would change posting behaviour). The tag is only ever read through group_member_balances.

The tag says the account is inter-company; who the counterparty is comes from the line’s party_id, which receivables posting sets on every line of an invoice (read in receivables/posting.go; payables and others not audited, task 1 verifies each posting rule sets it). A member-side table, party_group_counterparty (org_id = member, party_id, counterparty_org_id), maps a party to the platform organization it stands for. It is made and ended by the member’s own administrator (new permission group.intercompany.manage), is append-only with end dates, and the read function validates that counterparty_org_id is a member entity of the same group, so a party cannot be mapped to an organization outside the group, and a member cannot invent a counterparty the group does not recognise. The table is member-owned (RLS) because the party is.

A transaction is inter-company when its line is on an account carrying an ic_* tag and its party maps to a counterparty in the group. Either alone is not enough: a tagged account with an unmapped party is listed as unidentified in the run (and makes it provisional); a mapped party on an untagged account is listed as untagged. Both are visible because silently absent eliminations understate the problem. Registration number (party.registration_no, party.tin) is used only to suggest a mapping to the administrator, never to apply it (decision D4).

6.2.1 Items posted by the AR and AP modules (owner ruling 2026-10-05, option 1)

Section titled “6.2.1 Items posted by the AR and AP modules (owner ruling 2026-10-05, option 1)”

AR and AP documents post to the organization’s single ar / ap control account, which cannot carry an ic_* tag without flagging every external party. They are therefore identified on the read side only: in group_member_ic_balances (migration 00944), a journal line on an account whose control_kind is ar (read as ic_receivable) or ap (read as ic_payable), and whose party_id maps to a group counterparty in party_group_counterparty (the same active, in-group mapping 6.2 requires), carries that derived tag. An account’s own consolidation_tag always wins, so manual inter-company accounts are unchanged. A line on an ar/ap account whose party is not mapped is not read at all (an external customer is not “unidentified”); an unmapped member-side party of a group company stays the manual-elimination fallback. No column on journal_line, no posting-rule change and no ledger mutation. A tag derived from control kind plus party is a semantic tag: nothing here reads a state name. It applies only to ar and ap; no other account, control or not, gets a derived tag.

Unmapped parties are reported, not guessed (owner ruling 2026-10-05, option 1B). Because an unmapped party is invisible to the derivation and we cannot know whether it is a group company, a run reports a count of the member’s ar/ap parties that carry a non-zero balance (net per party and control kind, base currency, to the run’s as_of) and are not currently mapped in party_group_counterparty for the group (the latest row as of as_of is not active; an ended mapping counts as unmapped, consistent with the derivation, which ignores it; owner ruling 2026-10-05). Migration 00945 adds group_member_unmapped_party_count (00946 makes an ended mapping count) (SECURITY DEFINER, balances_read, returns a number, never a name). The run records it as the check unmapped_ar_ap_parties, with detail.unmapped_parties (total) and detail.members (count by member name), only when the total is above zero. The check always passes: it is advisory, is never a provisional reason, never counts toward checks_failed, and does not change a run’s status. The run page shows it as a warning: “N customers/suppliers with balances are not mapped as group counterparties; if any is a group company, map it or eliminate manually.” It does not claim any of them is a group party.

6.3 Investment against equity (MFRS 10, MFRS 3) (unverified)

Section titled “6.3 Investment against equity (MFRS 10, MFRS 3) (unverified)”

For each fully consolidated subsidiary, eliminate the parent’s ic_investment carrying amount against the subsidiary’s equity at the acquisition date, recognise goodwill, and attribute post-acquisition reserves.

Acquisition accounting involves fair values of identifiable assets and liabilities, contingent consideration and the choice of NCI measurement basis (unverified): judgments a trial balance cannot supply. They are therefore owner-entered inputs, never inferred:

acquisition_record (org_id = owner, group_id, subsidiary entity, acquisition date, consideration, fair-value adjustments to identifiable net assets, NCI measurement basis, notes, entered_by, approved_by): append-only, dated, second-person approved, one current row per subsidiary.

The engine reads the subsidiary’s equity at the acquisition date through the same function (group_member_balances at that date; the ledger can be read at any date) when the member was on the platform then, and otherwise takes it from the record as a stated input. It computes goodwill = consideration + NCI measured as per the record - fair value of identifiable net assets; a negative result (bargain purchase) is refused to a person, not booked automatically (decision D8). Goodwill impairment is an approved, dated input, not computed. Goodwill is held in the foreign operation’s currency and translated at closing (unverified).

6.4 Elimination entry types (closed vocabulary of strings)

Section titled “6.4 Elimination entry types (closed vocabulary of strings)”

Each is a balanced entry with a kind, a rule name and the rule_version, with lines against group reporting lines (Section 7.2) and source references (member, account code, counterparty) so that every figure traces to the member balance it came from.

kind Eliminates Notes
investment_equity investment vs subsidiary’s pre-acquisition equity; goodwill; NCI at acquisition Section 6.3
ic_balance ic_receivable against ic_payable (and loans) per pair Section 6.5
ic_income_expense ic_revenue against ic_cost; interest by pair; goes to zero net of differences
ic_dividend ic_dividend_income against ic_dividend_paid Dividend to NCI is not eliminated (it leaves the group) and reduces NCI
unrealized_profit profit on goods still in stock Section 6.6
nci_attribution NCI share of result, OCI and net assets Section 7.4
equity_method associates Section 7.5
policy_adjustment an approved difference in accounting policy Section 7.1
ic_difference residual of an unmatched pair classified rate / unexplained

Rules are code, deterministic, registered by kind in platform/consolidation. Same inputs, same output; a golden-file test pins it (Section 12). Changing a rule is a new rule_version.

For each ordered pair (A, B) the engine takes A’s ic_receivable against B’s ic_payable at as_of (and the same for loans, income and cost) after translation to the presentation currency, and eliminates the matching amount. The difference is not hidden: timing (goods or cash in transit), a disputed invoice, rate, or an error on one side. Each difference becomes an ic_difference line, classified as rate when recomputing at a common rate removes it, else unexplained. A group-level tolerance (stored on the group, default zero, recorded on the run) decides which differences a run may carry: any unexplained amount over it makes the run provisional and unapprovable until an approver accepts it with a reason (an appended event). With ledger_read the owner can drill into either side’s lines to resolve; with balances_read only the aggregates are visible and the difference is a fact the member must be asked about.

6.6 Unrealized profit in inventory (unverified)

Section titled “6.6 Unrealized profit in inventory (unverified)”

Profit on goods sold between group entities and still in the buyer’s stock at as_of is eliminated in full against the seller; for upstream sales by a subsidiary, the elimination is shared with NCI, and for downstream sales by the parent it is borne entirely by the parent (unverified). Any tax effect (deferred tax on the elimination, MFRS 112) is an input from the owner’s tax adviser, not computed (unverified).

Whether the goods are still in stock and at what margin cannot be derived across organizations from a trial balance. platform/inventory is per-org and a stock line does not know which supplier was a group entity. v1 therefore takes UPI as an input: upi_adjustment (org_id = owner, group, seller, buyer, as_of, goods still in stock at transfer price, margin percent, direction, evidence, entered_by, approved_by). The engine computes the entry and the NCI split; people supply the stock fact; a second person approves. The opening UPI (previous year) is reversed against retained earnings from the approved input set of the prior year-end, not from a prior run (a prior run may not exist). Automatic detection is out of scope (decision D10).

7.1 Uniform policies, group chart and mapping

Section titled “7.1 Uniform policies, group chart and mapping”

Members have their own charts (customer-editable, seeded per country pack). A run needs one vocabulary:

  • group_reporting_line (org_id = owner, group, code, kind asset|liability|equity|income|expense, statement, sort) with names as translation rows, never a column; the owner’s group chart.
  • group_account_map (org_id = owner, group, member org, member account code, reporting line), owner-managed, dated. An account with a non-zero balance and no mapping refuses the run, naming the member and account code. This is the “mapping complete” control; a member renaming or recoding an account surfaces as unmapped, never as a silent drop.

Uniform accounting policies (MFRS 10 requires them (unverified)): when a member’s policy differs (depreciation method, inventory costing: the platform supports several methods per org), the owner enters an approved dated policy_adjustment input with a rationale; the engine applies it as a policy_adjustment entry before translation, in the member’s currency. v1 does not detect differences; it makes them recordable and visible.

Differing period ends. MFRS 10 expects the same reporting date, with limits on the gap and adjustment for significant events (unverified). On this platform the ledger is date-addressable, so a member’s books can be read as of the group’s date regardless of that member’s own fiscal year. The run’s P&L for a member is its movement from period_from to as_of excluding the member’s own year-end closing entries; pre-period_from unclosed profit is treated as part of opening retained earnings. The read functions take from / to for this reason. What is not solved: a member whose books are not closed at as_of is provisional (open period), and cut-off errors in a member’s own accounting are the member’s. A member on a different fiscal year is shown on the run with both dates, so the difference is disclosed rather than hidden.

7.2 Pipeline (in order, in one transaction)

Section titled “7.2 Pipeline (in order, in one transaction)”
  1. Resolve scope and consent (Section 2); refuse on any hard gap.
  2. Read each member (Section 3); store the raw member balances.
  3. Map to group reporting lines; refuse on unmapped (7.1).
  4. Apply approved policy_adjustment inputs.
  5. Translate (Section 5); freeze rates.
  6. Aggregate: sum members line by line. Check: aggregate debits = credits.
  7. Eliminate, in this order: investment_equity, ic_balance, ic_income_expense, ic_dividend, unrealized_profit, ic_difference. Check: every entry balances.
  8. Attribute NCI; recognise associates.
  9. Produce results; run the closing checks (7.6); store the run.

A controlled subsidiary is consolidated 100% line by line; the owners’ and NCI shares are attributed afterwards from the effective ownership, computed by the register’s derivation (TotalOwnership through chains: 60% of a company holding 50% of another is 30% of the latter). Voting percent decides control (Section 2); ownership percent decides attribution. They can differ and the run records both per member.

NCI in each subsidiary = (1 - effective ownership) x [its net assets at as_of] adjusted for the acquisition record’s NCI basis (Section 6.3), and its share of the period result, OCI and (Section 5.3) translation reserve. In multi-level groups NCI is computed per subsidiary on its own net assets including its consolidated sub-subsidiaries, which is why the derivation’s chain products are used and why entities are processed bottom-up. Preference shares and other classes with rights different from ordinary shares are out of scope (the register’s share class exists but v1 treats all classes of one subject as one proportion); a subsidiary whose NCI structure needs that is refused with a reason (decision D6).

7.5 Associates (MFRS 128 equity method) (unverified)

Section titled “7.5 Associates (MFRS 128 equity method) (unverified)”

An associate is not line-by-line consolidated. The carrying amount is: cost (from the ic_investment account) + the group’s share of post-acquisition profit and OCI - dividends received

  • impairment, in one line on the balance sheet and one line in profit or loss. Inputs: the associate’s results from its own trial balance when it is on the platform and has granted balances_read (same read functions), otherwise an approved associate_input (results for the period, OCI, dividends, as of date, evidence). Associate policies must match the group’s (unverified); the same policy_adjustment mechanism applies. Unrealized profit on transactions with an associate is eliminated to the extent of the group’s interest (unverified) and is a UPI input of the same shape. Joint ventures (MFRS 11) are not in the register and are out of scope.

7.6 Closing checks (recorded on the run, each with pass/fail and the figures)

Section titled “7.6 Closing checks (recorded on the run, each with pass/fail and the figures)”
  • every stage balances: members raw, translated, aggregate, post-elimination;
  • consolidated balance sheet balances (assets = liabilities + equity incl. NCI);
  • translation reserve: balancing figure equals the analytic computation (5.3);
  • each ic_* pair nets to zero after elimination apart from recorded ic_difference lines;
  • retained earnings roll-forward: opening retained earnings = the previous final run’s closing figure for the same group and basis (first run for a group takes an owner-approved opening position input; a mismatch is shown, not hidden);
  • ownership: the sum of owners’ and NCI shares = 100% for every subsidiary;
  • scope: every in-scope entity is read, excluded with approval, or named as a blocker.

7.7 Tables (owner org_id, forced RLS, org_isolation, retention-bearing)

Section titled “7.7 Tables (owner org_id, forced RLS, org_isolation, retention-bearing)”

Run tables, append-only (REVOKE UPDATE, DELETE, plus a trigger that refuses both so a superuser slip in the app path is also loud): consolidation_run, consolidation_run_member, consolidation_run_rate, consolidation_run_balance (member x member account code x reporting line, native and translated), consolidation_entry, consolidation_entry_line, consolidation_run_result (reporting line x stage), consolidation_run_check, consolidation_run_event (approved, superseded, difference accepted, each a new row).

Input tables, append-only with end dates (never edited): group_reporting_line, group_account_map, acquisition_record, associate_input, adjustment_input (policy and UPI), group_exclusion, opening_position. All figures NUMERIC(19,4), rates NUMERIC(19,10) (matching exchange_rate), percentages NUMERIC(9,6), all strings on the wire; money.Amount / money.Rate in Go. No float anywhere, including the average-rate computation (decimal arithmetic, a fixed rounding mode recorded on the run). One member-side table: party_group_counterparty (Section 6.2).

  1. Never edited. A run’s rows have no UPDATE or DELETE path.
  2. Status is derived, not stored. “approved”, “superseded” and “difference accepted” come from consolidation_run_event rows; a run has no mutable status column. A run whose checks failed cannot be approved (a CHECK across the event and check tables, or a refusal in the service plus a trigger).
  3. Staleness evidence. At read time the run stores each member’s ledger watermark (group_member_watermark: highest posted_seq of posted entries, the entry count to as_of, and a hash over the per-account balances; posted_seq exists from the ledger entry guard per 00051’s comment (not re-read here, task 1 confirms the column)). A later check of whether the member’s books have since changed (a period reopened, a late back-dated entry) compares the live watermark to the stored one and shows “member X changed after this run”, without altering the run. The remedy is a new run.
  4. Provisional vs final. A run is final-capable only when: every in-scope member read, every member period to as_of closed or locked, no unassessed entity, no unidentified or untagged IC item, no exclusion unapproved, no unexplained difference over tolerance, all checks pass. Otherwise it is provisional, labelled so on every output and every export. Only a final-capable run can be approved.
  5. Approval is a separate event by someone other than the creator (consolidation.run.approve, a database CHECK that creator differs from approver, the register’s assessment rule). A claimant never picks their own approver: grants are administrator-configured and system-resolved, as for every approval here.

New keys, Owner-only by default, each with an en / ms / zh team-screen label (existing label drift test) and routes declared in api/openapi.yaml first, every route behind httpx.Authorize:

  • consolidation.run.read, consolidation.run.create, consolidation.run.approve
  • consolidation.input.manage (group chart, mapping, acquisition, UPI, policy, exclusions)
  • consolidation.input.approve (the second person for inputs)
  • group.intercompany.manage on the member side (the party map), next to group.grant.manage.

Audit. Owner chain: run created (parameters, scope list, result checksum, rule version), every input write and approval, run approved or superseded. Member chain: one row for every cross-org read (Section 3), shown to the member in a “who read my figures” list built from its own audit rows, so consent is visible, not just recorded.

Retention. Runs and inputs support statutory accounts and are retention-bearing: the application role has no privilege that would let one be erased (existing retention drift test must see them). The retention period is unverified (register spec, gate G2) and applies unchanged.

All read the stored run, never recompute, and say provisional or final at the top.

  1. Consolidated trial balance: by reporting line, with stage columns (aggregate, eliminations, consolidated) and the checks.
  2. Consolidated statement of financial position: assets, liabilities, equity attributable to owners (with the translation reserve as a separate component) and NCI.
  3. Consolidated statement of profit or loss and other comprehensive income: profit attributable to owners and to NCI; OCI with the translation reserve movement; the share of associates as one line.
  4. Per-member contribution: each member’s translated lines, the eliminations attributable to it, and its NCI, so a member’s effect on the group is traceable.
  5. Elimination schedule: every entry by kind with its source references, ordered by pair, showing differences and their classification.

Export (CSV / XLSX / PDF) reuses platform/reporting/export and render; statutory headings come from translation rows (the ms locale is statutory for record-bearing text, zh is UI only). Not produced: statement of changes in equity, cash flow statement, notes, segment and related party disclosures, or comparatives as recomputation (a comparative column is a reference to a prior run, deferred below).

11. Companies Act 2016 and Malaysian specifics (unverified)

Section titled “11. Companies Act 2016 and Malaysian specifics (unverified)”

What I believe, to be confirmed, and not asserted as law:

  • A holding company is expected to prepare group accounts alongside its own, subject to exemptions I recall for some wholly-owned or intermediate parents. I did not verify the section numbers, the exemptions or the conditions, and the register spec already records that even section 4’s wording was not read by me.
  • Directors’ obligations on true-and-fair view, the filing and audit of group accounts, and the retention period of accounting records are adviser matters. The retention period is the open register gate G2 and is reused here.
  • Private entities may report under a framework other than full MFRS (unverified, MPERS). This design supports mfrs10 only; a group that reports on a different basis cannot use the run as statutory accounts. The basis column exists so a later basis is a new value, not a rewrite.
  • MFRS 127 as it affects the separate (parent) financial statements is the owner’s own ledger’s matter, not consolidation’s.

The run is therefore labelled a management consolidation until the adviser has confirmed Section 14. That label is a banner on the output, not a mode.

Each follows “establish the presence before you assert the absence” and is watched failing once (break the protection, read the message, restore):

  1. Tenant isolation. Every new table is isolated: assert a non-zero candidate count first (existing test must see the new tables), then that a second org sees nothing; watch it fail by dropping one policy.
  2. Cross-org reads. Each new function: presence (a member with a live grant of the right scope returns its figures; the figures equal the member’s own reporting.TrialBalance), then absence (no grant refuses; a revoked grant refuses; a balances_read grant refuses group_member_ledger_lines; a caller who does not own the group refuses; a member that is not in the group refuses). Each is in the SECURITY DEFINER allowlist, not executable by PUBLIC (existing test).
  3. Audit in the member’s chain. After a read, the member’s chain has one new row and audit.VerifyChain passes; a refused read also leaves a row. Watch it fail by hashing a different byte string.
  4. Append-only. UPDATE and DELETE on every run table are refused for the app role, after an INSERT by the same role succeeds. The retention-bearing test must see them.
  5. Never in a ledger. A static guard walks internal/platform/consolidation and asserts it read a non-zero number of files and found a non-zero number of SQL strings before asserting none name journal_entry or journal_line for writing, and none import the accounting Poster. Watch it fail by planting one write.
  6. Balance. An unbalanced consolidation entry is refused (a balanced one is accepted first); the deferred trigger is exercised.
  7. Elimination zero-sum. On a fixture group, the ic_* tagged lines are non-zero in aggregate before elimination (presence), net to the recorded differences after.
  8. Determinism. The same inputs twice produce the same result checksum; a golden-file test pins the schedule for a fixture covering a foreign subsidiary, an NCI of 30%, an associate, a UPI and an unmatched balance. Watch it fail by changing a rule without bumping rule_version.
  9. Rates frozen. A run stores its rates; a later override changes fx.Resolve (presence) but not the stored run or its outputs.
  10. Translation reserve. The analytic movement equals the balancing figure on the fixture; a deliberate rate error in the fixture makes it fail.
  11. Refusals. Each of: unmapped account, unconsented member, ownership change in the period, off-platform controlled entity without exclusion, missing rate. Each has a case showing the adjacent valid input is accepted.
  12. Tags, not names. The existing nosst_test.go style: platform/consolidation names no workflow state or semantic role; a planted reference fails it.
  13. Tag vocabulary. account.consolidation_tag CHECK matches constants.yaml (as control_kind does).
  14. No float. The money guard sees the new package (non-zero candidate count first).
  15. Routes and permissions. Every route behind httpx.Authorize; the new keys have labels in three locales; the creator cannot approve their own run.
  16. Migration range. New migrations fall in 940-959 (extending the register’s declared 920-939 test).
Section titled “13. Owner decisions (recommended option first)”
# Decision Recommended Alternative
D1 Which entity is the parent of a group for scope? The register does not name one. Add a nullable parent_entity_id on company_group (set by the owner; defaults to the entity whose member_org_id is the owner). Always the owner’s entity; a group whose owner is not the ultimate parent cannot run.
D2 Consolidation journals: separate tables (Section 4). Separate owner-side tables, never a ledger. A “consolidation org” with a ledger (reuses posting, but then each run is a reversal storm and an org is a legal entity).
D3 Average rate method. Unweighted mean of daily resolved reference rates, method recorded on the run. Month-end average, or a user-supplied rate per period (overridden with reason).
D4 Inter-company counterparty. Member-administered explicit party mapping, registration number only as a suggestion. Match on registration number automatically (no member setup, fuzzy and wrong when a number is mistyped).
D5 Off-platform controlled entities. Refuse unless an approved exclusion is recorded; list prominently. Import their trial balances (a CSV imports flow) as a later stage.
D6 Ownership changes in the period, multi-class shares, preference shares, step acquisitions. Refuse the entity in v1 with the reason. Part-year consolidation in v1.
D7 Net-investment exception and intra-group FX. Leave FX on IC balances in the result, classified rate. Model net investment loans.
D8 Bargain purchase and goodwill impairment. Refused to a person; impairment is an approved input. Compute automatically.
D9 Is a grant date-limited? No; while unrevoked it covers all periods of that member. Grant carries effective_from.
D10 Unrealized profit in stock. Input-based (Section 6.6); detection later. Cross-org stock detection (needs a supplier-is-group-entity flag in inventory).
D11 Final vs provisional. Only a run with no unassessed, unidentified, unmatched-over-tolerance or open-period item can be approved. Allow approval of a provisional run with a recorded reason.
D12 Opening position for the first run of a group. Owner-approved opening_position input, then every run checks its opening against the previous final run. Require starting the group at the beginning of a financial year from zero.
D13 IC tolerance. Group-level, default zero, recorded on the run. A global constant (rejected: it hides unmatched amounts).
D14 Group chart and mapping owner. Owner administers; mapping by member account code, stale codes surface as unmapped. Map by member account id (the owner cannot see ids; would need a new function return).
D15 Comparatives. A comparative is a reference to a prior final run for the previous period; no recomputation. Not in v1; the statutory statements are a later stage.
D16 Who the adviser is and when. The adviser confirms Section 14 before the first run is shown outside the owner’s team. Proceed and label management-only.

Everything marked (unverified) above, in one list, so the adviser has a review set:

  1. MFRS 10: control, consolidation procedures, uniform policies, differing reporting dates and the permitted gap, elimination of intragroup balances and profits in full, allocation of upstream UPI to NCI, no part-year mechanics in v1.
  2. MFRS 3: goodwill formula, NCI measurement basis options, fair value of identifiable net assets, bargain purchase.
  3. MFRS 121: rate classes, average rate approximation, historical rate for equity, goodwill of a foreign operation as an asset of that operation at closing, the reserve in OCI and its recycling, intra-group monetary items and the net-investment exception.
  4. MFRS 127 (separate statements) and 128 (equity method, policy alignment, UPI with associates), MFRS 11 (joint ventures, excluded), MFRS 112 (deferred tax on eliminations).
  5. Companies Act 2016: the group-accounts duty, its exemptions, the section numbers, the filing and audit obligations, the retention period, plus MPERS applicability.
  6. Whether each posting rule (payables, expenses, payroll, petty cash, bank) sets party_id on the lines that matter; only receivables was read here.
  7. That journal_entry.posted_seq exists in the form the watermark assumes.
  8. That the platform’s seeded charts share account codes across members, which decides how much mapping the owner must do by hand.
  9. Whether the existing platform/reporting trial balance supports the “exclude the member’s own closing entries from period_from” view for an arbitrary start (it has ExcludeClosing; the interaction with a mid-period closing entry is not read).

Migration range 00940-00959 (stage one’s 920-939 is declared; the range test is extended). Tasks are independent after Task 1.

  1. Audit and fixtures. Verify each posting rule’s party_id and the posted_seq and closing-entry facts above; build consolidationtest fixtures (three orgs, a foreign subsidiary, NCI, an associate). No schema yet; output is a short findings note in the plan.
  2. 00940 Tables and constraints: run, member, rate, balance, entry, entry line, result, check, event; inputs; RLS, FORCE, org_isolation, retention privileges, append-only triggers, balance trigger. Range declared.
  3. 00941 account.consolidation_tag (CHECK, constants.yaml, chart UI field and labels) and the member-side party_group_counterparty with permission group.intercompany.manage.
  4. 00942 The cross-org read functions, one shared audit helper, allowlist entries, refusal and presence tests (the highest-risk task; review it first).
  5. platform/accounting/fx: Average and the method value; tests against the existing sources.
  6. platform/consolidation: scope resolution (consumes group.RelationshipsAt), mapping check, translation, aggregation.
  7. Elimination rules: each kind with its golden file and rule_version.
  8. Run creation, closing checks, events, approval; staleness check.
  9. 00943 Permission grants for existing Owners (like 00922), permission labels in three locales.
  10. API in api/openapi.yaml first, handlers, generated client.
  11. Web: group chart and mapping, inputs (acquisition, UPI, exclusion), run list and creation, the five outputs, the member-side counterparty map and “who read my figures” list; en / ms / zh, phone width.
  12. Drift tests of Section 12 collected and each watched failing once; the full suite with -p 2 once before commit.
  13. Roadmap and docs: tick items, record what stays open (Section 14).

Part-year consolidation, acquisitions and disposals within a period, step acquisitions, loss of control, and recycling of the translation reserve; preference and multi-class NCI; joint ventures (MFRS 11); the net-investment-in-a-foreign-operation exception; deferred tax on eliminations (an input at most); automatic detection of unrealized profit or inter-company transactions from stock or documents; importing off-platform members’ trial balances; a cash flow statement, statement of changes in equity, notes and disclosures; statutory filing (SSM) or XBRL; MPERS or any basis other than mfrs10; comparatives computed by re-running prior periods; a consolidated chart that changes mid-period; the module-extraction and AI items of Phase 6; any change to the existing journal tables, the posting engine or workflow semantics.

D1-D4 and D6-D16: the recommended option, as written in Section 13.

D5 (off-platform controlled entities): the owner wants the choice per entity, not one rule: each off-platform controlled entity is either excluded (approved exclusion, listed prominently) or imported (its trial balance supplied through the imports flow). Exclusion ships in v1; the import path is the later stage Section 13 names, and the register records the per-entity choice now. Open for the plan: whether import ships in v1 or follows.

group_member_balances also returns local_code, so the group-map pre-fill can match on local_code plus type when entity prefixes differ. Required by the structured account codes spec; Task 4 must include it.