# Elibrium two-track onboarding — specification

Companion to the diagram at https://doc-0001.elibrium.io (English by default, `?lang=ru` for Russian).
Every block on the diagram has an id; `https://doc-0001.elibrium.io/#n3` opens that block's note. The ids are
used below in brackets, e.g. **[n3]**, so the text and the picture can be read side by side.

Status: target design, agreed 2026-09-10/11, revised 2026-09-13 and 2026-09-14 (Persona gate, officer confirmation). Nothing of the new service exists yet. Facts
about the existing systems were read from the `backend_app` and `processing_app` repositories (branch
`develop`) and from the Highnote and Persona documentation; the URLs are listed in section 12.

How to read this document: **"Decided"** items are the design — build them as written; change them only with
a written ADR in the repository. **"Default"** items are values chosen so that nothing is left undefined; they
may be tuned in configuration without an ADR. **"Open"** items (section 10.1) depend on a third party and are
listed with the behaviour to implement meanwhile.

---

## 1. What is being built, in one paragraph

A new **registration and verification service** — a React front end and three Node.js back-end services
(onboarding API, auth/RBAC, notifications), deployed in the Kubernetes cluster of the new DigitalOcean
account — takes over customer onboarding. It owns the customer from the first email until both verifiers have
said yes, **in sequence**: first the **Persona KYC** inquiry (embedded widget) as the quality barrier, then a
**KYC officer's confirmation** in the console, and only for customers Persona approved and the officer confirmed
the **Highnote KYB/KYC application** (API + Highnote's document-upload widget).
It also runs the retry loop, the customer emails, the HubSpot funnel statuses (six, ending with `onboarding
done` at hand-over) and the **KYC officer's console**. Only after Highnote APPROVED does it hand the customer
over to the current infrastructure (the Laravel BFF `backend_app` and the processing core `processing_app`,
on VMs in a separate DigitalOcean account) — and it hands over **only the email, the company data and the
Highnote ids**. The current infrastructure then does what it does today after the operator's clicks, without
the clicks.

## 2. Hard constraints

1. **No documents, ever, on Elibrium infrastructure.** Elibrium holds no licence to store or process identity
   documents. Documents go from the customer's browser straight to Persona (embedded inquiry) or to Highnote
   (Document Upload SDK widget). No upload endpoint of ours, no temporary files, no proxying, no logging of
   widget payloads.
2. **Sensitive person data is minimised.** Highnote requires SSN and date of birth for the primary authorized
   person and every 25%+ owner. These fields are typed into our page but sent browser → Highnote through
   Highnote's client-token tokenisation; the onboarding API receives a token, never the values. The core and
   the BFF never receive SSNs either (today's `POST /api/users` payload carries `authorized_person.ssn`; the
   new call does not).
3. **The current infrastructure hears about a customer once, at hand-over, after both verdicts.** Before that
   no BFF user, no KYB profile, no core user exists. The public `POST /api/auth/register` and `POST /api/kyb`
   are no longer part of onboarding.
4. **Persona is the quality barrier in front of Highnote.** One of the purposes of this refactoring is to
   send Highnote only KYC-grade customers. Nothing is sent to Highnote — no account holder, no application —
   until Persona's decision for the case is `approved` **and a KYC officer has confirmed the case in the
   console**; the API enforces this server-side, not the UI. The gate is therefore sequential AND: Persona
   approved first, officer confirmation second, Highnote APPROVED third, hand-over fourth. The BFF never
   holds an unverified customer that came through the new service.
5. **HubSpot gets funnel statuses, never the SSN, a date of birth or a password.**
6. **Two isolated infrastructures talk only over the public internet.** Calls from the new service to the BFF
   travel over the internet; the cluster's single egress address is `209.38.116.110`, so the BFF allow-lists
   it. Webhooks from Persona and Highnote reach the new service on a public HTTPS hostname.
7. Repositories, commits, comments and documents are in English.
8. **Highnote's required data is collected only after the Persona verdict.** The account-holder token that
   carries the SSN lives one hour, so the KYB fields are typed at step 3, once Persona has approved, and
   submitted to Highnote in the same request — never stored waiting for a verdict.

## 3. Components and topology

### 3.1 Deployables (Decided)

| Deployable | Runtime | Role | Exposed |
|---|---|---|---|
| `onboarding-ui` | React (static build served by nginx) | customer pages (steps 1–3, email confirmed, document widget, retry, status) and the officer console under `/officer` | public, `https://onboarding.elibrium.io/` |
| `onboarding-api` | Node.js 22 LTS, TypeScript | the onboarding case: steps, Persona and Highnote integration, verdict handling, release and hand-over, HubSpot pushes, officer actions | public, same host under `/api/` and `/webhooks/` |
| `onboarding-worker` | same image as `onboarding-api`, different command | queue consumers: webhook processing, polls, hand-over, HubSpot pushes, scheduled jobs | internal |
| `auth-service` | Node.js 22 LTS, TypeScript | accounts, credentials, sessions/tokens, RBAC, 2FA, audit log | internal (the API and the UI reach it through `onboarding-api` routes `/api/auth/*`) |
| `notification-service` | Node.js 22 LTS, TypeScript | templates, delivery, retries, delivery log | internal |

- One repository (monorepo, `pnpm` workspaces), one CI pipeline, one Helm chart with a sub-chart per
  deployable; images built per deployable.
- HTTP framework, ORM and validation library are the team's choice, fixed once by an ADR (recommended:
  Fastify, Kysely or Prisma, Zod). All three services use the same choice.
- Internal calls are HTTP/JSON over cluster DNS (`auth-service.onboarding.svc`, …). A `CiliumNetworkPolicy`
  allows `onboarding-api`/`onboarding-worker` → `auth-service`/`notification-service` and nothing else into
  those two. Internal calls carry `X-Internal-Key` (a per-service secret) and the propagated `X-Request-Id`.
- Data stores: one PostgreSQL 16 instance (managed DigitalOcean database, same VPC) with **three databases**
  — `onboarding`, `auth`, `notifications` — one owner role each; no cross-database queries. Redis 7 for
  queues (BullMQ), rate limits and short-lived tokens.
- Namespace `onboarding` in the cluster; a namespace-scoped deploy credential like the other projects
  (`devops/iac` pattern).

### 3.2 Configuration (Decided — the complete list; values live in Kubernetes Secrets / ConfigMaps)

`onboarding-api` / `onboarding-worker`

| Variable | Meaning |
|---|---|
| `PUBLIC_URL` | `https://onboarding.elibrium.io` — base for links in mails |
| `BRAND_HOSTS` | JSON map host → brand, e.g. `{"onboarding.elibrium.io":"elibrium"}`; other brands are added by DNS + one entry |
| `DATABASE_URL`, `REDIS_URL` | stores |
| `AUTH_SERVICE_URL`, `NOTIFICATION_SERVICE_URL`, `INTERNAL_KEY` | internal calls |
| `PERSONA_API_KEY`, `PERSONA_WEBHOOK_SECRET`, `PERSONA_TEMPLATE_ID_<BRAND>`, `PERSONA_ENV` (`sandbox`/`production`) | Persona |
| `HIGHNOTE_ENV` (`test`/`live`), `HIGHNOTE_API_KEY`, `HIGHNOTE_API_URL` (`https://api.us.test.highnote.com/graphql` or `https://api.us.highnote.com/graphql`), `HIGHNOTE_WEBHOOK_SECRETS` (comma-separated, for rotation), `HIGHNOTE_CARD_PRODUCT_ID_<BRAND>` | Highnote |
| `HUBSPOT_TOKEN` | HubSpot private app token |
| `BFF_ONBOARDING_URL`, `BFF_SERVICE_KEY` | hand-over |
| `RELEASE_MODE` (`auto` default / `manual`) | who releases a case (4.7) |
| `TURNSTILE_SECRET` | captcha |
| `MAGIC_LINK_SECRET` | HMAC key for magic links |

