Skip to content

Plan 2b — Org Structure and Onboarding 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. Steps use checkbox (- [ ]) syntax for tracking.

Goal: An operator can create an organization from the command line, its first Owner can sign in and invite colleagues, and the org’s unit tree, position tree and assignments are complete enough for Plan 3’s workflow grants to resolve “the claimant’s superior” against them.

Architecture: Two materialized-path trees — org_unit (structure) and org_position (seats) — plus member_position joining people to seats over time windows. Paths are maintained in Go inside the caller’s transaction; the one constraint Go cannot win a race on (at most one primary position per member at any instant) is an EXCLUDE USING gist in Postgres. Resolution of the reporting line is a platform package so the workflow engine can call it without importing a module. Onboarding is operator-provisioned: a cmd/orgadmin CLI creates the org, the first user, the member and the company profile in one transaction, then runs the existing provisioning registry.

Tech Stack: Go 1.26 · Gin · pgx v5 · goose · testcontainers-go · testify · btree_gist (Postgres contrib)

Specs:

  • Go floor is 1.26.0. CI derives its version from go.mod; never restate a version.
  • Every business table carries org_id UUID NOT NULL, has ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation. The drift test at internal/kernel/db/rlscoverage_test.go enforces this and will fail the build otherwise.
  • An exemption is a decision: db/rls_exempt.txt and db/org_id_nullable.txt each require a written justification in the file.
  • Dependencies point downward only: modules/ → platform/ → kernel/. Enforced by internal/kernel/layering/layering_test.go. Nothing under internal/modules may touch users, session_family, refresh_token or invitation directly.
  • Every request runs inside a transaction, including reads, because SET LOCAL app.current_org is transaction-scoped. Use db.InTx.
  • Errors are RFC 7807 problem+json. Handlers record an *apperr.Problem with c.Error(...) and never write an error body.
  • Times are UTC through kernel/clock. Never call time.Now() in code under test.
  • Record-bearing free text is English or Bahasa Malaysia (§11A.3). zh is a UI locale only.
  • Audited actions write to audit_log on the same pgx.Tx as the change.
  • Org structure is never deleted (§4.5 of this plan’s spec). Units and positions deactivate; assignments close with valid_to. REVOKE DELETE is part of the migration, and the retention drift test enforces it.
  • Presence before absence. Every test asserting something is absent must first establish it present without the mechanism under test. See AGENTS.md § “Establish the presence before you assert the absence”. Nine vacuous tests were found on the previous branch; do not add a tenth.
  • Every new route is registered through httpx.Protected or listed in api/public_routes.txt and in internal/app.Router’s unauthenticated list. httpx.Public panics at wiring time otherwise.
  • Migration numbers are assigned by this plan. 00018 org structure, 00019 invitation, 00021 tenant foreign keys. (00020 belongs to a Plan 2 fix on another branch.) Do not renumber; do not pick your own.
  • Git commits end with: Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

File Responsibility
00018_org_structure.sql org_unit, org_position, member_position; btree_gist; organization_member.manager_override_id; RLS; DELETE revokes.
00019_invitation.sql invitation — global-shell adjacent but org-scoped; RLS-protected.
Path Responsibility
internal/platform/orgstruct/ Both trees, assignments, and reporting-line resolution. The package Plan 3 calls.
internal/platform/identity/invite/ Invitation issue, verify, accept. Owns invitation.
internal/modules/orgstructure/ HTTP surface for units, positions, assignments, chart.
internal/modules/activity/ HTTP surface for the activity feed. One route.
cmd/orgadmin/ Operator CLI: create an organization and its first Owner.
Path Change
internal/platform/provisioning/provisioning.go Check gains a warnings return; Registry.Run carries them into the readiness report.
internal/platform/provisioning/steps.go Step 50 org_structure.
internal/kernel/db/retention_test.go Three more tables in the no-DELETE set.
internal/app/app.go Register the two new modules; add the accept route to unauthenticated.
internal/modules/identity/routes.go Invitation endpoints.
api/public_routes.txt, api/openapi.yaml The accept route and every new operation.
docs/ROADMAP.md Tick Plan 2b’s items.

Tasks within a wave touch disjoint files and may run in parallel. Waves are ordered.

Wave Tasks
1 1 (migration 00018), 6 (migration 00019 + invite package), 10 (activity endpoint)
2 2 (tree helper + units), 4 (assignments)
3 3 (positions), 9 (invitation endpoints and their route declaration)
4 5 (superior resolution), 7 (provisioning step 50)
4 also 14 (composite tenant FKs — independent, slotted here)
4.5 15 (audit the org-structure mutations — after 2/3/4, before 7/8)
5 8 (org structure routes)
6 11 (cmd/orgadmin), 12 (app wiring + OpenAPI)
7 13 (multi-module guards + roadmap)

This table was revised by the pre-flight conflict scan, which found three collisions in the first version. The rulings and their reasoning are in the ledger; the short form:

  • Task 11 moved out of wave 5. Its wiring test asserts that cmd/orgadmin’s import graph reaches internal/modules/orgstructure — the package Task 8 creates in that same wave. A test that cannot compile until its sibling lands is not parallel work.
  • Task 9 carries its own route declaration — app.go’s unauthenticated list, api/public_routes.txt and the OpenAPI entry — rather than leaving them to Task 12. httpx.Public panics at wiring time on an undeclared path, which is the constraint working as designed; but it means the route and its declaration must land in one commit, or every internal/app test fails for three waves.
  • Task 13 moved to its own wave, because it and Task 12 both edit internal/app/routes_test.go.

Task 1: Migration — org structure tables

Section titled “Task 1: Migration — org structure tables”

Files:

  • Create: db/migrations/00018_org_structure.sql
  • Modify: internal/kernel/db/retention_test.go
  • Test: existing internal/kernel/db/rlscoverage_test.go and retention_test.go cover this — no new test file.

Interfaces:

  • Produces: tables org_unit, org_position, member_position; column organization_member.manager_override_id. Every later task consumes these names exactly.

  • Step 1: Write the migration

Create db/migrations/00018_org_structure.sql:

