← All epicsBRD.md §9 · lines 212–580

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 1 — Merchant install & BC app authentication (derived view)

Read-only per-epic slice of BRD.md §9, lines 212–580. The canonical source of truth is BRD.md — edit there, never here. The stable address for a story is its US-ID (US-1.x), not a line number. Regenerates on every dev → main sync via derive-state-on-main.

  • Stories (7): US-1.1, US-1.2, US-1.3, US-1.4, US-1.5, US-1.6, US-1.7
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 1 — Merchant install & BC app authentication

<!-- traceability:start:BRD:Epic-1 -->

Prototype: OAuth Handshake · Welcome · Setup Checklist · Registration Health · Uninstall Preview · Setup Error

<!-- traceability:end:BRD:Epic-1 -->

Value: Merchant can install the app from the BC App Marketplace and load it inside the BC control panel in under 2 minutes.

Epic context. The install flow is the first impression and the only irreversible step: a failure here leaves orphaned webhook registrations, unbound app extensions, and the merchant confused. Every story in this epic must assume transient BC API failures and retry gracefully. Full install + first load must complete in ≤ 10 seconds end-to-end (P95).

US-1.1: OAuth install handshake

<!-- traceability:start:US-1.1 -->

Prototype: OAuth Handshake

<!-- traceability:end:US-1.1 -->

Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin

As a Merchant Admin, I want to install the subscription app from the BC Marketplace with a single click, so that I can start configuring subscriptions without manual API setup.

Acceptance criteria:

  • Given I click "Install" from the BC Marketplace, When BC redirects to our /api/auth with code, scope, context, Then we exchange the code for an access token and persist encrypted credentials.
  • Given the install succeeds, When I return to the BC control panel, Then I see the app listed under "My Apps" with the correct label and icon.
  • Given the install fails mid-flight, When the handshake errors out, Then the merchant sees a descriptive message and the BC side does not show the app as installed.

UX notes.

  • Surface: no user-visible UI during handshake — merchant lands on our /api/auth endpoint and is redirected either to the first-run setup page or an error page.
  • Success path: redirect to /stores/[storeHash]/setup with a "Welcome" toast.
  • Error states: (a) OAuth code exchange 4xx/5xx → generic "Install failed, please retry" page with support link; (b) code already used (merchant hit refresh) → detect and redirect to setup page without error; (c) BC API 5xx during downstream setup → queue retry, show "Setting things up…" page with polling.
  • Accessibility: error pages must have live-region announcements of state changes.

Data contract.

  • Inputs (GET query): code, scope, context
  • BC API calls: POST https://login.bigcommerce.com/oauth2/token (code exchange)
  • Our DB writes: stores row (encrypted access token, scope, store_hash from context), events row (store.installed)
  • Events emitted: store.installed
  • Secrets: BIGCOMMERCE_CLIENT_SECRET, CREDENTIAL_ENCRYPTION_KEY

Success metrics.

  • Functional: 100% of successful OAuth callbacks result in a stores row and a redirect to setup
  • Product (target): install success rate ≥ 99% (by install-initiated / install-completed ratio)
  • Operational (target): P95 latency of handshake ≤ 3s; error rate < 0.5%

Dependencies.

  • Upstream: none
  • Downstream: US-1.3 (App Extensions — triggered on successful install), US-1.4 (webhook registration), US-2.3 (processor compat detection at first load)

Non-functional.

  • Idempotency: exchanging the same code twice must not create duplicate stores rows (check for existing before insert)
  • Encryption: access token encrypted at rest with AES-256-GCM using per-store nonce
  • Rate: expect burst installs from marketing campaigns — handshake must scale horizontally

Risks / open questions.

  • What if BC deprecates the OAuth v2 endpoint? Track BC platform changelog.
  • Do we need to support installation from within BC control panel (no Marketplace click path)? Yes — the signed_payload_jwt flow handles that case (US-1.2).

UI states.