`auth-service`: `DATABASE_URL`, `REDIS_URL`, `JWT_PRIVATE_KEY` (RS256, rotated yearly), `JWT_ISSUER`
(`https://onboarding.elibrium.io`), `INTERNAL_KEY`, `PASSWORD_PEPPER`.

`notification-service`: `DATABASE_URL`, `REDIS_URL`, `MAILGUN_API_KEY`, `MAILGUN_DOMAIN_<BRAND>`,
`MAIL_FROM_<BRAND>`, `INTERNAL_KEY`.

Environments: `test` (Highnote Test, Persona sandbox, Mailgun sandbox domain, BFF polygon) and `live`. One
Helm values file per environment; no environment is inferred from a hostname.

### 3.3 Actors (diagram lanes, left to right)

| Lane | What it is | Where |
|---|---|---|
| Persona · HubSpot | Persona: KYC verifier (embedded inquiry + webhooks). HubSpot: CRM receiving funnel statuses. | external |
| UI · React | `onboarding-ui`: customer pages (step 1 email, step 2 Persona, step 3 KYB fields, document widget, retry) and the **officer console**. | Kubernetes infra |
| API · Node.js | `onboarding-api` + worker, with `auth-service` and `notification-service` behind it. The only thing that talks to Persona, Highnote, HubSpot and — at hand-over — the BFF. | Kubernetes infra |
| Highnote | Card issuer. Runs KYB on the business and KYC on the persons inside the card product application. | external |
| BFF | `backend_app`, Laravel API behind the current Flutter app. | current infra (VMs) |
| Core | `processing_app`, the processing core. Issues cards. | current infra (VMs) |

The React UI talks only to `onboarding-api` over the service's own API. The API alone makes every external
call.

### 3.4 Platform services inside the new service [a1] [n4]

**Auth service with full RBAC [a1]** — the identity provider of the platform. Decided:

- Accounts for every kind of user: customers (created at step 1), KYC officers, administrators; later service
  accounts for machine-to-machine calls. One `users` table, `kind` = `customer` | `staff`.
- Passwords: argon2id, server-side pepper; policy 12+ characters, no composition rules, breached-password
  check against a local k-anonymity list is optional.
- Sessions: RS256 JWT access token (15 minutes) + rotating refresh token (30 days, single use, family
  revocation on reuse) in `httpOnly; Secure; SameSite=Lax` cookies on the onboarding host. Officer sessions:
  refresh 12 hours, **TOTP 2FA mandatory** for `officer` and `admin`, recovery codes.
- Email verification and password reset tokens: 24 hours, single use, stored hashed.
- RBAC: roles `customer`, `officer`, `admin`; permissions attached to roles; the code checks permissions,
  never role names. Initial permission set: `case.read.own`, `case.write.own` (customer); `cases.read`,
  `cases.confirm_kyc`, `cases.release`, `cases.retry_handover`, `cases.grant_persona_retry`, `cases.reject`,
  `cases.note` (officer); everything plus `users.manage`, `roles.manage` (admin).
- Audit log: every login, failed login, 2FA event, password change, officer action — actor, permission, target,
  IP, user agent, request id. Kept 2 years.
- Token format designed for reuse: `sub` = stable user id (uuid), `iss` = `JWT_ISSUER`, `aud` = `onboarding`
  now, the current app's audience later. Evolution: the auth service becomes the identity provider for the
  current app when the BFF is switched to it (SSO); until then the BFF keeps its own passwords (4.8).

**Notification service [n4]** — the delivery channel of the platform. Decided:

- API: `POST /notifications` `{ idempotency_key, recipient: { email }, brand, locale, template, variables,
  channel: "email" }` → `202 { notification_id }`; `GET /notifications/{id}` → delivery state.
- Email through Mailgun (one sending domain per brand, DKIM/SPF/DMARC set up by ops, see 10.1); SMS / push /
  in-app later behind the same interface.
- Templates versioned in the service repository, per brand and per locale (`en` first); magic links are
  generated by the caller and passed as variables, never composed in templates.
- Reliability: BullMQ queue, retries 1 m, 5 m, 30 m, 2 h, 12 h, then `failed`; idempotency key per
  notification; delivery log with provider events (accepted, delivered, bounced, complained) via the Mailgun
  webhook; hard bounces suppress the address; alert when the failure rate over 15 minutes exceeds 5 %.
- Scope now: the mails of section 6. Scope later: user and system notifications for the whole platform with
  per-user preferences.

## 4. The flow, step by step

### 4.1 Step 1 — email and password [p1] [n1] [p1b]

- The customer enters **email and password** on `https://onboarding.elibrium.io/` (the host selects the
  brand, 3.2). `onboarding-api` calls `auth-service` `POST /users` → the account (email, argon2id hash,
  brand, role `customer`) and a **case** in state `registered` with `onboarding_ref` = case uuid. Nothing is
  created in the BFF.
- Bot protection: Cloudflare Turnstile on this form; the token is verified server-side before anything else.
  Rate limit: 5 registrations per IP per 10 minutes.
- If the email already exists, the response is the same `200 { "next": "check_email" }` (no enumeration), and
  the existing account gets a "you already have an account" mail with a login link instead of a verification
  link.
- The API sends **its own verification mail** (template `verify_email`) with a link
  `PUBLIC_URL/verify?token=…`, token single-use, 24 hours; resend allowed 3 times per hour. The link lands on
  the *Email confirmed* page [p1b]; the page posts the token to `/api/auth/verify-email`; the auth service
  marks the email confirmed; the case moves to `email_confirmed`; the page opens step 2 — the Persona check.
- HubSpot: `email_entered` on account creation; `email_confirmed` on verification.

### 4.2 Step 2 — Persona KYC, the barrier [n2] [p2] [s1] [s2]

- When the step-2 page opens, the UI calls `POST /api/case/persona/inquiry`; the API creates the inquiry
  server-side (template `PERSONA_TEMPLATE_ID_<BRAND>`, `reference-id` = case uuid) — lazily, here (Default;
  abandoned cases cost nothing) — and returns `{ inquiryId }`; on a later visit it returns the current inquiry
  (and a session token if it had to be resumed, 4.3). Case → `kyc_pending`.
- The page embeds Persona's flow by inquiry id. The customer verifies identity and uploads documents into
  Persona. Callbacks: `onComplete({ inquiryId, status, fields })` → UI posts `/api/case/persona/completed`;
  `onCancel` → nothing; `onError` → shown, logged. **Nothing is sent to Highnote at this step.**
- HubSpot: `step2_submitted` on `onComplete`.
- Inquiry phases: `created → pending → completed | failed | expired`; decisions: `approved`, `declined`,
  `needs review`.
- Webhook `POST /webhooks/persona`: signature HMAC-SHA256 in `Persona-Signature` (`t=` timestamp, `v1=`
  signature); reject if `|now − t| > 5 minutes`. Delivery is at-least-once with up to 7 retries over about
  two days.
- Fallback poll (Default): first poll 10 minutes after `onComplete`, then every 30 minutes for 48 hours
  (`GET /api/v1/inquiries/{id}`); a polled decision is stored like a webhook one (source `poll`, dedupe on
  the decision).

### 4.3 Persona verdict, officer confirmation, retry loop [n3] [o0] [n4] [p3] [s3] [s4]

The webhook and poll handler (mechanics in 4.5) stores the decision keyed by the event id and applies:

- `approved` → case `kyc_review`: it enters the **officer's KYC queue** [o0]. Nothing is mailed to the
  customer yet (the status page shows "identity verified, under review").
- Officer `confirm_kyc` → case `kyc_passed`. The customer is emailed (template `kyc_passed`) a magic link to
  step 3. HubSpot: `kyc_passed`. This is the milestone that admits the customer to the Highnote procedure —
  it needs both Persona's `approved` and the officer's click; there is no automatic path.