-- +goose Up
-- btree_gist lets an EXCLUDE constraint mix an equality operator on a UUID
-- with an overlap operator on a range. Without it the one-primary-position
-- constraint below cannot be expressed, and "at most one primary at any
-- instant" would have to be a read-then-write in Go — which two concurrent
-- assignment requests both pass.
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- THE STRUCTURE TREE. Company -> Division -> Department -> Team.
--
-- path is the materialized ancestry, '/<root>/<...>/<self>/', of UUIDs rather
-- than codes so that renaming a unit never rewrites its subtree. Descendant
-- queries are one indexed prefix match instead of a recursive CTE per
-- approval check. It is maintained in Go, inside the caller's transaction —
-- see internal/platform/orgstruct/tree.go.
CREATE TABLE org_unit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
parent_id UUID REFERENCES org_unit (id),
code TEXT NOT NULL,
-- Record-bearing name: English or Bahasa Malaysia (spec 11A.3).
-- Other renderings are translation rows (00006), not a second column.
name TEXT NOT NULL,
path TEXT NOT NULL,
depth INT NOT NULL CHECK (depth BETWEEN 0 AND 9),
unit_head_position_id UUID,
-- Units retire, they do not disappear: a retired department still counts as
-- a source of a unit head (spec 5.4's chain, adopted from korre).
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, code)
);
CREATE INDEX org_unit_path_idx ON org_unit (org_id, path text_pattern_ops);
CREATE INDEX org_unit_parent_idx ON org_unit (org_id, parent_id);
ALTER TABLE org_unit ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_unit FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON org_unit
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- THE SEAT TREE. CEO -> CTO -> FE Manager. Seats exist whether or not anyone
-- fills them, which is the whole point: storing the title on the person means
-- a resignation silently breaks approval routing.
--
-- Named org_position, not position: POSITION is a SQL keyword, and the pair
-- org_unit / org_position names the two trees as what they are.
CREATE TABLE org_position (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
org_unit_id UUID NOT NULL REFERENCES org_unit (id),
code TEXT NOT NULL,
title TEXT NOT NULL,
-- THE hierarchy. Not derived from org_unit_id: a team lead can report
-- outside their own unit, and two stored hierarchies drift.
reports_to_position_id UUID REFERENCES org_position (id),
path TEXT NOT NULL,
depth INT NOT NULL CHECK (depth BETWEEN 0 AND 9),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, code)
);
CREATE INDEX org_position_path_idx ON org_position (org_id, path text_pattern_ops);
CREATE INDEX org_position_reports_to_idx ON org_position (org_id, reports_to_position_id);
CREATE INDEX org_position_unit_idx ON org_position (org_id, org_unit_id);
ALTER TABLE org_position ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_position FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON org_position
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE org_unit
ADD CONSTRAINT org_unit_head_fk
FOREIGN KEY (unit_head_position_id) REFERENCES org_position (id);
-- WHO SITS IN WHICH SEAT, AND WHEN.
--
-- valid_to NULL means open-ended. A row is never deleted: closing it with a
-- valid_to is what keeps "who was entitled to approve this, on the date they
-- approved it" answerable for the seven years ITA s.82A requires.
CREATE TABLE member_position (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
member_id UUID NOT NULL REFERENCES organization_member (id),
position_id UUID NOT NULL REFERENCES org_position (id),
is_primary BOOLEAN NOT NULL DEFAULT false,
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (valid_to IS NULL OR valid_to > valid_from)
);
CREATE INDEX member_position_member_idx ON member_position (org_id, member_id);
CREATE INDEX member_position_position_idx ON member_position (org_id, position_id);
-- Exactly one PRIMARY position per member at any instant.
--
-- This cannot be a unique index: the conflict is an overlap in time, not an
-- equality of keys. And it cannot be a check in Go: two concurrent assignment
-- requests each read a clean state before either writes. Postgres is the only
-- place this can be enforced correctly, so it is enforced here.
ALTER TABLE member_position ADD CONSTRAINT member_position_one_primary
EXCLUDE USING gist (
member_id WITH =,
daterange(valid_from, valid_to, '[)') WITH &&
) WHERE (is_primary);
ALTER TABLE member_position ENABLE ROW LEVEL SECURITY;
ALTER TABLE member_position FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON member_position
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- The explicit exception at the head of spec 5.4's resolution chain. A dotted
-- line or an acting arrangement that the position tree cannot express.
ALTER TABLE organization_member
ADD COLUMN manager_override_id UUID REFERENCES organization_member (id);
GRANT SELECT, INSERT, UPDATE ON org_unit, org_position, member_position TO erp_app;
-- NO DELETE, and this is the point rather than an oversight.
--
-- Org structure is not reference data. It is the evidence of who was entitled
-- to approve what, on the date they approved it (spec 11A.4). A deleted
-- position takes with it the meaning of every approval it ever gave, and
-- s.82A wants that legible for seven years. Units and positions deactivate;
-- assignments close. internal/kernel/db/retention_test.go fails the build if a
-- later migration grants DELETE back without amending it.
-- +goose Down
ALTER TABLE organization_member DROP COLUMN manager_override_id;
DROP TABLE member_position;
ALTER TABLE org_unit DROP CONSTRAINT org_unit_head_fk;
DROP TABLE org_position;
DROP TABLE org_unit;
  • Step 2: Add the three tables to the retention drift test

In internal/kernel/db/retention_test.go, find the list of tables that must not grant DELETE and add:

// Org structure is the evidence of who was entitled to approve what,
// on the date they approved it (spec 11A.4). A deleted position takes
// with it the meaning of every approval it ever gave. Units and
// positions deactivate (is_active); assignments close (valid_to).
"org_unit",
"org_position",
"member_position",
  • Step 3: Run the drift tests and watch them pass for the right reason

Run: go test ./internal/kernel/db/ -run 'TestRLS|TestRetention' -count=1 -v Expected: PASS. Then temporarily add GRANT DELETE ON org_unit TO erp_app; to the end of the Up migration, re-run, and confirm the retention test goes RED naming org_unit. Remove the grant and confirm green again. This is the “watch it fail once” rule in AGENTS.md.

  • Step 4: Commit
Terminal window
git add db/migrations/00018_org_structure.sql internal/kernel/db/retention_test.go
git commit -m "Add org structure tables"

Files:

  • Create: internal/platform/orgstruct/tree.go, internal/platform/orgstruct/unit.go, internal/platform/orgstruct/errors.go
  • Test: internal/platform/orgstruct/tree_test.go, internal/platform/orgstruct/unit_test.go

Interfaces:

  • Consumes: org_unit from Task 1; db.InTx and tenancy.OrgFromContext from kernel.

  • Produces, and Task 3 reuses the first two verbatim:

    func childPath(parentPath string, id uuid.UUID) string
    func reparent(ctx context.Context, tx pgx.Tx, table string, id uuid.UUID, oldPath, newPath string, depthDelta int) error
    const MaxDepth = 10
    var ErrCycle, ErrTooDeep, ErrNotFound error
    type Unit struct {
    ID, OrgID uuid.UUID
    ParentID *uuid.UUID
    Code, Name, Path string
    Depth int
    UnitHeadPositionID *uuid.UUID
    IsActive bool
    }
    func CreateUnit(ctx context.Context, tx pgx.Tx, in UnitInput) (Unit, error)
    func GetUnit(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Unit, error)
    func ListUnits(ctx context.Context, tx pgx.Tx) ([]Unit, error)
    func RenameUnit(ctx context.Context, tx pgx.Tx, id uuid.UUID, name string) error
    func MoveUnit(ctx context.Context, tx pgx.Tx, id uuid.UUID, newParent *uuid.UUID) error
    func DeactivateUnit(ctx context.Context, tx pgx.Tx, id uuid.UUID) error
  • Step 1: Write the failing tree tests

