Skip to content

Org Onboarding Wizard — apps/internal (team.ordermatic.co)

Status: Draft — design only, no implementation Author: David Boone (research-assisted) Date: 2026-07-02 Branch: claude/trusting-lamport-1f2vxc · PR base: staging Upstream: apps/docs/src/content/docs/design/erp-self-serve-onboarding.md · debt/backlog/erp-onboarding-internal-spike-plan.md · apps/docs/src/content/docs/design/internal-ops-console-revamp.md (locked decisions L1–L4; Phase 5 = onboarding/connection axis) · PR #1663 (Connections tab + Nango flow + shared test lib)


The manual onboarding sequence this wizard encodes is documented in data-engineering/ONBOARDING.md (written 2026-07-02). That file is not on any pushed branch of this repo — it appears to be unpushed local work. The nearest committed kin is SOP-OB-001 (committed at apps/docs/src/content/docs/guides/sop-ob-001-supported-erp.md, with a copy on the unmerged docs/onboarding-staging-docs branch) — the 14-step CS+ENG fast path, which corroborates the shape but predates the current bronze/dbt/Typesense pipeline vocabulary. This doc therefore works from the six-step sequence as stated by the owner, verified against the code on staging:

  1. Clerk org creation (today: Clerk dashboard)
  2. Org settings configuration (org_settings incl. fuzzyMatchingConfig; per-connection gates like pricingConfig.autoRepriceOnExtraction, matchingConfig.xrefWriteback)
  3. Email-pull connection setup (Gmail / Microsoft 365 OAuth)
  4. First bronze_sync Dagster partition for the new connection_id + verify rows landed
  5. dbt_transform per-connection + Typesense reindex
  6. Scaffold the data-engineering/ workbench folder

Action item: commit ONBOARDING.md and reconcile it against §5 of this doc before implementation starts; where they disagree, ONBOARDING.md wins on process, this doc wins on mechanism. One disagreement is already known: the six-step list above never creates the ERP connection that step 4’s connection_id presupposes — the wizard inserts it as step 2a; the reconciliation should confirm that ordering.

Also note a lineage correction: the ticket previously cited as “ENG-383 (Connections tab)” now resolves in Linear to an ops-mcp email-tools issue. The durable anchor for the connections lineage is PR #1663; implementation work from this doc should get a fresh ticket rather than reusing ENG-383.


