Owner Add-Account Flow Design

complete-self-service-connection-onboardingProject noteWorking notes
Created Updated openspec/changes/complete-self-service-connection-onboarding/design-notes/owner-journey-flow-design-2026-06-10.mdView on GitHub →

Status: proposed design Owner: reference implementation owner Created: 2026-06-10 Related:

  • design-notes/owner-journey-slvp-realignment-plan-2026-06-10.md (the plan this design completes — it defines the honesty floor; this defines the flow)
  • research/connection-onboarding-prior-art-2026-06-10.md (Plaid Link, Stripe Connect, Zapier, GitHub Importer — sources and dates inside)
  • tmp/workstreams/onboarding-capability-inventory-2026-06-10.md (ground truth of what exists; file:line citations live there)
  • openspec/specs/reference-connection-health/ (the state model every flow state projects from)

Design Test

SLVP means Stripe, Linear, Vercel, Plaid. For connection onboarding the test is literal: would this flow look at home inside Plaid Link? Plaid's job — "let a person connect an account they own to a service they trust, including the awkward credential moment" — is exactly this feature.

The Skeleton: One Flow, Three Modalities

Prior art converges on a single shape (Plaid's event sequence, Zapier's test step, GitHub Importer's progress page). PDPP should have ONE add-account flow skeleton whose middle step varies by modality, not three unrelated flows:

A. CHOOSE      Sources page → "Add account" on a source card
B. CONNECT     modality-specific (see below)
C. IDENTITY    "Connected as tim@gmail.com" — echo who/what was connected,
               auto-derive the connection label, create the ACTIVE connection
D. FIRST SYNC  named-stage progress page with records-so-far
E. DONE        "1,243 records collected · View records" (or honest failure
               with ONE repair action)

Two structural decisions distinguish this from today's flow:

1. Activation moves earlier. Plaid fires CONNECTED before any data is pulled. Today PDPP keeps the connection invisible until first ingest completes (createDraft → captureCredential → runNow → redirect with a transient notice). In this design, the connection becomes a visible, ACTIVE entity at the IDENTITY moment (C); the first sync is then a normal run on an active connection, projected through the existing health model. This single change eliminates the draft black hole, makes multi-account self-evident (each card is born labeled tim@gmail.com), and means the Sources page never lies about what the owner just did.

2. The flow ends on a status surface, not a redirect notice. GitHub Importer's pattern: submit → immediately land on a progress page with named stages → terminal state offers the next action. The run timeline already carries PROGRESS messages (text, stream, count/total) and the console already polls at 3s — the data and transport exist; only the surface is missing.

Stage B by Modality

B1. Static-secret (Gmail, GitHub, YNAB, …)

Manifest-driven credential form (exists), plus the one new primitive this design introduces — the synchronous validation moment (Zapier's test step):

[paste credential] → Validate (≤10s) →
  ✓ "Connected as tim@gmail.com"        → IDENTITY (C)
  ✗ "GitHub rejected this token — it may be expired. [Create a new token ↗]"
     (form state preserved, help link in new tab)

Where validation lives — decision. The Collection Profile has no VALIDATE/PREFLIGHT message, and it should not gain one for this (the boundary map says reference needs must not leak into protocol semantics until they earn it). Instead:

  • Add an OPTIONAL connector runtime-binding hook, probeCredential(secret) → { identity, detail } | error, reference-only.
  • The setup planner (already the single source consumed by console, REST, and CLI) advertises per connector: validation: synchronous | first_sync.
  • Connectors without the hook degrade gracefully: the flow skips the ✓ echo, the connection activates at credential capture with label pending, and first-sync failure carries the repair action. Honest, just less polished.
  • Promotion trigger: if three or more connectors implement the hook and a second implementation wants it, consider promoting a probe scope into the Collection Profile — not before.

For the launch set: Gmail = IMAP LOGIN + fetch account email; GitHub = GET /user with the PAT; YNAB = GET /user with the token. All three are single-request probes that also return the identity for stage C.

B2. Browser-bound (Amazon, Chase, ChatGPT) — the productize path

Owner decision 2026-06-10: productize, never demote. The SLVP form is "Finish in this browser" — an in-dashboard interactive session, not a copied terminal command. The streaming machinery for this exists but is hard-coupled to an existing run_id (viewer route /runs/<runId>/stream, registry keyed on run+interaction). The construction that satisfies it:

Add account → server creates connection shell + starts an ENROLLMENT RUN
  (a bounded run whose scope is: establish session, verify identity)
→ dashboard embeds the existing streaming viewer for that run
→ owner logs into the provider inside it (MFA and CAPTCHA land in the same
  surface — the assisted-interaction machinery already handles these)
→ connector captures the session into the connection's profile and emits the
  account identity → IDENTITY (C) → first collection proceeds as a normal run

This reuses the run-target registry, surface allocator, companion factory, viewer route, and persistent profile storage. The NEW work is: (a) a connection shell that can exist before credentials (browser-bound connectors have no draft mechanism today), (b) the enrollment-run scope, (c) identity emission at session capture. This construction also subsumes add-browser-collector-enrollment-primitive per the realignment plan.

Repair uses the same path (Plaid update mode): a needs attention connection's primary action is "Reconnect", which starts an enrollment run against the EXISTING connection — landing the owner directly at the provider login, never back at stage A.

B3. Local-device (Claude Code, Codex collectors)

Already published and working: dashboard mints a one-time code, owner runs pdpp collector enroll --code <code> on the device. Two polish items make it match the skeleton: the post-mint screen should poll until the device enrolls and then advance to IDENTITY automatically ("Device 'Simon laptop' connected") instead of leaving a static code on screen; and the command block must carry the exact published package/version per the plan's command-surface contract. No new machinery.

State Model (projection, not a new enum)

Setup states are projections of existing connection-health states plus one new pre-active phase. No parallel vocabulary:

flow stage          stored as                          card shows
─────────────────────────────────────────────────────────────────────────
B validating        (ephemeral, in-request)            spinner in form
B2 enrollment run   connection shell + active run      "Finish in this browser"
C identity echoed   ACTIVE connection, no runs yet     "Active · first sync starting"
D first sync        ACTIVE + running run               "Active · syncing — 1,240 records"
E success           ACTIVE + fresh                     "Active · 1,243 records · synced now"
E failure           needs_attention (existing state)   "Needs attention · [Reconnect]"
abandoned B2 shell  retired by TTL (data-ops rule)     (nothing — never a ghost card)

The one genuinely new stored state is the B2 connection shell. It carries a TTL at creation (per the data-ops retirement contract) so abandoned enrollments self-clean.

Copy Model

The voice at the credential moment is borrowed from Plaid: name the provider, name the artifact, say what PDPP will and won't do with it.

  • Form header: "Connect your GitHub account" — never "Configure connector".
  • Credential field: manifest-authored label + "PDPP stores this encrypted on YOUR server. It is never shared with apps or agents." (true today — credential store is encrypted; grants never expose credentials)
  • Validation failure vocabulary is owner-causal: "GitHub rejected this token" / "This looks like a password, not an app password" — never error codes.
  • Identity echo: "Connected as {identity}" + editable label defaulted to it.
  • First-sync stages: connector PROGRESS text verbatim when present, else "Collecting {stream display name}…".

Acceptance Additions (rows for the plan's matrix)

JourneyAcceptance bar
Credential validation momentFor registry connectors with a probe: wrong credential is rejected in ≤10s with provider-named copy and preserved form; right credential echoes account identity.
Identity-derived labelsSecond Gmail account is born labeled with its own address; owner never types a label to disambiguate.
Time-to-first-valueDuring first sync the owner sees records-so-far counts or named stages within 15s of submit; terminal success offers "View records" which shows real records.
Browser-bound enrollmentOwner adds an Amazon account entirely inside the dashboard (no terminal); MFA handled in-surface; abandoning mid-login leaves no ghost card.
Reconnect/update modeA needs-attention connection's primary action lands the owner directly at the repair step, not at the start of the flow.

Build Ledger (honesty about scope)

Reused as-is: setup planner + catalog, manifest-driven forms, static-secret action chain, streaming viewer + interaction machinery, run timeline PROGRESS, 3s polling, local-collector enrollment, health-state projection, encrypted credential store.

New: probeCredential hook + planner validation field (small), setup-status page (small), identity-derived labels (small), early activation refactor (medium — moves the activation moment, touches health projection), browser connection shell + enrollment-run scope (large — this IS Phase 5), reconnect update-mode routing (small once enrollment runs exist).

Plan Integration

  • Phase 2 absorbs: setup-status page, early activation, identity echo, validation moment for the static-secret registry.
  • Phase 3 builds the Sources IA on the state projection above (no new enum).
  • Phase 5 implements B2 exactly as specified here; its acceptance is the browser-bound matrix row.
  • The five acceptance rows above append to the plan's owner acceptance matrix.