commerce · onboarding requirements · design report
RENTAL-613 · 614 · 616 · 617 — for David

Requirements handling: a Stripe-style checklist projected from facts we already store

One table, onboarding_requirement: a row exists only while a requirement is unmet, satisfied means the row is deleted, and the whole table is re-derivable from upstream facts (MT legal-entity status, external accounts). The onboarding gate stops being hand-set — onboarding_completed is now just "zero outstanding rows."

jb55 · 2026-07-09 · built on spikes stripe-requirements (RENTAL-593) & mt-failure-webhooks (RENTAL-594)

01

Where this came from

Two spike questions shaped the design. From Stripe Connect (spike, RENTAL-593): a requirement is a key, not data — the checklist names what's missing (external_account), it never holds the thing itself; and urgency is membership in buckets (currently_due, pending_verification), not a status enum — a key can legitimately sit in both at once. From Modern Treasury (spike, RENTAL-594): MT's status webhooks carry no requirement-keyed failure detail (RENTAL-594), only an entity-level verdict — which caps how granular our error model can usefully be, so we deliberately don't build a child error table that would never be populated.

The one rule everything follows from: the table is a projection, never a system of record. Every row must be re-derivable from upstream facts, because satisfying a requirement deletes its row. Anything whose only evidence would be its own row (say, a one-shot attestation) must persist that fact elsewhere and project it here.
02

Data model

Org-scoped checklist rows, one row per outstanding requirement key. Landed as migration 0004_warm_invaders.sql (commit 44b561c889ec).

organization
  • id text pk
  • controller_organization_id null = platform
  • legal_entity_id
  • onboarding_completed bool — derived gate
onboarding_requirement
  • id text pk (uuid)
  • organization_id fk → organization
  • requirement_key text — open vocabulary
  • currently_due bool
  • pending_verification bool
  • error_code text null
  • error_reason text null
  • created_at / updated_at
Constraints — the model's invariants live in the schema
constraintwhat it enforces
UNIQUE (organization_id, requirement_key)One row per requirement per org. Reconciles are idempotent upserts against this key.
CHECK (currently_due OR pending_verification)Satisfied requirements are deleted, so a row in neither bucket is a bug by construction — the DB rejects it.
requirement_key is plain textDeliberately not a PG enum or column enum: keys are open-ended (provider-sourced keys will arrive over time) and must not cost a migration each. Known keys live in a TS const list (OnboardingRequirementKeys): today legal_entity.verification and external_account.
error_code / error_reason on the rowSingle optional entity-level error, not a child table — MT webhooks carry no requirement-keyed detail to fill one (RENTAL-594).

No history on this table — it only ever says "what's outstanding right now." The audit trail of how we got here lives where it already lives: webhook_event.

03

Bucket semantics — membership, not a status machine

The two booleans are bucket memberships. There's no status column and no state machine to keep valid; the four combinations mean:

currently_due only

Action needed from the org. Nothing submitted, or the submission failed — error_code/error_reason carry the failure (e.g. legal_entity_suspended).

pending_verification only

Submitted, provider is reviewing, nothing due from the org. First KYB submission looks like this for the whole review window.

both buckets

Resubmit in flight: the requirement is still due (last verdict was a failure) and a new attempt is pending. The next status webhook resolves it one way or the other.

no row

Satisfied. Absence is the success state — which is exactly why the table must be a projection (§01).

04

Derivation: facts in, desired rows out

The old evaluateOnboarding boolean check became a pure function deriveRequirements(facts) (commit f216d40afe1f, RENTAL-614). The facts are cross-module reads the treasury service already assembles; the output is the exact set of rows the table should hold.

// OnboardingFacts — everything the checklist is derived from, today
{ legalEntityStatus: LegalEntityStatus | null, hasExternalAccount: boolean }
deriveRequirements — full mapping
factdesired row
legalEntityStatus = nulllegal_entity.verification → currently_due (nothing submitted yet)
legalEntityStatus = PENDINGlegal_entity.verification → pending_verification
legalEntityStatus = SUSPENDEDlegal_entity.verification → currently_due, error legal_entity_suspended
legalEntityStatus = DENIEDlegal_entity.verification → currently_due, error legal_entity_denied
legalEntityStatus = ACTIVEno row — satisfied
hasExternalAccount = falseexternal_account → currently_due
hasExternalAccount = trueno row — satisfied

recomputeOnboarding then reconciles the table to match exactly — upsert each desired row, delete rows whose key is no longer desired — and flips the gate: onboarding_completed = (desired rows = 0). Pending rows gate too: an org whose first verification is still in flight has not completed onboarding. The recompute is scoped to top-level platform orgs only (skips sub-accounts and the house org), consistent with the org-structure tenets — only platforms onboard for API access.

