Skip to content

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.


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.

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.

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”

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.

  1. Tree lock (orgstruct.LockTree) first, before any audit.Write (existing lock-order rule).
  2. organization. 3. login via invite.EnsureLogin (reused if the email exists, no password).
  3. party (employee). 5. organization_member.
  4. Roles step only (provisioning step 40: Owner role + grants). Not the full registry (see §4.3).
  5. member_role Owner. 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).

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.

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.

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).

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 only complete, never pending. 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.

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.

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).

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 PUT of 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 visibilitychange to hidden, and on pagehide (a keepalive request), 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 version from each response is sent as the next If-Match. A draft_conflict shows “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.

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:

  1. 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 returns setup_already_complete. Double click and two tabs are safe.
  2. Re-check pending (inside the lock). If complete, return setup_already_complete.
  3. orgstruct.LockTree, then the existing class order (tree lock before the first audit.Write), audit last.
  4. Apply each registered section in order (company and tax together call company.Create; code_template calls chart.SetTemplate with the same effective-from rule as today; modules sets the switches).
  5. provisioning.Run over 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.
  6. Delete the org’s setup_draft rows.
  7. Audit setup_completed with 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.

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):

  1. Invite your team and assign roles (/team).
  2. Approval grants and thresholds (/accounting/approvers, /admin/workflows): until set, a claim cannot be routed, so this is flagged first.
  3. Org structure and positions (/org-structure): units, positions, reporting lines.
  4. Opening balances (/imports/migration), then review the chart (/accounting/accounts).
  5. Bank accounts (/banking), exchange rates if multi-currency (/accounting/exchange-rates).
  6. 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.
  7. 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/me already tolerates ErrNotProvisioned; add setup_state to it so the web app needs one call.
  • Grep every company.Get / GetForShare caller outside request paths and classify it. Result recorded in the plan (task 1), as the structured-codes plan did.
  • 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. createOrgParams shrinks. 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). Gauge erp_orgs_setup_pending (no per-org label, so the metrics label drift test passes) and histogram erp_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).
  • 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/sections does not check it; raised as open decision D3.
  • The lock is server-side only; the web redirect is a convenience, never the control.
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

Each watched failing once; absence assertions paired with a presence assertion (AGENTS.md).

  1. 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.
  2. Locked while pending: an authenticated Owner of a pending org gets 403 setup_required on a sample of routes from every module; presence: the same call succeeds after Finish.
  3. Allowlist drift: every setupExempt entry names a real route and an ordinary route is not exempt (default-deny is what locks the rest).
  4. Unlocked after Finish: GET /v1/setup/status flips; ordinary routes return 200.
  5. 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).
  6. Idempotent and concurrent Finish: two parallel calls, one set of rows, both succeed.
  7. Owner only: a non-Owner member of a pending org (fixture) gets 403 on complete.
  8. Existing companies unchanged: the existing fixture orgs pass through the gate with no setup call; existing onboarding tests updated for the short input.
  9. Schedulers skip pending orgs: a pending org in the fixture does not fail ops_reconcile or the daily jobs.
  10. Lock order: onboarding lockorder test extended to Part 1 and Finish (tree lock before first audit).
  11. Wiring test (cmd/orgadmin) unchanged and must still pass: the permission registry must include org.setup.complete, so Part 1’s Owner role carries it.
  12. Reissue: old token dead, new works, refused after acceptance.
  13. Draft: save, reload, resume; stale If-Match returns 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 unknown schema_version is ignored; Finish deletes the drafts and a rolled-back Finish keeps them; the draft endpoints are in the setup allowlist and gated by org.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 and pagehide flush; 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 to docs/review/*-review.csv, need native review).
  14. Hub: a row is green only from data (add a bank account, row flips; presence first); a module switched off contributes no row.
  15. Duration: Finish on the Malaysia pack under the 15 s ceiling, duration logged.
  1. Audit note: every company.Get caller and every org-iterating job; classify (task 1, as before).
  2. provisioning.RunSteps; org.setup.complete permission; tests 1, 10, 11.
  3. Shrink onboarding.Input; CLI, opsjobs create_org, console form; RUNBOOK; test 8.
  4. setup package: section registry, setup_draft migration, draft endpoints, CompleteSetup (the single transaction); tests 5, 6, 7, 13, 15.
  5. Gate middleware + cache + openapi + endpoints + /me.setup_state; tests 2, 3, 4.
  6. Scheduler predicate and console pending state; metrics; test 9.
  7. invite.Reissue, CLI and console action; test 12.
  8. 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).

  • 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).