- Officer `reject` from the queue → `rejected` (template `case_rejected`); officer `grant_persona_retry` →
  back to `kyc_pending` with a new inquiry (when the officer wants the identity re-checked).
- `needs review` → case stays `kyc_pending`; nothing to do for the customer; on resolution the customer is
  mailed (`kyc_passed` or `persona_retry`).
- `declined`, `expired`, `failed` → stored with Persona's reason and the attempt counter; the customer is
  emailed (template `persona_retry`) a magic link to the *Retry page* (link rules in 4.6).
- The retry page asks `GET /api/case/me`, shows the reason and one button; the button calls
  `POST /api/case/persona/inquiry`, which:
  - for `declined`: creates a **new inquiry** with the same reference-id. **Cap: 3 inquiries per case**
    (Default). On the fourth request the API answers `409 { code: "persona_attempts_exhausted" }` and the
    case goes to the officer as exception `persona_attempts_exhausted`;
  - for `expired` / `failed`: calls `POST /api/v1/inquiries/{id}/resume` (the inquiry returns to `pending`)
    and returns `{ inquiryId, sessionToken }`; the session token is fetched at this moment because its
    lifetime is undocumented.
- The page opens the widget again with `{ inquiryId }` or `{ inquiryId, sessionToken }`. The new verdict
  arrives through the same handler.
- Persona's `fields` from `onComplete` (name, date of birth, address read from the document) may prefill the
  step-3 form **in the browser only**; the API never receives or stores them.

### 4.4 Step 3 — KYB fields and the Highnote application, only after the officer's confirmation [p4] [n6] [h1]

Highnote has **no hosted or embeddable application form** (verified against the docs). The integrator builds
the form and submits the data through GraphQL. An application cannot be opened with partial data and completed
later, so step 3 collects Highnote's required minimum:

| Subject | Required by Highnote |
|---|---|
| Business | legal business name, EIN, business type, business phone, street address, city, state, postal code |
| Primary authorized person | first name, last name, email, phone, home address (street, city, state, postal code), SSN, date of birth |
| Each beneficial owner (25 %+) | first name, last name, email, phone, home address, SSN, date of birth, percentage ownership |

The page also shows the **cardholder agreement**; the submit button is enabled only with the agreement
checkbox ticked. `consent_timestamp` = server time of the submit; the client IP is stored with it.

The page is reachable from the `kyc_passed` link and from the status page; every route of this step answers
`409 { code: "kyc_not_passed" }` unless the case is in `kyc_passed` — i.e. Persona approved **and** an officer
confirmed — the barrier is enforced by the API, not by hiding a button.

Sensitive person fields never reach our servers:

1. UI → API `POST /api/case/highnote/tokenize-token` → API calls `generateTokenizeAccountHolderClientToken` →
   `{ clientToken, expiresAt }` (15 minutes, reusable). The UI requests it when the step-3 page opens and
   again if it expired before submit.
2. Browser → Highnote `tokenizeUSBusinessAccountHolder(input)` with the client token → an account-holder
   token (valid 1 hour). The browser then posts to `/api/case/step3` the token **plus the non-sensitive
   fields** (the API needs them for the hand-over and HubSpot); SSN and date of birth are never in that
   request — the API rejects a body that contains them.
3. API → Highnote `createUSBusinessAccountHolderFromToken({ token })` → account holder id, primary authorized
   person id. Then `createAccountHolderCardProductApplication({ accountHolderId, cardProductId:
   HIGHNOTE_CARD_PRODUCT_ID_<BRAND>, cardHolderAgreementConsent: { consentTimestamp }, ipAddress })` →
   application id, `applicationState.status = PENDING`. Both calls are made once per case: the case stores
   the ids and never calls again while they exist. A Highnote `UserError` is stored on the case
   (`highnote_error`) and shown to the customer as a field error where the `path` maps to a field, otherwise
   as a generic error with a case reference; the officer sees the raw error.
4. Case → `kyb_pending`. HubSpot: `step3_submitted` — company fields, names, phones, EIN, addresses;
   **never SSN / DOB**.

What the API stores after step 3: company fields, persons' names, emails, phones, addresses, roles and
ownership percentages, EIN; Highnote ids; consent timestamp + IP. What it must not store: SSN, date of birth,
the account-holder token after use (deleted from memory, never logged).

Re-submitting step 3 (customer edits after the account holder exists) is not allowed by the UI; the API
returns `409 { code: "account_holder_exists" }`. Corrections are an officer exception (4.7).

Highnote's review runs asynchronously (KYB, KYC of every person, OFAC, fraud). In Live an application is
approved only when every applicant's `currentVerification` is PASSED. The customer sees "under review".

### 4.5 Verdict handlers [n3] [n7] [h2] [s4]

Both webhook endpoints do the same thing: verify the signature, store the raw event with its id, answer
`200` within 2 seconds, and enqueue processing. The worker then:

1. Deduplicates on `(source, event_id)`; a duplicate is acknowledged and dropped.
2. Matches the event to the case by inquiry id / application id. An event for an inquiry or application that
   is not the case's **current** one is stored as `superseded` and ignored.
3. Applies the transition of section 5. Out-of-order events are handled by the state table, not by arrival
   order (e.g. `APPROVED` after `IN_REVIEW` is fine; `IN_REVIEW` after `APPROVED` is ignored).
4. Emits side effects: notifications, HubSpot pushes, the release check.

Highnote specifics:

- Signature: HMAC-SHA256 over the raw body with each secret in `HIGHNOTE_WEBHOOK_SECRETS`, timing-safe
  compare against the comma-separated `highnote-signature` header; `extensions.signatureTimestamp` (ms) must
  be within 15 minutes.
- Statuses: `PENDING`, `IN_REVIEW`, `APPROVED`, `DENIED`, `CLOSED`. Events on the
  `AccountHolderCardProductApplication` node: `CARD_PRODUCT_APPLICATION_APPROVED`, `_DENIED`, `_CLOSED`,
  `_MANUAL_REVIEW`, `_DOCUMENT_UPLOAD_REQUESTED`, `_IDENTITY_UPDATE_REQUESTED`; document sessions:
  `DOCUMENT_UPLOAD_REQUESTED_EVENT`, `DOCUMENT_UPLOAD_SESSION_COMPLETE_EVENT`.
- Target registration (Decided): a CLI command in `onboarding-api` (`register-highnote-target`) calls
  `addWebhookNotificationTarget` with exactly the subscriptions above for the environment's key, stores the
  target id and the signing secret in the Kubernetes Secret, and verifies activation (Highnote sends a test
  event; the endpoint must answer 2xx within 10 s). Live retries 9 times with backoff, then the target is
  disabled; a daily job checks the target status and alerts; missed events are re-fetched with
  `webhookNotificationTargetEvents` after a reactivation.
- **The core's targets stop subscribing to `CARD_PRODUCT_APPLICATION_*`** once no application created by the
  core is in flight (cut-over task). Until then the core's `ProcessApplicationStatus` must log and skip
  unknown application ids instead of failing.
- Fallback poll (Default): while the application is `PENDING` or `IN_REVIEW`, `node(id:)` every 60 minutes
  (`applicationState.status`, `applicationWorkflows`, `expiresAt`).
- `APPROVED` → case `ready_for_release`. `DENIED` is final for the application: no customer retry loop; the
  case goes to the officer as exception `highnote_denied`. `CLOSED` → exception `highnote_closed`.

### 4.6 Highnote asks for documents [n7] [p5]

- Trigger: `CARD_PRODUCT_APPLICATION_DOCUMENT_UPLOAD_REQUESTED` (status IN_REVIEW). The payload carries the
  application node and a memo — **no link, no session id**. Highnote has no hosted upload page; its guide says
  to send the customer a URL to *your* document upload page.
