Skip to content

Org Structure and Onboarding — Design Spec (Plan 2b)

Parent spec: 2026-09-20-erp-architecture-design.md — §4.7 (org structure), §5.4 (grant resolution, the consumer), §20.2 (provisioning), §20.1.7 (activity vs audit), §11A (statutory compliance).

This document refines the parent spec where §4.7 is a sketch, and adds the onboarding path the parent spec never assigned to a plan. It does not restate the parent; read §4.7 and §5.4 first.


1. Why this plan exists, and what is already built

Section titled “1. Why this plan exists, and what is already built”

The roadmap lists three items for Plan 2b. Two of them landed early, inside Plan 2:

  • activity_log has its table (00012), its RLS policy, its Record and Feed functions, and keyset pagination on (occurred_at DESC, id DESC). What is missing is an HTTP endpoint.
  • Organization provisioning has its registry, its idempotence, its ordered steps, its POST /v1/org/provision and GET /v1/org/readiness endpoints — and it already reserves order slot 50 org_structure, labelled “Plan 2b” in internal/platform/provisioning/steps.go.

So the real remaining work is §4.7’s three tables, the resolution chain §5.4 depends on, and one gap nothing in Phase 1 had been assigned:

Nothing in the codebase creates a user, an organization, or a member. company.Create requires an organization that already exists, and no code path creates one. The system as merged can only be logged into if rows are inserted by hand. Plan 2b closes that, because positions and reporting lines are unusable and untestable without people to attach them to.

  1. Organization creation and the first Owner — operator-provisioned, via CLI (§2).
  2. Member invitation — invite, accept, and the membership it creates (§3).
  3. org_unit, org_position, member_position and the tree mechanics (§4).
  4. Reporting-line resolution, including the refusal §5.4 requires (§5).
  5. Provisioning step 50 — root unit, root position, Owner assignment (§6).
  6. GET /v1/activity — the feed’s HTTP surface (§7).
  7. Readiness surfacing of the §5.4 configuration gaps (§8).

Job grades, salary bands, headcount planning, vacancy and requisition workflows — §4.7 assigns all of these to the HR layer in Phase 5. Grant configuration, thresholds and the on_vacancy policy are Plan 3: this plan builds the resolution primitives Plan 3 calls, not the grant rows that call them. Email delivery is out of scope; see §3.4.


2. Organization creation — operator-provisioned

Section titled “2. Organization creation — operator-provisioned”

An organization is created by an operator running a CLI command, not by a public sign-up endpoint. There is no anonymous tenant-creation surface.

The reasoning is specific to this product. Every organization here is a Malaysian tax record holder under ITA 1967 s.82 — the records it accumulates must be retained seven years (§11A.2) and cannot be deleted (§17.3, as amended by §11A). An anonymously created tenant is a seven-year retention obligation with no known counterparty. A public sign-up endpoint would also need email verification, abuse rate limiting and a deletion story for abandoned orgs, none of which exist and all of which are real work in service of a capability nobody has asked for.

A fourth binary beside api, migrate and worker.

orgadmin create-org \
-name "Acme Sdn Bhd" \
-country MY -currency MYR -fiscal-year-start 2026-01-01 \
-owner-email owner@acme.my \
-owner-name "Siti binti Abdullah"

In one transaction, in this order:

  1. organization row (org_id = id, per the self-scoped CHECK in 00002).
  2. users row for the owner, if the email is not already known. A user is global across tenants — an existing user joining a second org reuses their row. The password is not set here: the command prints an invitation link (§3), so no operator ever handles a customer’s password.
  3. party row, kind = 'employee', legal_name from -owner-name.
  4. organization_member joining the user to the org via that party.
  5. company_profile from the country/currency/fiscal-year flags — this is provisioning step 10’s gate, and supplying it here is what lets the rest run.
  6. provisioning.Run over the full registry. Step 40 creates the Owner role and its grants; step 50 creates the root unit and position. Neither invents the member — both take it as an explicit input, per the contract Plan 2’s fix wave established.
  7. member_role joining the owner member to the Owner role step 40 created. This is the CLI’s job, not step 40’s: provisioning seeds an organization’s roles, and who holds them is a fact about this particular owner that a re-run must not reassert.

