Skip to content

Phase 6 — Group Register (consolidation stage 1) 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: A register, kept by one organization (the group owner), of the entities in a group, dated shareholdings, approved relationship assessments (Companies Act s.4, MFRS 10, MFRS 128), beneficial owners, and the consent grants member organizations give the owner. No consolidation runs, eliminations or translation.

Architecture: a new platform/group package. The register tables carry the owner’s org_id with the standard org_isolation policy, so tenancy is unchanged. A consent grant carries the member’s org_id and a composite foreign key (group_id, grantee_org_id) → company_group (id, org_id) that makes the grantee rule a constraint. The one cross-organization read, group_read_allowed, is an allowlisted SECURITY DEFINER function that checks an unrevoked grant and audits in the member’s chain. Relationships are never stored: they are derived by a read function and confirmed or overridden by an approved relationship_assessment.

Tech Stack: Go, pgx v5, Postgres (goose migrations), gin, oapi-codegen, React + TanStack Router/Query + i18next, vitest + msw.

Spec: docs/superpowers/specs/2026-10-03-group-register-design.md (owner decisions in §9, all five settled; the Companies Act 2016 s.4 wording is unverified, see Gate G1).

Gates (owner or adviser, before the step they block)

Section titled “Gates (owner or adviser, before the step they block)”
  • G1 — adviser confirms Companies Act 2016 s.4 (board control, more than half the voting power, more than half the issued shares excluding preference). Blocks Task 3’s labels (subsidiary / not_subsidiary) and Task 6’s display of them; until confirmed the derivation returns presumed_* only. Tasks 1, 2, 4, 5 do not wait.
  • G2 — adviser confirms the accounting-records retention period that the retention grants (Task 1) and the revocation rule rely on. Task 1 uses the platform’s existing seven-year working figure and records it as provisional.
  • Migration range: 00920–00939 (this plan uses 00920–00923). Task 1 declares it in internal/kernel/db/migrationranges_test.go; TestEveryMigrationIsNumberedInItsOwnersRange fails otherwise.
  • A float never touches money or shares. Percentages NUMERIC(9,6), share counts NUMERIC(20,2); strings on the wire, a decimal type in Go.
  • Every business table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, policy org_isolation; every tenant foreign key is composite on org_id, except the one grant key above, which is the deliberate cross-tenant reference.
  • Every constraint is named; every function pins SET search_path = pg_catalog, public, pg_temp and schema-qualifies reads.
  • Retention: shareholding, relationship_assessment, beneficial_owner and group_access_grant never grant DELETE to the application role; rows are ended by date or revoked.
  • Vocabulary values are strings: entity kinds, tests, outcomes, grant scopes, basis (mfrs10).
  • Every user-facing name is a translation row; permission keys get en/ms/zh labels (TestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocale).
  • No handler reads an organization from a request header. The owner is the session’s organization; a member is named by a body or path id and validated against the grant.
  • The contract is api/openapi.yaml; make generate leaves the tree clean. Generated files are never edited by hand.
  • Layering: platform/group imports kernel and platform/audit-level packages only; platform/hr and others do not import it.
  • Drift tests: presence before absence, each watched failing once (AGENTS.md). Iterate on touched packages; run go test -p 2 ./... once, in Task 7, on a clean Docker.
  • Commits: one per task, ending with the attribution line the session specifies. Merging and pushing are the owner’s.
  • GR-1 Change a holding = end and insert. effective_to is exclusive. An edit never updates a percentage; group.ChangeShareholding ends the current row on the change date and inserts the new one in the same transaction.
  • GR-2 The 100% cap is a constraint trigger per (subject, share_class, day) over the periods that overlap the written row, taking the subject’s row lock first, so two concurrent writes cannot both pass.
  • GR-3 Indirect holdings. Ownership multiplies along a chain; voting passes through an entity only while that entity is presumed or assessed as controlled. The walk has a depth limit of 12 and records a cycle as circular instead of looping.
  • GR-4 Presumptions. Voting above 50% (direct or through controlled entities) is presumed_control; 20% up to that is presumed_influence. An approved assessment for the same holder, subject, test and date wins, either way. Board control comes only from an assessment or a recorded board_control fact.
  • GR-5 Assessments need a second person. approved_by differs from the asserter, enforced by a CHECK, and an unapproved row is ignored by every derivation.
  • GR-6 Beneficial-owner candidates are individuals at or above 20% of shares or voting, directly or through any chain, with no declared beneficial_owner row covering the date. They are listed, never auto-declared.
  • GR-7 Grants. Only group.grant.manage holders of the member make or revoke one. Revoke sets revoked_at; a revoked grant is never reused. group_read_allowed(group, member, scope) is true only for an unrevoked grant whose scope covers the request; it audits every call in the member’s chain.
  • GR-8 Revocation keeps history. Revoking stops new reads of the member. Records the owner already produced stay readable to the owner.
  • GR-9 Membership of several groups is allowed: group_entity is unique on (group_id, member_org_id), not on member_org_id; grants are per group.
  • GR-10 Beneficial-owner personal data is behind group.register.sensitive: identity numbers are masked for others, and every read of them is audited.

Task 1 — Migration 00920, schema guards, range

Section titled “Task 1 — Migration 00920, schema guards, range”