internal/platform/orgstruct/tree_test.go — build a three-level tree, then:

func TestMoveRewritesTheWholeSubtree(t *testing.T) {
// root > division > department > team, then move division under a second
// root and assert ALL FOUR paths and depths changed, not just the moved
// node's. A reparent that rewrites only the node is the defect this
// guards: the descendants keep a path that no longer names their
// ancestry, and every prefix query silently returns the wrong set.
}
func TestMoveUnderOwnDescendantIsRefused(t *testing.T) {
// PRESENCE: the same move to a NON-descendant succeeds first, so a
// refusal that refused everything could not pass this test.
// ABSENCE: moving the division under its own team returns ErrCycle.
}
func TestDepthIsCapped(t *testing.T) {
// Ten nested units succeed; the eleventh returns ErrTooDeep. Spec 5.4
// walks this tree bounded by max_escalation_levels, and an unbounded tree
// makes an unbounded worst case.
}
func TestAUnitInAnotherOrgIsInvisible(t *testing.T) {
// PRESENCE: read it from org A and get a row.
// ABSENCE: read it from org B and get none, INCLUDING through the path
// prefix query — a LIKE prefix is the easiest place to drop a tenant
// predicate, because it still returns plausible rows.
}
  • Step 2: Run them and watch them fail

Run: go test ./internal/platform/orgstruct/ -count=1 Expected: FAIL — the package does not compile yet.

  • Step 3: Write tree.go
// Package orgstruct owns the two trees approval routing depends on: the org
// unit tree (structure) and the position tree (seats), plus the assignments
// that put people in seats over time.
//
// It is platform, not a module, because the workflow engine resolves grants
// against it and nothing under internal/modules may be imported downward.
package orgstruct
// MaxDepth bounds both trees.
//
// Spec 5.4's on_vacancy: escalate walks up the position tree to the next
// filled seat. An unbounded tree makes that walk's worst case unbounded, and
// ten levels is deeper than any Phase 1 customer's org chart. Exceeding it is
// an error at insert time, never a silent truncation.
const MaxDepth = 10
// childPath is the one definition of a node's materialized ancestry:
// "/<root>/<...>/<self>/". UUIDs rather than codes, so that renaming a node
// never rewrites its subtree. A root's parentPath is "/".
func childPath(parentPath string, id uuid.UUID) string {
return parentPath + id.String() + "/"
}
// containsID reports whether path names this id as an ancestor or as itself.
// Because path is the FULL ancestry, this is the complete cycle check: a move
// creates a cycle exactly when the proposed parent is the moving node or one
// of its descendants.
func containsID(path string, id uuid.UUID) bool {
return strings.Contains(path, "/"+id.String()+"/")
}
// reparent rewrites a subtree's paths and depths in one statement.
//
// table is spliced rather than parameterized because a table name cannot be a
// bind parameter; the only two callers pass compile-time constants declared in
// this package, never anything reaching it from a request.
//
// The substring arithmetic is what keeps this O(1) statements rather than one
// per descendant: every descendant's path begins with oldPath, so replacing
// that prefix with newPath and shifting depth by the same delta is the whole
// operation.
func reparent(ctx context.Context, tx pgx.Tx, table string, oldPath, newPath string, depthDelta int) error {
if table != tableUnit && table != tablePosition {
return fmt.Errorf("orgstruct: reparent: unknown table %q", table)
}
_, err := tx.Exec(ctx, `
UPDATE `+table+`
SET path = $2 || substring(path from length($1) + 1),
depth = depth + $3,
updated_at = now()
WHERE path LIKE $1 || '%'`,
oldPath, newPath, depthDelta)
if err != nil {
return fmt.Errorf("orgstruct: reparent subtree: %w", err)
}
return nil
}
const (
tableUnit = "org_unit"
tablePosition = "org_position"
)

Note on the LIKE pattern: oldPath is built only from UUIDs and /, so it can contain neither % nor _ and needs no escaping. Say so in a comment where the query is, because the next person will wonder.

  • Step 4: Write errors.go and unit.go

errors.go:

var (
ErrCycle = errors.New("orgstruct: a node cannot be moved under its own descendant")
ErrTooDeep = errors.New("orgstruct: the tree is limited to 10 levels")
ErrNotFound = errors.New("orgstruct: no such node in this organization")
)

unit.go implements the six functions in the Interfaces block. Every query is org-scoped by RLS and carries an explicit org_id = $n predicate — RLS is the second layer, not the only one, per the two-layer rule in §4.1 of the architecture spec. MoveUnit reads the node and the proposed parent, refuses on containsID(parent.Path, id) with ErrCycle, refuses when the resulting depth would exceed MaxDepth with ErrTooDeep, then calls reparent.

  • Step 5: Run the tests

Run: go test ./internal/platform/orgstruct/ -count=1 -v Expected: PASS, all four.

  • Step 6: Sabotage-check the cycle guard

Comment out the containsID refusal in MoveUnit, re-run, confirm TestMoveUnderOwnDescendantIsRefused goes RED, restore, confirm green. Record what the red output said in the task report.

  • Step 7: Commit
Terminal window
git add internal/platform/orgstruct/
git commit -m "Add the org unit tree and its path mechanics"

Files:

  • Create: internal/platform/orgstruct/position.go
  • Test: internal/platform/orgstruct/position_test.go

Interfaces:

  • Consumes: childPath, reparent, containsID, MaxDepth, ErrCycle, ErrTooDeep from Task 2 — reuse them, do not reimplement. A second copy of the path arithmetic is the defect this task is most likely to introduce.
  • Produces:
    type Position struct {
    ID, OrgID, OrgUnitID uuid.UUID
    Code, Title, Path string
    Depth int
    ReportsToPositionID *uuid.UUID
    IsActive bool
    }
    func CreatePosition(ctx context.Context, tx pgx.Tx, in PositionInput) (Position, error)
    func GetPosition(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Position, error)
    func ListPositions(ctx context.Context, tx pgx.Tx) ([]Position, error)
    func MovePosition(ctx context.Context, tx pgx.Tx, id uuid.UUID, reportsTo *uuid.UUID) error
    func DeactivatePosition(ctx context.Context, tx pgx.Tx, id uuid.UUID) error
    func SetUnitHead(ctx context.Context, tx pgx.Tx, unitID, positionID uuid.UUID) error