- The worker reads the application: `accountHolderSnapshot.{primaryAuthorizedPerson | businessProfile |
  businessProfile.ultimateBeneficialOwners[]}.currentVerification.requiredDocuments[]
  .documentUploadSession.id` and `verificationResultCodes`; stores one `document_sessions` row per session
  (subject, session id, opened at, deadline = +30 days).
- The customer is emailed (template `documents_requested`) a **magic link** to the *Docs · Highnote widget*
  page. Magic link (Decided): `PUBLIC_URL/l/<token>`, token = base64url of `case_id.purpose.exp` +
  HMAC-SHA256 with `MAGIC_LINK_SECRET`; `exp` = 7 days; reusable until the case leaves the state the link was
  issued for; opening it exchanges the token for a customer session scoped to that case
  (`POST /api/case/magic-link/exchange`). A reminder mail goes out 7 days before the session deadline if the
  session is still open. The same link rules apply to the `kyc_passed` and `persona_retry` mails.
- The page asks `GET /api/case/highnote/document-sessions` → `[{ sessionId, subject, clientToken,
  environment }]`; the client token comes from `generateDocumentUploadClientToken({ documentUploadSessionId,
  permissions: [MANAGE_DOCUMENT_UPLOAD_SESSION] })`, valid 15 minutes, reusable; the page re-requests it on
  `InvalidCredentialError`.
- The page runs Highnote's **Document Upload SDK** (`@highnoteplatform/document-upload`, 1.1.5, still marked
  pre-release): a widget rendered into a `<div>` by CSS selector — not an iframe, not a hosted page.
  `initializeDocumentUploadSdk({ clientToken, documentUploadSessionId, environment: 'test' | 'live',
  documentUploadComponent: { selector }, onSuccess, onError })`. The widget lists required documents, uploads
  PDF/PNG/JPG (~10 MB) straight to Highnote storage and ends the session itself when nothing is left. Files
  never touch our servers. `onSuccess` → UI posts `/api/case/highnote/document-session/{id}/completed` (hint
  only).
- Outcome: `DOCUMENT_UPLOAD_SESSION_COMPLETE_EVENT` closes the row; the application decision follows by
  webhook.
- Limits: a session lives **30 days**; unmet, the application is `DENIED`. Documents must be unexpired and
  dated within 60 days. Per-file upload links (raw API alternative) expire in 5 minutes; the raw API is not
  used.
- Highnote can also request documents after approval (`ManualDocumentRequestUploadSession`,
  `DOCUMENT_UPLOAD_REQUESTED_EVENT`); the same page and mechanics apply, on a case already released.

### 4.7 Officer console [o1]

`onboarding-ui` under `/officer`, behind `auth-service` with the `officer` role and TOTP. Decided:

- Lists: **KYC queue** (`kyc_review` — Persona approved, waiting for confirmation; the mandatory human
  step of the flow), **ready** (`ready_for_release`, manual mode only), **exceptions** (`highnote_denied`,
  `highnote_closed`, `highnote_in_review_overdue` — IN_REVIEW for more than 14 days,
  `persona_attempts_exhausted`, `documents_overdue` — session past its deadline, `application_expiring` /
  `application_expired` (4.8), `handover_failed`, `email_conflict` (4.8)), **all cases** with search by email,
  company, ids.
- Case view: timeline of events (source, event id, received at), the Persona decision with its reasons and
  attempt history, the person and company data typed so far (no SSN, no date of birth — they do not exist on
  our side), current Highnote state, document sessions, notifications sent, hand-over attempts, notes.
- Actions (each writes to the audit log): `confirm_kyc` (KYC queue → `kyc_passed`, mails the step-3 link),
  `release` (manual mode, or re-release), `retry_handover`, `grant_persona_retry` (raises the cap by one, or
  sends a queued case back for another identity check), `reject` (closes the case, template `case_rejected` to
  the customer, HubSpot status unchanged), `note`. `confirm_kyc` requires the permission `cases.confirm_kyc`. There is no override of a Highnote `DENIED`: a new application
  on the same holder is only possible through Highnote support and is handled outside the console. There is
  no override of the Persona barrier either: a customer Persona has not approved never reaches Highnote.
- Release mode (Decided): **`RELEASE_MODE=auto`** — a case moves from `ready_for_release` to `releasing`
  automatically, because the officer already reviewed it at the KYC gate; the officer handles exceptions only.
  `manual` keeps the case in `ready_for_release` until an officer clicks release. Switchable per environment.
  The KYC confirmation itself is never automatic.
- Queue SLA (Default): a case in `kyc_review` for more than 48 hours raises an alert and appears at the top
  of the queue; officers get a daily digest of the queue through the notification service (template
  `officer_queue_digest`).
- The three clicks that exist today in the core admin (Integration, Start onboarding, Create financial
  account) disappear.

### 4.8 Hand-over to the current infrastructure [n8] [b4] [c1] [c2] [b5]

Executed by `onboarding-worker` as a queue job when the case enters `releasing`. Before the call the job
checks the Highnote application `expiresAt`: if it is in the past → exception `application_expired`; if it is
less than 7 days away → the hand-over proceeds and exception `application_expiring` is raised for the officer.

**Request** — `POST {BFF_ONBOARDING_URL}` (placeholder path `POST /api/internal/onboarding` on the BFF;
authentication: `Authorization: Bearer {BFF_SERVICE_KEY}` compared with `hash_equals` like the credentials
endpoint; source IP allow-listed to `209.38.116.110`; header `Idempotency-Key: {onboarding_ref}`):

```json
{
  "onboarding_ref": "c1b2…-uuid",
  "brand": "elibrium",
  "email": "owner@example.com",
  "locale": "en",
  "timezone": "America/New_York",
  "person": { "first_name": "…", "last_name": "…", "phone": "+1…", "job_title": "ceo" },
  "company": {
    "legal_name": "…", "ein": "…", "business_type": "…", "phone": "+1…", "website": null,
    "address": { "line1": "…", "line2": null, "city": "…", "state": "…", "postal_code": "…", "country": "US" }
  },
  "highnote": {
    "environment": "live",
    "application_id": "ap_…",
    "account_holder_id": "og_ah…",
    "primary_authorized_person_id": "…",
    "consent_timestamp": "2026-09-10T12:00:00.000Z"
  },
  "verified_at": { "persona": "2026-09-10T12:30:00Z", "highnote": "2026-09-10T13:00:00Z" }
}
```

Nothing else leaves: no SSN, no dates of birth, no documents, no Persona details, no password. HubSpot gets
the final funnel status `onboarding_done` when the response below is `200`.

**Response** (Decided): `200 { "bff_user_id": "uuid", "kyb_profile_id": "…", "state": "created" |
"already_exists" }` — `already_exists` when the same `onboarding_ref` was processed before (idempotent
replay); `409 { "code": "email_conflict" }` when the email belongs to a BFF user that was **not** created by
this `onboarding_ref` (a legacy customer) → exception `email_conflict` for the officer; `422` with field
errors → exception `handover_rejected`; `5xx` / timeout → retry.

**Retries** (Default): 1 m, 5 m, 15 m, 1 h, then hourly up to 24 hours; then state `handover_failed` and the
officer's `retry_handover`. Timeout 15 s per attempt.

**Why these Highnote fields and no others** (read from `processing_app`, branch `develop`):

