Specification: Tenant Onboarding Milestones (NUX)
Author: Seb · Created: 2026-09-04 · Revised to as-built: 2026-10-19 · Status: shipped; supersedes/amends PDEV-1830. See the Goal for scope and the Atlas for the ticket/PR map.
Shipped as four stacked pull requests on arda-frontend-app, merged 15–16 October 2026:
| PR | Branch | What it carries |
|---|---|---|
| #1438 | nail60-ag/onboarding-bff-endpoints | The @arda-cards/bff/onboarding package: vocabulary, endpoints, handlers, the starter-kit claim |
| #1441 | nail60-ag/item-create-via-bff | Every item create goes through the BFF, so a create can carry how it was made |
| #1443 | nail60-ag/onboarding-milestones | The onboarding provider and the hooks it is composed from |
| #1422 | nail60-ag/onboarding-guide-prototype | The guide, the header meter, kit fulfilment, and completion |
Overview
Section titled “Overview”Track a tenant’s progress through onboarding as a set of milestones — observed facts like “added first item via scan” — each recording when it was completed and which user completed it. Progress powers the Onboarding Guide (NUX) in the frontend: a goal of 25 items, and a free starter kit earned at the goal, shipped free via a standing Shopify discount code carried on the kit’s order URL.
This pivoted from PDEV-1830’s creationChannel-on-Item approach to a per-tenant, key-based milestones model. V1 stores milestones on the free-form Tenant.settings JSON blob (DQ-M2); the dedicated bitemporal entity remains the graduation path.
Decision Summary
Section titled “Decision Summary”| # | Decision | Answer | Status |
|---|---|---|---|
| DQ-M1 | Where does milestone data live? | V1: Tenant.settings (accounts), read and written only by the frontend BFF. Graduation: dedicated onboardingMilestone module in accounts. | AS BUILT (V1) |
| DQ-M2 | Storage | Tenant.settings.onboardingMilestones; read-merge-PUT server-side in the BFF, in one round trip; first completion wins per key; completedAt/completedBy stamped server-side from the verified JWT. Nothing is recorded once nux-completed is held — every writer comes through one function, so the gate holds for all of them. | AS BUILT |
| DQ-M3 | Data shape | { "<key>": { "completedAt": ISO, "completedBy": userSub } } per key. The kit’s identity is a sibling setting, onboardingStarterKitItemEId, by entity id — a name is editable, and an item that happens to share one is not the kit. | AS BUILT |
| DQ-M4 | Key vocabulary | Allowlisted at the BFF, defined once in @arda-cards/bff/onboarding and enforced where it is written: item-added-manual, item-added-scan, item-added-csv-import, item-added-email, item-added-browser-extension, nux-skipped, sample-kit-queued, nux-completed. Any key enrols a tenant, so arbitrary keys are not writable. | AS BUILT |
| DQ-M5 | Who records? | The BFF, off actual feature use. An item create carries the method it was made by, and the BFF records the milestone over the item it just created — never over one that failed — and answers with the tenant’s milestones. Exception: item-added-email records on click-out to the start.arda.cards wizard, because creation happens outside the app. | AS BUILT |
| DQ-M6 | Duplicates | First completion wins per key, enforced in the BFF; a repeat answers with the existing record and writes nothing. | AS BUILT |
| DQ-M7 | Fate of PDEV-1830 (creationChannel on Item) | Descoped from NUX; item-level provenance remains a separate backlog item. PDEV-1830 itself re-scoped to this design. | DECIDED |
| DQ-M8 | Reset / undo | Not in V1. | AS BUILT |
Product rules (as built — decided with the product owner, Oct 2026)
Section titled “Product rules (as built — decided with the product owner, Oct 2026)”-
Goal: 25 items. One goal only — the orders goal was dropped from the NUX.
-
The offer window never closes. Phases, from tenant
createdAt:day-one(first 24h, “23h 59m left”),week(through day 7, “4 days left”),post-week(no countdown; a “Book a free consultation” notice linking to the HubSpot trial-onboarding page, with every add method still open). There is no grace day and no closed state. -
Who is in the NUX (
active): a tenant still short of 25 items, or one with any onboarding milestone, until completion. A tenant first seen at ≥25 items with no milestones is pre-NUX and sees nothing — this is what protects every existing tenant from a surprise meter or auto-gifted kit on deploy. Once judged in, a tenant stays in for the session (a one-way latch, per workspace), and item-creation milestones pin it in across sessions. Only a tenant already in the NUX has its adds recorded, so ordinary use of the app by a pre-NUX tenant enrols nobody. -
At 25 items: the BFF claims the kit in one call — creates the item “Arda Solutions Starter Kit” from its canonical draft (min/order qty 1 each, supplier Arda, online order, shop listing photo), remembers its id on the tenant, queues its card, and records
sample-kit-queuedlast, only once the card is verifiably queued. A run that created the kit and failed afterwards resumes from the remembered id, so a retry never mints a second kit. -
Completion: the kit order being placed records
nux-completed, permanently hiding the meter and guide. The moment is the kit card’sstartProcessingtransition — checkout finished — notaccept, which is checkout being opened; abandoning checkout completes nothing. -
Free shipping, independent of the reward ladder (revised 2026-10-12): the kit is a $0 Shopify product that requires shipping, so the only cost to cover is postage. The kit’s order URL is a cart permalink carrying a standing free-shipping discount code plus two attribution attributes:
https://shop.arda.cards/cart/<variant>:1?discount=<code>&attributes[Arda tenant]=<tenant uuid>&attributes[Arda account email]=<signed-in user's email>Because the code is known at build time, the URL is correct the moment the kit item is created — no polling, no held checkout, no progress bar. The attributes ride onto the Shopify order and render under Additional details in admin, which is how whoever fulfils the order ties it back to an Arda account. Verified end to end on 2026-10-12 with test order
#G2GCNFIVJ: Standard shipping $8.00 → FREE, $0 total, no payment, both attributes present on the order.Attribution, never authentication — a cart attribute is a URL parameter the buyer can edit, so nothing may be granted on the strength of it.
This supersedes the original design, in which the kit rode on the SupportToolKit trial ladder’s rung 3 ($10 store credit): the BFF read the minted code’s HubSpot receipt and nudged the minting cron via Render, and the guide held checkout until the code landed. That coupled a launch-blocking path to an asynchronous, email-keyed reward chain that had never successfully minted, to cover a cost that turned out to be $8 of postage. Rung 3 still exists as a reward in its own right; it is no longer on the kit’s critical path. Per-tenant minted codes — single-use and non-shareable — are PDEV-2150.
V1 write path (as built)
Section titled “V1 write path (as built)”Everything is served by the BFF package @arda-cards/bff/onboarding, under /bff/onboarding/*. The BFF holds the session and talks to ARDA; the browser never writes Tenant.settings itself.
| Endpoint | Does |
|---|---|
GET /bff/onboarding/milestones | The tenant’s milestones. For a tenant that has been given a kit and not yet finished, it also checks the kit card’s status and records nux-completed if the card is already ordered — a completion whose write failed after the card moved cannot be retried by the walk (a card already processing sends nothing), so the read reconciles it. (Kept pending a reviewer’s call; it was not in the agreed design and lifts out cleanly.) |
POST /bff/onboarding/milestone | Records one milestone that is not an item creation — opting out, the inbox click-out. |
POST /bff/onboarding/item | Creates an item and records how it was added, in one call: the milestone is written over the item that was just created, so never over one that failed. Answers with the row and the tenant’s milestones. This is the one route every add takes. |
POST /bff/onboarding/starter-kit | The claim: creates the kit, remembers its id, queues its card, records sample-kit-queued last. Idempotent on retry; answers alreadyClaimed when done before. |
kanban card walk (POST /bff/kanban-card/…) | When a walk sends startProcessing and the card’s item is the tenant’s remembered kit, records nux-completed. One tenant read per batch of cards, however many ask; a tenant that has completed, skipped, or never had a kit pays that read and stops. Reported rather than raised: the card moved, and failing to notice what it was must not undo that. |
Every write of a milestone goes through one function (recordMilestone): read the tenant, return without writing if the key is held or the NUX is complete, otherwise merge and PUT. mutation-mode: PROPAGATE stands once on the BFF’s ARDA client; there is no per-call header plumbing.
On the client the create is the collections’ (src/hooks/collections/items/useAddItem, one route for the form, bind-a-code, pending capture; useAddItems for CSV). The method travels with the create and only a tenant already in the NUX sends one, read at the moment of the create rather than when the hook was built. The milestones that come back settle into the onboarding provider; the server’s last answer is the state — no optimistic write, no merge, no rollback. The starter-kit claim is likewise the collections’ (useClaimStarterKit: one BFF call, the kit’s row settled as a create’s is, the ordering cards re-read because the card moved where no reader saw it), with onboarding holding only the policy — when it is due, a three-attempt budget against a broken service, re-arming when the guide is opened, and which tenant the answer is for.
A workspace switch discards anything in flight for the previous tenant: a response is settled only for the tenant that asked, and a write is refused before it is sent if the session has moved on.
The provider (as built)
Section titled “The provider (as built)”OnboardingProvider (src/components/onboarding/) is composed from hooks that each own one thing:
useTenantOnboarding— one BFF read for the milestones, held with the tenant it describes (a switch shows nothing rather than the previous tenant’s), and the two writes that change it.useNuxEngagement— the one-way latch, per workspace; leaving a workspace ends its judgement.activeis the latch and not completed.useAutoOpenGuide— presents the guide once per sign-in: a stored marker is compared with the sign-in stamp, the claim is taken under a cross-tab lock with the marker re-read inside it, and a cancelled effect spends nothing. The sign-in stamp is kept by aSignInMarkermounted besideAuthInit, from the auth state itself — written when there is no sign-in to continue (a reload or token exchange continues one), cleared when the session ends, and never by one tab answering another.- The tenant’s age comes from the account load that already read the tenant record (
activeTenantCreatedAtMson the account slice), not from a second read; a workspace switch reloads it, and only the newest load’s answer counts. The provider decides nothing until items, milestones and age are known.
Accepted V1 trade-offs
Section titled “Accepted V1 trade-offs”- Milestone write race — the read-merge-PUT can lose a concurrent tenant-settings write. Rare in practice (single-actor onboarding, ~8 writes per tenant lifetime); the real fix is a conditional update, i.e. the graduation path.
- Client-observed click-out —
item-added-emailrecords on click-out, gated on enrolment; it retires when the import records it server-side. - Simultaneous kit claim — a retry never mints a second kit (the claim resumes from the remembered id), but two users crossing the goal in the same instant can both claim before either remembers. Server-side idempotency is the named follow-up.
- Completion reaches a tab on its next read — the server records
nux-completed; the tab that placed the order sees the meter and guide go on its next milestones read rather than instantly. Chosen over a client-side watch on the kit’s card, which was built and removed: the server decides and records, the client reads. - Countdown staleness —
Date.now()is sampled on render; the label can lag between renders. Nothing money-gated depends on phase. - Shared free-shipping code — one code for every tenant, a pinned constant. It is shareable, and attribution rests on buyer-editable cart attributes. Acceptable for an $8 postage waiver on a $0 product; PDEV-2150 replaces it with per-tenant single-use codes minted on the backend.
Onboarding Guide UI (as built)
Section titled “Onboarding Guide UI (as built)”The header holds no onboarding state: it renders OnboardingMeterSlot and OnboardingOverlays, which read what they show, and lends the scanner it already owns. The guide itself takes two props — the method cards’ CTA, whose surfaces belong to the caller, and the scanner — and reads everything else from context.
| Surface | Behavior |
|---|---|
| Header meter | Radial ring + n/25 items; ring-only on mobile; lucide Gift at goal; hidden for pre-NUX/completed tenants. Accessible name carries count and goal. Click opens the guide. |
| Guide modal | Content left, starter-kit panel right (dark): “What’s in the starter kit” — cards for the first 25 items, quickstart labels, quickstart cards, 2× card drop points, 3× bin divider card clips, mounting hardware — each spelled out as a KitContent. On a narrow screen a KitSheet, a nested dialog of its own (focus held, returned on close, closed on widening past md). Close X and a “Don’t show this again” button pinned to the modal’s corners. |
| Pre-goal content | Static title “Add 25 items and get a free starter kit”; sticky segmented progress bar + “N to go” + countdown; five method cards written out inline (circle-check treatment: used = filled circle + primary outline; no numbers, no chips); “Step 2 · Get your cards” (CardsSection: Print yourself → items page, Quickstart cards “Scan to bind” → scanner, Order from Arda — “Laminated and shipped. Free for your first 25 items.” — unlocks at goal → order queue). The inbox method obeys NEXT_PUBLIC_ADD_FROM_EMAIL_ENABLED. |
| Post-week notice | ”Your first week is up — we’ll finish with you” + “Book a free consultation” (HubSpot link, plain anchor), methods still open. |
| Goal met | Celebration (“Claim your starter kit!”). Only once the kit’s card is verifiably queued (kitQueued, the recorded milestone) does it say “The kit is waiting in your order queue…” and offer Go to order queue; until then it says the kit is on its way and offers nothing to order, since the claim can still fail. |
| Add-item entry points | The items-page menu’s “Add from your inbox” and the guide’s “Connect inbox” are plain target="_blank" anchors to the start.arda.cards wizard (the addittoarda modal is retired); both record the click-out for an enrolled tenant. Link-outs carry the DS buttonVariants classes (the DS Button’s asChild drops className — ux-prototype ticket candidate). |
| Auto-open | Once per sign-in, for active tenants inside the first week and short of the goal; a new tab is the same sign-in and does not re-present it; nux-skipped opts out permanently. |
| Mock tooling | A scenario picker (mock-only, guarded before anything it reads) sets what the MSW handlers answer with through src/mocks/scenarioOverrides.ts and reloads; it surfaces in the header when the meter is hidden so no scenario strands the session. The mock BFF mirrors the real one: the tenant comes from the mock JWT, a refused create is refused, the claim posts the canonical draft and queues the card, and a card walked to IN_PROCESS records completion — so mock mode reaches the end of the NUX. |
Graduation Path — dedicated table (retained design)
Section titled “Graduation Path — dedicated table (retained design)”Kept for the day server-authoritative recording or cross-tenant reporting is required — it retires trade-off 1 above; trade-off 3 additionally requires server-side kit-claim idempotency. New module cards.arda.accounts.system.onboardingMilestone, following the standard four-layer DataAuthority pattern (template: system/invitation).
The module mirrors Invitation end-to-end: @Serializable input DTO, ScopedMetadata carrying tenantId (stamped from the TenantActor, never trusted from the client), ScopedUniversalCondition for automatic tenant filtering on queries, and a validator enforcing single completion per (tenant, milestoneKey) via universe.noDuplicate(...).
completedBy follows the Invitation.inviter pattern (Reference.Value filled from actor.userEid); the bitemporal bts_author/bts_author_sub columns additionally capture identity for free.
API Contract (graduation path)
Section titled “API Contract (graduation path)”Generated by DataAuthorityEndpoint.reify, restricted via declareOperations to:
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/{module}/onboarding-milestone | Record a completion. Body: { "milestoneKey": "item-added-scan" } — tenant + user derived server-side. Duplicate → validation failure (client ignores). |
| GET | /v1/{module}/onboarding-milestone/{entity-id} | Standard read. |
| POST | /v1/{module}/onboarding-milestone/query | Widget read path: returns the tenant’s completed milestones (auto tenant-scoped); each carries key, effective_as_of (completed-at), completedBy. |
No PUT/DELETE in V1 (DQ-M8). Auth: standard secure routes; tenant + acting user resolved by EndpointActorInterceptor (JWT custom:tenant claim / X-Tenant-Id).
Persistence (graduation path)
Section titled “Persistence (graduation path)”New Flyway module: src/main/resources/onboarding-milestone/database/migrations/V001__onboarding_milestone.sql — canonical bitemporal DDL (all bts_* columns folded into V001, indexes on eid, effective_as_of, recorded_as_of, plus (tenant_id, milestone_key) non-unique index), own DataSource + application.conf include, per repo convention.
Cross-system dependencies (as built)
Section titled “Cross-system dependencies (as built)”| System | Role | Change needed |
|---|---|---|
| Shopify | Kit product, free-shipping code, cart permalink | The free-shipping discount code must exist and stay enabled. Variant id, listing-image URL and the code are pinned constants; all three move only if the product or code is recreated. Open product call: the 48h welcome-kit producer overlaps this offer — pause/re-target or some tenants get two kits (Kyle). |
| SupportToolKit | No longer on this path. Rung 3 ($10 store credit) remains an independent reward. | None. |
Testing (as built)
Section titled “Testing (as built)”Jest, in the app and the BFF package: the milestone store (first-wins, the completion gate, allowlist, cross-tenant refusal, malformed blobs), the claim (create, resume from a remembered kit, queue-then-record ordering), completion on the walk (the kit’s card vs an ordinary one, one read per batch, an already-complete tenant asking once and writing nothing), read-time reconciliation, the provider’s composition (engagement per workspace, age-gated settlement, the auto-open allowance across sign-ins and tabs), the fulfilment policy (budget, re-arm, tenant switch), the guide’s states, and the mock handlers. Playwright: the create and supplier-contract specs run against the BFF path in mock mode. The whole stack was reviewed under Copilot’s per-push re-review (160+ threads across #1422 alone); every thread was answered on the PR.
Rollout
Section titled “Rollout”- Shipped: #1438, #1441, #1443, #1422 merged 15–16 October 2026.
- PDEV-1830 re-scoped to this design (done);
creationChannelprovenance stays backlog (DQ-M7). - Follow-ups: PDEV-2150 (per-tenant single-use shipping codes); server-side idempotency for the simultaneous claim;
item-added-emailrecorded by the import itself; the reviewer’s call on read-time reconciliation (open on #1422); DS ButtonasChildfix (ux-prototype); the welcome-kit producer overlap decision.
Copyright: © Arda Systems 2025-2026, All rights reserved