<!-- ui-states US-1.1 -->
surface: "Admin (React/BigDesign) post-OAuth onboarding. TWO panels: (1) Welcome hero card — apps/admin/src/pages/onboarding/Welcome.tsx, routed at /v2/ and /v2/home via the `Home` re-export in apps/admin/src/routes/v2/home/index.tsx (line 21); runs a first-visit gate then renders a success hero + next-step cards. (2) SetupError recovery panel — apps/admin/src/pages/onboarding/SetupError.tsx — the install-failure landing for extension-scope-missing / webhook-5xx / scope-drift / duplicate-install. Persona: Merchant Admin. NOTE: SetupError is NOT in the App.tsx route table (only onboarding/setup + onboarding/health are routed, lines 133-134) so the error surface is unreachable in the SPA — see gaps."
idle:
  render: "Welcome: once the first-visit gate resolves (gateChecked=true, lines 124/149) a HeroCard with a success CheckCircleIcon, a 'you are set up' title/subtitle, and a single primary 'Open checklist' Button (line 166), plus a Panel of numbered next-step cards (NEXT_STEPS create_first_plan + storefront_activation, line 54). SetupError (north-star landing): an ErrorHero (role=alert, line 79) with an ErrorIcon, the scenario title + error-code chip, a 'What is next' Panel with recovery copy, and a diagnostic Panel."
  primary_action: "Welcome 'Open checklist' navigates to /v2/onboarding/setup (lines 166/132). SetupError: for retriable scenarios a primary 'Open registration health' Button navigates to /v2/onboarding/health (line 108); a secondary 'Back to Welcome' Button navigates to / (line 112)."
loading:
  trigger: "Welcome on mount calls getOnboardingChecklist() (line 128) to evaluate shouldRedirectToOnboarding (line 114)."
  render: "While the gate probe is in flight gateChecked stays false and the component renders nothing — `if (!gateChecked) return null` (line 149) — deliberately, to avoid a Welcome to setup flicker. No spinner. SetupError has no async load (the scenario is local useState, line 43)."
error:
  surfaced_at: "Welcome: the gate probe is best-effort — on failure the .catch sets gateChecked=true and renders Welcome anyway (lines 137-141), so a probe error never blocks the merchant (no banner; the redirect simply does not fire). SetupError IS the error surface for install failures: the failure is shown inline in the ErrorHero (role=alert, aria-live=polite, line 79) with the scenario body + a monospace error code (SCENARIO_DETAIL, lines 33-38)."
  recovery: "Welcome probe failure: the merchant lands on Welcome and proceeds via the hero CTA (no retry needed). SetupError: retriable scenarios (extension_scope_missing, webhook_register_5xx) expose 'Open registration health' to /v2/onboarding/health to re-register (line 108); non-retriable scenarios (scope_drift, duplicate_install) expose only 'Back to Welcome' (line 112). REALITY GAP: because SetupError is route-orphaned a real install failure cannot route here — see gaps."
empty:
  render: "Not a collection surface — both panels are single hero/recovery cards. Welcome's next-step Panel always renders the fixed NEXT_STEPS array (line 54), never empty; SetupError always renders one of four fixed scenarios. The subscriptions-list empty state is owned elsewhere (US-17.1)."
edge_status:
  - status: "install partial-failure: extension_scope_missing (retriable)"
    affordance: "ErrorHero explains the missing scope; primary 'Open registration health' to /v2/onboarding/health re-registers extensions (line 108)."
  - status: "install partial-failure: webhook_register_5xx (retriable)"
    affordance: "ErrorHero explains the webhook failure; 'Open registration health' to /v2/onboarding/health re-subscribes failed webhooks (line 108)."
  - status: "install failure: scope_drift (not retriable)"
    affordance: "ErrorHero explains the drift; no health CTA (detail.retriable=false, line 107) — 'Back to Welcome' is the only path; north-star is a re-consent/reinstall link (not built)."
  - status: "install failure: duplicate_install (not retriable)"
    affordance: "ErrorHero explains the duplicate; 'Back to Welcome' returns to the app root (line 112)."
inputs: []
disabled_focus:
  keyboard: "Welcome's hero CTA and next-step CTAs are real BigDesign <Button>s reachable in Tab order (lines 166, 203); during the gate the page renders nothing so there is no focus trap. SetupError's scenario <Select> (line 63), the retriable primary Button (line 108) and the back Button (line 112) are real focusable BigDesign controls in DOM order; the ErrorHero carries role=alert + aria-live=polite (line 79) so its content is announced when rendered."
  gaps: "SetupError's ErrorHero announces via aria-live, but Welcome's next-step Panel has no focus management on the gate-to-render transition; acceptable since the gate renders synchronously."