Adding a future requirement (e.g. an operating-agreement acceptance) is: persist the underlying fact wherever it belongs, add a key to the const list, extend OnboardingFacts and the derivation. No migration, no new endpoint, no gate change.

05

When the projection updates

The projection is trigger-driven. Three kinds of writes, with a strict pecking order — only the full reconcile updates or deletes; everything else can merely add or soften:

Write paths, strongest first
pathfires onmay do
full reconcileMT legal-entity status webhook (fans out to every org linked to the entity) · external account createdupsert + delete — authoritative, table ends exactly equal to derived state; gate re-evaluated
transient flipsKYB submit → verification row becomes pending-only (nothing due during provider review) · legal-entity PATCH (the patch is the resubmission, RENTAL-594) → row keeps currently_due and gains pendingupsert one row — bridges the gap until the next webhook reconciles
read-side repairGET onboarding-requirements finds derived-outstanding requirements with no row (e.g. org fresh from signup, no trigger has fired yet)insert missing rows only — never updates or deletes, so it can't clobber a transient pending flip
Why reads only insert: a full reconcile-on-read would recompute PENDING-from-facts and stomp the resubmit flip (due + pending) that a PATCH just set, silently losing "a new attempt is in flight." Read repair fixes under-materialization (commit 31cdae294101) while trigger-driven reconciles stay the single authority for changing or removing rows.
06

The API surface

One read endpoint (RENTAL-616): GET /api/v1/organizations/onboarding-requirements, ts-rest contract in packages/api-client. Stripe-style: requirement keys only, no PII. Session-allowed on purpose — a mid-onboarding platform needs to poll its checklist before it has an API key. Empty lists mean onboarding is complete.

// mid-resubmit after a suspension — key in both buckets, error retained
{
  "currentlyDue":         ["legal_entity.verification", "external_account"],
  "pendingVerification":  ["legal_entity.verification"],
  "errors": [{
    "requirement": "legal_entity.verification",
    "code":        "legal_entity_suspended",
    "reason":      "Legal entity verification was suspended"
  }]
}

The onboardingCompleted gate already rides along on /me and identity responses; this endpoint is the "why not" detail behind it.

07

Lifecycle, end to end

This exact walk is automated as a hurl scenario (RENTAL-617, commit 4e9a0417aa34) against a live API + DB:

signup
Platform org created. No trigger has fired — first checklist read materializes both rows.
legal_entity.verification · dueexternal_account · duegate: not onboarded
KYB submitted
Verification leaves currently_due — nothing due from the org while the provider reviews (days, in production).
legal_entity.verification · pendingexternal_account · due
MT webhook: active
Full reconcile. Verification satisfied — row deleted.
legal_entity.verification ✓ (row gone)external_account · due
bank account added
Reconcile finds zero desired rows. Checklist empty, gate flips: API keys, sub-accounts, money movement unlocked.
checklist emptygate: onboarded
MT webhook: suspended
Requirement re-opens with error detail and the derived gate revokes access — post-onboarding suspension is the same code path as never-onboarded, nothing bespoke.
legal_entity.verification · due · legal_entity_suspendedgate: revoked
PATCH resubmit
The patch is the resubmission: row keeps due and gains pending until the next status webhook resolves it.
legal_entity.verification · due + pending
08

Deliberate limits & open items

  • Errors are entity-level only. MT gives us a verdict per entity, not per field (RENTAL-594). If MT ever ships requirement-keyed detail, the row-level error_code/error_reason generalize without schema change; a child error table stays unbuilt until something can populate it.
  • Suspended self-serve platform can't self-recover yet. The gate flip kills the org's API key, and legal-entity PATCH isn't session-allowed — so a suspended platform is stuck without operator help. Surfaced by the hurl scenario; needs its own issue before self-serve onboarding matters (currently deprioritized in favor of Managed Brokkr flows).
  • Vocabulary is intentionally small. Two keys today. The const list + facts + derivation is the extension seam; new keys are code changes, not migrations.
  • Managed flow untouched. Sub-accounts and the house org are skipped by every write path; requirements are a platform-onboarding concept, matching the flat org-structure tenets.
Pointers
whatwhere
schema + migrationapps/api/src/schema/onboarding-requirement.ts · commit 44b561c889ec (RENTAL-613)
derive + reconcileapps/api/src/organizations/organizations.service.ts · commit f216d40afe1f (RENTAL-614)
contract + endpointpackages/api-client/src/contract.ts, treasury controller · commit fc3d63592b7c (RENTAL-616)
staleness fixesread-side repair + submit flip · commit 31cdae294101
e2e scenariohurl/ onboarding requirements lifecycle · commit 4e9a0417aa34 (RENTAL-617)
spikesStripe Connect requirements (RENTAL-593) · MT failure webhooks (RENTAL-594)

generated for David · sugar-teach-visa investigation → RENTAL-613/614/616/617 · jb55 · 2026-07-09