| What the core needs | Used for | Where it comes from in the new flow |
|---|---|---|
| organisation API key + base URL (`HIGHNOTE_API_KEY`, `HIGHNOTE_TEST_API_KEY`) | every Highnote call, card issuing included | already in the core's configuration; Highnote has **no per-customer credentials** — nothing to hand over |
| `environment` (`live` / `test`) | selects the key and the provider (`HIGHNOTE` vs `HIGHNOTE_TEST`) | hand-over payload |
| `application_id` → `applications.provider_application_id` | **every card**: `issueFinancialAccountForApplicationWithOnDemandFundingSource(applicationId, …)`; status lookups | hand-over payload |
| funding financial account id → `credentials.financial_account_id`, `accounts.external_id` | **every card** as `sourceFinancialAccountId`; ledger import, direct-deposit details, ACH, balance; pushed to the BFF | the core creates it itself (`CreateAccount` job, `issueFundingFinancialAccountForApplication`) — not created in the new service |
| `account_holder_id` | external bank links (Plaid), ACH withdrawals, `FetchApplicationStatus`; pushed to the BFF (company profile, balance, fees) | hand-over payload |
| `primary_authorized_person_id` | ACH withdrawal drafts (`CreateAchWithdrawalDraft`) | hand-over payload |
| `consent_timestamp`, `card_product_id` | only when the application is created — not needed by the core afterwards | `consent_timestamp` for the record; the card product id stays in the core's config |
| `financial_account_external_id` | the non-funding account variant and a balance fallback | generated by the core (ordered uuid) as today |
| BIN rows `428842` / `450897` with `payload.profile_id` (card profile set) | `cardProfileSetId` on `issuePaymentCardForFinancialAccount` | created by the core's `CreateAccount` job (`DefaultCardBinIssuer`), hard-coded; needs the company's business address for the BIN billing address |
| the user's core API token (`brocard.token` in the BFF) | every BFF → core call | minted by the core (`TokenCreateAction`, source ClientApp, ability FULL) and pushed to the BFF — never by the new service |

**What the BFF does with it** (new internal endpoint, Decided):