gaps: "SetupError.tsx is route-orphaned — App.tsx registers only onboarding/setup and onboarding/health (lines 133-134), so the documented install-failure landing is unreachable from the SPA; a real OAuth-install error has no in-app error UI to land on. Worse, SetupError is a prototype port driven by a manual `scenario` useState + <Select> picker (lines 43, 63-73), NOT wired to real install-failure data — even if routed it could not receive an actual error code. The contract north-star (merchant lands on SetupError on a real failure with the failing scope/webhook pre-selected) requires BOTH a route registration AND wiring the scenario from server-provided install-failure state."

US-1.2: Signed payload JWT verification on app load

<!-- traceability:start:US-1.2 -->

Prototype: Welcome

<!-- traceability:end:US-1.2 -->

Phase: MVP · Priority: P0 · Effort: S · Persona: Merchant Admin

As a Merchant Admin, I want to open the app from within BC and be authenticated automatically, so that I don't enter credentials a second time.

Acceptance criteria:

  • Given I click the app icon in BC control panel, When BC redirects to /api/load?signed_payload_jwt=..., Then we verify the JWT against BIGCOMMERCE_CLIENT_SECRET and mint an internal session cookie.
  • Given the JWT is malformed or signed with a wrong secret, When verification fails, Then we return 401 and do not set a cookie.
  • Given a valid load, When the cookie is set, Then it is httpOnly, Secure, SameSite=none, Partitioned, scoped to /stores/{storeHash}.

UX notes.

  • Surface: this is a middleware redirect; the user sees a flash of loading state at most.
  • Loading state: branded "Loading your subscriptions…" skeleton — do not show BC's generic loading.
  • Error state: 401 error page styled to match BC's admin shell so it doesn't look like a crash.

Data contract.

  • Input: GET /api/load?signed_payload_jwt=<JWT> + query (channel_id, url for extension loads)
  • JWT verification: HS256 against BIGCOMMERCE_CLIENT_SECRET
  • Extract: sub (→ stores/{hash}), user.{id,email}, owner.{id,email}, channel_id, url
  • Our DB: no writes on load (stateless auth)
  • Session: mint internal JWT with {userId, email, storeHash, channelId}, sign with JWT_KEY, 24h expiry
  • Cookie: session-token, httpOnly, Secure, SameSite=none, Partitioned, path=/stores/{hash}
  • Redirect: to /stores/{hash}/ or extension-specific path if JWT url field contains an extension path

Success metrics.

  • Functional: every valid signed_payload_jwt results in authenticated session
  • Operational (target): P95 JWT verification < 50ms; 401 rate < 0.1% (mostly merchants with stale clipboard-copied URLs)

Dependencies.

  • Upstream: BIGCOMMERCE_CLIENT_SECRET configured
  • Downstream: every authenticated admin route (all of Epic 21, 22, etc.)

Non-functional.

  • Partitioned cookie is required for iframe context (Chrome 3rd-party cookie restrictions)
  • Clock skew: accept JWTs issued ≤ 5 minutes in the future to tolerate BC/our clock drift

Risks / open questions.

  • If browsers further restrict partitioned cookies, fall back to postMessage-based session handoff.

UI states.

<!-- ui-states US-1.2 -->
surface: 'NOT YET BUILT — forward-looking contract. Admin app-load gate — branded loading skeleton and 401 error page rendered
  before the main React/BigDesign shell mounts. Persona: Merchant Admin.'
idle:
  render: No true idle — GET /api/load always transitions immediately to the loading state on entry from the BC control-panel
    app tile.
  primary_action: n/a (no interactive controls in idle; the route is a one-shot redirect gate)
loading:
  trigger: GET /api/load?signed_payload_jwt=<JWT> received; JWT is being verified against BIGCOMMERCE_CLIENT_SECRET.
  render: 'Full-viewport branded skeleton: BC-blue header bar + ''Loading your subscriptions…'' centered headline + pulsing
    skeleton rows beneath — matches BC admin shell chrome so the transition feels native, not a flash of an unrelated app.'
error:
  surfaced_at: Full-page 401 error view styled to match BC admin shell — headline 'Unable to authenticate', BC-blue header
    intact so it does not look like a crash. No toast (redirect context means no prior page to anchor one to).
  recovery: A 'Return to BigCommerce' <a> link (href to BC control panel root) as the single primary affordance. Error copy
    explains in one sentence that re-entry via the app tile generates a fresh signed payload.
empty:
  render: 'Not a list surface — no empty state applies here. Documented so the state is considered: if the JWT is valid but
    scopes to a store with zero subscriptions, the route redirects normally and the main admin shell owns the empty-subscriptions
    state.'