The position tree’s parent is reports_to_position_id, not org_unit_id. A team lead can report outside their own unit, and §4.7 is explicit that the reporting line has one source of truth.

  • Step 1: Write the failing tests — the same four shapes as Task 2 (subtree rewrite, cycle refusal with its presence half, depth cap, cross-tenant invisibility), plus:
func TestSetUnitHeadRejectsAPositionFromAnotherOrg(t *testing.T) {
// PRESENCE: a position in this org is accepted as head.
// ABSENCE: one from org B is refused. The FK alone does not catch this —
// both rows exist, and only the org_id predicate distinguishes them.
}
  • Step 2: Run and watch fail. Run: go test ./internal/platform/orgstruct/ -run Position -count=1

  • Step 3: Implement position.go, calling the Task 2 helpers.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit

Terminal window
git add internal/platform/orgstruct/position.go internal/platform/orgstruct/position_test.go
git commit -m "Add the position tree"

Files:

  • Create: internal/platform/orgstruct/assignment.go
  • Test: internal/platform/orgstruct/assignment_test.go

Interfaces:

  • Produces:
    type Assignment struct {
    ID, MemberID, PositionID uuid.UUID
    IsPrimary bool
    ValidFrom time.Time
    ValidTo *time.Time
    }
    func Assign(ctx context.Context, tx pgx.Tx, in AssignmentInput) (Assignment, error)
    func CloseAssignment(ctx context.Context, tx pgx.Tx, id uuid.UUID, on time.Time) error
    func AssignmentsForMember(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, on time.Time) ([]Assignment, error)
    func HoldersOf(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, on time.Time) ([]uuid.UUID, error)
    func PrimaryPositionOf(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, on time.Time) (uuid.UUID, error)
    var ErrNoPrimaryPosition = errors.New("orgstruct: member has no primary position")
    var ErrOverlappingPrimary = errors.New("orgstruct: member already has a primary position covering that period")

HoldersOf returns a set: a seat can be job-shared, or held by two people across a handover. §5.4 resolves grants to member sets, so returning one id would force a choice this layer cannot justify.

“Current as of on” is valid_from <= on AND (valid_to IS NULL OR valid_to > on) — half-open, matching the daterange(..., '[)') in the exclusion constraint. Write that predicate once, as a package constant spliced into the three queries that need it, so the constraint and the queries cannot disagree about what “current” means.

  • Step 1: Write the failing tests
func TestTwoOverlappingPrimaryAssignmentsAreRefused(t *testing.T) {
// PRESENCE: two NON-overlapping primaries for the same member are both
// accepted — otherwise a constraint that rejected every primary would
// pass the absence half below.
// ABSENCE: an overlapping second primary returns ErrOverlappingPrimary.
}
func TestANonPrimaryOverlapIsAllowed(t *testing.T) {
// A person can hold two seats at once. Only the PRIMARY one is unique.
}
func TestHoldersOfReturnsEveryCurrentHolder(t *testing.T) {
// Two members in one seat, plus one whose assignment closed yesterday.
// Exactly the two current ones come back.
}
  • Step 2: Run and watch fail.

  • Step 3: Implement. Assign maps Postgres SQLSTATE 23P01 (exclusion_violation) on constraint member_position_one_primary to ErrOverlappingPrimary. Do not pre-check in Go and then insert — the pre-check is the race the constraint exists to win. Let the database refuse, and translate.

  • Step 4: Run. Expected: PASS.

  • Step 5: Sabotage-check the constraint. Drop it in a scratch database, re-run TestTwoOverlappingPrimaryAssignmentsAreRefused, confirm RED, restore.

  • Step 6: Commit

Terminal window
git add internal/platform/orgstruct/assignment.go internal/platform/orgstruct/assignment_test.go
git commit -m "Add member position assignments"

Files:

  • Create: internal/platform/orgstruct/superior.go
  • Test: internal/platform/orgstruct/superior_test.go

Interfaces:

  • Consumes: everything from Tasks 2–4.

  • Produces — this is the API Plan 3 calls, and the signature is load-bearing:

    type NoSuperiorReason string
    const (
    ReasonNoPrimaryPosition NoSuperiorReason = "no_primary_position"
    ReasonNoReportsTo NoSuperiorReason = "no_reports_to"
    ReasonVacantSuperior NoSuperiorReason = "vacant_superior"
    ReasonNoUnitHead NoSuperiorReason = "no_unit_head"
    )
    type NoSuperiorError struct {
    MemberID uuid.UUID
    PositionID *uuid.UUID
    Reason NoSuperiorReason
    }
    func (e *NoSuperiorError) Error() string
    func SuperiorOf(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, on time.Time) ([]uuid.UUID, error)
    func NextFilledAbove(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, maxLevels int, on time.Time) ([]uuid.UUID, error)
  • Step 1: Write one test per branch of the chain, and one for the refusal

The chain, from §5.4, one hop and never transitive:

func TestManagerOverrideWinsOverThePositionTree(t *testing.T)
func TestOtherwiseTheReportsToPositionsHolders(t *testing.T)
func TestOtherwiseTheUnitHeadIncludingARetiredUnit(t *testing.T)
func TestOtherwiseARefusalNamingTheMissingLink(t *testing.T)

The fourth asserts on Reason, not merely that an error came back:

var nse *orgstruct.NoSuperiorError
require.ErrorAs(t, err, &nse)
require.Equal(t, orgstruct.ReasonNoReportsTo, nse.Reason,
"the caller renders this into a message naming the gap; a generic "+
"error sends the admin looking in the wrong place")

And the bound:

func TestNextFilledAboveStopsAtTheBound(t *testing.T) {
// A chain of five vacant seats, maxLevels 2, returns empty — not the
// sixth. Spec 5.4: unbounded escalation routes an RM100 claim to the CEO.
}
func TestNextFilledAboveRefusesANonPositiveBound(t *testing.T) {
// maxLevels 0 is an error, not an unbounded walk.
}
  • Step 2: Run and watch fail.

  • Step 3: Implement superior.go. Write the chain as four explicit steps in one function, each commented with the case it covers and why. Step 3 deliberately ignores is_active on the unit — a retired department still supplies a head — and that must be a comment, because it reads like a missing filter.

  • Step 4: Run. Expected: PASS, all six.

  • Step 5: Commit

Terminal window
git add internal/platform/orgstruct/superior.go internal/platform/orgstruct/superior_test.go
git commit -m "Resolve the reporting line, and refuse when it cannot"

Task 6: Invitations — migration and platform package

Section titled “Task 6: Invitations — migration and platform package”