1. Looks up `onboarding_ref`; if known, returns `200 already_exists` with the stored ids.
2. If the email exists on a user without this `onboarding_ref` → `409 email_conflict`.
3. Creates the user **already email-verified** (`email_verified_at` = now), brand, names, locale, timezone,
   `onboarding_source = new_service`, no password; the company; a KYB profile in `Submitted` carrying the
   application id. The KYB profile validation must accept a profile without SSN (today's `/api/kyb` rules
   require the authorized person's SSN — the internal endpoint bypasses that rule).
4. Sends the **set-password mail** (the existing reset-password flow, link to the current app) — the new
   service does not know the customer's password and must not keep it in plaintext.
5. Calls the core as the KYB chain does today (`SendKybProfileToRemote` → core `POST /api/users`), with the
   `highnote` object added and **without** `authorized_person.ssn`, `date_of_birth`, `country_of_citizenship`,
   `percentage_ownership`.
6. Responds `200 created`. The core call runs in the queue as today; its failure is retried by the BFF's
   queue, not by the new service.

**Gate close after hand-over** (Decided): a second internal endpoint `PATCH /api/internal/onboarding/{ref}`
`{ "gate": "closed", "reason": "persona_reversed" | "highnote_reevaluated" | "officer" }` puts the KYB
profile into `Review` (the current app then refuses card issuing, as it does for `Review` today); `{ "gate":
"open" }` returns it to `Accepted` when the BFF's poll confirms the application is still APPROVED. The new
service calls it when a verdict changes after release.

**Legacy customers** (Decided): the AND gate and the new endpoints apply only to users with
`onboarding_source = new_service`. Customers onboarded through the old path are untouched.

**What the core does with it**, in two steps that replace the operator's three clicks:

**[c1] `POST /api/users` + Highnote ids** — the existing user-creation endpoint (`UserOnboardingRequest`,
called by the BFF's `SendKybProfileToRemote` as today), extended with the `highnote` object above.

1. Creates the user, the legal entity and the company exactly as today (`RegisterUserHandler`).
2. Creates the integration straight in `Active` (provider `HIGHNOTE` or `HIGHNOTE_TEST` by `environment`,
   `card_product_key` from the product catalogue) with the credentials JSON the clicks used to fill:
   `account_holder_id`, `primary_authorized_person_id`, `consent_timestamp`, `card_product_id` (from config),
   `financial_account_external_id` (a fresh ordered uuid, as `IntegrationController::defaultCredentials`
   does), and the `applications` row with `provider_application_id` and `approved_at`.
3. Required code change: the `AuthorizedPerson` validation rule demands `ssn`, `date_of_birth`,
   `country_of_citizenship` and `percentage_ownership`. They only ever fed `createUSBusinessAccountHolder`,
   which this path never calls; the hand-over variant accepts the authorized person without them, and
   `LegalEntity->authorized_person` tolerates their absence.
4. Idempotent on `provider_application_id` (unique already): a repeated call returns the existing user and
   integration.

**[c2] Funding financial account · BINs · token** — the existing `CreateAccount` job, dispatched by the
endpoint instead of the third click. All of it is code that runs today:

1. `issueFundingFinancialAccountForApplication(applicationId, name: "Funding Financial Account #<integration>")`
   with the organisation API key → `credentials.financial_account_id`, `financial_account_created_at`.
2. `HighnoteOnboardingProvider::complete` (requires the application approved and the funding account present).
3. `AccountIssuer::issueMainFor` → the owner's main `accounts` row with `external_id` = the funding account
   id; `IntegrationAccountIssuer::issueFor` → the `integration_accounts` row.
4. `CardBinIssuer::issueFor(428842, 450897)` → the company's BIN rows; `450897` carries
   `payload.profile_id` (the card profile set, hard-coded), the billing address comes from the legal
   entity's business address.
5. `TokenCreateAction` → the user's core API token (source ClientApp, ability FULL, bound to the
   integration), pushed to the BFF as `brocard.token`.
6. `ApiClient` → `POST /api/external/service-credentials` for `highnote.account_holder_id`,
   `highnote.application_id`, `highnote.financial_account_id` and `brocard.token` (today split between
   `OnboardAccountHolder::sendWebhook` and `CreateAccount`; here they go in one pass).

From then on every card is issued as today (`CardManagement\Providers\Highnote::create`):
`issueFinancialAccountForApplicationWithOnDemandFundingSource(applicationId, sourceFinancialAccountId =
accounts.external_id)` followed by `issuePaymentCardForFinancialAccount(financialAccountId,
cardProfileSetId = bin.payload.profile_id)`. Nothing per customer is needed beyond what [c1] stored and
[c2] created.

The BFF's unchanged scheduled poll (`ProcessPendingKybProfiles`, every 10 minutes) finds the application
`APPROVED` and all four keys present → the profile becomes `Accepted` → the current app allows card issuing.
The new service learns about it by polling `GET /api/internal/onboarding/{ref}` (`{ "state":
"accepted" | "pending" }`) every 10 minutes for 24 hours after the hand-over; `accepted` closes the case as
`accepted_in_bff`, otherwise exception `bff_acceptance_overdue`.

### 4.9 API of the new service (Decided)

All routes under `https://onboarding.elibrium.io`; JSON; errors as `{ "code": "…", "message": "…",
"fields": { … } }`; `X-Request-Id` accepted from the UI and generated otherwise. Customer routes require a
customer session for the case; officer routes require the listed permission.

| Route | Auth | Purpose |
|---|---|---|
| `POST /api/auth/register` | Turnstile | `{ email, password }` → `200 { next: "check_email" }` (always) |
| `POST /api/auth/verify-email` | — | `{ token }` → `200 { case_state }` |
| `POST /api/auth/resend-verification` | — | `{ email }` → `200` (rate-limited) |
| `POST /api/auth/login`, `/logout`, `/refresh` | — / session | sessions; officers get `mfa_required` and finish with `POST /api/auth/mfa/verify` |
| `POST /api/auth/password/forgot`, `/reset` | — | reset by mail, 24 h token |
| `GET /api/case/me` | customer | the case view: `state`, `steps`, `persona { inquiry_id, status, attempts }`, `highnote { status, document_sessions[] }`, `actions[]` |
| `POST /api/case/persona/inquiry` | customer, state `email_confirmed` or `kyc_pending` | `{ inquiryId, sessionToken? }` (create, current, or resume) |
| `POST /api/case/persona/completed` | customer | `{ inquiryId }` — onComplete hint |
| `POST /api/case/highnote/tokenize-token` | customer, state `kyc_passed` | `{ clientToken, expiresAt }`; `409 kyc_not_passed` otherwise |
| `POST /api/case/step3` | customer, state `kyc_passed` | company + persons (non-sensitive) + `account_holder_token` + `consent: true` → `200 { state }`; `409 kyc_not_passed` / `account_holder_exists` |
| `GET /api/case/highnote/document-sessions` | customer | open sessions with client tokens |
| `POST /api/case/highnote/document-session/{id}/completed` | customer | onSuccess hint |
| `POST /api/case/magic-link/exchange` | — | `{ token }` → session cookie scoped to the case |
| `GET /api/officer/cases?list=kyc_queue\|ready\|exceptions\|all&q=` | `cases.read` | lists |
| `GET /api/officer/cases/{id}` | `cases.read` | case view with timeline |
| `POST /api/officer/cases/{id}/actions` | per action | `{ action, note }` — `confirm_kyc`, `release`, `retry_handover`, `grant_persona_retry`, `reject`, `note` (4.7) |
| `POST /webhooks/persona`, `POST /webhooks/highnote` | signature | receivers (4.4) |
| `GET /healthz`, `GET /readyz` | — | probes |

## 5. State of an onboarding case (Decided)

| State | Entered when | Leaves to |
|---|---|---|
| `registered` | account created | `email_confirmed` (verification link) |
| `email_confirmed` | link used | `kyc_pending` (Persona inquiry created when step 2 opens) |
| `kyc_pending` | inquiry exists | `kyc_review` (Persona `approved`), `kyc_pending` again (retry: new inquiry or resume), `exception` (`persona_attempts_exhausted`) |
| `kyc_review` | Persona approved, waiting for the officer | `kyc_passed` (officer `confirm_kyc`), `kyc_pending` (officer `grant_persona_retry`), `rejected` (officer `reject`) |
| `kyc_passed` | officer confirmed — the barrier is open | `kyb_pending` (step 3 accepted by Highnote: account holder + application created) |
| `kyb_pending` | application `PENDING` / `IN_REVIEW` | `ready_for_release` (`APPROVED`), `exception` (`highnote_denied`, `highnote_closed`, `highnote_in_review_overdue`, `documents_overdue`) |
| `ready_for_release` | Highnote `APPROVED` (Persona approved by construction) | `releasing` (auto, or officer release) |
| `releasing` | hand-over job running | `released` (BFF `200`), `exception` (`handover_failed`, `email_conflict`, `handover_rejected`, `application_expired`) |
| `released` | BFF accepted the hand-over | `accepted_in_bff` (BFF poll says accepted), `exception` (`bff_acceptance_overdue`) |
| `accepted_in_bff` | terminal success | `gate_closed` (late verdict change) |
| `gate_closed` | verdict reversed after release | `accepted_in_bff` (gate reopened) |
| `exception` | any exception code | back to the previous state by an officer action, or `rejected` |
| `rejected` | officer `reject` | terminal |
| `abandoned` | retention job (5.2) | terminal |

Track sub-states, stored on the case: `persona_status` ∈ {`none`, `pending`, `completed`, `approved`,
`declined`, `needs_review`, `expired`, `failed`}, `persona_attempts` (int); `highnote_status` ∈ {`none`,
`PENDING`, `IN_REVIEW`, `APPROVED`, `DENIED`, `CLOSED`}. Invariant enforced in code: `highnote_status` is
`none` unless `persona_status` is `approved` and an officer's `confirm_kyc` is recorded for the case. Every transition is driven by a stored, signature-verified
event, a poll result or an officer action; widget callbacks only change what the page shows and the HubSpot
funnel.

### 5.1 Data model (Decided — tables of the `onboarding` database; columns are the minimum)

- `cases` (id uuid = `onboarding_ref`, user_id, brand, state, persona_status, persona_attempts,
  highnote_status, exception_code, created_at, updated_at, last_activity_at)
- `case_company` (case_id, legal_name, ein, business_type, phone, website, address…)
- `case_persons` (id, case_id, kind `primary` | `owner`, first_name, last_name, email, phone, job_title,
  ownership_percent, address…) — no SSN, no date of birth columns exist
- `highnote_applications` (case_id, environment, account_holder_id, primary_authorized_person_id,
  application_id, status, expires_at, consent_timestamp, consent_ip, last_polled_at, last_error jsonb)
- `persona_inquiries` (id, case_id, inquiry_id, attempt, status, decision, reason, created_at, decided_at)
- `document_sessions` (id, case_id, session_id, subject, status, deadline_at, completed_at)
- `verifier_events` (id, source `persona` | `highnote` | `poll`, event_id, case_id, kind, payload jsonb,
  received_at, processed_at, result `applied` | `duplicate` | `superseded` | `error`) — payloads purged after
  90 days
- `handovers` (case_id, attempt, requested_at, response_code, response jsonb, next_attempt_at)
- `hubspot_pushes` (case_id, status, payload jsonb, state, attempts, last_error)
- `officer_actions` (id, case_id, actor_user_id, action, note, created_at) — mirrored to the auth audit log

### 5.2 Retention (Decided)

- Unfinished cases with no activity for 30 days **before** step 3 (no Highnote account holder yet): the case
  and the auth account are deleted; HubSpot keeps the contact with its last status; the Persona inquiry
  expires on Persona's side.
- Unfinished cases with no activity for 90 days **after** step 3 (a Highnote account holder exists): the case
  is marked `abandoned`, person rows are anonymised, the auth account is deleted. Highnote has no API to delete
  an account holder; the open application expires on Highnote's side.
- `verifier_events.payload` purged after 90 days; `verifier_events` rows and `handovers` kept 2 years;
  `officer_actions` and the audit log kept 2 years.

## 6. Emails sent by the new service (Decided)

| Template | When | To | Link, TTL |
|---|---|---|---|
| `verify_email` | account created | customer | verification link, 24 h, single use |
| `account_exists` | registration with a known email | customer | login link |
| `kyc_passed` | officer confirmed the KYC (Persona approved before) | customer | magic link → step 3, 7 days |
| `officer_queue_digest` | daily, when the KYC queue is not empty | officers | list of waiting cases, oldest first |
| `documents_requested` | Highnote document request | customer | magic link → widget page, 7 days |
| `documents_reminder` | 7 days before a document session deadline, if open | customer | same |
| `persona_retry` | Persona declined / expired / failed | customer | magic link → retry page, 7 days, reason |
| `persona_reviewed` | Persona `needs review` resolved | customer | magic link → status page |
| `case_rejected` | officer reject | customer | — |
| set-password | hand-over done | customer, **sent by the BFF** | reset-password link of the current app |

All of them go through the notification service (3.4); brand and locale come from the case. Magic links carry
no PII; mail bodies carry no PII beyond the first name.

## 7. HubSpot (Decided)

Contact keyed by email, created on `email entered`. Custom contact properties: `elibrium_onboarding_status`
(enumeration below), `elibrium_onboarding_status_at` (datetime), `elibrium_brand`, `elibrium_onboarding_ref`;
company properties from step 2 go to the standard contact/company fields (`company`, `phone`, `address`,
`city`, `state`, `zip`) plus `elibrium_ein`, `elibrium_business_type`. Pushes run from a queue, one job per
status change, retried on `429` / `5xx` with backoff (1 m, 5 m, 30 m, 2 h), never blocking the flow.

| Status | Sent when | Extra fields |
|---|---|---|
| `email_entered` | account created | — |
| `email_confirmed` | verification link used | — |
| `step2_submitted` | Persona widget `onComplete` | — |
| `kyc_passed` | officer `confirm_kyc` after Persona `approved` — the customer is admitted to Highnote | — |
| `step3_submitted` | step 3 accepted by Highnote (application created) | company fields, names, phones, EIN, addresses — **never SSN / DOB** |
| `onboarding_done` | hand-over answered `200` | — |

Passwords, SSNs and dates of birth never go to the CRM. A re-registration with a known email updates the
existing contact.

## 8. Data boundaries

| Data | Browser | New service (API/DB) | Persona | Highnote | BFF / core |
|---|---|---|---|---|---|
| email, names, phones, company, EIN, addresses | yes | yes | reference only | yes | yes (at hand-over) |
| password | yes | argon2id hash only | — | — | set by the customer later |
| SSN, date of birth | yes (typed) | **no** (token only, no columns) | — | yes | **no** |
| identity documents | yes (upload) | **no** | yes | yes | **no** |
| verdicts, reasons, event ids | — | yes | — | — | Highnote status via BFF poll |
| Highnote ids | — | yes | — | yes | yes (hand-over) |

## 9. Non-functional requirements (Decided)

- **Public endpoints of the new service**: the UI, `/api/*`, `/webhooks/*`. Everything else is internal.
  Webhook bodies limited to 1 MB; no authentication besides the signature.
- **Secrets** (3.2): Kubernetes Secrets fed from masked CI variables; nothing in the repository; Highnote
  API keys rotated every 90 days (Highnote's recommendation), webhook secrets rotated with the
  comma-separated overlap.
- **Idempotency everywhere**: registration by email, webhooks by `(source, event_id)`, polls by decision,
  notifications by idempotency key, HubSpot pushes by `(case, status)`, hand-over by `onboarding_ref`.
- **Access control**: every route behind `auth-service`; permissions, not role names, checked in code;
  officer and admin actions audited; TOTP for officers and admins; customer sessions scoped to one case.
- **Observability**: `X-Request-Id` from the browser through the API to the BFF and the core (the BFF logs
  it); JSON logs without payloads that may carry PII; metrics per funnel step and per queue; alerts on
  webhook signature failures (> 5 in 10 minutes), on Highnote target deactivation, on document sessions
  within 7 days of their deadline, on cases in `kyc_review` for more than 48 hours, on cases in `kyc_pending` or `kyb_pending` for more than 14 days, on hand-over failures, on
  notification failure rate.
- **Availability**: two replicas of `onboarding-api` and `onboarding-ui`; one worker with the queues being
  idempotent so that a second worker can be added; PostgreSQL managed with daily backups.
- **Test environment**: Highnote Test with the simulation mutations (`simulateApplicationStatusChange`,
  street address `123 Manual Review St.` forces IN_REVIEW, legal business name `FORCE-DECLINE` forces
  DENIED), Persona sandbox, Mailgun sandbox domain, the BFF on the polygon. End-to-end tests cover: happy path,
  Highnote document request, Persona decline and retry, hand-over replay, gate close.
- **Retention**: section 5.2; no payload dumps in logs.

## 10. Decisions and defaults

| # | Topic | Decision |
|---|---|---|
| 0 | Persona and the officer before Highnote | no Highnote call until Persona `approved` **and** an officer's `confirm_kyc`; enforced by the API (`409 kyc_not_passed`); KYB fields are collected at step 3, after both |
| 0a | Officer confirmation | mandatory, never automatic, permission `cases.confirm_kyc`, queue SLA 48 h with a daily digest |
| 1 | Officer release | `RELEASE_MODE=auto`; officer for exceptions; `manual` available per environment |
| 2 | Persona inquiry creation | lazily when step 2 opens |
| 3 | Password for the current app | set-password mail from the BFF at hand-over; SSO through the auth service is phase 2 |
| 4 | Late verdict changes | `PATCH /api/internal/onboarding/{ref}` `gate: closed` → KYB profile `Review` |
| 5 | Existing customers | gate and endpoints apply only to `onboarding_source = new_service` |
| 6 | Brand | from the host (`BRAND_HOSTS`); selects Persona template, Highnote card product, mail domain; passed at hand-over |
| 7 | Persona retry cap | 3 inquiries per case, officer can grant one more |
| 8 | Polls | Persona: 10 min after `onComplete`, then every 30 min for 48 h; Highnote: every 60 min while PENDING / IN_REVIEW |
| 9 | Magic links | 7 days, HMAC, reusable while the state holds; verification and reset links 24 h, single use |
| 10 | Hand-over retries | 1 m, 5 m, 15 m, 1 h, hourly to 24 h, then `handover_failed` |
| 11 | Highnote webhooks | the new service registers its own target; the core drops `CARD_PRODUCT_APPLICATION_*` subscriptions at cut-over |
| 12 | Step-3 resubmission | not allowed; corrections are an officer exception |
| 13 | Retention | 30 days before step 2, 90 days after; payloads 90 days; audit 2 years |
| 14 | Captcha | Cloudflare Turnstile on step 1 |

### 10.1 Open — depends on a third party; behaviour meanwhile

- **Highnote application `expiresAt` semantics.** To confirm with Highnote what an expired APPROVED
  application needs (re-evaluation vs a new application). Meanwhile: `application_expired` is an officer
  exception; the hand-over is not attempted.
- **Highnote: a second application on the same account holder after DENIED.** To confirm with Highnote
  support. Meanwhile: `reject` only.
- **Mailgun sending domains** for `onboarding.<brand>` (DKIM/SPF/DMARC) — ops task before the first live
  mail; the test environment uses the sandbox domain.
- **Persona session-token lifetime** is undocumented; the token is fetched at page-open time.

## 11. The current systems — facts the design relies on

**BFF (`backend_app`, Laravel)**

- `POST /api/auth/register` (public): requires `flow` (`direct` | `invitation` | `fast_track`), `first_name`,
  `last_name`, unique `email`, `password` (8+, mixed case, digits, symbols, confirmed), `device_name`; `direct`
  also needs a questionnaire. Returns a Sanctum token (24 h). Not used by the new flow.
- Email verification link is built in `AuthServiceProvider` and points at the brand front end
  (`FRONTEND_URL`): `/email/verification?id&hash&expires&signature`, 60 minutes. Not used by the new flow.
- Everything under `/api/kyb` sits behind the `verified` middleware.
- `POST /api/external/service-credentials` (`StoreServiceCredentialsRequest`): `user_id` (uuid), `service` in
  `{highnote, brocard}`, `environment`, `key` restricted per service; authorised by a shared key in the
  `Authorization` header. Credentials are stored encrypted in `personal_service_credentials`.
- `HighnotePendingKybHandler` reads `highnote.application_id` from the user's credentials, polls Highnote with
  the BFF's own key; `APPROVED` → `Accepted` only with `financial_account_id`, `account_holder_id` and
  `brocard.token` present; `IN_REVIEW` → `Review`; `DENIED`/`PENDING`/`CLOSED` → unchanged.
- `brocard` is the legacy name of the BFF's client for the processing core (`App\Services\Brocard\Client`,
  base URI `ELIBRIUM_PROCESSING_BASE_URI`); `brocard.token` is the user's API token for the core.

**Core (`processing_app`, Laravel)**

- Today's operator steps in the admin: Integration (`IntegrationController::store`) → Start onboarding (job
  `CreateAccountHolder` → `OnboardAccountHolder`: `createUSBusinessAccountHolder`,
  `createAccountHolderCardProductApplication`; stores `integrations.credentials` JSON and
  `applications.provider_application_id`; pushes `account_holder_id`, `application_id` and a freshly issued
  core API token to the BFF) → Create financial account (job `CreateAccount`:
  `issueFundingFinancialAccountForApplication`; stores `financial_account_id`; pushes it; marks the integration
  Active).