edge_status:
- status: JWT malformed — HS256 signature verification fails
  affordance: '''Return to BigCommerce'' link — re-entering via the BC app tile generates a fresh signed payload.'
- status: JWT expired — issued more than 5 minutes ago (outside clock-skew tolerance)
  affordance: '''Return to BigCommerce'' link — re-entry produces a new JWT with a current iat claim.'
- status: JWT valid but store_hash not found in our database — store not installed
  affordance: Redirect to the OAuth install flow rather than a 401 — surfaced here so this case does not fall through to the
    generic error page.
disabled_focus:
  keyboard: The 'Return to BigCommerce' affordance in the 401 error page is a real <a> anchor reachable via Tab and activatable
    with Enter — never a div-onClick. The loading skeleton has no interactive controls so no focus management is required
    during the JWT verification window.

US-1.3: App Extensions registered on install

<!-- traceability:start:US-1.3 -->

Prototype: Setup Checklist · Registration Health · Setup Error

<!-- traceability:end:US-1.3 -->

Phase: MVP · Persona: Merchant Admin

As a Merchant Admin, I want to see "Subscriptions" panels appear on Orders, Products, and Customers pages after install, so that I can access subscription info in context.

Acceptance criteria:

  • Given the OAuth handshake completes, When the install callback finishes, Then we issue GraphQL createAppExtension mutations for PANEL context on Orders, Products, and Customers models. (CARTS is not a supported App Extension model per BC's GraphQL schema; cart-side merchant UX uses a different surface — see docs/architecture/app-extension-model-enum.md.)
  • Given an extension already exists (reinstall), When we try to create it, Then we detect the conflict and skip gracefully.
  • Given extension creation partially fails, When I open the app, Then a health badge warns me and offers "Re-register extensions."

UI states.

<!-- ui-states US-1.3 -->
surface: "Admin (React/BigDesign) onboarding — registration health. apps/admin/src/pages/onboarding/RegistrationHealth.tsx, routed at /v2/onboarding/health (App.tsx line 134). Two BigDesign <Table>s (extensions, webhooks) driven by GET /api/v1/onboarding/health (fetchHealth, line 66), with per-row recovery buttons, a batch 'Re-register all' CTA, and an always-visible 'Re-register extensions' action. Persona: Merchant Admin."
idle:
  render: "A 'Back to Welcome' link, a title/subtitle, then (when totalFailed>0) a top-right primary 'Re-register all' Button (line 252). A summary Message — green 'all healthy' with counts (line 308) or amber 'N failed' (line 320). An Extensions Panel with a <Table> of Surface/Label/Status badge/Verified-time/Action (line 349), a Message type=error per failed extension (line 412), and an always-visible 'Re-register extensions' Button (line 477). A Webhooks Panel with a <Table> of Scope/Status badge/Delivery (timestamp + delivery sub-badge via deliveryBadge, line 194)/Action, and a Message type=error per failed webhook (line 561)."
  primary_action: "Per-row 'Re-register' (failed extensions, line 392) / 'Re-subscribe' (failed webhooks, line 541); batch 'Re-register all' for all failing (onReRegisterAll, line 145); always-visible 'Re-register extensions' (onReRegisterExtensions, line 160)."
loading:
  trigger: "On mount reload() to fetchHealth() GET /api/v1/onboarding/health (lines 93/110)."
  render: "While data===null and no error, a plain 'Loading' Text (line 299). Each in-flight action shows isLoading on its own Button: per-row buttons gate on pendingIds (lines 396/545), batch on batchPending (line 256), re-register-extensions on extReRegisterPending (line 481); other buttons disable while a batch runs (disabled includes ...||batchPending)."
error:
  surfaced_at: "Inline BigDesign <Message> banners, never toasts. A page-level dismissable Message type=warning carries fetch/action errors (line 268, onClose line 274). Per failed extension/webhook a Message type=error shows the row's error_message (lines 412-420, 561-569). A batch partial-failure Message type=error reports how many re-registers failed (line 279)."
  recovery: "Every failure is actionable: failed rows expose 'Re-register'/'Re-subscribe' (lines 392/541); the batch 'Re-register all' retries every failing item (line 252); the always-visible 'Re-register extensions' re-runs extension registration regardless of row state (line 477). After any action reload() refetches health (lines 125/136/150/164) so the tables reflect the new state."
empty:
  render: "When the store has no registered extensions/webhooks, data.extensions and data.webhooks are empty: each <Table> renders BigDesign's default empty body and the summary Message shows green 'all healthy' with 0 extensions / 0 webhooks (line 308). The always-visible 'Re-register extensions' Button (line 477) is still the recovery path to (re)create the extension set — the empty table is never a dead-end."
edge_status:
  - status: "extension status=failed"
    affordance: "danger badge + a Message type=error with the error_message; per-row 'Re-register' Button (line 392) and the batch 'Re-register all' both retry."
  - status: "extension status=pending (registration in flight)"
    affordance: "warning badge, row Action shows a dash (line 405); the always-visible 'Re-register extensions' Button (line 477) re-runs registration; resolves to registered/failed on the next health fetch."
  - status: "webhook status=failed"
    affordance: "danger badge + Message type=error; per-row 'Re-subscribe' Button (line 541) and 'Re-register all' (which targets failing items) retry."
  - status: "webhook status=pending (subscription in flight)"
    affordance: "warning badge, row Action shows a dash; transient — resolves to active/failed on the next health fetch. NOTE: there is no manual webhook recovery for a stuck-pending webhook (the per-row button gates on status==='failed' at line 540 and there is no webhook-level always-visible batch like the extensions') — see gaps."
  - status: "webhook delivery=retry / never"
    affordance: "delivery sub-badge under the timestamp via deliveryBadge (line 194); informational — if the underlying webhook is failed, 'Re-subscribe' restores delivery; 'never'/'retry' on an active webhook resolve as deliveries succeed."
disabled_focus:
  keyboard: "All actions are real BigDesign <Button>s inside <Table> rows / panel footers, reachable in Tab order; the 'Back to Welcome' link, 'Re-register all', per-row buttons and 'Re-register extensions' are native focusable elements. In-flight buttons use native disabled (removed from tab order) with an isLoading spinner; no div-onClick dead-ends."
  gaps: "The page-level error <Message> (line 268) and the per-row error Messages (lines 412/561) are inserted on state change with no role=alert/aria-live wrapper and no programmatic focus move — a keyboard/screen-reader merchant gets no announcement when a re-register fails (focus stays on the re-enabled Button). Same class as the US-22.1 finding."
gaps: "Webhook-pending has no manual recovery affordance: the per-row 'Re-subscribe' renders only for status==='failed' (line 540) and there is no webhook equivalent of the always-visible 'Re-register extensions' button (line 477), so a webhook stuck in 'pending' has no operator-driven path — only auto-resolve on the next fetch. Low severity; pending is normally transient. Secondary: dynamic error Messages lack aria-live announcement (see disabled_focus.gaps)."

US-1.4: Webhook subscriptions registered on install

<!-- traceability:start:US-1.4 -->

Prototype: Registration Health · Setup Error

<!-- traceability:end:US-1.4 -->

Phase: MVP · Persona: System

As the System, I want to register for all BC webhooks I depend on during install, so that downstream flows work without merchant intervention.

Acceptance criteria:

  • Given install completes, When we subscribe to webhooks, Then store/order/created, store/order/updated, store/cart/created, store/cart/updated, store/cart/deleted, store/customer/updated, store/product/updated, store/product/deleted are all registered to our endpoints. (store/app/uninstalled is auto-fired by BC and does not require explicit registration.)
  • Given any webhook subscription fails, When we detect the failure, Then we queue a retry and surface the gap on the settings page.

US-1.5: Uninstall hook cleans up credentials and extensions

<!-- traceability:start:US-1.5 -->

Prototype: Uninstall Preview

<!-- traceability:end:US-1.5 -->

Phase: MVP · Priority: P0 · Effort: M · Persona: Merchant Admin

As a Merchant Admin, I want my credentials and app extensions to be removed when I uninstall, so that I don't leave stale resources in my store.

Acceptance criteria:

  • Given I uninstall from BC, When BC POSTs to /api/uninstall, Then we verify the signed payload, revoke credentials, deregister extensions, and pause all active subscriptions.
  • Given subscriptions exist on uninstall, When uninstall processes, Then we retain subscription data for 30 days for potential reinstall recovery.
  • Given 30 days pass post-uninstall without reinstall, When the retention window closes, Then we hard-delete subscriber PII and retain only anonymized revenue records.

UX notes.

  • Not user-facing in our app (user is uninstalling and won't see our UI)
  • If there are active subscriptions, BC itself may prompt the merchant about data retention — we emit uninstalled_with_active_subs: true to a post-uninstall survey email

Data contract.

  • Input: BC store/app/uninstalled webhook (POST with storeHash)
  • Our DB writes: stores.uninstalled_at = now(), subscriptions.status = 'paused' for all active subs (do not cancel yet), events row
  • Our DB retention: subscription data retained 30 days (queryable by merchant for recovery)
  • External: deregister App Extensions via GraphQL deleteAppExtension mutations
  • External: cancel all our webhook subscriptions (we can't use BC webhooks post-uninstall anyway)
  • Events emitted: store.uninstalled, subscription.paused (× N)
  • Scheduled task: enqueue store.purge workflow for T+30 days

Success metrics.

  • Functional: 100% of uninstall webhooks result in subs paused + extensions deleted
  • Product [estimate]: reinstall-within-30-days rate (should be ~5–10% — they often come back)
  • Operational (target): purge job completion rate ≥ 99.5%; zero PII retained post-purge

Dependencies.

  • Upstream: US-1.4 (webhooks must be registered to receive the uninstall event)
  • Downstream: data-subject-access + erasure flows (Epic 28)

Non-functional.

  • Uninstall is idempotent — repeating the webhook does not double-pause
  • Access token may already be revoked by BC by the time we try to delete extensions — treat those failures as non-fatal

Risks / open questions.

  • If a merchant reinstalls at T+31 (1 day after purge), we've lost their subscription data. Consider a 60-day retention with explicit merchant opt-out for shorter.

UI states.

<!-- ui-states US-1.5 -->
surface: "Admin (React/BigDesign) onboarding — uninstall policy disclosure. apps/admin/src/pages/onboarding/UninstallPolicy.tsx. A static, read-only 3-phase timeline (immediate cleanup / 30-day recovery window / 30-day PII purge) built from a fixed PHASES array (lines 50-88), each phase a TimelineCard with a badge + bullet list. Persona: Merchant Admin. NOTE: not registered in App.tsx (only onboarding/setup + onboarding/health are routed) so the page is unreachable from the SPA; per the BRD this story is 'Not user-facing in our app' — the real AC (backend webhook cleanup) is not a UI surface — see gaps."
idle:
  render: "A 'Back to Welcome' subtle Button, a title/subtitle, an info <Message> callout (line 111), then a row of three TimelineCards (PHASES.map, line 119) — each with an H4 header, a phase Badge (primary/warning/danger), and a BulletList of check/warning/delete-iconed bullets — followed by a 'Data retention' Panel with body + GDPR note (line 145)."
  primary_action: "The only action is the 'Back to Welcome' Button to navigate('/') (line 96); the panel is otherwise informational."
loading:
  trigger: "None — fully static i18n content; no data fetch, no async, no mutation."
  render: "Renders synchronously on mount; there is no loading state because there is nothing to load. North-star: if the retention dates ever became live store config, a load skeleton would attach to the retention Panel."
error:
  surfaced_at: "No data fetch or mutation, so there is no runtime error surface. The only failure mode is a missing i18n key, in which case FormattedMessage renders the raw message id in place (a visible-but-degraded label), not a thrown error or blank panel."
  recovery: "The always-present 'Back to Welcome' Button (line 96) lets the merchant leave the panel; nothing to retry. A north-star live-config variant would surface an inline <Message> + retry, consistent with the sibling onboarding panels."
empty:
  render: "Never empty — the three phase cards are statically defined in PHASES (lines 50-88) and always render; the retention Panel is always present. There is no collection that could be empty."
inputs: []
disabled_focus:
  keyboard: "The single interactive element — the 'Back to Welcome' BigDesign <Button> (line 96) — is a real focusable control reachable via Tab and activatable with Enter/Space. The phase bullets are a non-interactive <ul>/<li> (BulletList/Bullet) and the icons are decorative; there are no div-onClick handlers, so no keyboard dead-ends."
gaps: "UninstallPolicy.tsx is route-orphaned — it is imported nowhere and has no <Route> in App.tsx, so a merchant cannot reach this informational disclosure from the SPA. The core US-1.5 acceptance criteria (verify signed payload, revoke creds, deregister extensions, pause subs, 30-day retention, T+30 PII purge) are backend webhook behavior with no UI surface; this panel is only the optional pre-uninstall disclosure and it is unreachable. North-star: register the route and link it from Settings/Welcome before uninstall."

US-1.6: Persist BC staff on first load

Status: PROPOSED — Hive proposal 44f9cae7. Synthesis target 2026-05-08. Final acceptance criteria + final placement land after synthesis approval, citing the synthesis ID in the commit message per WAYS-OF-WORKING.md spec-change rule.

Phase: MVP · Priority: P1 · Effort: S · Persona: System

As the System, I want to persist BC staff identities on first app-load, so that we can attribute mutating actions to specific users without per-merchant configuration.

Acceptance criteria (proposed):

  • Given a successful signed_payload_jwt verify on /api/load, When we extract user.{id, email} (and owner.{id, email} if present), Then we upsert a users row keyed by (store_hash, bc_user_id) with email, is_owner, first_seen_at, last_seen_at.
  • Given the same user loads again, When the upsert runs, Then last_seen_at updates and first_seen_at is unchanged.
  • Given the user's email changes in BC, When we observe the new email at next load, Then we update the persisted email and emit a user.email_changed event.

Data contract.

  • Trigger: every successful /api/load JWT verification (US-1.2)
  • DB writes: users row upsert keyed (store_hash, bc_user_id)
  • Schema: users(store_hash, bc_user_id, email, is_owner, first_seen_at, last_seen_at, email_history_jsonb)
  • BC remains the source of truth for who can access; we mirror identity only.

Success metrics.

  • Functional: every authenticated /api/load results in a users row present.
  • Operational (target): P95 upsert overhead < 5ms on the existing JWT-verify hot path.

Dependencies.

  • Upstream: US-1.2 (signed JWT verify already extracts user.id / user.email)
  • Downstream: US-1.7 (depends on users row existing for FK reference)

Non-functional.

  • Idempotent — same (store_hash, bc_user_id) on second load updates last_seen_at only.
  • No invite UI; no role assignment. RBAC layer deferred to Epic 29 (post-MVP).

US-1.7: Stamp mutating events with actor_user_id

Status: PROPOSED — Hive proposal 44f9cae7. Synthesis target 2026-05-08.

Phase: MVP · Priority: P1 · Effort: M · Persona: System

As the System, I want every mutating action to carry the acting user's identity, so that audit reports (Epic 28) and internal investigations can answer "who changed X."

Acceptance criteria (proposed):

  • Given a session-authenticated mutating call (any POST/PATCH/DELETE on subscription/plan/payment/charge/customer routes), When we write the change, Then the resulting events row carries actor_user_id resolved from the session JWT.
  • Given a system-initiated mutation (scheduled charge, webhook handler, cron), When we write the change, Then actor_user_id is null and events.actor_kind is 'system' | 'webhook_bc' | 'webhook_processor' | 'subscriber'.
  • Given Epic 28 audit-report queries, When they filter by actor_user_id, Then they return the per-user mutation history.

Data contract.

  • Schema additions:
    • events.actor_user_id — nullable FK → users.bc_user_id scoped by store_hash
    • events.actor_kind enum ('merchant_user' | 'subscriber' | 'system' | 'webhook_bc' | 'webhook_processor') — already present per PRD §8.1 line 327; this story confirms its use
    • subscriptions.created_by, subscriptions.updated_by — nullable FK → users.bc_user_id
    • Same created_by / updated_by columns added to mutating tables: plans, payment_methods, charges, customers
  • Session JWT already carries userId per US-1.2; thread it through the request context to the persistence layer.

Success metrics.

  • Functional: 100% of mutating events have a non-null actor_kind; merchant-user-driven mutations have non-null actor_user_id.
  • Compliance: Epic 28 DSAR / audit reports can answer "show me everything user X did across this store" without ambiguity.

Dependencies.

  • Upstream: US-1.6 (users table existence)
  • Downstream: Epic 28 audit reports (US-28.4); compliance and dispute-defense flows

Non-functional.

  • Backfill: NOT required for MVP. Existing events without actor_user_id predate this feature.
  • Performance: column adds are zero-cost on read; write path adds ~one cookie-parse + 8-byte FK write per mutation — negligible.

Deferred to Epic 29 (post-MVP RBAC layer).

  • App-side roles (admin / billing-only / read-only) on top of BC's permissions
  • Staff list mirror UI in our admin
  • Role assignment flow
  • BC staff-removed webhook → revoke our session
  • Staff invite flow (none — BC is the authority on who can install/access)