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)
0. Source-of-truth note (read first)
Section titled “0. Source-of-truth note (read first)”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:
- Clerk org creation (today: Clerk dashboard)
- Org settings configuration (
org_settingsincl.fuzzyMatchingConfig; per-connection gates likepricingConfig.autoRepriceOnExtraction,matchingConfig.xrefWriteback) - Email-pull connection setup (Gmail / Microsoft 365 OAuth)
- First
bronze_syncDagster partition for the newconnection_id+ verify rows landed dbt_transformper-connection + Typesense reindex- 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.
1. Problem
Section titled “1. Problem”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?”
2. Goal
Section titled “2. Goal”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) |
4. Design principles
Section titled “4. Design principles”- 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_connectionsrow exists, anerp_sync_cursorsrow sayssuccesswithrecordsProcessed > 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 oftodo | 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 suppressestodo, nevererror. - 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.
- Sub-route, not another tab (revamp T-B). The flow lives at
/orgs/new(create) and/orgs/[slug]/onboarding(resume/status). The existingconnectionstab is embedded, not duplicated. - Both environments, honestly (revamp T-D). Every read/write threads
locals.environment; Dagster deeplinks use the env-aware builders inlib/embeds/deeplinks.ts. Dogfood path: run the whole wizard against staging first via theerp-internal-envcookie. - Every mutating wizard action is audit-logged (via the
AdminService.logAction→sysAdminAuditLogpattern thatoverride.tsalready uses). This also retrofits the gap in the shipped connections routes. - Default-OFF for anything ERP-write-adjacent. Gates like
xrefWritebackandautoRepriceOnExtractionare presented as explicit opt-in toggles with the activation ladder from14-activation-runbook.md, each behind a confirm step — consistent with the feature-flag policy for ERP writes.
5. The wizard: steps, and automate-vs-deeplink per step
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.
6. The one contested posture: Dagster launch (deeplink vs server-side trigger)
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 — thoughbronze_syncitself 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).
7. API surface
Section titled “7. API surface”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 |
Extended
Section titled “Extended”| 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) |
Explicitly reused as-is
Section titled “Explicitly reused as-is”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).
Not reused (deliberately)
Section titled “Not reused (deliberately)”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).
Data model
Section titled “Data model”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 extendedPOST /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 isstatus='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.
10. Risks & open questions
Section titled “10. Risks & open questions”- 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. - §6 decision — auto-full-sync env flip vs gated launch endpoint vs deeplink-only. Owner call; default (a).
- 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-externalwith an explicit “alias missing — mail will be skipped” error state derived fromemail_pull_connectionsrows with nullemail_alias_id. A callback-side guard is a webapp change — flag it to that surface’s owner rather than smuggling it in here. - Env-mismatch foot-gun (T-D) —
getClerksilently falls back to the prod Clerk key whenSTAGING_CLERK_SECRET_KEYis unset (apps/internal/src/lib/clerk.ts:4-7; the key is optional inenv.d.ts) whilegetDbhard-fails withoutSTAGING_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 likegetDb) 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. - Coarse internal RBAC — every
@ordermatic.couser is admin; real boundary is CF Access. Mutating steps get confirm dialogs + audit; no new roles (consistent with the spike plan’s stance). - 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_cursorsis keyed reads), and the endpoint is per-org (no cross-customer scans). - 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.
- Flagsmith identity model nuance — overrides are segments on the
organizationIdtrait; 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.
11. Test & dogfood plan
Section titled “11. Test & dogfood plan”- 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=Falsemust 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/defaultsapplied twice = once, per plane); no-secret-serialization assertion on every new read endpoint (aggregator,email/status,sync-status) with the excluded columns named —oauthAccessToken/oauthRefreshTokenonemail_pull_connections,verificationTokenonemail_aliases, anderp_sync_cursors.lastErrorscrubbed 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_KEYis unset); Dagster GraphQL client mocked acrossok/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.