Then it prints the organization id and the owner’s invitation link.

Plan 2’s final review turned up exactly one Critical, and this is where it would recur. go list -deps ./cmd/migrate showed no internal/modules packages, so the permission registry — filled by init() in module packages — was empty in that binary. Provisioning seeded an Owner with zero grants and recorded the step complete, producing a permanently unadministrable organization.

cmd/orgadmin is a second binary that reads that same registry, so it is a second chance to make the same mistake. It ships with cmd/orgadmin/wiring_test.go from its first commit, asserting that the binary’s import graph reaches every module package that registers permissions, and that the set the binary sees is non-empty and equals member.RegisteredPermissionSet() under the same imports.

The lesson stated once, for the plan and for the code: a registry filled by init() is only as complete as the import graph of the binary that reads it.


invitation (id, org_id, email CITEXT, role_id, invited_by_member_id,
token_hash BYTEA, expires_at, accepted_at, accepted_user_id,
revoked_at, created_at)

The token follows the refresh-token pattern already in this codebase: 32 random bytes, base64url to the caller, and only its sha256 stored. A database dump does not yield a usable invitation. Single use — accepted_at is set in the same statement that consumes it, conditioned on it being NULL, so two concurrent accepts cannot both win.

Expiry is seven days. An invitation names the role the invitee receives, so accepting is not a second decision by an administrator.

Route Permission Notes
POST /v1/org/invitations org.members.invite Creates one; returns the link.
GET /v1/org/invitations org.members.invite Lists pending ones.
DELETE /v1/org/invitations/:id org.members.invite Revokes; sets revoked_at.
POST /v1/auth/invitations/accept — (unauthenticated) Token + name + password.

The accept endpoint is unauthenticated because the invitee has no credential yet — that is the whole point of the flow. It is therefore a fourth entry in internal/app.Router’s unauthenticated list and a fourth line in api/public_routes.txt, with its justification: the invitation token is the credential, it is single-use, and it is verified before anything is written.

Accepting creates the users row (or reuses an existing one, in which case the password field is ignored rather than reset — an invitation must never be a password-reset primitive), the party, the organization_member, and the member_role naming the invited role.

Activation — the one accept that creates no member (added in Task 11). §2.2 seats the first Owner as a member, with the Owner role, before it prints their invitation, because provisioning step 50 must seat a member that exists. Read literally, that invitation could never be accepted: the membership already exists (409), and the Owner’s login — created by invite.EnsureLogin with a hash that verifies nothing, since no operator may choose a customer’s password — would have its password ignored like any existing login’s. So invite.Accept has one further path, activateSeated. When the invitee already has a membership in the organization, the accept succeeds only if the invitation was issued by that same membership (cmd/orgadmin issues it in the Owner’s own name; invited_by_member_id is NOT NULL and the Owner is the only member) and the member already holds the invited role. It creates and grants nothing. If the login has never had a password, the supplied one becomes its first, through a single UPDATE … WHERE password_hash = '!unset'; a login that has a password keeps it. Every other existing-membership case is still 409 — including a second invitation from another organization, which is what stops that organization’s administrator setting the password of this one’s Owner. The accept is audited as activated (with created_login when a first password was set — a security event on the Owner’s account), and the permission version is not bumped, since nobody’s authority changed. A login with no password also costs the decoy Argon2 verification at sign-in, so a pending Owner’s address cannot be told apart by timing.

The accept endpoint takes a token and is therefore guessable in principle. It reuses throttle, keyed on the client address, with the same account-wide second counter that Plan 2’s fix wave added. A token is 256 bits, so this is defence in depth rather than the primary control, but an unthrottled credential-accepting endpoint is not a thing this codebase should contain.

This line first said “keyed on the invitation id”, and that was wrong. The id is not derivable from an unverified token, so keying on it forces the caller to supply the id — and a counter whose key the attacker supplies is a counter the attacker resets. Keying on the token hash is worse still: every guess is a different token, so every guess gets a fresh counter. The only dimension an attacker must pay to rotate is the source address.

That address is trustworthy here for a specific reason. httpx.NewRouter calls SetTrustedProxies with a list that defaults to EMPTY, so forwarding headers are ignored unless an operator names a real proxy, and ClientIP() falls through to the connection’s own remote address. That fix exists because the login throttle was defeatable by rotating X-Forwarded-For; the same property is what makes an address-keyed counter meaningful here.

