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."
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.
Org-scoped checklist rows, one row per outstanding requirement key. Landed as migration 0004_warm_invaders.sql (commit 44b561c889ec).
| constraint | what 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 text | Deliberately 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 row | Single 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.
The two booleans are bucket memberships. There's no status column and no state machine to keep valid; the four combinations mean:
Action needed from the org. Nothing submitted, or the submission failed — error_code/error_reason carry the failure (e.g. legal_entity_suspended).
Submitted, provider is reviewing, nothing due from the org. First KYB submission looks like this for the whole review window.
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.
Satisfied. Absence is the success state — which is exactly why the table must be a projection (§01).
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 }
| fact | desired row |
|---|---|
| legalEntityStatus = null | legal_entity.verification → currently_due (nothing submitted yet) |
| legalEntityStatus = PENDING | legal_entity.verification → pending_verification |
| legalEntityStatus = SUSPENDED | legal_entity.verification → currently_due, error legal_entity_suspended |
| legalEntityStatus = DENIED | legal_entity.verification → currently_due, error legal_entity_denied |
| legalEntityStatus = ACTIVE | no row — satisfied |
| hasExternalAccount = false | external_account → currently_due |
| hasExternalAccount = true | no 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.
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:
| path | fires on | may do |
|---|---|---|
| full reconcile | MT legal-entity status webhook (fans out to every org linked to the entity) · external account created | upsert + delete — authoritative, table ends exactly equal to derived state; gate re-evaluated |
| transient flips | KYB 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 pending | upsert one row — bridges the gap until the next webhook reconciles |
| read-side repair | GET 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 |
31cdae294101) while trigger-driven reconciles stay the single authority for changing or removing rows.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.
This exact walk is automated as a hurl scenario (RENTAL-617, commit 4e9a0417aa34) against a live API + DB:
currently_due — nothing due from the org while the provider reviews (days, in production).
error_code/error_reason generalize without schema change; a child error table stays unbuilt until something can populate it.| what | where |
|---|---|
| schema + migration | apps/api/src/schema/onboarding-requirement.ts · commit 44b561c889ec (RENTAL-613) |
| derive + reconcile | apps/api/src/organizations/organizations.service.ts · commit f216d40afe1f (RENTAL-614) |
| contract + endpoint | packages/api-client/src/contract.ts, treasury controller · commit fc3d63592b7c (RENTAL-616) |
| staleness fixes | read-side repair + submit flip · commit 31cdae294101 |
| e2e scenario | hurl/ onboarding requirements lifecycle · commit 4e9a0417aa34 (RENTAL-617) |
| spikes | Stripe 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