Files:

  • Create: db/migrations/00019_invitation.sql, internal/platform/identity/invite/invite.go
  • Test: internal/platform/identity/invite/invite_test.go

Interfaces:

  • Produces:

    type Invitation struct {
    ID, OrgID, RoleID uuid.UUID
    Email string
    ExpiresAt time.Time
    AcceptedAt, RevokedAt *time.Time
    }
    // Issue returns the invitation AND the raw token, which is the only time
    // the raw token exists. Only its sha256 is stored.
    func Issue(ctx context.Context, tx pgx.Tx, c clock.Clock, in Input) (Invitation, string, error)
    func Accept(ctx context.Context, tx pgx.Tx, c clock.Clock, rawToken string, in AcceptInput) (AcceptResult, error)
    func List(ctx context.Context, tx pgx.Tx) ([]Invitation, error)
    func Revoke(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID) error
    var ErrInvalidToken, ErrExpired, ErrAlreadyAccepted, ErrRevoked error
  • Step 1: Write the migration

-- +goose Up
-- An invitation is the credential that lets someone with no account join an
-- organization. It follows refresh_token's pattern exactly: 32 random bytes
-- go to the invitee, and only their sha256 is stored, so a database dump
-- yields no usable invitation.
CREATE TABLE invitation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
email CITEXT NOT NULL,
role_id UUID NOT NULL REFERENCES role (id),
invited_by_member_id UUID NOT NULL REFERENCES organization_member (id),
token_hash BYTEA NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
accepted_user_id UUID,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX invitation_org_pending_idx ON invitation (org_id, created_at DESC)
WHERE accepted_at IS NULL AND revoked_at IS NULL;
ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitation FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON invitation
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Accepting happens BEFORE the invitee has any tenant context, so the accept
-- path looks the row up by token hash with no app.current_org set. That read
-- must not be governed by the tenant policy, and it is deliberately narrow:
-- it matches one row by a 256-bit secret, and it can only SELECT.
CREATE POLICY invitation_by_token ON invitation FOR SELECT
USING (current_setting('app.accepting_invitation', true) = 'on');
GRANT SELECT, INSERT, UPDATE ON invitation TO erp_app;
-- +goose Down
DROP TABLE invitation;

Note for the implementer: app.accepting_invitation is set with SET LOCAL by invite.Accept and by nothing else. Add a one-line comment at that SET LOCAL saying it is the only writer, and add invitation to whichever drift-test file needs it. If the RLS coverage test rejects a second policy, extend the test rather than weakening the table — and say in the report which you did and why.

  • Step 2: Write the failing tests
func TestOnlyTheHashIsStored(t *testing.T) {
// PRESENCE: Issue returns a raw token that Accept accepts.
// ABSENCE: that raw token appears in NO column of the row. Select the
// whole row as text and assert the token is not a substring — a
// column-by-column check passes when someone adds a column.
}
func TestAnInvitationIsSingleUse(t *testing.T) {
// Two sequential accepts: the second returns ErrAlreadyAccepted.
}
func TestTwoConcurrentAcceptsProduceExactlyOneMember(t *testing.T) {
// Two goroutines, two transactions, one token. Exactly one succeeds and
// exactly one organization_member row exists afterwards. This is what the
// conditional UPDATE is for; a read-then-write passes the sequential test
// above and fails this one.
}
func TestAnExpiredInvitationIsRefused(t *testing.T) {
// PRESENCE: the same invitation one second before expiry is accepted.
// ABSENCE: one second after, ErrExpired.
}
func TestAcceptingWithAnExistingEmailDoesNotResetThePassword(t *testing.T) {
// An invitation must never be a password-reset primitive. The existing
// user's password_hash is byte-identical afterwards, and their old
// password still verifies.
}
  • Step 3: Run and watch fail.

  • Step 4: Implement. Accept consumes the invitation with a conditional update in one statement:

UPDATE invitation
SET accepted_at = $2, accepted_user_id = $3
WHERE token_hash = $1
AND accepted_at IS NULL
AND revoked_at IS NULL
AND expires_at > $2
RETURNING id, org_id, role_id, email

Zero rows means one of four things, and the caller deserves to know which, so follow a zero-row result with a second read that distinguishes expired from accepted from revoked from unknown. Return ErrInvalidToken for unknown — never reveal that a token exists but is spent.

  • Step 5: Run. Expected: PASS, all five.

  • Step 6: Commit

Terminal window
git add db/migrations/00019_invitation.sql internal/platform/identity/invite/
git commit -m "Add invitations"

Task 7: Provisioning step 50, and the warnings contract

Section titled “Task 7: Provisioning step 50, and the warnings contract”

Files:

  • Modify: internal/platform/provisioning/provisioning.go, internal/platform/provisioning/steps.go
  • Test: internal/platform/provisioning/provisioning_test.go

Interfaces:

  • Consumes: orgstruct.CreateUnit, CreatePosition, SetUnitHead, Assign from Tasks 2–4.

  • Produces: orderOrgStructure = 50; a widened Step.Check.

  • Step 1: Widen the Check contract

Check currently returns (bool, error). It becomes:

// Warning is a configuration gap that does not make a step incomplete but
// that an administrator has to see. Spec 20.2 is explicit that readiness is
// also what surfaces the 5.4 failure modes appearing AFTER provisioning — a
// vacant seat leaving a threshold ladder with a hole — "so they are visible
// in a settings screen rather than discovered by a claimant whose submission
// is refused".
//
// It rides on Check rather than on a second inspection method so that the two
// cannot drift: whatever decides a step is complete is what reports its gaps.
type Warning struct {
Code string // stable, for the frontend to switch on
Detail string // human-readable, English or Bahasa Malaysia
Ref uuid.UUID // the position, unit or member the gap is about
}
Check(ctx context.Context, tx pgx.Tx) (done bool, warnings []Warning, err error)

Every existing step returns nil warnings. Update all of them, and carry the warnings into the readiness report.

  • Step 2: Write the failing step test
func TestOrgStructureStepSeedsARootUnitAndPositionAndSeatsTheOwner(t *testing.T)
func TestOrgStructureStepIsIdempotentAndKeepsCustomerEdits(t *testing.T) {
// Run, rename the root unit, add a second unit, run again. The rename
// survives and the second unit survives. Spec 20.2's first property: an
// org that adopted its accountant's own structure keeps it.
}
func TestReadinessReportsAVacantPositionAsAWarningNotAFailure(t *testing.T) {
// PRESENCE: with the seat filled, no warnings and the step is done.
// ABSENCE: close the assignment, and the step is STILL done but now
// carries a vacant_position warning naming that position.
}
  • Step 3: Run and watch fail.

  • Step 4: Implement step 50. Seeds: root org_unit code ROOT named from the company profile’s legal_name; root org_position code OWNER in it, no reports_to; the unit’s head set to it; a primary member_position for the Owner member. Like every step since Plan 2’s fix wave, the Owner member is an explicit input on provisioning.Input, never discovered.