No mailer exists in this codebase and this plan does not build one. Both the CLI and the invite endpoint return the invitation link to the caller, who delivers it. This is honest about the seam: when a notification platform arrives it will send this link, and nothing about the invitation model has to change for that.


org_unit (id, org_id, parent_id, code, name, path, depth,
unit_head_position_id, is_active, created_at, updated_at)
org_position (id, org_id, org_unit_id, code, title, reports_to_position_id,
path, depth, is_active, created_at, updated_at)
member_position (id, org_id, member_id, position_id, is_primary,
valid_from DATE, valid_to DATE, created_at)

§4.7 calls the second table position. It is named org_position here for two reasons: POSITION is a SQL keyword (Postgres classes it non-reserved-but-not-a-function- name, so a bare position table works but reads badly in every query that joins it), and org_unit / org_position names the pair as the two trees they are.

organization_member additionally gains manager_override_id UUID REFERENCES organization_member (id) — the explicit exception at the head of §5.4’s chain.

4.2 Deviation from §4.7: no name_i18n / title_i18n

Section titled “4.2 Deviation from §4.7: no name_i18n / title_i18n”

§4.7’s sketch gives both trees a *_i18n column. This design drops them in favour of plain name and title TEXT.

This repository already has translation rows with per-row locale fallback (00006, i18n.Load(ctx, tx, kind, ids, field)), used by the registry primitive. A JSONB column beside that is a second mechanism for one job: two places to look when a name renders in the wrong language, and two things to keep in step. The stored name is the record default — English or Bahasa Malaysia per §11A.3 and the org’s default_locale — and any other rendering is a translation row keyed ("org_unit", id, "name").

path is a materialized ancestry string of UUIDs, /<root>/<child>/<self>/, written and rewritten by the package that mutates the tree, inside the caller’s transaction. A descendants query is one indexed prefix match:

WHERE path LIKE $1 || '%'

UUIDs rather than codes, because a rename must not rewrite a subtree. depth is stored beside it so a levels-up walk is arithmetic rather than string counting.

Depth is capped at 10. §5.4’s escalation walks up this tree bounded by max_escalation_levels; an unbounded tree makes an unbounded worst case, and ten levels is more organizational depth than any Phase 1 customer has. Exceeding it is a typed error at insert time, not a truncation.

Cycles. Reparenting refuses when the proposed parent’s path contains the moving node’s own id — which is the complete cycle check, since the path is the full ancestry. Reparenting rewrites every descendant’s path and depth in one statement:

UPDATE org_unit
SET path = $new_prefix || substring(path from length($old_prefix) + 1),
depth = depth + $delta
WHERE org_id = $org AND path LIKE $old_prefix || '%'

Both trees — units and positions — use the identical mechanic. It is written once, in an unexported helper parameterized by table name, and not copied.

4.4 One primary position per member, enforced by Postgres

Section titled “4.4 One primary position per member, enforced by Postgres”

A member may hold several positions, and may hold one for a fixed window. Exactly one of their concurrent positions is is_primary, because §5.4 resolves against the primary.

“At most one primary at any instant” cannot be expressed as a unique index — the windows overlap in time, not in key — and enforcing it in Go means a race between two concurrent assignment requests, each reading a clean state before the other writes.

This is the one place in this design where the database is the correct enforcer:

CREATE EXTENSION IF NOT EXISTS btree_gist;
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);

§5.4 says a member with no primary position is “a configuration error caught at assignment time”. This constraint is what makes the two-primary version impossible rather than merely caught, and the no-primary version is checked by the assignment path and surfaced by readiness (§8).

valid_to IS NULL means open-ended; daterange(valid_from, NULL, '[)') is unbounded above, which is exactly the intended semantics.

Org structure is not reference data. It is the evidence of who was entitled to approve what, on the date they approved it. §11A.4 requires every number to be traceable to a document and a decision, and a decision’s authority is only reconstructible if the reporting line as it stood on that date can be reconstructed. A deleted position takes with it the meaning of every approval it ever gave.

So, following 00015’s pattern for company_profile and party:

REVOKE DELETE ON org_unit, org_position, member_position FROM erp_app;

