Two-Phase Company Onboarding — Design Spec
Status: in build on branch two-phase-onboarding (2026-10-09). Backend and wizard built; hub, console reissue and browser pass open (see ROADMAP). Owner decisions in §12 D3-D7 are still open.
Builds on: 2026-09-21-org-structure-and-onboarding-design.md (§2 creation, §3 invitations),
2026-10-03-structured-account-codes-design.md (template, company code),
2026-09-25-operator-console-design.md (A1 create-org).
Touches: internal/platform/onboarding, internal/platform/provisioning, internal/kernel/httpx,
cmd/orgadmin, internal/platform/opsjobs, operator console, web/src/features/auth and a new setup feature.
1. Problem
Section titled “1. Problem”onboarding.CreateOrganization makes the operator choose everything in one call: country, currency, fiscal
year start, company code or flat digits, then it provisions the lot. Those are the customer’s decisions, some
are irreversible (base currency and fiscal year lock after the first posting; the code template is fixed for
life), and the operator has to collect them out of band. The operator should only open the door; the owner
decides about their own books.
2. Decision
Section titled “2. Decision”Split creation in two.
- Part 1 — operator, minimal. Name, owner name, owner email. One transaction creates the org, the owner’s login, party, member, Owner role with all permissions, and the invitation. No company profile.
- Part 2 — owner, wizard. On first login the owner is locked into a wizard. Finish writes the profile and the code template, then runs the rest of provisioning, in one transaction.
Setup pending = the organization has no company_profile row. No new column. Existing organizations all
have a profile, so nothing about them changes, and there is no backfill and no migration for state.
Why derive it rather than store a flag: a flag can disagree with the profile (flag says done, profile
missing, and every company.Get caller breaks). The profile is the fact the rest of the system already
depends on (company.ErrNotProvisioned, provisioning step 10’s gate), so the lock and the data cannot drift.
3. Scope
Section titled “3. Scope”In: Part 1 shrink, setup status, server-side draft and complete endpoints, the setup hub (§5.7), the pending lock, the wizard, CLI and console short form, operator invitation reissue, tests, i18n (en/ms/zh).
Out: changing a template kind after Finish (existing rule), group-entity onboarding, module selection in the wizard (the extension point is §9).
4. Part 1 — operator creates the company
Section titled “4. Part 1 — operator creates the company”4.1 Input
Section titled “4.1 Input”onboarding.Input loses CountryCode, BaseCurrency, FiscalYearStartsOn, CompanyCode,
FlatCodeDigits. It keeps Name, OwnerEmail, OwnerLegalName, OwnerPermissions. The create surface
(CLI, console form, create_org job) takes exactly three fields.
4.2 What the transaction writes, in order
Section titled “4.2 What the transaction writes, in order”- Tree lock (
orgstruct.LockTree) first, before anyaudit.Write(existing lock-order rule). organization. 3. login viainvite.EnsureLogin(reused if the email exists, no password).party(employee). 5.organization_member.- Roles step only (
provisioningstep 40: Owner role + grants). Not the full registry (see §4.3). member_roleOwner. 8.invitation(invite.Issue). Each leaves its audit row, as today.
Not written: company_profile, account_code_template, chart, journals, tax codes, fiscal year, org structure
(step 50 needs the profile’s legal name for the root unit, so it moves to Finish).
4.3 Provisioning needs a partial run
Section titled “4.3 Provisioning needs a partial run”Registry.Run runs every step in order and step 10 is a hard gate, so today it cannot run step 40 without a
profile. Add Registry.RunSteps(ctx, tx, c, in, keys ...string): runs only the named steps, in registry order,
with the same Check, Run, re-Check, record discipline. Run becomes RunSteps over all keys. Step 40 does not
read the profile (verified), so it is safe alone. Finish later calls plain Run; step 40 is already
complete and skipped by its own Check, so the Owner role is never reseeded or its grants reasserted.
Scalability note: RunSteps keyed by step key is the general mechanism. Future phase splits (for example a
module that provisions on enable) use it instead of inventing another entry point.
4.4 Result and reissue
Section titled “4.4 Result and reissue”Result drops Report detail for the skipped steps and gains SetupPending: true. The invitation link is
printed/sealed exactly as today.
Invitation expiry. There is no reissue path today. Add invite.Reissue(orgID, ownerMemberID): revokes the
open invitation (revoked_at) and issues a new one for the same email/role in one transaction, audited. Exposed as
orgadmin reissue-invite -org <id> and the console action reissue_invitation (sealed link, shown once, like
A1). Refused if the owner has already accepted. The org is never recreated.
5. Part 2 — owner’s first login
Section titled “5. Part 2 — owner’s first login”5.1 The pending lock
Section titled “5.1 The pending lock”A single middleware, default-deny, mounted right after Authenticate and Tenant():
- Org is complete (profile exists): pass through, zero cost after the first check (see §5.2).
- Org is pending: allow only the setup allowlist; everything else returns
403{code: "setup_required", detail: "Complete company setup first"}.
Allowlist (declared once, as setupExempt in internal/app/setupgate.go beside the unauthenticated list, with
TestSetupExemptNamesRealRoutes failing on an entry that names no route; GET /v1/setup/status also carries its
[no-permission] justification in api/public_routes.txt): GET /v1/me, the caller’s own session routes and POST /v1/auth/switch-org, and the five setup routes
(status, sections, draft get and put, complete). Refresh and logout are public routes before the gate. Unauthenticated routes (login, accept invitation) are
before the gate and unaffected.
Default-deny is the scalability decision: every module added later is locked while pending without being
edited, and TestEveryRouteIsAuthorized’s sibling asserts every registered route is either gated or in the
allowlist. An allowlist drift test, watched failing once, is part of the build. API keys go through the same
gate (a key for a pending org gets the same 403).
5.2 Cost of the check at scale
Section titled “5.2 Cost of the check at scale”The gate runs on every request, so it must not be a per-request query for the 99.9% case.
Setup completion is monotone: an org goes pending to complete once and never back (no delete of the profile; the profile table already forbids it). A monotone fact can be cached forever in process.
- In-process LRU keyed by
org_id, caches onlycomplete, neverpending. Bounded (default 10k orgs). - A pending org hits the database (
SELECT 1 FROM company_profile WHERE org_id=$1, primary key lookup) per request. Pending orgs are few and short-lived, and the allowlisted calls are cheap. - Multi-instance safe by construction: a cache that only ever says “complete” can be stale only in the harmless direction (an instance not yet knowing it is complete asks the database).
- No JWT claim: baking “setup complete” into the token would force a re-login or perm-version bump at Finish.
Ceiling, recorded: the LRU bound. Past ~10k hot orgs raise the bound; it is a number, not a redesign.
5.3 Who may complete it
Section titled “5.3 Who may complete it”Owner only. POST /v1/setup/complete carries httpx.Protected with a new permission key org.setup.complete
held only by the Owner role (seeded in Part 1 via step 40 like every Owner grant; listed in the permission
labels test). OwnerMemberID for provisioning is the authenticated caller, after asserting the caller holds
the Owner role and that the org has exactly one active member. It is named by the session, never discovered
(spec 11A.4 rule on Input.OwnerMemberID stands).
While pending, POST /v1/org/invitations is refused by the lock, so a second member cannot exist before Finish.
5.4 Endpoints (openapi first)
Section titled “5.4 Endpoints (openapi first)”| Route | Permission | Notes |
|---|---|---|
GET /v1/setup/status |
any authenticated member | {state: "pending" | "complete"}. Cheap; the web app calls it after login. |
GET /v1/setup/sections |
org.setup.complete |
The registered sections (§5.5) in order: key, required or optional, schema_version, and the options each needs (countries from the country pack registry, per-country default currency, template kinds and limits, module list with dependencies). Server-owned, so a new country, template kind or module appears with no wizard rewrite. |
GET /v1/setup/draft |
org.setup.complete |
{sections: {key: {data, version, schema_version, updated_at}}}. |
PUT /v1/setup/draft/{section} |
org.setup.complete |
Saves one section. Body {data, schema_version}, header If-Match: <version>; 409 draft_conflict returns the newer version (two tabs). Lenient: any JSON object under the size cap is saved, invalid or half-filled included. |
POST /v1/setup/complete |
org.setup.complete |
Finish. Reads the saved draft on the server (§5.6); the body only names the sections’ versions the owner reviewed. Idempotent. |
Error codes (declared in x-codes): setup_required (403), setup_invalid (422, per-section field errors), draft_conflict (409), draft_too_large (422; the error kinds here have no 413),
setup_already_complete (200 with state: complete, not an error: a double submit succeeds).
5.5 Wizard and draft
Section titled “5.5 Wizard and draft”Setting up a company is many decisions, so the wizard saves to the server and the owner can stop and resume on any device. (This reverses the first draft’s “answers live in the browser”; owner request 2026-10-09.)
Sections are a server-side registry, the same shape as provisioning steps:
setup.Section{Key, Order, Required, SchemaVersion, Options(ctx), Validate(draft) []FieldError, Apply(ctx, tx, draft)}.
A module adds its own section with one Register call and the Finish transaction picks it up; the wizard needs
only a component for a new key (an unknown key is listed but not rendered, and is never required, so a stale web
build cannot block a Finish). Core sections:
| # | Section | Required | Fields | Applied by |
|---|---|---|---|---|
| 1 | company |
yes | legal name (default: org name), trading name, registration no, country, base currency (default from pack), fiscal year start, registered and mailing address, timezone, default locale | company.Create |
| 2 | tax |
no | TIN, SST registration no, MSIC code (fields come from the country pack; shown for MY) | company.Create (same row) |
| 3 | code_template |
yes | structured (company code 1-8 [A-Z0-9]) or flat (digits); live preview of three account codes |
chart.SetTemplate |
| 4 | modules |
yes (defaults pre-selected) | which modules are on; dependencies enforced from the module registry | module switch (existing) |
| 5 | review |
n/a | not stored; shows every section’s validation result and what will be created | none |
Locked-after-posting and fixed-for-life notices sit on the fields that have them (currency, fiscal year, code template).
Autosave (owner request).
- Debounce 5 seconds after the last edit in a section, then one
PUTof that section. - A visible Save draft button saves immediately (flushes the debounce). It is always enabled; pressing it with nothing changed is a no-op that still shows “Saved”.
- Also flushed on step change, on
visibilitychangeto hidden, and onpagehide(akeepaliverequest), so leaving the tab loses at most what was typed in the last moments. - A status chip: Unsaved changes, Saving, Saved at hh:mm, Could not save (Retry). Failures retry with backoff and never discard the in-memory value. Leaving with unsaved changes warns.
- One request in flight per section; the
versionfrom each response is sent as the nextIf-Match. Adraft_conflictshows “this draft changed in another tab” with Reload / Overwrite. - The existing checklist autosave queue (
web/src/features/checklist/autosave.ts) is the pattern to reuse; it is per-cell and 500 ms, so the setup feature uses its own small hook with the same retry and ordering rules.
Draft storage. New table setup_draft(org_id, section_key, data jsonb, schema_version, version, updated_by, updated_at), primary key (org_id, section_key), org_id, forced RLS and org_isolation as every table
(migration range reserved in the plan). Cap 64 KB per section (draft_too_large). Holds no secrets, and
nothing else may be put in it. It is working state, not a record: deleted in the Finish transaction, which is
why it is exempt from the retention-privilege drift test (listed there with the reason). Rate limit 30 writes per
minute per user, well above the 5 s debounce. A schema_version the server no longer knows is ignored with a
notice, not an error, so deploys cannot strand a draft.
5.6 Finish: one transaction
Section titled “5.6 Finish: one transaction”Body: {reviewed: {section_key: version}}. The server reads the saved draft itself, so there is one source of
truth and a browser cannot submit something it did not save. It refuses with 409 draft_conflict if a section’s
version moved since the owner reviewed it, and re-validates every section (setup_invalid, field errors per
section) because the draft was lenient.
In order:
- Take the org single-flight lock:
SELECT 1 FROM organization WHERE id=$1 FOR UPDATE. A concurrent second Finish waits, then sees the profile and returnssetup_already_complete. Double click and two tabs are safe. - Re-check pending (inside the lock). If complete, return
setup_already_complete. orgstruct.LockTree, then the existing class order (tree lock before the firstaudit.Write), audit last.Applyeach registered section in order (companyandtaxtogether callcompany.Create;code_templatecallschart.SetTemplatewith the same effective-from rule as today;modulessets the switches).provisioning.Runover the full registry: chart, journals, tax codes, fiscal year, org structure (step 50 seats the caller). Step 40 already done, skipped. Module-owned provisioning steps run only for enabled modules.- Delete the org’s
setup_draftrows. - Audit
setup_completedwith the choices (country, currency, template kind, enabled modules; no free text beyond names the audit already carries).
Any error rolls everything back; the org stays pending; the response is a coded error and the draft is untouched (the delete is inside the rolled-back transaction), so the owner fixes the field and presses Finish again. No half-provisioned state exists because there is one transaction. The lock lifts the moment it commits (the gate reads the profile).
Transaction length. Chart + journals + tax codes + fiscal year is the heaviest write the system does at
once. Budget: Finish p95 under 5 s on the reference country pack; an integration test records the duration and
fails above 15 s so growth in packs is noticed. If a pack ever exceeds the budget the fallback is moving the
heavy steps to an ops_job and showing progress, which the response shape (state) already allows; not built.
5.6a Who sees what
Section titled “5.6a Who sees what”| Person | Sees |
|---|---|
| The Owner the operator named | The lock and the wizard until Finish, then the setup hub |
| A member the Owner invites later | Neither. The org is already complete, so they accept the invitation, set a password and land in the normal app with the role given |
| A login reused from another org | Same as above for the org they join; if that org is still pending they are the Owner and get the wizard |
Before Finish nobody else can exist: the lock refuses POST /v1/org/invitations. The hub (§5.7) is shown only to
holders of the Owner role or org.setup.complete/org.members.invite; ordinary members never see it. Test:
a non-admin member of a completed org gets no hub and no setup redirect.
Optional, not committed (D7): a light “Complete your profile” prompt for ordinary users on first login, linking the existing Settings screens (profile photo, job title, payout details, notifications). No new backend; a dismissible banner driven by whether those fields are empty.
5.7 After Finish: the setup hub (guided, never blocking)
Section titled “5.7 After Finish: the setup hub (guided, never blocking)”Not everything belongs in the lock. Finish provisions only what the books cannot exist without. The rest is a
setup hub the owner lands on after Finish, a checklist whose every row links to the screen that already
exists. Rows are computed from real data (/v1/org/readiness warnings and simple counts), never self-ticked, so
a row turns green only when the work is done. The hub is dismissable and reappears as a banner while a required-
for-use row is open.
Ordered by what blocks other work first (derived from the app’s navigation and the “claimant never picks their approver” rule):
- Invite your team and assign roles (
/team). - Approval grants and thresholds (
/accounting/approvers,/admin/workflows): until set, a claim cannot be routed, so this is flagged first. - Org structure and positions (
/org-structure): units, positions, reporting lines. - Opening balances (
/imports/migration), then review the chart (/accounting/accounts). - Bank accounts (
/banking), exchange rates if multi-currency (/accounting/exchange-rates). - Module setups, shown only when the module is on: payroll setup (
/payroll/setup), payout channels (/admin/payout-channels), petty cash floats, inventory locations, leave types. - Look and feel: logo and document layouts (
/admin/layouts), document numbering (/admin/numbering), notifications.
Scalability: each row is a HubItem registered by its module next to its readiness check (Key, Order, Module,
Done(ctx, tx), Href). A module that is switched off contributes no row, and a new module adds its row with one
Register call, without editing the hub. Done checks are cheap counts and run only when the hub is opened.
6. Other consumers of “has no profile”
Section titled “6. Other consumers of “has no profile””An org with no profile is now a normal, long-lived state, not a transient one. Every place that assumed a profile must tolerate it. This is the main risk to scale and must be audited, not assumed:
- Worker and schedulers iterate organizations (nightly reconcile
ops_reconcile, daily HR jobs, e-Invoice, recurring jobs). Add one predicate,org_setup_complete, used by the org iterator so pending orgs are skipped, not errored. Test: a pending org in the fixture does not make any job fail or log an error. - Operator console health and integrity checks assume a chart. Pending orgs show state
setup pending(with age) and are excluded from the red/green checks and the compliance counts. /v1/mealready toleratesErrNotProvisioned; addsetup_stateto it so the web app needs one call.- Grep every
company.Get/GetForSharecaller outside request paths and classify it. Result recorded in the plan (task 1), as the structured-codes plan did.
7. Operator surfaces
Section titled “7. Operator surfaces”orgadmin create-org -name -owner-name -owner-email. Country, currency, fiscal year, company code and flat digits flags are removed (not deprecated: a flag that silently does nothing is worse). RUNBOOK 6.x updated.- Operator console create form: three fields.
createOrgParamsshrinks. The existing A1 flat/structured gap (“console still requires a company code”) disappears. - Console list: filter
Setup pending, sorted by age. A pending org older than 14 days raises a count in the console (counts only, PDPA stays intact). Gaugeerp_orgs_setup_pending(no per-org label, so the metrics label drift test passes) and histogramerp_org_setup_duration_seconds(invitation issued to Finish). - Pending orgs are never auto-deleted: the retention rules (§17.3, 11A) apply to the audit rows already written. Abandoned orgs are an operator decision (suspend exists).
8. Security
Section titled “8. Security”- Setup endpoints rate limited per org and per user (existing limiter).
- The invitation token model is unchanged (sha256 stored, single use, 7 days).
- Existing login reuse is unchanged: an owner who already has a password logs in and accepts, no new password.
- Company code uniqueness across platform (structured-codes D4, “not enforced”) is still open; with
owners choosing codes themselves it becomes more visible.
GET /v1/setup/sectionsdoes not check it; raised as open decision D3. - The lock is server-side only; the web redirect is a convenience, never the control.
9. Scalability summary
Section titled “9. Scalability summary”| Concern | Answer |
|---|---|
| Per-request cost of the lock | Monotone state cached as “complete” only; pending is a PK lookup (§5.2) |
| New modules and the lock | Default-deny; no module edits; drift test on the allowlist (§5.1) |
| New countries and template kinds | Server sections endpoint from the pack registry; wizard has no hard-coded list (§5.4) |
| New wizard steps | Data-driven step array and one Finish body field (§5.5) |
| Partial provisioning reuse | RunSteps(keys...) (§4.3) |
| Concurrent / repeated Finish | Org row lock, idempotent result (§5.6) |
| Many pending orgs | Anti-join / count only, skipped by every scheduler, no per-org metric label (§6, §7) |
| Onboarding many companies | Part 1 is 3 fields and a short transaction; heavy work happens in the owner’s own request, spread over time instead of operator-side batch |
| Setup grows as modules grow | Section registry (§5.5) and hub item registry (§5.7); a module registers once, no wizard or hub edits |
| Draft state | Server-side, one row per org and section, versioned, 5 s debounce plus manual save, resumable on any device (§5.5); size capped, deleted on Finish |
| Write load from autosave | At most one write per section per 5 s per user, rate limited; only pending orgs write drafts, so the volume is bounded by onboarding rate, not by customer count |
10. Tests
Section titled “10. Tests”Each watched failing once; absence assertions paired with a presence assertion (AGENTS.md).
- Part 1: creates org, login, party, member, Owner role + grants, invitation; no
company_profile, template, accounts, journals; step 40 recorded, others not. Fails if Part 1 still provisions. - Locked while pending: an authenticated Owner of a pending org gets 403
setup_requiredon a sample of routes from every module; presence: the same call succeeds after Finish. - Allowlist drift: every
setupExemptentry names a real route and an ordinary route is not exempt (default-deny is what locks the rest). - Unlocked after Finish:
GET /v1/setup/statusflips; ordinary routes return 200. - Finish rollback: inject a failure in the last provisioning step; org stays pending, zero rows from profile/template/chart (presence: the injected failure ran after the first writes).
- Idempotent and concurrent Finish: two parallel calls, one set of rows, both succeed.
- Owner only: a non-Owner member of a pending org (fixture) gets 403 on complete.
- Existing companies unchanged: the existing fixture orgs pass through the gate with no setup call; existing onboarding tests updated for the short input.
- Schedulers skip pending orgs: a pending org in the fixture does not fail
ops_reconcileor the daily jobs. - Lock order:
onboardinglockorder test extended to Part 1 and Finish (tree lock before first audit). - Wiring test (
cmd/orgadmin) unchanged and must still pass: the permission registry must includeorg.setup.complete, so Part 1’s Owner role carries it. - Reissue: old token dead, new works, refused after acceptance.
- Draft: save, reload, resume; stale
If-Matchreturns 409 and changes nothing (presence: the newer value is still stored); lenient save of an invalid section succeeds but Finish refuses it; a 64 KB+ body is refused; an unknownschema_versionis ignored; Finish deletes the drafts and a rolled-back Finish keeps them; the draft endpoints are in the setup allowlist and gated byorg.setup.complete; RLS isolates two pending orgs. 13a. Audience: a non-admin member of a completed org sees no hub and no setup redirect; an invited member’s first login goes straight to the app. 13b. Web (vitest, fake timers): nothing sent before 5 s and exactly one request after; edits inside the window collapse to one request; Save draft sends at once; step change andpagehideflush; a failure keeps the value and retries; two tabs surface the conflict; redirect while pending; draft restore after reload; en/ms/zh keys present (ms/zh rows appended todocs/review/*-review.csv, need native review). - Hub: a row is green only from data (add a bank account, row flips; presence first); a module switched off contributes no row.
- Duration: Finish on the Malaysia pack under the 15 s ceiling, duration logged.
11. Build plan (order)
Section titled “11. Build plan (order)”- Audit note: every
company.Getcaller and every org-iterating job; classify (task 1, as before). provisioning.RunSteps;org.setup.completepermission; tests 1, 10, 11.- Shrink
onboarding.Input; CLI,opsjobscreate_org, console form; RUNBOOK; test 8. setuppackage: section registry,setup_draftmigration, draft endpoints,CompleteSetup(the single transaction); tests 5, 6, 7, 13, 15.- Gate middleware + cache + openapi + endpoints +
/me.setup_state; tests 2, 3, 4. - Scheduler predicate and console pending state; metrics; test 9.
invite.Reissue, CLI and console action; test 12.- Web: gate redirect, wizard with autosave, setup hub (admins only), i18n; tests 13b, 14. Browser pass at 375px and desktop in en/ms/zh (not just vitest).
Deploy note: one new table (setup_draft) plus, if the permission needs one, (new key row for existing Owner roles: existing
organizations’ Owners gain org.setup.complete via the usual role-permission migration, so nothing is
re-provisioned, and it is inert for them because they are never pending).
12. Open decisions
Section titled “12. Open decisions”- D1. Owner-only completion. Recommended: yes (§5.3).
- D2. Wizard state. DECIDED 2026-10-09 (owner): server-side draft, 5 s debounce autosave and a manual Save draft button (§5.5).
- D7. Personal welcome prompt for non-owner users (§5.6a). Recommended: yes, small, after the wizard ships.
- D6. Which sections are required. Recommended: company, code template and modules required; tax optional; everything else in the hub (§5.5, §5.7).
- D3. Platform-wide company code uniqueness (structured-codes D4). Recommended: leave open, revisit if two owners pick the same code in practice; it is not a correctness problem inside one org.
- D4. Pending org age alert at 14 days. Recommended number; owner may change.
- D5. Fiscal year start default. Recommended: no default, owner must pick (a wrong default is a silent lie).