Skip to content

Onboarding funnels pin recipe projections

FOXY-1329 introduces editable onboarding funnels before character creation. Marketing editors need to adjust order and copy without changing recipe semantics or accessing workflow editing. An anonymous visitor must see a stable published flow even while its draft or source recipe changes. Phase 1 stops at signup and a configured app redirect; it does not create characters, run workflows, or upload the visitor’s photos.

The parser architecture explains the projection pipeline and service boundaries behind this decision.

The original pinning and immutable-publication decision remains. Phase 2 adds typed signup destinations, stage-keyed browser inputs with recipe/RI-VI/gender, source status checks, manual source rebuilding, and restoring an immutable publication into the draft. The reviewed source-refresh and checkpoint-undo workflow was subsequently removed, including its checkpoint-table migration. New character creation remains a separate adapter. These changes supersede the Phase 1 arbitrary redirect and progress-reconciliation behavior described below; see the current architecture for storage and deployment compatibility rules. Old descriptors and immutable snapshots are preserved.

Sirloin owns a separate funnel entity and immutable published versions. Editors choose RI only, VI only, or Both before configuring sources. Each supported character type has exactly one recipe or family source, and source pickers show only recipes and families of that type. RI-only and VI-only funnels enter their configured flow directly. Both adds a required “Who’s the main character?” entry step: Me selects RI, and AI influencer selects VI. Only the chosen type’s family selectors and branch steps are shown. Switching type discards answers and photos from the previous path. A family can expose its own selector branches. Source projection pins supported visible controls once. Publish snapshots the draft without re-reading sources. Canonical JSON and SHA-256 bind each publication to its pinned recipe and family versions. Optimistic revisions prevent lost updates; account-scoped request IDs replay identical mutations and reject reused IDs with different payloads. Source rebuild checks for a replay and valid editable revision in a short transaction, resolves recipes outside funnel locks, then rechecks replay/revision before committing. A concurrent successful retry takes precedence even if local preparation fails. Unpublishing returns a funnel to draft and removes public availability while retaining history. Archiving removes public availability and locks editing while retaining drafts, publications, request history, and metrics. Archived funnels remain discoverable through the list filter. Reactivation restores draft status; an editor must publish explicitly before the public link works again. There is no permanent-delete action; the legacy DeleteFunnel operation archives the funnel.

Flank’s /funnels editor permits ROOT, ADMIN, and FUNNEL_EDITOR. Its funnel-only catalog endpoint does not grant access to the existing recipe/family editing server functions. Brain’s role hierarchy gives FUNNEL_EDITOR only USER capabilities; the persisted role column is already a string, so this additive role requires no schema migration. Fennec exposes the role in its admin assignment dropdown and uses the same ordering for navigation guards. Flank uses its existing controls for authoring and shared Foxy DS components for step previews. Brisket uses the same step components for the public flow, including signup background, selection cards, upload states, and sticky actions. Preview interactions do not create visitor impressions or sign up users.

RI strict mode is enabled by default. Only the Face & Chest photo step remains available among RI character settings and photo steps. Other RI steps stay visible but cannot be enabled, edited, or reordered until strict mode is disabled. Routing and signup remain available. VI branches are unaffected. The source projection and public definition enforce the restriction, not only the editor UI. Disabling strict mode preserves the saved order and settings.

The searchable list supports Draft, Published, and Archived filters and displays last impression time, impressions, conversions, and conversion rate. Published rows expose Unpublish with confirmation; archived rows expose Reactivate with confirmation. Create and full-screen editing use separate dialogs. Publish, unpublish, and archive also require confirmation.

The editor manages optional quick links, each with a title and HTTP(S) URL. These remain authoring metadata outside the published visitor definition and appear in the row Actions column only when configured. Valid quick-link edits save on blur, independently of draft edits; removing links saves the remaining collection. An omitted links update preserves them, while an explicit empty collection removes them. Link-only updates omit the draft and preserve its stored contents. Published visitor definitions are unchanged. An external-link icon beside Published opens the visitor page in a new tab; Edit opens the editor. Public links resolve from the current host: Flank PR previews target the matching Brisket PR preview, and known production/staging hosts use their public app. Local Compose uses port 9930. Custom Flank hosts set FLANK_BRISKET_BASE_URL to the public Brisket origin (scheme and host, without a path or credentials). Unknown hosts or invalid overrides disable the link instead of sending users to a different environment.