Units and positions deactivate (is_active = false); assignments close (valid_to). An assignment that has not started yet (valid_from today or later) is instead withdrawn (withdrawn_at, migration 00023, audited): a withdrawn row is in force on no day and is excluded from currentAsOf and from member_position_one_primary, so a mistaken future or same-day assignment can be corrected without granting a day of authority it never should have had. org_unit.is_active also carries korre’s own rule, adopted in §5.4’s chain: a retired department still counts as a source of a unit head.

The retention drift test in internal/kernel/db/retention_test.go grows these three tables, so a future migration that re-grants DELETE fails the build with the reason attached.

All under /v1/org, all through httpx.Protected:

Route Permission
GET/POST /v1/org/units, PATCH /v1/org/units/:id org.structure.read / .manage
GET/POST /v1/org/positions, PATCH /v1/org/positions/:id same pair
POST /v1/org/members/:id/positions org.structure.manage
DELETE /v1/org/members/:id/positions/:pid org.structure.manage (closes a started assignment, withdraws one not yet started; never deletes)
GET /v1/org/chart org.structure.read

PATCH on a unit or position is where reparenting happens, and is the operation that triggers §4.3’s subtree rewrite.


Per §5.4, in order, one hop and never transitive:

  1. organization_member.manager_override_id, if set.
  2. The holders of the member’s primary position’s reports_to_position_id.
  3. The holders of the member’s org unit’s unit_head_position_id — including a unit whose is_active is false.
  4. Nothing — and this is a refusal, not a silence.

SuperiorOf returns []uuid.UUID, not one id. A position may be held by more than one member (job share, an acting arrangement overlapping a handover), and §5.4’s floor computation is defined over resolved member sets anyway — “resolve every grant on the step to a concrete member set → compute the winning floor over that resolved list → test the actor’s membership”. Returning one id would force a choice this layer has no basis to make.

§5.4 is explicit that korre’s null superior “produces no warning”, and that a claim routed to nobody rots in a queue no one is watching. So step 4 returns a typed error:

type ErrNoSuperior struct {
MemberID uuid.UUID
PositionID *uuid.UUID // the primary position, if they had one
Reason string // which link was missing
}

Reason distinguishes the cases that need different fixes: no primary position, a primary position with no reports_to, a reports_to pointing at a vacant seat, a unit with no head. Plan 3 renders this into the submission refusal that names the gap.

§5.4’s on_vacancy: escalate walks up the reports_to tree to the next filled seat, bounded. The policy — which grants escalate, and the bound — is Plan 3. The walk is here:

func NextFilledAbove(ctx, tx, positionID uuid.UUID, maxLevels int) ([]uuid.UUID, error)

It climbs reports_to_position_id, at most maxLevels hops, returning the first position’s holders that is non-empty. A maxLevels of zero or less is an error, not an unbounded walk: §5.4 is explicit that unbounded escalation routes an RM100 claim to the CEO.

5.5 What “as of a date” covers — and what it does not

Section titled “5.5 What “as of a date” covers — and what it does not”

SuperiorOf and NextFilledAbove take the day to resolve for, but only member_position is dated. The day decides which assignments are in force; everything else the chain reads — org_position.reports_to_position_id and is_active, org_unit.unit_head_position_id, organization_member.manager_override_id and active — is read as it stands now, because those columns are updated in place (the previous value survives only in audit_log). Resolving for a past day therefore answers “who held, on that day, the seats today’s chart points to”, not “who was the superior on that day”.

So a consumer that needs a durable answer — Plan 3’s approval decisions — must persist the resolved approver set (and the day it was resolved for) at decision time, and must not re-resolve later to explain a past decision. Re-resolution is for routing work that is still open.


The slot is already reserved. The step seeds:

  • one root org_unit, code ROOT, named from the company profile’s legal_name;
  • one root org_position, code OWNER, in that unit, with no reports_to (it is the root);
  • the root unit’s unit_head_position_id pointing at that position;
  • a member_position assigning the Owner member to it, is_primary = true.

Idempotence, per §20.2’s first property: Check asks whether a root unit and a root position exist and whether the root position has at least one current holder. Re-running repairs a missing holder without touching a structure the customer has since edited — an org that renamed ROOT or built ten units beneath it keeps all of it.

