Stringer Onboarding + Offboarding¶
This page is the lifecycle contract for the Stringer entity: what fields are collected at first sign-in, what acceptance criteria the onboarding flow must meet, and what happens when a stringer leaves the platform. Cross-cuts: V2 scope M3, M21, data model — Stringer, use cases — UC-5, client identity & sharing, the receipt-content requirements (racket-book#82, parallel MR — business-identity fields backing receipts). Tracked in racket-book#84.
What this page does NOT cover: the auth mechanics (magic-link, password, JWT) — those are pinned by M3, M21, and the architecture's
docs/architecture/auth-and-tenancy.md. This page picks up at the moment the stringer first lands in RBO post-magic-link (the "self-fill profile" step in M3) and ends at the moment the stringer's account is finalized after offboarding.
Onboarding¶
Recap of the entry point (from M3 + M21)¶
- Admin (Stefan) creates the
Stringerrow with email; clicks "send invite". - RBO calls gotrue's invite endpoint; gotrue mints + emails a magic-link via Resend SMTP.
- Stringer clicks the link.
- gotrue verifies; issues a JWT; lands as HttpOnly cookie.
- (Iris's lane starts here) RBO renders the onboarding "self-fill profile" form.
- Stringer fills the form, saves, lands in their (empty) workspace.
- Stringer optionally sets a password from account settings (post-onboarding; M21 dual-method).
Profile fields collected at first sign-in¶
The onboarding form prompts for the field set below. The flow is single-page (no multi-step wizard); fields are grouped into "Required" and "Optional now / can-fill-later". Defaults are sensible-but-overridable.
Required fields¶
| Field | Default | Notes |
|---|---|---|
display_name |
None — must be entered. | The "who strung this" name on every receipt (TL-2 per the receipt-content requirements). Validation: non-empty, ≤ 80 chars. |
default_locale |
Browser Accept-Language if EN or DE; else EN. |
Drives both the UI locale and the receipt-template variant for clients who don't have their own Person.default_locale set. EN | DE only in V2 (M19). |
Optional now / fill later (recommended)¶
| Field | Default | Notes |
|---|---|---|
business_name |
NULL | Trading name when different from display_name (e.g. display_name = "Stefan Wagen", business_name = "RacketLab"). Rendered below display_name on receipts (TL-3). |
business_address |
NULL | Multi-line postal address. Rendered in receipt footer (F-2). The form prompts with the hint: "your customers will see this on every receipt". Onboarding should encourage but not require it. |
phone |
NULL | Rendered in receipt footer (F-3). |
notification_template |
Platform-default localized string. | Outbound message template for V3 notifications + (V2) receipt-email body. The stringer can edit at any time via account settings. |
logo |
NULL | Image asset. Onboarding does not collect the logo (per OQ-R-5 default in receipt-content); it is uploaded later via account settings. The onboarding form surfaces a "you can add a logo later in account settings" note. |
receipt_thankyou_text |
Not present in V2 by default (per OQ-R-3 default). | If Stefan flips OQ-R-3, this field appears as optional in onboarding. |
Self-Profile (is_stringer_self) slot¶
The stringer's own ClientProfile (the slot used when they string for themselves — locked decision 1 from V2 scope) is created lazily, not at onboarding:
- Onboarding does NOT auto-create a self-Person + self-ClientProfile.
- The first time the stringer creates a self-job (an Order with no client picked, or an explicit "this is for me" affordance), RBO creates:
- A
Personrecord bound to the stringer's gotrue user ID (viaPerson.gotrue_user_id), withdisplay_first_name/display_last_nameextracted fromStringer.display_name(split on whitespace; stringer can correct in account settings). - A
ClientProfileunder the stringer withis_stringer_self = TRUEreferencing that Person. - This avoids forcing every newly-onboarded stringer through a "create my own client record" step they may never need (a stringer who only strings for clients never needs a self-ClientProfile).
Acceptance: the lazy-creation path is one transaction; if the stringer creates two self-jobs in quick succession, only one self-Person + ClientProfile is created (idempotency keyed on gotrue_user_id).
Onboarding acceptance criteria¶
- A1. The form refuses to save until
display_nameanddefault_localeare non-empty. - A2.
default_localedefaults from the browser'sAccept-Languageif EN or DE; else EN. The stringer can override before save. - A3. Skipping all optional fields is allowed; the stringer lands in an otherwise-empty workspace.
- A4. The form surfaces explicit guidance for
business_address: "your customers will see this on every receipt". - A5. The form surfaces a "you can add a logo later in account settings" note where the logo upload would otherwise sit.
- A6. After save, the stringer is redirected to the empty workspace (matching M3 / UC-5).
- A7. The self-Person + self-ClientProfile is NOT created at onboarding; it is created lazily on the first self-job (idempotent on
gotrue_user_id). - A8. Re-rendering the onboarding form after partial save (e.g. browser refresh mid-fill) MUST preserve the entered fields. (Implementation detail; called out so Pax / Mira don't ship a wipe-on-refresh flow.)
- A9. All onboarding fields are individually editable post-onboarding via account settings.
Hidden requirements (UI affordances the rules imply)¶
- Single-page onboarding form with required fields visually distinguished from optional.
- Locale toggle at the top of the form (so the stringer can switch the form itself between EN and DE before completing it).
- "Add logo" prompt in account settings with file upload affordance (Mira owns the design).
- Self-job creation flow that detects "no ClientProfile picked → assume self" and triggers the lazy self-ClientProfile creation transparently.
- Account settings page allowing edit of all onboarding fields plus password set/change (M21).
UX surfacing of items 1–5 is owned by Mira — flagged as a follow-up requirement; not in scope for this requirements doc.
Offboarding¶
Triggers¶
| Trigger | Actor | Notes |
|---|---|---|
| Admin (Stefan) deactivates a stringer | Admin | The escalation path for stringers who become inactive, abuse the platform, or otherwise need removing. |
| Stringer self-deactivates ("close my account") | Stringer (any) | Account-settings affordance; one-click + confirmation with reason field. |
| Email-bounce sustained beyond N retries | System (advisory) | NOT a hard offboarding trigger in V2. RBO marks the stringer's email status as "bouncing" and surfaces the alert in admin's view; admin decides whether to intervene. Documented here so it's not assumed-automatic. |
Lifecycle states (offboarding)¶
active ─► deactivated ─► (90-day grace) ─► admin-finalized (hard delete)
▲ │
│ │
└──── re-activate (admin or self) ◄──┘
(only during grace)
| State | Description | Read access | Login | Receives notifications? |
|---|---|---|---|---|
| active | Normal operation. | All own data + shared-in. | Yes. | Yes. |
| deactivated | Stringer.deactivated_at set. The stringer's account is locked: cannot log in, cannot mutate data, cannot receive notifications. Their existing data (ClientProfiles, Orders, share grants issued, audit rows) is retained. |
None (account locked). | Refused. Magic-link and password attempts both rejected with a "this account has been deactivated" message. | Suppressed. |
| grace (90-day window after deactivation) | The stringer (if self-deactivated) or admin can re-activate without admin-finalization. After 90 days from deactivated_at, the admin gains a "finalize (hard-delete)" action; before 90 days, finalize is hidden / refused with a "wait until grace ends" message. |
None. | Refused. | Suppressed. |
| admin-finalized (hard-deleted) | Admin has clicked "finalize" after the grace window expires. The stringer's PII fields are scrubbed; data with retention obligations (orders, receipts, audit) survives in a redacted form (see Cascade rules). | N/A — no account exists. | N/A. | N/A. |
Re-activation during grace: clears Stringer.deactivated_at, clears any reactivation_blocker flag, restores normal operation. Magic-link login starts working again. The stringer's data was never moved or modified, so no restoration step is needed beyond flipping the flag.
Cascade rules — what happens to owned data on admin-finalize¶
This is the FADP-aligned part. Default policy: retention with PII-scrub, not bulk-cascade-delete. Rationale: orders, receipts, and the audit trail are platform-wide records (other stringers may have read shared rows; clients have already received emailed receipts; the platform must remain able to demonstrate FADP compliance via the audit log).
| Owned entity | Behaviour on admin-finalize |
|---|---|
Stringer row |
PII fields scrubbed: email, phone, business_address, logo, display_name, business_name set to [redacted by request] placeholders. The row is not deleted — orphaning the FK from Orders, ClientProfiles, audit, etc. would be worse than a soft-redact. Stringer.deactivated_at and a new Stringer.finalized_at are set; is_active = FALSE. |
ClientProfile rows the stringer owned |
Retained as historical records (orders reference them). ClientProfile.internal_notes, nickname, default_tension_memo are scrubbed (they are stringer-private data — the stringer chose to leave). The link to Person survives (the Person is platform-level identity, not the stringer's to delete). |
Person rows referenced by the stringer's ClientProfiles |
Untouched. Persons are platform-level. A stringer leaving does NOT delete the human's identity record; other stringers may have ClientProfiles for the same Person, and the V3 portal still serves the Person's own history. |
Order rows the stringer created |
Retained (financial-record / receipt-history reasons). Order.comments is scrubbed (free-text may contain stringer-internal notes). All other fields (lifecycle dates, pricing, racket FK, string spec, etc.) are retained — they are part of the receipt-emit history that other parties may have copies of. |
Racket rows the stringer owned |
Catalogue-shared rackets are retained (other stringers depend on them). catalogue-private rackets owned only by this stringer's self-ClientProfile (i.e. Stefan's own rackets if Stefan offboards) are retained as orphaned by the redacted ClientProfile (no PII risk). one-off rackets attached to specific orders are retained with the order. |
String rows the stringer created |
Same as Racket. |
Share grants this stringer issued (order_shares rows where granter_kind = stringer AND granter_stringer_id = this) |
Auto-revoked (set revoked_at = finalized_at). The grantee no longer sees the shared orders from the next request. Audit-logged as event_kind = grant_revoked with actor_kind = system, meta = {"reason": "granter_offboarded"}. |
| Share grants this stringer received | Auto-revoked from this stringer's side (the stringer can no longer read shared-in orders because their account is gone). The granter's order_shares row is marked revoked_at with actor_kind = system. |
Catalogue submissions in pending state |
Marked rejected automatically by admin-finalize, with reason "submitter offboarded". (If they are in promoted state, the underlying Racket/String row is catalogue-shared and has its own life independent of the submitter.) |
share_audit rows where this stringer is the actor |
Retained verbatim. The audit trail is append-only and a record of historical accesses; redacting it would defeat FADP positioning. |
Order.receipt_emit_log rows |
Retained verbatim. A receipt was sent to a customer's inbox; the platform must remain able to answer "when did this happen?" via the log. |
Offboarding acceptance criteria¶
- B1. Self-deactivation is a one-click affordance + confirmation dialog with optional free-text reason; on confirm,
Stringer.deactivated_atis set immediately and the stringer is logged out. - B2. Admin-deactivation is a one-click affordance from the admin's user-list view + confirmation with required reason; same effect.
- B3. A deactivated stringer's login attempts (magic-link OR password) are rejected with a "this account has been deactivated" message.
- B4. Re-activation (admin OR self via a "I changed my mind" magic-link path within the grace window) clears
deactivated_atand restores normal operation. No data restoration step required. - B5. The 90-day grace clock starts at
deactivated_at; the admin's "finalize" action is hidden / refused before grace ends. - B6. Admin-finalize is a one-click affordance from the admin's deactivated-stringers view + confirmation with required reason; on confirm, the cascade rules execute in a single transaction.
- B7. Auto-revocation of share grants on finalize fires for both directions (issued by this stringer + received by this stringer), each writing one
share_auditrow withactor_kind = system. - B8. Admin-finalize is the only path to hard-deletion in V2. There is no time-bombed automatic finalize; admin must always click. (V2 keeps the human-in-the-loop; V2.x or V3 may add an auto-finalize for stringers that have been deactivated >> 90 days, with admin-notify, but that is not in V2.)
- B9.
Personrows linked to the offboarded stringer's ClientProfiles are NOT modified by offboarding. - B10. Every offboarding action (deactivate / re-activate / finalize) is audit-logged in
share_auditwithactor_kind = stringer | admin | systemas appropriate,target_kind = stringer, andmetacarrying the reason.
Hidden requirements (UI affordances the rules imply)¶
- "Close my account" affordance in stringer's account settings (Mira owns the design).
- Confirmation dialog with reason text field for both self-deactivate and admin-deactivate.
- Admin's deactivated-stringers view — list of stringers with
deactivated_atset, sortable by date, with "re-activate" and (post-grace) "finalize" buttons per row. - Re-activation magic-link flow — for the self-deactivated case during grace, a magic-link sent to the original email triggers re-activation. (Implementation detail: gotrue can mint the link; RBO's middleware checks
deactivated_atand routes to the re-activate-confirmation page rather than the empty-workspace page.) - Login-refused message — friendly localized message visible on both magic-link and password login attempts when the account is deactivated.
- Grace-window indicator in admin's deactivated-stringers view: countdown until finalize-eligible, plus an explicit "finalize-eligible" state once 90 days have elapsed.
- Finalize confirmation — destructive-action UI per Mira's design system (probably a typed-confirm pattern — "type the stringer's email to confirm").
- Offboarding-cascade dry-run preview — admin's finalize confirmation dialog lists affected entities (count of orders preserved, count of share grants auto-revoked, count of pending catalogue submissions auto-rejected) so the admin sees what will happen before clicking.
UX surfacing of items 1–8 is owned by Mira — flagged as a follow-up requirement; not in scope for this requirements doc.
Edge cases¶
| Case | Handling |
|---|---|
| Stringer deactivates themselves but their email later bounces (e.g. they let the mailbox lapse). | Re-activation magic-link cannot reach them. Admin can re-activate manually from their deactivated-stringers view; the stringer can then update their email in account settings. |
| Stringer was the only admin (Stefan) attempts to self-deactivate. | Refused with a clear message. The platform requires at least one admin; Stefan must promote another stringer to admin before self-deactivating. (V2 reality: there is only Stefan, so this case is essentially "Stefan cannot self-offboard"; a future stringer-with-admin-rights would unblock it.) |
| Admin attempts to finalize a stringer who has issued or received share grants currently being viewed by other stringers. | Allowed. Auto-revocation happens in the cascade transaction; the next request from the affected grantee/granter sees the revocation. No mid-flight session disruption beyond that. |
| A Person had only one ClientProfile (under the offboarding stringer) and now has zero. | The Person row is retained. If the Person never claimed an account (no email_verified_at), they remain a draft Person with no ClientProfiles — orphaned but still queryable. If the Person claimed and verified an email at some point, their V3 portal access is unaffected. |
| Offboarded stringer's email is later re-claimed by a new Person (gotrue invite to a different human at the same address — vanishingly unlikely but possible). | Treated as a distinct identity (gotrue user IDs differ). The new Person and the redacted Stringer share an email value but not a gotrue_user_id; the chokepoint resolves on UUID, not email. No silent conflation. Documented here so a future debugger doesn't get confused. |
FADP positioning¶
- Lawful basis for retention: Orders + receipts are financial records (Swiss commercial-record retention practice; not VAT-required given the disclosure on every receipt, but kept as good-faith business records). Audit + receipt-emit logs are FADP-relevant proof of who-saw-what-and-when. Persons are retained because the human did not request their deletion (FADP grants the human the right to request erasure; the stringer's offboarding is not the human's request).
- Right to erasure (Person side): unrelated to stringer offboarding; handled separately by ADR-0004's
Personsoft-delete on request (setPerson.deleted_at, scrub PII). This page does not re-document that path. - Stringer-side data scrubbed on finalize:
email,phone,business_address,logo,display_name,business_name, plusinternal_notes/nickname/default_tension_memoon every ClientProfile they owned, pluscommentson every Order they created. The remaining structural fields (FKs, dates, prices, racket/string spec) are retained as records. - No bulk hard-delete. Cascade-delete of Orders and ClientProfiles would orphan FKs in
share_audit,order_shares,person_stringer_share, andOrder.receipt_emit_log— defeating the audit trail. Soft-redact-with-retention is the only FADP-defensible posture. - The 90-day grace is a recover-from-mistake window; it is NOT a "you-have-90-days-to-export" window. Export is independent (M20 — per-stringer self-service XLSX + JSON export — works on any active account); a stringer wanting their data should export before deactivating, or admin can re-activate-then-export-then-re-deactivate during grace.
Open questions¶
- OQ-S-1 (Stefan to confirm or flip): offboarding default — drafted as (b) deactivate + 90-day grace + admin-finalize, FADP-aligned. Alternatives: (a) deactivate + retain indefinitely (no auto-finalize); (c) immediate hard-delete with cascade (rejected by Iris on FADP/audit grounds). Default (b) applied unless Stefan specifies otherwise.
- OQ-S-2 (Stefan to decide): is the self-deactivation re-activation magic-link path enabled in V2, or is re-activation admin-only?
- Default applied: enabled in V2 (matches the M21 dual-method spirit — the stringer owns their own account).
- One-flag flip if Stefan prefers admin-only.
- OQ-S-3 (Stefan to decide): can a stringer who self-deactivated and was finalized later re-onboard with the same email?
- Default applied: yes, treated as a new Stringer + new gotrue user ID (same email, distinct UUID). The redacted-but-retained old Stringer row keeps its FK references intact.
- If Stefan prefers blocking re-onboard at the same email, a UNIQUE constraint on
Stringer.email WHERE deleted_at IS NULLalready gives that behaviour for an active row, but blocking against a finalized row needs an explicit policy.
Cross-references¶
- Architecture / auth:
docs/architecture/auth-and-tenancy.md, M3, M21. - Sibling requirements: the receipt-content requirements (racket-book#82, parallel MR — the business-identity field group is scoped there; this page references which fields onboarding collects); the order-lifecycle requirements (racket-book#80, parallel MR — admin-override on locked orders is a related admin-only mechanism).
- Identity model: client identity & sharing — Person/ClientProfile split. Self-Person + self-ClientProfile are created lazily, not at onboarding.
- Use cases: UC-5 — Multi-stringer admin — onboarding overview lives there; this page is the rules-layer detail behind it.
- Data model: Stringer entity — gains the new business-identity fields (per the receipt-content requirements) plus
deactivated_at,finalized_at,is_activefor the offboarding lifecycle. - UX: Mira owns the surface design for items 1–5 (onboarding) and 1–8 (offboarding) in the hidden requirements sections.
- Implementation: Pax (after Mira's design lands).
- Issue tracking: racket-book#84 (this page).