The connection-credential path already exists and works (PR #1663): OrganizationConnectionsPanel.tsx with the Nango Connect UI route (connect-session.ts + finalize.ts; secrets live in Nango, idempotent upsert keyed on extraConfig.nangoConnectionId, atomic one-active-per-org swap via db.batch + the erp_connections_active_org_unique partial index) and the Infisical route (connections/index.ts → CredentialService.storeCredentials ERP_CRED_<UUID> / storeDatabaseCredentials ERP_DBCRED_<UUID>, packages/integrations/src/auth/credential-service.ts:310,336, rollback on failure), plus a per-leg test endpoint (connections/[connectionId]/test.ts).

Everything around that panel is manual. Onboarding a new customer today means: create the Clerk org in the Clerk dashboard; hand-write SQL/scripts for org settings and per-connection gates (scripts/genfit-xref-flywheel-enable.ts is the state of the art); walk the customer through the webapp OAuth flow for email pull, then query email_pull_connections by hand because the UI never shows the connection id; watch the Dagster UI to see whether the discovery sensor picked up the new partition and whether the first bronze run landed rows; check dbt/Typesense runs the same way; and remember to scaffold the data-engineering workbench folder. Every step is tribal knowledge with no single place that answers “how far along is this customer, and what’s blocking?”

A guided, resumable onboarding flow in apps/internal that makes the onboarding sequence — §0’s six steps plus the ERP-connection step they presuppose — visible, verifiable, and (where safe) one-click — reusing the shipped connection panel as its centerpiece. The operator opens one page per new customer and sees every step’s live status derived from the system of record, with the right action per step: an in-app write where internal already owns the write, a deeplink where the trigger posture (L3) says look-don’t-touch, and a tracked handoff where only the customer can act (OAuth consent).

Non-goals (named, per the revamp’s L1–L4):

  • No customer-facing wizard (webapp self-service is a separate, later decision — the spike plan’s Phase 4).
  • No ERP connector read/write depth (SuiteQL/SuiteTalk etc.) — separate track.
  • No infra write-buttons. §6’s single candidate (first-sync launch) would ship only as an explicit owner exception to L3; deploys, prod promotion, schedule toggles, and — per L3’s own text — sync triggers stay deeplinks.
  • No rebuild of CSR actions (ops-mcp + Plain own those, L2).
  • Not the workbench generator itself (workbench new <customer> is the deferred customer-data-workbench v1; this wizard only tracks the step).
  • Not a replacement for the SOP docs — the wizard automates/verifies the ENG half; the CS half (contract, intake, UAT, go-live sign-off) stays a visible checklist.

3. What already exists (verified inventory — build on, don’t rebuild)

Section titled “3. What already exists (verified inventory — build on, don’t rebuild)”
Capability Where State
Connection create/test (Nango + Infisical), one-active-per-org swap, 23505→409 apps/internal/src/pages/api/organizations/[orgId]/connections/{index,finalize,connect-session,[connectionId]/test}.ts Shipped (PR #1663). Uses db.batch, not db.transaction. No audit logging; createdByUserId not set
Connections UI (ERP picker → capture → per-leg result) apps/internal/src/components/organizations/OrganizationConnectionsPanel.tsx Shipped; status vocabulary (connected/degraded/untested/failed/draft) defined but deriveStatus only produces connected/untested; Nango test short-circuits to fabricated success; MSSQL leg stubbed “Phase 2 (Dagster)”
Programmatic Clerk org creation apps/internal/src/pages/api/organizations/index.ts POST (admin-gated) Shipped — real contract: requires adminEmail (Zod) and, when no Clerk user matches, silently creates a passwordless Clerk user (clerk.users.createUser, skipPasswordRequirement) before createOrganization; treats slug as optional; writes no local organizations / settings row
Draft-org substrate packages/db/src/schema/organizations.ts (local organizations: status draft/active, readinessSteps jsonb, intakeEmail, inboxProvider) + org_drafts + webapp /api/orgs/{draft,create}.ts Tables + webapp APIs live; webapp wizard UI reduced to a redirect (BUG-005 / ENG-449). Internal ProvisionStep1/2.tsx scaffold exists but is orphaned (no step 3, no container, never mounted)
Org detail spine /orgs/[slug] + OrganizationDetailTabs (overview, pipeline, prompt, billing, members, activity, connections) Shipped; no settings tab
Per-org billing/feature override (audited) api/organizations/[orgId]/override.ts → AdminService.updateOrganizationOverride → sysAdminAuditLog Shipped
Flagsmith per-org override API api/admin/feature-flags.ts (add_override → segment org_<clerkOrgId> on trait organizationId) Shipped
Org settings storage organization_settings (text-JSON columns; fuzzyMatchingConfig, uiConfig and integrationConfig have runtime readers — the last read by internal’s own langfuse.ts:199-206, so seeding writes must never touch it; rows created lazily on first webapp write; no internal write path — the webapp write service OrganizationSettingsService.updateFuzzyMatchingConfig owns Zod validation, lastModifiedBy, and an atomic version increment) Partial
Per-connection gates erp_connections.extraConfig: pricingConfig.autoRepriceOnExtraction, matchingConfig.xref, matchingConfig.xrefWriteback.enabled, matchingConfig.customerResolution — all default-OFF, checked at runtime (e.g. apps/ts-temporal-worker/src/activities/persist-auto-priced-matches.ts:294-324); activation ladder in docs/designs/ck-matching/14-activation-runbook.md Shipped (script-flipped today)
Connection capability/email config erp_connections.capabilityOverrides; extraConfig technical keys (access_method, order_creation_method, …); emailConfig trusted-sender domains/patterns — triage.ts:155 returns trusted:false when emailConfig is null, so without it every PO rides LLM triage with no trusted-sender short-circuit Schema shipped; set by hand/SQL today per SOP-OB-001 steps 4–7; no UI anywhere
Email-pull machinery email_pull_connections (AES-256-GCM tokens in DB), webapp OAuth routes api/email-pull/oauth/[provider]/{authorize,callback}.ts (Clerk-org-session-bound), Temporal dispatcher every 3 min, callback auto-links a routing email_aliases row — org-scope connects require status='verified'; personal-scope connects deliberately bind to the user’s own active alias regardless of status (no active user alias ⇒ the personal connect is refused rather than bound to the org alias, and each member may hold one personal connection; org-scope and shared-mailbox connects are admin-only, enforced in both routes; the pull worker’s connection-path alias lookup never gates on status either, only the legacy webhook path does) — either branch, or an explicit-scope reconnect that resolves no alias, can leave email_alias_id null ⇒ every message for that connection is silently skipped by the worker with no per-message log — only the connect-time resolution failure gets a one-time console.warn Shipped, webapp+worker only; zero internal surface; connection id never shown in any UI
Per-org inbound address email_aliases.email stores the canonical qualified address on new-format rows (<subaddress>@${EMAIL_ALIAS_DOMAIN} is the legacy fallback, per inbox.astro); the connection-keyed generation entry is generateAliasesForConnection behind /api/alias/generate (AliasService.storeOrganizationAlias is private and organization-keyed); Clerk organization.created webhook generates aliases only if an ERP connection already exists Shipped, webapp only; generation hard-requires RESEND_API_KEY even for the org alias, uses the process-env db singleton and env-less getClerkClient
Dagster partition lifecycle erp_connection_discovery_sensor (apps/dagster/erp_pipeline/partitions.py:137-291, RUNNING, 5-min tick) auto-registers/deletes connection_id partitions from active erp_connections rows — but only for ERPSourceFactory.is_implemented types (prophet21/demo/netsuite/acumatica/wherefour/eclipse_e4; sap/dynamics/odoo connections are skipped and any existing partition actively deleted); auto-first-sync gated by ERP_DISCOVERY_AUTO_FULL_SYNC (default false); apps/dagster/scripts/ensure_erp_connection_partition.py for immediate registration Shipped
Pipeline self-drive bronze_sync_schedule (every 2h, all active partitions) → bronze_to_dbt_sensor → dbt_transform → dbt_to_typesense_sensor → typesense_sync, all default_status=RUNNING Shipped
Programmatic Dagster run-launch from Node apps/webapp/src/lib/dagster/trigger-job.ts (launchRun GraphQL, CF-Access service token, job allowlist — bronze_sync/dbt_transform not allowlisted) Shipped in webapp; internal has only the read-only health client (lib/embeds/dagster.ts) + env-aware deeplinks (lib/embeds/deeplinks.ts)
Rows-landed signal readable from Node erp_sync_cursors (packages/db/src/schema/erp-sync-cursors.ts): per (connection_id, entity) → status, recordsProcessed, lastSuccessAt, consecutiveFailures, lastError Shipped
Bronze write-safety rule resolve_iceberg_write_disposition always merge; never replace on shared bronze (apps/dagster/docs/patterns.md §Write Disposition Strategy) Constraint the wizard must never violate (it never writes bronze — noted for completeness)
Workbench folder design docs/designs/customer-data-workbench.md — approved with reframe, deferred behind order-automation playbook; approved v1 = cleaning-primitives library (the spine) with a workbench new <customer> generator as its scaffold Deferred dependency
RBAC / env CF Access + isInternalAdminRole; erp-internal-env cookie → locals.environment → getDb(env, environment) Shipped; every new route must thread both (revamp T-D)

  1. Checklist with derived state, not a stateful step machine. Onboarding spans days (customer OAuth consent, first 2-hour schedule tick) and steps get done out-of-band (scripts, Dagster UI, Clerk dashboard). Each step’s status is computed from the system of record on every load — a Clerk org exists, an active erp_connections row exists, an erp_sync_cursors row says success with recordsProcessed > 0 — never from a “wizard progress” flag that can lie. Only operator intent (manual check-offs, skips, notes) is persisted. This is the revamp’s T-A contract applied to onboarding: a step card renders one of todo | waiting-external | in-progress | done | degraded | error | skipped(manual) with problem/cause/fix/who-fixes copy, and a down signal never renders as done. Precedence: where a derived signal exists it always wins; check-offs are advisory annotations, and a skip suppresses todo, never error.
  2. Reuse the shipped write paths; add none where a deeplink is honest. The wizard is an orchestration shell around existing endpoints (org create, connections panel, override, Flagsmith override) plus a small number of new, audited, confirm-gated writes (§7). Triggers that L3 classifies as infra stay deeplinks.
  3. Sub-route, not another tab (revamp T-B). The flow lives at /orgs/new (create) and /orgs/[slug]/onboarding (resume/status). The existing connections tab is embedded, not duplicated.
  4. Both environments, honestly (revamp T-D). Every read/write threads locals.environment; Dagster deeplinks use the env-aware builders in lib/embeds/deeplinks.ts. Dogfood path: run the whole wizard against staging first via the erp-internal-env cookie.
  5. Every mutating wizard action is audit-logged (via the AdminService.logAction → sysAdminAuditLog pattern that override.ts already uses). This also retrofits the gap in the shipped connections routes.
  6. Default-OFF for anything ERP-write-adjacent. Gates like xrefWriteback and autoRepriceOnExtraction are presented as explicit opt-in toggles with the activation ladder from 14-activation-runbook.md, each behind a confirm step — consistent with the feature-flag policy for ERP writes.

Section titled “5. The wizard: steps, and automate-vs-deeplink per step”

The core deliverable table. “Automate” = server-side write from internal. “Deeplink” = env-aware link out, wizard verifies the result. “Handoff” = only an external party can act; wizard prepares, tracks, and verifies.

# Step Posture Rationale Done-signal (derived)
1 Clerk org creation Automate (exists) POST /api/organizations already creates the Clerk org, admin-gated — but its real contract must be surfaced, not papered over: it requires adminEmail and provisions a passwordless Clerk user for it when none exists, so the create form carries that field and the card states the side effect. Extend the endpoint to upsert the local organizations row (status='draft', slug read back from Clerk when omitted — the column is notNull-unique) with create-failure cleanup (delete the just-created Clerk org if the local write fails, the webapp create.ts pattern) + audit. Invitation emails stay a deeplink to the Clerk dashboard (CS-owned, per SOP) Clerk org resolvable by slug and local organizations row exists; dashboard-created orgs get an Adopt action on the card that backfills the local row instead of staying red forever
2a ERP connection (credentials) Automate (exists) Embed the shipped OrganizationConnectionsPanel unchanged Active erp_connections row; test endpoint per-leg results (see honesty fixes, §8 P2)
2b Connection & org configuration Automate (new, curated) Internal has no organization_settings write path today; gates and connection config are script/SQL-flipped. A curated “new-customer defaults” form — not a full settings editor (that’s revamp Phase 4) — covering five planes: organization_settings.fuzzyMatchingConfig (seed DEFAULT_FUZZY_MATCHING_CONFIG via the extracted webapp write service, §7 — never a second hand-rolled writer, never touching integrationConfig); per-connection extraConfig gates (default OFF, opt-in + confirm); capabilityOverrides seeded from ERP-type defaults and adjusted per the SOP’s discovery findings (third-party pricing engine ⇒ product_pricing off, or the platform pushes prices it must not); extraConfig technical keys (access_method, order_creation_method — the one the SOP requires customer-IT sign-off on, captured with an explicit “unconfirmed” state); emailConfig trusted-sender domains/patterns (without them triage.ts short-circuits nothing and every PO rides LLM triage); Flagsmith per-org overrides (reuse add_override) Settings row exists; gate/capability/emailConfig values readable back; order_creation_method confirmed; Flagsmith segment org_<id> present
3 Email intake Automate prep + Handoff + verify Alias generation is automatable via the connection-keyed generateAliasesForConnection path (see §7 for the real extraction scope — encoding the real dependency: org → connection → alias → OAuth). The OAuth consent itself is inherently the customer’s (their Google/M365 admin), Clerk-org-session-bound in webapp — internal cannot do it. Wizard shows: the verified alias’s stored canonical address (email_aliases.email, deriving <subaddress>@EMAIL_ALIAS_DOMAIN only for legacy rows) for the forwarding path, a copyable settings deeplink for the customer, then polls email_pull_connections A resolved email_alias_id on the email_pull_connections row (status='verified' for an org alias, active for a personal alias); and (status='active' with recent lastPolledAt) or confirmed forwarding_setups row
4 First bronze_sync partition + rows landed Verify-first; deeplink to launch; server-side trigger as a gated Phase-3 upgrade The pipeline is largely self-driving: the discovery sensor registers the partition ≤5 min after the connection row lands; the 2-hourly bronze_sync_schedule picks it up. So the wizard’s primary job is verification, with a Dagster launchpad deeplink (partition preselected) when the operator won’t wait. A server-side “Launch first sync” button is deferred and flag-gated (§6 discusses the L3 tension) Partition registered (GraphQL) — with an explicit error branch when the ERP type is not in ERPSourceFactory.is_implemented (the sensor will never register it, so the card must say why, not wait forever); erp_sync_cursors rows status='success' with recordsProcessed > 0 for the expected entity set = ERP-type capabilities ∩ capabilityOverrides (e.g. eclipse_e4 has products=False and capability-skipped assets never write cursor rows — a hard-coded products+customers predicate would never go green)
5 dbt_transform + Typesense reindex Verify (sensors already automate the runs) bronze_to_dbt_sensor and dbt_to_typesense_sensor fire automatically per partition. The wizard reads run status via the existing internal Dagster GraphQL client (runsOrError filtered by dagster/partition = connection_id tag) + deeplinks to the run timeline. No trigger needed in any phase; re-runs are a Dagster-UI deeplink dbt_transform and typesense_sync runs for this partition ended SUCCESS, newer than the bronze success
6 Workbench folder scaffold Manual checklist item The workbench new <customer> generator belongs to the deferred customer-data-workbench v1 (whose spine is the cleaning-primitives library) and doesn’t exist yet; having a web app commit files to the monorepo is the wrong shape anyway. Wizard shows the copyable command (or the interim manual data-engineering/customers/<slug>/ convention) + a check-off persisted to readinessSteps. Upgrade to derived state (folder exists on staging) only if/when the workbench track lands Operator check-off (persisted); later: path exists in repo
7 Go-live checklist Manual checklist Intake, user invites accepted, test email, UAT, sign-off — SOP-owned, human-paced — plus the SOP step-14 ENG confirmations go-live is gated on: production credential cutover (“no sandbox overrides remaining” — if UAT ran against a sandbox connection, the active connection must be swapped to production via the existing one-active-per-org machinery, and the wizard must ask), scheduled sync healthy, and a post-go-live sandbox test scheduled when UAT deferred order submission. The wizard renders them as check-off items so the page answers “how far along is this customer” end-to-end; completing sign-off flips organizations.status draft → active (audited) Operator check-offs; local org row status='active'

Sequencing constraint the wizard encodes (today it’s tribal knowledge): org → connection → alias → email-pull OAuth. The Clerk organization.created webhook only generates aliases when a connection already exists, and the OAuth callback only auto-links a resolvable alias — org-scope requires it already verified, personal-scope just needs it active — so a connection made before any alias exists means mail is silently skipped. The wizard makes step 3 unavailable until 2a is green.


Section titled “6. The one contested posture: Dagster launch (deeplink vs server-side trigger)”

L3’s full text already answers the classification: “Every deploy/sync trigger is a deeplink to GitHub/Dagster” — with the carve-out that “ERP connection credential config is not an infra trigger and stays in scope.” So a server-side launch first bronze_sync for connection X is not an open question; it would be an explicit owner exception to a locked decision, and this section presents that request honestly rather than reclassifying by stealth.

The case for granting the exception:

  • The customer webapp already ships the same class of write: force-sync.ts / connections/[id]/sync.ts → triggerDagsterJobFromServer, launching per-connection entity-sync jobs (products_sync, …, full_sync_pipeline) with an allowlist and partition tags — though bronze_sync itself is not in that allowlist (§3), so this is precedent for the mechanism and risk class, not the exact job. An operator console being less capable than the customer app for the same product action is an odd asymmetry.
  • It is per-connection, allowlisted, idempotent-ish (a duplicate bronze run merges — the write-disposition rule makes over-running safe by construction), and nothing like a deploy/promotion.

The case against asking at all:

  • L3 is a locked owner decision that already names sync triggers; exceptions should be earned, not assumed.
  • The pipeline self-drives within ≤2h with zero code; the wizard’s real gap is visibility, not triggering. TTHW impact of the button is minutes, not days.
  • ERP_DISCOVERY_AUTO_FULL_SYNC=true (per-tick cap already built) would remove even the wait, with a one-line env change on the Dagster side instead of a new Node write path.

Recommendation: Phases 1–2 ship verify + deeplink (launchpad URL with the partition preselected). Separately, put the question to the owner as a named decision: either (a) flip ERP_DISCOVERY_AUTO_FULL_SYNC=true (cheapest; first sync starts within 5 min of connection creation, wizard just watches), or (b) add a flag-gated, audited, confirm-stepped POST .../sync/launch in Phase 3 that extracts trigger-job.ts into a shared package and adds bronze_sync to the allowlist. Default if unanswered: (a).


All routes: isInternalAdminRole gate, getDb(env, locals.environment), Zod-validated, audit-logged on mutation, db.batch (never db.transaction) for multi-statement writes.

Route Method Purpose
/api/organizations/[orgId]/onboarding GET The aggregator: returns every step’s derived state in one call. Fans out server-side to Clerk (org), DB (organizations, erp_connections, organization_settings, email_aliases, email_pull_connections, forwarding_setups, erp_sync_cursors) and Dagster GraphQL (partition existence, runs by dagster/partition tag), each sub-signal wrapped in the T-A degraded contract with hard timeouts so one down dependency degrades one card, not the page
/api/organizations/[orgId]/onboarding PATCH Persist operator intent only: manual check-offs, skips, notes → organizations.readinessSteps / metadata — reading and normalizing the legacy string-array shape webapp orgs/create.ts wrote (['org','intake','erp']) rather than clobbering it. Completing the step-7 sign-off flips organizations.status draft → active (audited)
/api/organizations/[orgId]/settings/defaults POST Curated step-2b write across the five planes in §5. organization_settings seeding goes through the extracted OrganizationSettingsService write path (Zod parse + lastModifiedBy + atomic version increment preserved; webapp stays the proven consumer; integrationConfig never written) — not a second hand-rolled writer. Per-connection extraConfig gates + technical keys, capabilityOverrides, and emailConfig are jsonb/column updates (the genfit-xref-flywheel-enable.ts pattern, generalized + audited). Flagsmith org overrides reuse flagsmith-admin.ts. Each plane is applied and reported independently — Flagsmith is an external call that cannot share the DB batch, so partial success is a first-class response state with per-plane retry, matching the tri-part done-signal. Idempotent per plane; returns resulting effective config
/api/organizations/[orgId]/email/alias POST Generate the org alias for a connectionId. The extraction is real work, not a lift: the connection-keyed entry is generateAliasesForConnection (AliasService.storeOrganizationAlias is private and organization-keyed), it hard-throws without RESEND_API_KEY, uses the process-env db singleton, and calls env-less getClerkClient. The shared module must take injected (db, clerkClient), make Resend optional for the org-alias path (org aliases insert status='verified' with no verification email), and expose a public org-alias-only entry point; internal’s Env gains EMAIL_ALIAS_DOMAIN (and RESEND_API_KEY only if member aliases are ever generated here). Webapp stays the proven consumer — same extract-don’t-fork rule as PR #1663
/api/organizations/[orgId]/email/status GET Read pane with an explicit field whitelist per table — no whole-row selects: aliases (id, subaddress, status, and the stored canonical email, deriving <subaddress>@EMAIL_ALIAS_DOMAIN only as legacy fallback; never verificationToken), email_pull_connections (id — finally surfaced, provider, emailAddress, status, lastPolledAt, lastError; never the OAuth token columns), forwarding setups (status, expiresAt)
/api/organizations/[orgId]/sync-status GET erp_sync_cursors per entity + Dagster partition/run status for the connection (could be folded into the onboarding aggregator; kept separate so the connections tab can reuse it — this is also revamp-Phase-5’s “connection sync-cursor health” item). The cursor query/serializer is extracted from the shipped webapp api/erp/connections/[id]/cursors.ts, not re-rolled, and lastError is length-capped + secret-scrubbed before serialization (pipeline errors can embed connection-string fragments)
/api/organizations/[orgId]/sync/launch POST Phase 3, flag-gated, only if the owner picks option (b) in §6. Extracted trigger-job.ts; allowlist + confirm + audit
Route Change
POST /api/organizations Today it requires adminEmail, provisions a passwordless Clerk user when none matches, treats slug as optional, and creates the Clerk org only. Extend it to: upsert the local organizations row (status='draft', createdBy = the resolved admin user, slug read back from Clerk when omitted — the local column is notNull-unique); delete the just-created Clerk org if the local write fails (the webapp create.ts cleanup pattern); and write both audit trails — sysAdminAuditLog (internal activity tab) and the org-scoped ORGANIZATION_CREATED entry in auditLogs that create.ts writes today, so customer-visible audit doesn’t lose org creation when the webapp path is deleted
connections/* routes Add audit logging + set createdByUserId (retrofit; no behavior change)

connections/{index,finalize,connect-session,[connectionId]/test}, override.ts, admin/feature-flags (add_override), deeplinks.ts + cf-fetch.ts. Two more are reused with a named change, not as-is: getClerk(env, environment) only after its silent prod-key fallback is removed (§10.4), and lib/embeds/dagster.ts gains new parameterized functions — a partition-existence query and runsOrError filtered by the dagster/partition tag per job (the shipped unfiltered 15-run getDagsterHealth window must not be scraped client-side: a busy day pushes the new connection’s runs out of view and a healthy pipeline reads as todo).

Webapp /api/orgs/draft + /api/orgs/create and the org_drafts table — that surface was the source of BUG-005/ENG-449 (internal endpoints exposed customer-side) and its draft model duplicates what organizations.status='draft' + derived state already give us. The orphaned ProvisionStep1/2.tsx components can donate markup to the create form; the half-wizard architecture they imply (persisted step position) is superseded by derived state. Delete both scaffolds as part of Phase 1 (they are the “consolidate the split provision-wizard” debt named in revamp Phase 5).

No new tables, no migration in Phase 1. organizations.readinessSteps (jsonb, exists, currently written by nothing on the internal side) stores manual check-offs as { step, checkedBy, checkedAt, note }[] — normalizing in place the legacy string-array entries (['org','intake','erp']) that webapp orgs/create.ts wrote, so existing draft rows neither 500 the parser nor lose their history. If richer per-step telemetry is wanted later (timestamps per transition, who unblocked what), that’s a Phase-3 Drizzle schema change — generated migration, per house rules.


Entry points

  • /orgs/new — create form (name, slug, plan, region, ERP type, admin email — the endpoint’s required field; the form states that a missing Clerk user is provisioned passwordless — notes) → calls extended POST /api/organizations → redirects to /orgs/[slug]/onboarding.
  • /orgs/[slug]/onboarding — the checklist page (sub-route per T-B; also linked from a status chip on the org detail hub for any org whose local row is status='draft').
  • Organizations list gets a “New organization” CTA + a draft-status column.

Checklist page — one step card per §5 table row (2a embeds the existing connections panel), each: derived status pill, the step-appropriate action (button / deeplink / copyable handoff link / check-off), last verified timestamp, and on degraded|error the problem/cause/fix/who-fixes copy. Step 2a embeds the existing OrganizationConnectionsPanel. Step 4–5 cards show per-entity cursor rows (entity, status, records, last success) and run chips deeplinking to dagsterRunUrl(env, runId). A persistent staging/prod banner reflects locals.environment.

Honesty fixes folded in (P2, small): deriveStatus in the connections panel starts producing degraded/failed from real test results; the Nango-managed test short-circuit (test.ts:37-46 returns fabricated success) is replaced with a real Nango connection-get probe (NANGO_SECRET_KEY is already server-side) — this is revamp Phase 5’s “real Nango probe” item, and the onboarding wizard is where a fake green is most dangerous.


9. Phased implementation plan (sized against the ops-console revamp)

Section titled “9. Phased implementation plan (sized against the ops-console revamp)”

The revamp’s phase map governs sequencing: its Phase 1 (fix-what’s-broken) is independent and assumed to land first; its Phase 4 (config station) and Phase 5 (onboarding station) are the surfaces this wizard partially delivers. Rule of thumb applied: wizard phases only build what L4 classified as Ordermatic-native, CF-gated, write-bearing; everything commodity stays read/deeplink.

Phase 1 — Walking skeleton: create + checklist + verify (S–M, ~1 wk) /orgs/new + extended org-create; /orgs/[slug]/onboarding with the full card set; derived-state aggregator (Clerk/DB signals + Dagster partition/run reads via the existing embeds client); email + sync read panes; manual check-offs to readinessSteps; Dagster launchpad + Clerk dashboard + webapp-settings deeplinks; remove getClerk’s silent prod-key fallback (hard-fail like getDb, §10.4); delete the two orphaned provision scaffolds. No new config-write planes (the only mutations are the extended org-create upsert and the check-off PATCH). Exit: onboard a staging demo org end-to-end from one page, zero raw SQL, every step verifiable. Revamp mapping: delivers Phase 5’s “consolidated provisioning” shell + “sync-cursor health”; consumes Phase 1.5’s <EmbedCard>/state-contract foundations if they’ve landed, else ships the onboarding cards on the same contract shape.

Phase 2 — The write steps: settings/gates + alias + audit retrofit (M, ~1–1.5 wk) settings/defaults endpoint + curated step-2b form (fuzzy seed via the extracted OrganizationSettingsService write path; capabilityOverrides/extraConfig technical keys/emailConfig coverage; gate toggles with confirm + runbook copy; Flagsmith overrides); org-alias extraction (injected (db, clerkClient), Resend-optional org path, public org-alias entry) + email/alias endpoint; audit logging retrofit on connections routes; connections-panel honesty fixes (real Nango probe, deriveStatus). Exit: steps 1–3 fully in-app; every mutation audited. Revamp mapping: the settings write is a deliberate down-scoped slice of Phase 4’s config station (curated defaults now, full editor later — the full editor still requires extracting FuzzyMatchingSection off Clerk context; this phase extracts the write service so Phase 4 becomes additive, not substitutive).

Phase 3 — Gated conveniences (M, trigger-gated per L4) Only with named triggers: (a) owner picks §6 option — either the ERP_DISCOVERY_AUTO_FULL_SYNC flip (env change, trivial) or the flag-gated sync/launch endpoint (+ shared trigger-job extraction, allowlist additions); (b) Dagster-hosted MSSQL test leg (the spike’s deferred Phase-2 item) wired into the per-leg checklist; (c) richer onboarding telemetry schema if the jsonb check-offs prove insufficient; (d) workbench check-off upgraded to derived state once the customer-data-workbench v1 (primitives library + its generator) exists. Gate suggestion: ≥2 more real onboardings scheduled, or a measured TTHW where the 2-hour schedule wait is the binding constraint.

Deliberately not in any phase: connection edit/rotate/delete (revamp Phase 5 owns it; the wizard only needs create+test), cross-customer onboarding dashboards (one org at a time until customer count argues otherwise — same logic as the deferred health aggregate), customer-facing self-serve.


  1. ONBOARDING.md provenance (§0) — commit it; reconcile. Highest-priority open item since it is named as the source of truth but is unverifiable from staging.
  2. §6 decision — auto-full-sync env flip vs gated launch endpoint vs deeplink-only. Owner call; default (a).
  3. Alias/OAuth ordering trap — the wizard enforces org→connection→alias→OAuth, but a customer clicking the OAuth link before the alias exists still lands a connection whose mail is silently skipped (callback only warns). Mitigation in Phase 1: the email card shows waiting-external with an explicit “alias missing — mail will be skipped” error state derived from email_pull_connections rows with null email_alias_id. A callback-side guard is a webapp change — flag it to that surface’s owner rather than smuggling it in here.
  4. Env-mismatch foot-gun (T-D) — getClerk silently falls back to the prod Clerk key when STAGING_CLERK_SECRET_KEY is unset (apps/internal/src/lib/clerk.ts:4-7; the key is optional in env.d.ts) while getDb hard-fails without STAGING_DATABASE_URL — so a staging-cookie create can write the Clerk org (and a passwordless user) to prod Clerk and the draft row to the staging DB, straight past any confirm dialog that truthfully says “staging”. The mitigation is code, not UX: remove the fallback (hard-fail like getDb) in Phase 1, add a preflight Clerk-instance check to the create path, and only then the display + double-confirm; the §11 env test must cover the unset-staging-key case.
  5. Coarse internal RBAC — every @ordermatic.co user is admin; real boundary is CF Access. Mutating steps get confirm dialogs + audit; no new roles (consistent with the spike plan’s stance).
  6. Aggregator fan-out cost on neon-http — the onboarding GET touches ~7 sources. Mitigate: parallel fetches with per-source timeouts, single round-trip per table, no joins across big tables (erp_sync_cursors is keyed reads), and the endpoint is per-org (no cross-customer scans).
  7. Typesense verification is indirect (run status, not document counts). Acceptable for v1; a count probe would need a Typesense client in internal — defer until a real onboarding shows run-success lying about index contents.
  8. Flagsmith identity model nuance — overrides are segments on the organizationId trait; they only bite once users exist with that trait (post-invite). The flags card should say so, or an operator will “verify” a flag against zero identities.
  • Unit: derived-state functions per step (every signal × every T-A state, incl. degraded-not-done) and per ERP class — capability-gated entities (eclipse_e4 products=False must not be in the expected set) and not-implemented types (sap/dynamics/odoo must render the explicit error branch, never eternal waiting); authz (non-admin 403 on every new route); audit-write assertions on every mutation; gate-write idempotency (settings/defaults applied twice = once, per plane); no-secret-serialization assertion on every new read endpoint (aggregator, email/status, sync-status) with the excluded columns named — oauthAccessToken/oauthRefreshToken on email_pull_connections, verificationToken on email_aliases, and erp_sync_cursors.lastError scrubbed of credential fragments.
  • Integration: seeded staging walk-through — create org → connection (mock/demo ERP) → settings → alias → simulated cursor rows → assert card transitions; env-toggle test (staging cookie hits staging DB + staging Dagster URL + the staging Clerk instance, and hard-fails — never falls back to prod — when STAGING_CLERK_SECRET_KEY is unset); Dagster GraphQL client mocked across ok/empty/auth-missing/unreachable.
  • Dogfood (the real exit criterion): onboard the next actual customer (or a full staging rehearsal of one) using only the wizard page. Record TTHW per step and every drop-to-SQL/Dagster-UI moment; each such moment becomes a Phase-3 backlog item with evidence attached.
  • Rollback: additive throughout — new sub-routes + new endpoints + one extended endpoint. Disable the nav entry to revert; no schema migration in Phases 1–2.