Check reports done when a root unit and a root position exist and the root position has at least one current holder. Warnings: no_primary_position, vacant_position, unit_without_head, reports_to_inactive.

  • Step 5: Run. Expected: PASS. Also run the whole provisioning package — the Check signature change touches every step.

  • Step 6: Commit

Terminal window
git add internal/platform/provisioning/
git commit -m "Provision the root org unit and position"

Files:

  • Create: internal/modules/orgstructure/routes.go, handlers.go, permissions.go
  • Test: internal/modules/orgstructure/routes_test.go, handlers_test.go

Interfaces:

  • Consumes: orgstruct (Tasks 2–5), httpx.Protected, db.InTx.

  • Produces: orgstructure.Register(g *gin.RouterGroup, deps Deps) — note it takes no httpx.Public: this module has no unauthenticated routes, and asking for a handle it does not need is how the affordance creeps back.

  • Step 1: Register the permissions

permissions.go:

const (
PermissionStructureRead = "org.structure.read"
PermissionStructureManage = "org.structure.manage"
)
func init() {
member.RegisterPermission(PermissionStructureRead,
"View the organization chart, its units and its positions")
member.RegisterPermission(PermissionStructureManage,
"Create, move, rename and deactivate units and positions, and assign people to them")
}
  • Step 2: Write the failing route test — assert each route’s exact permission, in the shape internal/modules/identity/routes_test.go already uses. A route silently re-registered under a permission nobody holds is as broken as one with none.

  • Step 3: Run and watch fail.

  • Step 4: Implement the routes and handlers

func Register(g *gin.RouterGroup, deps Deps) {
h := handlers{deps: deps}
p := deps.Perms
httpx.Protected(g, p, "GET", "/org/units", PermissionStructureRead, h.listUnits)
httpx.Protected(g, p, "POST", "/org/units", PermissionStructureManage, h.createUnit)
httpx.Protected(g, p, "PATCH", "/org/units/:id", PermissionStructureManage, h.patchUnit)
httpx.Protected(g, p, "GET", "/org/positions", PermissionStructureRead, h.listPositions)
httpx.Protected(g, p, "POST", "/org/positions", PermissionStructureManage, h.createPosition)
httpx.Protected(g, p, "PATCH", "/org/positions/:id", PermissionStructureManage, h.patchPosition)
httpx.Protected(g, p, "POST", "/org/members/:id/positions", PermissionStructureManage, h.assign)
httpx.Protected(g, p, "DELETE", "/org/members/:id/positions/:pid", PermissionStructureManage, h.closeAssignment)
httpx.Protected(g, p, "GET", "/org/chart", PermissionStructureRead, h.chart)
}

If this task adds a write path for organization_member.manager_override_id, it must resolve the target member INSIDE the tenant before writing. Task 5’s reviewer established that the blast radius is zero today — there is no Go writer for that column anywhere, so no row can hold a foreign id except by direct SQL — but the column is a plain foreign key with no org_id in it, so the database will accept another tenant’s member. Until Task 14’s composite keys land, an unresolved write there means an approval routed to another organization’s member with nothing downstream to catch it: SuperiorOf returns the override unverified, and that is correct — a resolver should not re-derive the tenancy its writer owes it.

DELETE on an assignment closes it with a valid_to and returns 200 with the closed row. It does not delete, and the handler comment must say so — the verb is the REST convention for “this relationship ends”, and the table has no DELETE grant to make the other reading possible.

Each mutating handler writes an audit_log entry on the same transaction.

  • Step 5: Run. Expected: PASS.

  • Step 6: Commit

Terminal window
git add internal/modules/orgstructure/
git commit -m "Add the org structure HTTP module"

Files:

  • Modify: internal/modules/identity/routes.go, internal/modules/identity/handlers.go, internal/modules/identity/permissions.go
  • Create: internal/modules/identity/invitation_handlers.go
  • Test: internal/modules/identity/invitation_test.go

Interfaces:

  • Consumes: invite (Task 6), throttle (existing).

  • Produces: the four routes in the spec’s §3.2 table. POST /v1/auth/invitations/accept is unauthenticated and must be added to internal/app.Router’s unauthenticated list in Task 12 — until then httpx.Public will panic, which is the intended behaviour and not a bug to work around.

  • Step 1: Register the permission

const PermissionMembersInvite = "org.members.invite"
  • Step 2: Write the failing tests — the three authenticated routes refuse without the permission; accept works end to end and produces a member who can then log in.

  • Step 3: Run and watch fail.

  • Step 4: Implement. The accept handler throttles on the invitation id, verifies, creates user/party/member/member_role, bumps perm_version, and returns 201 with the new membership — not a session. The invitee logs in afterwards through the existing login route, so there is exactly one place sessions are minted.

  • Step 5: Run. Expected: PASS.

  • Step 6: Commit

Terminal window
git add internal/modules/identity/
git commit -m "Add invitation endpoints"

Files:

  • Create: internal/modules/activity/routes.go, handlers.go, permissions.go
  • Test: internal/modules/activity/handlers_test.go

Interfaces:

  • Consumes: activity.Feed and activity.Filter, both already built.

  • Produces: activity.Register(g *gin.RouterGroup, deps Deps); permission activity.read.

  • Step 1: Write the failing test

func TestTheFeedPagesWithoutRepeatingOrSkipping(t *testing.T) {
// Twenty items sharing a timestamp, paged three at a time. Every item
// appears exactly once across the pages. A keyset that pages on the
// timestamp alone repeats and skips exactly here, which is why the index
// and the filter are on the (occurred_at, id) tuple.
}
func TestTheFeedIsTenantScoped(t *testing.T) {
// PRESENCE: org A sees its own items.
// ABSENCE: org B sees none of them.
}
func TestLimitIsCapped(t *testing.T) {
// limit=10000 returns at most 100. An uncapped limit is a feed endpoint
// that reads the whole table.
}
  • Step 2: Run and watch fail.

  • Step 3: Implement. GET /v1/activity, permission activity.read, query parameters before_occurred_at, before_id, limit (default 50, cap 100), optional subject + subject_id. Actor display names come from one batched lookup keyed by the member ids in the page, not a join per row.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit

Terminal window
git add internal/modules/activity/
git commit -m "Add the activity feed endpoint"

Files:

  • Create: cmd/orgadmin/main.go, cmd/orgadmin/createorg.go, cmd/orgadmin/wiring_test.go, cmd/orgadmin/createorg_test.go