Like every step since Plan 2’s fix wave, the seed takes the Owner member as an explicit input rather than discovering it, and Registry.Run re-runs Check after Run and refuses to record a completion that Check still denies.


GET /v1/activity, permission activity.read, keyset paginated on the (occurred_at, id) tuple the index already serves, with before_occurred_at + before_id query parameters and a limit capped at 100.

Optional subject and subject_id filters hit the second index. actor_member_id is rendered with the actor’s display name via one batched lookup, not a join per row.

The org-wide feed is permissioned, because “who did what across this organization” is exactly the sensitive part. A member’s own activity is not separately exposed in this plan; when a personal feed is wanted it is subject = 'member', subject_id = self, which the existing filter already supports.


§20.2 says the readiness endpoint is also what surfaces “the Section 5.4 failure modes that appear after provisioning — a vacant position leaving a threshold ladder with a hole, a superior grant that cannot resolve — so they are visible in a settings screen rather than discovered by a claimant whose submission is refused.”

Step 50’s Check therefore reports, beyond complete/incomplete, a list of warnings that do not fail provisioning but are rendered by the readiness endpoint:

  • an active member with no current primary position;
  • an active org_position with no current holder (a vacancy);
  • an active org unit with no unit_head_position_id;
  • an org_position whose reports_to_position_id points at an inactive position.

This requires a small widening of the provisioning step contract — Check gains a warnings return — and every existing step returns none. The alternative, a second inspection path beside Check, would let the two drift.


internal/platform/orgstruct/ unit.go, position.go, assignment.go,
tree.go (paths, reparent, cycles),
superior.go (§5), errors.go
internal/platform/identity/invite/ invitation.go
internal/modules/orgstructure/ routes.go, handlers.go, permissions.go
internal/modules/activity/ routes.go, handlers.go, permissions.go
cmd/orgadmin/ main.go, createorg.go, wiring_test.go

Layering is unchanged and still compiler-enforced: modules → platform → kernel, downward only. orgstruct is platform because Plan 3’s workflow engine consumes SuperiorOf and must not import a module.

This plan introduces the second and third modules. That matters beyond tidiness: 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 have never had a second module to be tested against. Plan 2’s own test comment says as much — the first version of the route test “guarded the one module whose author had already thought about authorization”.

So the plan adds the assertions that exercise the multi-module case rather than assuming it holds: both modules’ routes present in one assembled table, permissions from both modules visible to RegisteredPermissionSet, and the Owner seeded with grants spanning both.


Every table gets the RLS coverage the drift test demands. Beyond that, the tests that carry weight here:

  • Cross-tenant tree isolation. A unit in org A is invisible to org B, including through the path LIKE prefix query — a prefix match is the easiest place to lose a tenant predicate.
  • Reparent rewrites the whole subtree, verified three levels deep, and refuses a cycle, verified by attempting to reparent a node under its own grandchild.
  • The exclusion constraint actually fires: two overlapping primary assignments, second one rejected by Postgres. Then a non-overlapping pair, accepted — the presence half, without which the rejection proves nothing.
  • Each of the four SuperiorOf branches in isolation, plus the refusal, asserting on Reason and not merely on “an error occurred”.
  • NextFilledAbove stops at the bound — a chain of five vacant seats with maxLevels = 2 returns empty, not the sixth.
  • Invitation single use: two concurrent accepts, exactly one succeeds.
  • cmd/orgadmin end to end against a real container: create-org, accept the invite, then assert the new Owner can actually reach a permissioned endpoint. That last assertion is the one that would have caught Plan 2’s Critical.

Presence before absence applies throughout, per the convention now in AGENTS.md: every test that asserts something is absent first shows it present without the mechanism.


  • Grant configuration (superior, levels_up, unit_head, position grantee kinds; thresholds; on_vacancy; max_escalation_levels) — Plan 3. This plan builds only the resolution primitives.
  • Delegation and acting arrangements beyond valid_from/valid_to — §17.4’s delegation model is Plan 3’s, and rides on the workflow engine’s decision log.
  • Email delivery — §3.4.
  • Public self-service sign-up — §2.1. Adding it later does not invalidate anything here; it would be a second path to the same transaction.