- Application status arrives on `POST highnote/application-status` (Spatie webhook client, header
  `highnote-signature`, HMAC-SHA256 over the raw body, 15-minute timestamp window) and is also polled
  (`LookupApplication`, job `FetchApplicationStatus`).
- Card issuing reads the application id through `card_bin.integration` and the funding financial account;
  card product ids come from configuration.

## 12. External references

Highnote (docs.highnote.com):
- Onboard an account — https://docs.highnote.com/docs/issuing/accounts/onboarding/onboard-an-account
- Required fields (Design your card experience) — https://docs.highnote.com/docs/issuing/cards/design/design-card-experience
- Request documents for application review — https://docs.highnote.com/docs/issuing/accounts/onboarding/request-docs-for-app-review
- Request documents on demand — https://docs.highnote.com/docs/issuing/accounts/onboarding/request-docs-on-demand
- Document Upload SDK — https://docs.highnote.com/docs/developers/sdks/issuing/document-upload-sdk
- Client tokens — https://docs.highnote.com/docs/developers/sdks/client-tokens
- Notifications (webhooks) — https://docs.highnote.com/docs/developers/events/notifications
- Events reference — https://docs.highnote.com/docs/developers/events/events-reference
- Simulate application review (Test) — https://docs.highnote.com/docs/issuing/accounts/onboarding/sim-application-review
- API reference — https://docs.highnote.com/docs/api-reference/mutation