Interfaces:

  • Consumes: provisioning, orgstruct, invite, company, member, and — critically — blank-imports every module package that registers permissions.

  • Step 1: Write the wiring test FIRST, before the command

This is the task’s most important step. Model it on cmd/migrate/wiring_test.go:

// TestOrgadminSeesEveryRegisteredPermission is the guard for the defect that
// was Plan 2's only Critical.
//
// The permission registry is filled by init() in module packages. A binary
// that does not import them sees an EMPTY registry — and provisioning then
// seeded an Owner with zero grants and recorded the step complete, producing
// an organization nobody could ever administer. `go list -deps ./cmd/migrate`
// returned no internal/modules packages, and nothing failed.
//
// The lesson, which is why this test exists before the command it guards: a
// registry filled by init() is only as complete as the import graph of the
// binary that reads it.
func TestOrgadminSeesEveryRegisteredPermission(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", "./...").Output()
require.NoError(t, err)
deps := string(out)
for _, pkg := range []string{
"github.com/mcom/erp/internal/modules/identity",
"github.com/mcom/erp/internal/modules/orgstructure",
"github.com/mcom/erp/internal/modules/activity",
} {
require.Contains(t, deps, pkg,
"cmd/orgadmin does not import %s, so that module's permissions are "+
"absent from the registry it seeds the Owner role from", pkg)
}
require.Positive(t, member.RegisteredPermissionSet().Len(),
"the registry is empty; the Owner would be seeded with no grants at all")
}
  • Step 2: Run it and watch it fail — the package does not exist. Then create main.go with the blank imports and watch it pass. Then remove one blank import and confirm it goes red naming that package, and restore. Record the red output in the report.

  • Step 3: Implement create-org — the seven steps in the spec’s §2.2, in one transaction, printing the organization id and the owner’s invitation link.

  • Step 4: Write the end-to-end test

func TestCreateOrgProducesAnOwnerWhoCanActuallyAdminister(t *testing.T) {
// Against a real container: create-org, accept the invitation, log in,
// and call a permissioned endpoint. The last assertion is the one that
// would have caught Plan 2's Critical — every intermediate step passed
// while the org was unadministrable.
}
  • Step 5: Run. Expected: PASS.

  • Step 6: Commit

Terminal window
git add cmd/orgadmin/
git commit -m "Add the orgadmin CLI"

Files:

  • Modify: internal/app/app.go, api/public_routes.txt, api/openapi.yaml

  • Test: internal/app/routes_test.go (existing tests must keep passing)

  • Step 1: Register both new modules and widen the unauthenticated list

var unauthenticated = []string{
"POST /v1/auth/login",
"POST /v1/auth/refresh",
"POST /v1/auth/logout",
"POST /v1/auth/invitations/accept",
}
func Router(deps Deps) (*gin.Engine, *gin.RouterGroup) {
r, api := httpx.NewRouter(deps.HTTP)
identity.Register(api, httpx.PublicPaths(r, unauthenticated...), deps.Identity)
orgstructure.Register(api, deps.OrgStructure)
activity.Register(api, deps.Activity)
return r, api
}
  • Step 2: Declare the accept route in api/public_routes.txt with its justification: the invitation token is the credential; it is 256 bits, single-use, and verified before anything is written; the invitee has no account yet, so requiring one would make the flow impossible.

  • Step 3: Document every new operation in api/openapi.yaml — TestTheOpenAPIDocumentSaysWhatTheRouterEnforces fails the build otherwise, in both directions.

  • Step 4: Run the whole app package. Run: go test ./internal/app/ -count=1 -v Expected: PASS, including the route-authorization and OpenAPI drift tests.

  • Step 5: Commit

Terminal window
git add internal/app/ api/
git commit -m "Compose the org structure and activity modules"

Task 13: The multi-module guards, and the roadmap

Section titled “Task 13: The multi-module guards, and the roadmap”

Files:

  • Modify: internal/app/routes_test.go, internal/kernel/layering/layering_test.go, docs/ROADMAP.md
  • Test: as modified.

This plan introduces the second and third modules. internal/app.Router’s route guard, the httpx.Public constraint and the layering test were all written for the case where a second module does something wrong, and until now there has been no second module to test them against. Plan 2’s own test comment concedes the point: the first version “guarded the one module whose author had already thought about authorization”.

  • Step 1: Add the assertions that exercise the multi-module case
func TestEveryModuleContributesRoutesAndPermissions(t *testing.T) {
// The route table contains routes from all three modules, and
// RegisteredPermissionSet contains keys from all three. Until this
// branch, a guard that silently covered only the first-registered module
// would have passed every test in the repository.
}
  • Step 2: Confirm the layering test sees the new packages — it walks the tree, so the new module directories are covered automatically. Assert the count it walked is greater than it was, so a walker that stops early is visible.

  • Step 3: Write the deploy note for PUBLIC_BASE_URL.

Task 9 added a REQUIRED config value, and a required value is a deployment that refuses to start until an operator sets it. That is the right call — a silently wrong base URL produces an invitation link that exists in exactly one place, cannot be re-delivered, and fails days later as a colleague who could not join — but it must not arrive as a surprise.

Add it to whatever this repo uses for release notes, or create that file if none exists, saying what the variable is, that the container will not boot without it, and what a correct value looks like. The TRUSTED_PROXIES precedent belongs in the same note for the same reason: it defaults to trusting nobody, which is safe and which silently drops upstream trace correlation until an operator names their proxy.

  • Step 4: Tick the roadmap. In docs/ROADMAP.md, tick Plan 2b’s three bullets and add a line recording that organization creation and invitations landed here rather than in Plan 2, with the reason: nothing in Plan 2 created an organization, and positions have nothing to attach to without people.

  • Step 5: Run the full suite.

Run: go test -p 2 -count=1 ./... Expected: all packages pass.

  • Step 6: Commit
Terminal window
git add internal/app/ internal/kernel/layering/ docs/ROADMAP.md
git commit -m "Guard the multi-module case, and tick the roadmap"


Task 14: Make the two isolation layers true

Section titled “Task 14: Make the two isolation layers true”

Added mid-execution. Two findings, one theme: §4.1 claims two layers of tenant isolation and neither is as true as it reads. Task 4’s implementer found the first; Task 4’s reviewer found the second; I confirmed both are schema-wide rather than local to this plan.

Files:

  • Create: db/migrations/00021_tenant_foreign_keys.sql
  • Create: internal/kernel/db/tenantfk_test.go

The finding. Postgres performs referential-integrity checks with internal triggers that run as the table owner, and those checks bypass RLS. Every cross-table foreign key in this schema is single-column — member_position.position_id, org_position.org_unit_id, org_unit.parent_id, role_permission.role_id, member_role.member_id, organization_member.party_id, activity_log.actor_member_id — so a row inserted with org_id = A may reference a row belonging to org B, and the database will validate it happily. The architecture claims two layers of tenant isolation (§4.1); this is a seam where the second layer does not reach.