Brisket’s /f/<uuid> route uses anonymous, unbatched tRPC procedures: publicFunnel.get reads the publication, publicFunnel.impression records an opening, and publicFunnel.prepareSignup stores a server-readable visit cookie before signup. The Clerk exception names those exact procedures. App effects that fetch protected resources do not mount on funnel pages. Invalid, unpublished, archived, and unavailable funnels return to home with query parameters preserved. Funnel pages are noindex.

Opening a published funnel sends a random event UUID to RecordFunnelImpression. Storage records each event once, increments impressions, and updates last impression time in one transaction. Replaying the same event does not increment the counter, including after archival. Refreshing or opening a new page creates a new event; this is an opening count, not unique visitors. Public reads alone do not increment it.

Before signup, prepareSignup records or replays the visit and sets the foxy-funnel-signup HTTP-only cookie. It contains {id, eventId, expiresAt}, lasts seven days, uses SameSite=Lax for OAuth return, and contains no answers or photos. Signup waits for this attempt and its retries. Exhausted preparation failures emit a best-effort funnel_signup_preparation_failed browser telemetry warning and allow signup/sign-in to continue. Usable saved inputs retain the /hop destination; local save failure uses Explore after authentication. The attribution retry screen is removed. Cancellation stops the old operation, and authentication errors remain visible. This can leave conversion attribution unrecorded, but must not block account creation or access. The cookie is a visit reference, not proof of eligibility: the server validates its shape and checks the impression ledger against Clerk’s authenticated user and account creation time.

Authenticated server initialization applies attribution before rendering the app or handling tRPC data requests. It is independent of PostHog and profile reads. The application profile and conversion counter are written together. The Clerk webhook is not the ordering boundary because webhook delivery is asynchronous. PostHog only consumes the persisted profile value: browser identification and server character flag evaluation receive funnel_id directly, without waiting for analytics ingestion.

A conversion requires a recorded impression and a signup timestamp from that impression through seven days later. Existing-account sign-ins are ineligible. Storage atomically assigns users.profile.funnel_id only if unset and increments the funnel conversion count once. Repeated requests cannot change first attribution or count it twice. A conversion from an earlier valid impression can complete after the funnel is unpublished or archived. Success and permanent ineligibility clear the cookie on the next tRPC response; server rendering cannot mutate cookies. A timeout or transient initialization failure logs a funnel_initialization_failed warning and redirects through the cookie-cleanup endpoint to Explore. This clears the pending cookie and avoids an initialization loop; conversion attribution can remain unrecorded. The list’s conversion rate is conversions divided by impressions, not completed character creation or paid conversion.

The renderer uses XState for restore, answering, photo checking, photo accepted, signup, and email verification transitions. A control lookup composes shared Foxy DS primitives in funnel-owned components. This makes cancellation and restoration explicit without coupling the public page to characters-v2 state. An isolated minified ESM measurement with React external reports 14,716 gzip bytes for XState plus its React adapter. This is a dependency measurement, not the complete Next.js route payload.

IndexedDB database foxy-onboarding-funnels, version 1, stores one progress record per funnelId. Each save renews its seven-day expiry. The record contains:

  • Publication version (string) and digest (hex), branch and step IDs, and screen.
  • answers: recipe field keys mapped to JSON values.
  • photos: step IDs mapped to arrays of browser File objects.
  • resolvedRecipe: schema version, selected branch/type, pinned route/version and digests, selectors, public branch definition, active values, and photo bindings (fieldKey -> {stepId, fileIndexes}).
  • expiresAt: expiry timestamp in milliseconds.

The snapshot resolves as soon as the required branch selectors are known, before signup, and follows subsequent selections and photos. It is null while the branch or required pin is unresolved. Photo bindings refer to photos in the same record, avoiding duplicate file bytes. Full internal workflow configuration is not exposed anonymously. An eventual authenticated character handoff must fetch and validate the exact pinned recipe version, not the route’s latest version.

Restoring reconciles the publication and revalidates answers/photos before continuing. Stored snapshots are rebuilt from the current publication, including legacy records without a snapshot. Expired records are removed when loaded. Storage failures leave the active flow usable with a visible warning. Data is browser/origin-local; it is not account storage or a cross-device backup. Character creation does not yet consume this record automatically.

