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_loghas its table (00012), its RLS policy, itsRecordandFeedfunctions, 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/provisionandGET /v1/org/readinessendpoints — and it already reserves order slot 50org_structure, labelled “Plan 2b” ininternal/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.1 Scope
Section titled “1.1 Scope”- Organization creation and the first Owner — operator-provisioned, via CLI (§2).
- Member invitation — invite, accept, and the membership it creates (§3).
org_unit,org_position,member_positionand the tree mechanics (§4).- Reporting-line resolution, including the refusal §5.4 requires (§5).
- Provisioning step 50 — root unit, root position, Owner assignment (§6).
GET /v1/activity— the feed’s HTTP surface (§7).- Readiness surfacing of the §5.4 configuration gaps (§8).
1.2 Out of scope, deliberately
Section titled “1.2 Out of scope, deliberately”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”2.1 The decision
Section titled “2.1 The decision”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.
2.2 cmd/orgadmin
Section titled “2.2 cmd/orgadmin”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:
organizationrow (org_id = id, per the self-scoped CHECK in00002).usersrow 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.partyrow,kind = 'employee',legal_namefrom-owner-name.organization_memberjoining the user to the org via that party.company_profilefrom the country/currency/fiscal-year flags — this is provisioning step 10’s gate, and supplying it here is what lets the rest run.provisioning.Runover 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.member_rolejoining 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.
2.3 The wiring test is not optional
Section titled “2.3 The wiring test is not optional”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.
3. Invitations
Section titled “3. Invitations”3.1 Shape
Section titled “3.1 Shape”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.
3.2 Endpoints
Section titled “3.2 Endpoints”| 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.
3.3 Throttling
Section titled “3.3 Throttling”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.
3.4 No email is sent
Section titled “3.4 No email is sent”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.
4. Org structure
Section titled “4. Org structure”4.1 Tables
Section titled “4.1 Tables”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").
4.3 Paths, maintained in Go
Section titled “4.3 Paths, maintained in Go”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.
4.5 Nothing here is deleted
Section titled “4.5 Nothing here is deleted”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.
4.6 Endpoints
Section titled “4.6 Endpoints”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.
5. Reporting-line resolution
Section titled “5. Reporting-line resolution”5.1 The chain
Section titled “5.1 The chain”Per §5.4, in order, one hop and never transitive:
organization_member.manager_override_id, if set.- The holders of the member’s primary position’s
reports_to_position_id. - The holders of the member’s org unit’s
unit_head_position_id— including a unit whoseis_activeis false. - Nothing — and this is a refusal, not a silence.
5.2 It returns a set, not a member
Section titled “5.2 It returns a set, not a member”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.3 The refusal
Section titled “5.3 The refusal”§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 The escalation primitive
Section titled “5.4 The escalation primitive”§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.
6. Provisioning step 50
Section titled “6. Provisioning step 50”The slot is already reserved. The step seeds:
- one root
org_unit, codeROOT, named from the company profile’slegal_name; - one root
org_position, codeOWNER, in that unit, with noreports_to(it is the root); - the root unit’s
unit_head_position_idpointing at that position; - a
member_positionassigning 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.
7. The activity feed endpoint
Section titled “7. The activity feed endpoint”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.
8. Readiness surfaces the §5.4 gaps
Section titled “8. Readiness surfaces the §5.4 gaps”§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_positionwith no current holder (a vacancy); - an active org unit with no
unit_head_position_id; - an
org_positionwhosereports_to_position_idpoints 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.
9. Package and module layout
Section titled “9. Package and module layout”internal/platform/orgstruct/ unit.go, position.go, assignment.go, tree.go (paths, reparent, cycles), superior.go (§5), errors.gointernal/platform/identity/invite/ invitation.gointernal/modules/orgstructure/ routes.go, handlers.go, permissions.gointernal/modules/activity/ routes.go, handlers.go, permissions.gocmd/orgadmin/ main.go, createorg.go, wiring_test.goLayering 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.
9.1 Two modules, on purpose
Section titled “9.1 Two modules, on purpose”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.
10. Testing
Section titled “10. Testing”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 LIKEprefix 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
SuperiorOfbranches in isolation, plus the refusal, asserting onReasonand not merely on “an error occurred”. NextFilledAbovestops at the bound — a chain of five vacant seats withmaxLevels = 2returns empty, not the sixth.- Invitation single use: two concurrent accepts, exactly one succeeds.
cmd/orgadminend 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.
11. What this plan does not decide
Section titled “11. What this plan does not decide”- Grant configuration (
superior,levels_up,unit_head,positiongrantee 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.