Files: db/migrations/00920_group_register.sql, internal/kernel/db/migrationranges_test.go, internal/platform/group/migration00920_test.go

  • Tables company_group, group_entity, shareholding, relationship_assessment, beneficial_owner, group_access_grant with RLS, named constraints, the grant composite FK, exclusion constraint on overlapping holdings (btree_gist), the 100% trigger (GR-2), the approver CHECK (GR-5), retention grants.
  • Tests (each watched failing once): grant FK refuses a grantee that is not the owner (presence: the owner is accepted); overlapping holdings refused (presence: the adjacent non-overlapping row is accepted); over-100% refused under two concurrent writes; unapproved-by-same-person refused; DELETE privilege absent on the four retention tables; Down migration restores the prior state.
  • Verify: go test -p 2 ./internal/kernel/db/... ./internal/platform/group/....

Files: internal/platform/group/{group,entity,shareholding,beneficial_owner}.go and tests

  • Create group, entities (company or individual, optional member_org_id), ChangeShareholding (GR-1), beneficial owners, each audited.
  • Verify: create, change over time, history reproduces a past date, tenant isolation across two owners.

Files: internal/platform/group/{derive,assessment}.go and tests · Gate G1 for the legal labels

  • RelationshipsAt(group, date) per GR-3/4/6, returning presumed_*, assessed outcomes, circular, and the BO candidates.
  • Tests: chain multiplication (60% of 50% is 30%), de facto control via assessment below 50%, assessment overriding above 50%, unapproved assessment ignored, cycle reported, depth limit.
  • Until G1 clears, the legal test is exposed only as presumed_*.

Files: db/migrations/00921_group_read_allowed.sql, internal/platform/group/grant.go, the SECURITY DEFINER allowlist test, tests

  • Function per GR-7; allowlist entry; not executable by PUBLIC.
  • Tests: answers with an unrevoked grant (presence), refuses without one, refuses after revoke, refuses a scope that is not covered, refuses a different group; an audit row lands in the member’s chain each call.

Files: internal/modules/group/, api/openapi.yaml, constants.yaml (then make generate), web/src/lib/i18n/locales/{en,ms,zh}/team.json, migration 00922 (Owner grants for existing organizations)

  • Keys group.register.read, .manage, .sensitive, group.grant.manage, Owner-only by default; routes behind httpx.Authorize; route-authorization and label drift tests green.
  • Verify: handler tests for 403 without each key, masked identity numbers without .sensitive.

Files: web/src/features/group/*, nav entry, i18n

  • Register screens: group, entities, shareholdings with a history view, relationship view (labels gated on G1), beneficial owners; member-side consent screen.
  • Verify: vitest, typecheck, prettier; a manual en/ms/zh, phone-width and dialog-focus pass on a seeded stack including the data-dependent screens (the Phase 5 pass could not reach them).
  • Tick Phase 6 stage 1 in docs/ROADMAP.md, add the lock order for the subject row (Task 1) to docs/LOCK_ORDER.md, run go test -p 2 ./... and the web suite once on a clean Docker.

Execution record: owner rulings (2026-10-04)

Section titled “Execution record: owner rulings (2026-10-04)”

The owner accepted the recommendations of Part B of docs/superpowers/briefs/2026-10-04-phase-6-adviser-and-owner-questions.md. Built on phase6-group-rulings, one commit each, migrations in the group range:

  • B1 (00923). DELETE revoked from erp_app on company_group and group_entity. internal/kernel/db/retention_test.go moved both to the no-delete (amendable) list; TestMigration00923RevokesDeleteOnGroupAndEntity shows the privilege held at 00922, then absent. Watched both fail with the revoke commented out.
  • B3 (00924). shareholding_immutable BEFORE UPDATE trigger: only an open row’s effective_to may be set, once, and nothing else may change. source_document_id cannot be attached afterwards (no path does). It fires before shareholding_within_whole, so an edit of a percentage now fails with shareholding_immutable; the 100% trigger still guards inserts. TestAHoldingMayOnlyBeEndedOnce runs as the table owner: the end date is accepted, then each other column, a re-end and a re-open are refused. Watched it fail with the trigger disabled. Not applied to the other three retained tables (follow-on question in the brief).
  • B2 (00925). company_group.name dropped; names are translation rows (entity_kind = 'company_group', field name, locales en, ms, zh; en required). Existing names backfilled as en rows. One name per owner is the partial unique index translation_company_group_name_once on the English row, mapped to ErrDuplicateGroupName. GET returns name (caller’s locale, English fallback) and names; POST takes names. Web form has three inputs. Tests: translation rows, fallback for an en-only group, per-owner uniqueness (present for another owner), shape, migration backfill and Down. Watched uniqueness and fallback fail once. group_entity.legal_name is unchanged.
  • B4. No change: grantee_org_id stays; TestAGrantMayOnlyNameTheOwnerOfTheGroup already covers the composite key refusing a non-owner.

Open: adviser confirmation of s.4 and the retention period; manual en/ms/zh, phone-width and focus pass on a seeded stack (not done here).

Consolidation runs, inter-company eliminations, currency translation, consolidated statements, data-returning cross-org read functions, group-level chart templates (architecture spec §20.2). Each needs its own spec.