Upload policy comes from the pinned recipe. Local checks reuse character-creation metadata and dimension validators for MIME types, byte size, minimum dimensions and pixel count. Required policy fields must be present and within the shared application limits; invalid policies block selection instead of inventing fallback limits. Photo count defaults to one when minCount is omitted, and an omitted maxCount uses the minimum. Invalid explicit counts are rejected. JPEG, PNG, and WebP are supported only when the recipe allows them. HEIC/HEIF and AVIF are offered if JPEG is allowed; WebP can also be converted when the recipe excludes WebP but allows JPEG. Both Create and the funnel use Brisket’s shared browser converter, with dynamically loaded heic-to as a HEIC fallback. Conversion input has a 10 MiB safety cap; the converted JPEG must satisfy the recipe’s byte limit and dimension policy. A recipe allowing WebP receives it unchanged. A stricter recipe limit is never replaced by the safety cap. No photos are uploaded to a server in Phase 1. The checking animation is a local validation state, not evidence of server-side likeness or safety checks. Screen analytics contain funnel, version, step, and screen identifiers, without answers or photo content.

  • Reusing character sessions would create ownership and workflow side effects before signup and couple Phase 1 to the later creation handoff.
  • Re-parsing sources on each public request would silently change published flows and depend on recipe/family availability at visitor time.
  • Reusing the character renderer would carry authenticated data dependencies into the anonymous page. Shared design primitives remain reusable.
  • A collection of independent React booleans would make asynchronous photo checks, cancellation, and restore transitions harder to constrain and test.

Editors can reorder or drop branch steps and override display text while source pins, controls, values, validation, and signup identity remain immutable. Source changes are deliberate re-projections. Public media signing is limited to immutable media URL slots; editable copy cannot request arbitrary object keys.

Local validation cannot establish server-side likeness, safety, or identity checks. Photo selection confirms only local file requirements. The subsequent creation handoff, rollout/split logic, and stale source refresh are later phases. Clerk provider completion and hosted analytics need their own authorized runtime validation before release.

Main owns migrations 173 through 178. Migration 179 creates funnel storage, metrics, the impression event ledger and profile attribution together. The edit revision, published version and conversion columns use PostgreSQL INTEGER. The revision API uses protobuf int32; published versions retain protobuf int64 for API compatibility. Impressions remain BIGINT. Earlier funnel migrations 173, 174, 175 and 177 belonged to intermediate versions of this unreleased feature. The final sequence uses 179 for the consolidated funnel schema. Published versions are insert-only through storage code, without database functions or triggers. Request replay is scoped by the editor’s user ID, stored as onboarding.funnel_requests.user_id. Existing previews must rename account_id to user_id before deploying the updated queries; rename the column in place to preserve request-replay rows and the composite primary key.

Preview databases that applied earlier revisions of these migrations need an operator-reviewed schema and migration-history reconciliation before this branch boots. The old version-protection trigger and function may remain in existing previews until explicitly removed; normal storage writes remain compatible. Older previews may also need the removed JSON conversions. Editing migration files does not upgrade those databases: the migrator skips all numbers at or below its last applied version. An earlier funnel migration numbered 173, 174, 175 or 177 can hide main’s migration with the same number, while migration 179 would attempt to create existing funnel tables. Confirm that main’s migrations 173 through 178 have run, then reconcile the existing funnel schema with 179. Do not advance the recorded version alone. Its existing BIGINT revision, publication version and conversion columns also need explicit conversion to INTEGER after checking that their values fit the 32-bit range. Reconcile the schema and migration history together before bringing such a preview onto the final sequence. Fresh databases follow the numbered migrations normally; no hosted migration or data reset is part of this change.

After migration 179 commits, an operator can validate the profile foreign key in a separate session with apps/sirloin/ops/validate_profile_funnel_fk.sql. The migration adds the constraint as NOT VALID to avoid scanning the profile table while holding the column-addition lock. New writes are still checked. Do not run validation inside the boot migration transaction.

Terminal window
psql --no-psqlrc --dbname "$SIRLOIN_DATABASE_URL" --file apps/sirloin/ops/validate_profile_funnel_fk.sql