Skip to content

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:

PRBranchWhat it carries
#1438nail60-ag/onboarding-bff-endpointsThe @arda-cards/bff/onboarding package: vocabulary, endpoints, handlers, the starter-kit claim
#1441nail60-ag/item-create-via-bffEvery item create goes through the BFF, so a create can carry how it was made
#1443nail60-ag/onboarding-milestonesThe onboarding provider and the hooks it is composed from
#1422nail60-ag/onboarding-guide-prototypeThe guide, the header meter, kit fulfilment, and completion

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.

#DecisionAnswerStatus
DQ-M1Where 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-M2StorageTenant.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-M3Data 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-M4Key vocabularyAllowlisted 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-M5Who 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-M6DuplicatesFirst completion wins per key, enforced in the BFF; a repeat answers with the existing record and writes nothing.AS BUILT
DQ-M7Fate 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-M8Reset / undoNot 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-queued last, 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’s startProcessing transition — checkout finished — not accept, 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.

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.

EndpointDoes
GET /bff/onboarding/milestonesThe 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/milestoneRecords one milestone that is not an item creation — opting out, the inbox click-out.
POST /bff/onboarding/itemCreates 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-kitThe 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.

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. active is 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 a SignInMarker mounted beside AuthInit, 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 (activeTenantCreatedAtMs on 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.
  1. 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.
  2. Client-observed click-out — item-added-email records on click-out, gated on enrolment; it retires when the import records it server-side.
  3. 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.
  4. 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.
  5. Countdown staleness — Date.now() is sampled on render; the label can lag between renders. Nothing money-gated depends on phase.
  6. 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.

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.

SurfaceBehavior
Header meterRadial 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 modalContent 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 contentStatic 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 metCelebration (“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 pointsThe 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-openOnce 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 toolingA 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).

PlantUML diagram

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.

Generated by DataAuthorityEndpoint.reify, restricted via declareOperations to:

MethodPathPurpose
POST/v1/{module}/onboarding-milestoneRecord 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/queryWidget 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).

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.

SystemRoleChange needed
ShopifyKit product, free-shipping code, cart permalinkThe 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).
SupportToolKitNo longer on this path. Rung 3 ($10 store credit) remains an independent reward.None.

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.

  1. Shipped: #1438, #1441, #1443, #1422 merged 15–16 October 2026.
  2. PDEV-1830 re-scoped to this design (done); creationChannel provenance stays backlog (DQ-M7).
  3. Follow-ups: PDEV-2150 (per-tenant single-use shipping codes); server-side idempotency for the simultaneous claim; item-added-email recorded by the import itself; the reviewer’s call on read-time reconciliation (open on #1422); DS Button asChild fix (ux-prototype); the welcome-kit producer overlap decision.