Exploitability is low: it needs a known UUID from another tenant, which is unguessable. It is still a hole in a claim the design makes in writing, and the fix is mechanical.

  • Step 1: Write the failing drift test first

internal/kernel/db/tenantfk_test.go — query pg_constraint for every FK whose referencing AND referenced table both carry an org_id column, and require that org_id participates in the constraint on both sides:

// TestEveryTenantForeignKeyIsCompositeOnOrgID closes the seam between this
// schema's two isolation layers.
//
// Postgres runs referential-integrity checks in internal triggers that
// execute as the table owner, and those checks DO NOT see RLS. So a
// single-column FK between two org-scoped tables will happily validate a
// reference from org A's row to org B's row: the policy that would have
// hidden the target never runs. Making the key composite on (org_id, id)
// puts the tenant into the integrity check itself, where RLS cannot be
// asked to reach.

Run it and watch it fail, naming every existing single-column FK. Record that list in the report — it is the inventory the migration must fix.

  • Step 2: Write the migration

For each org-scoped referenced table add UNIQUE (org_id, id), then drop each single-column FK and recreate it as FOREIGN KEY (org_id, <col>) REFERENCES <table> (org_id, id). Keep every existing ON DELETE CASCADE. users, session_family and refresh_token are RLS-exempt and out of scope — the test must skip tables with no org_id, and that skip needs a comment saying why rather than looking like an oversight.

  • Step 3: Run the drift test and watch it pass, then sabotage it — recreate one FK as single-column, confirm red naming that constraint, restore.

  • Step 4: Run the full suite — and expect ONE named casualty.

A composite FK changes nothing about valid inserts, so every existing test should still pass with one known exception, flagged by Task 4’s re-reviewer before this task was written:

TestExplicitOrgPredicateConfinesResultsWithoutRLS in internal/platform/orgstruct/assignment_test.go builds its fixture by inserting a member_position row in org B that points at a seat in org A. That insert is only possible because of the very hole this task closes, and foreign keys — unlike RLS — are not superuser-exempt, so it will be rejected even on the admin connection the test uses.

Do not delete that test and do not weaken the FK to keep it alive. Rewrite the fixture: give each org its own seat and its own member, and assert that a read scoped to org A returns only org A’s holders on an RLS-free connection. The property under test is the explicit org_id predicate, and that property does not need a cross-tenant row to demonstrate — it needed one only because the hole made a cheaper fixture available.

If any OTHER test fails, it was relying on a cross-tenant reference and that is a finding, not a fixture problem. Report each one.

  • Step 5: The other half — prove the explicit predicate exists

Task 4’s review established a second, related gap: the reviewer deleted the explicit WHERE org_id = $1 from all four of that package’s queries and the entire suite stayed green, because RLS alone carried it. §4.1 claims two layers of tenant isolation; at present exactly one of them is under test, anywhere in this repo.

Add internal/kernel/layering/tenantpredicate_test.go: walk every .go file under internal/platform and internal/modules, find SQL string literals naming an org-scoped table, and require each to carry a literal org_id = $ predicate. Exemptions go in a file with written justifications, following the pattern of db/rls_exempt.txt.

This is a textual guard and that is deliberate — the property is about the SQL that reaches Postgres, and a type-level version would only constrain a wrapper the next author is free not to use. Say so in the test’s comment, in the same terms lockconvention_test.go uses.

Watch it fail: remove one predicate, confirm red naming that file and line, restore.

  • Step 6: Commit
Terminal window
git add db/migrations/00021_tenant_foreign_keys.sql internal/kernel/db/tenantfk_test.go \
internal/kernel/layering/tenantpredicate_test.go
git commit -m "Make both tenant isolation layers real, and keep them"

Task 15: Audit the org-structure mutations

Section titled “Task 15: Audit the org-structure mutations”

Added mid-execution. Tasks 2 and 4 independently flagged it; the omission was mine — I wrote signatures that took no clock.Clock, which made auditing impossible without breaking the contract the next task consumed.

Files:

  • Modify: internal/platform/orgstruct/unit.go, position.go, assignment.go
  • Test: internal/platform/orgstruct/audit_test.go

Why it has to be here rather than in the handlers. company.Create takes a clock.Clock and writes audit.Write on the caller’s transaction; that is this repo’s established shape, and it is the shape that makes the audit row impossible to omit. A handler-level audit is a second place to remember. And this table in particular is the evidence of who was entitled to approve what on a given date (§11A.4, ITA s.82A) — a reorganization that leaves no trail is the change most worth having a trail of.

  • Step 1: Write the failing test — every mutating function writes exactly one audit_log row, on the same transaction, and a rolled-back transaction leaves none. The second half is the presence-before-absence pair: show the row written before showing it absent after rollback.

  • Step 2: Run and watch fail.

  • Step 3: Add c clock.Clock to every mutating signature — CreateUnit, RenameUnit, MoveUnit, DeactivateUnit, CreatePosition, MovePosition, DeactivatePosition, SetUnitHead, Assign, CloseAssignment — and write the audit entry. Read functions do not change. Update every caller, including provisioning step 50.

  • Step 4: Run the whole package and the provisioning package.

  • Step 5: Commit

Terminal window
git add internal/platform/orgstruct/ internal/platform/provisioning/
git commit -m "Audit every org-structure mutation"

Wave placement: Task 14 may run any time after Task 1 and is independent of everything else. Task 15 must follow Tasks 2, 3 and 4, and should precede Tasks 7 and 8 so those call the final signatures rather than being rewritten.

Checked against the spec before execution:

  • Spec coverage. §2 → Tasks 11, 6. §3 → Tasks 6, 9. §4 → Tasks 1–4. §5 → Task 5. §6 → Task 7. §7 → Task 10. §8 → Task 7. §9 → Tasks 8, 10, 12, 13. §10’s test list is distributed across the tasks that own each area.
  • Type consistency. orgstruct.Position is produced by Task 3 and consumed by Tasks 5, 7, 8. invite.Issue’s two-value return (invitation + raw token) is produced by Task 6 and consumed by Tasks 9 and 11. provisioning.Warning is produced by Task 7 and consumed by nothing else in this plan — the readiness endpoint already renders whatever the registry reports.
  • The one contract change that ripples: Step.Check gains a warnings return in Task 7, touching every existing step. It is confined to one task deliberately, so the ripple lands in one review rather than three.
  • Migration numbers are fixed here — 00018 and 00019 — because Tasks 1 and 6 run in the same wave and would otherwise both reach for the next free number.