Persona (docs.withpersona.com):
- Resume an inquiry — https://docs.withpersona.com/reference/resume-an-inquiry
- One-time link for an inquiry session — https://docs.withpersona.com/api-reference/inquiry-sessions/generate-a-one-time-link-for-an-inquiry-session.md
- Embedded flow, inquiry lifecycle and webhooks — https://docs.withpersona.com

## 13. Changelog

Key changes against the first target diagram (2026-09-09/10), the version in which the core still owned the
Highnote application and the operator's three clicks.

**Highnote application moved into the new service**
- The Node.js API creates the account holder and the card product application itself
  (`createUSBusinessAccountHolderFromToken` → `createAccountHolderCardProductApplication`); the three operator
  clicks in the core admin (Integration, Start onboarding, Create financial account) are gone.
- Highnote has no hosted or embeddable application form (verified against the docs), so step 2 is our own page
  sized to Highnote's required minimum; the person PII (SSN, date of birth) is tokenised in the browser
  straight to Highnote and never reaches our servers.
- Application status webhooks (`CARD_PRODUCT_APPLICATION_*`) land on the new service, signed and idempotent by
  event id; DENIED is final and goes to the officer — no customer retry loop on this track.
- Document requests are handled on our page with Highnote's Document Upload SDK widget; files go straight to
  Highnote storage. The webhook carries no link, so the customer is emailed a magic link to our page (30-day
  session, then DENIED).

**Hand-over instead of continuous integration with the current infrastructure**
- The BFF and the core hear about a customer once, after Persona approved and Highnote APPROVED. The public
  `POST /api/auth/register`, `POST /api/kyb` and the planned `POST /api/persona` are no longer part of
  onboarding.
- Payload: email, company data, environment and the Highnote ids the service already holds (application,
  account holder, primary authorized person, consent timestamp). No SSN, no documents, no password, no Persona
  details. Highnote has no per-customer credentials, so nothing secret is passed.
- A new internal BFF endpoint creates the user already verified, the company and a KYB profile with the
  application id, sends a set-password mail, then calls the core as the KYB chain does today.
- The core's existing user-creation endpoint is extended with the Highnote ids and writes the integration
  straight to Active; the existing `CreateAccount` job then issues the funding financial account, the BIN
  rows, the core API token and pushes the four keys to the BFF. The core's `AuthorizedPerson` validation must
  drop the SSN requirement for this path.
- The AND gate is enforced before hand-over; the BFF's unchanged poll only confirms APPROVED plus four keys.

**Registration and identity live in the new service**
- Account, password hash and email confirmation are in the new service, with its own mail and signed link;
  nothing is created in the BFF at step 1.
- A full auth/RBAC service is a platform component: roles customer/officer/admin with permissions, sessions
  and tokens, 2FA for officers, audit log; designed to become the identity provider for the current app later
  (SSO), which retires the set-password mail.

**Officer console moved into the new service**
- A React page behind the officer role: cases with both verdicts, exceptions (Highnote IN_REVIEW/DENIED,
  Persona declined, retries exhausted, documents overdue), release to the current infrastructure. Open
  decision: release by click or automatic with exceptions only.

**Notification service**
- All onboarding mails (verification, Highnote document request, Persona retry, later status changes) go
  through a notification service with templates, retries and a delivery log; scoped to grow into user and
  system notifications for the whole platform.

**Persona track — unchanged in substance, tidied on the diagram**
- One "Notify · action needed" block covers both Highnote document requests and Persona declined/expired; the
  retry pages are merged into one; the fallback poll is drawn as the alternative way into the verdict block.

**HubSpot**
- Five statuses instead of four: `step 3 submitted` at the widget's `onComplete`, `KYC done` only at
  hand-over.

**Layout and deliverables**
- Lanes reordered: Highnote sits next to the new service; the core lane is "cards only".
- The page has an EN/RU switch; this specification is served next to it for developers and coding agents,
  including the open items of section 10.1.

**Revision of 2026-09-14, later — the officer confirms the KYC before step 3**
- A case Persona approved enters the officer's KYC queue (`kyc_review`); only the officer's `confirm_kyc`
  moves it to `kyc_passed` and mails the step-3 link. New block [o0] in the Persona phase; the retry mechanics
  now live in the inquiry endpoint (block [n5] removed); HubSpot `kyc_passed` fires on the confirmation.

**Revision of 2026-09-14 — Persona becomes the barrier in front of Highnote**
- The two tracks are no longer parallel. Step 2 is the Persona inquiry; nothing is sent to Highnote until
  Persona's decision is `approved` (`409 kyc_not_passed` otherwise). Step 3 — the KYB fields with the
  browser-side tokenisation — opens from the "KYC passed" mail and creates the Highnote account holder and
  application in the same request. Diagram phases: REGISTER · KYC · PERSONA GATE · KYB · HIGHNOTE · RELEASE.
- States renamed (`kyc_pending`, `kyc_passed`, `kyb_pending`); HubSpot statuses are six (`kyc_passed` added,
  `onboarding_done` at hand-over); the Persona verdict and the Highnote verdict have separate handler blocks.

**Revision of 2026-09-13 — decisions instead of open questions**
- Every "some minutes / a few attempts / short TTL" is now a number (section 10 lists the defaults).
- Added: deployables and topology (3), configuration (3.2), the API of the new service (4.9), the case state
  table and the data model (5), the hand-over contract with response codes and retries (4.8), the gate-close
  signal into the BFF, the legacy-customer rule, the abandoned-case retention rule, the Highnote webhook
  subscription split, the brand mapping.
- Open items are reduced to what genuinely depends on a third party (10.1).

## 14. Block index

| id | block | id | block |
|---|---|---|---|
| hub | HubSpot · CRM | o0 | Officer · confirm KYC (mandatory human step) |
| p1 | Step 1 · email | p4 | Step 3 · KYB fields |
| n1 | Account · auth service | n6 | Holder · application (Highnote, after the barrier) |
| p1b | Email confirmed | h1 | Application (Highnote) |
| a1 | Auth · RBAC | p5 | Docs · Highnote widget |
| n2 | Inquiry created | n7 | Highnote verdict stored |
| s1 | Inquiry (Persona) | h2 | Decision → webhook (Highnote) |
| p2 | Step 2 · Persona iframe | o1 | Officer console |
| s2 | Verdict → webhook (Persona) | n8 | Hand-over |
| n3 | Persona verdict stored | b4 | POST /api/onboarding (BFF) |
| s4 | No webhook? Poll | c1 | POST /api/users + ids (core) |
| n4 | Notify · next action (notification service) | c2 | Funding FA · BINs · token (core) |
| p3 | Retry page | b5 | Accepted → cards (BFF) |
| s3 | Inquiry again (Persona) | g1 | Later: Persona KYB replaces Highnote |
