Skip to content

Funnel Recipe Parser Architecture

Sirloin converts published character recipes into a saved, editable funnel definition. Parsing happens when an editor selects or rebuilds sources. It does not execute recipe actions, create a character, or run again for every visitor.

Funnel V2 adds optional custom steps built in Step Studio and General funnels that have no recipe source. Character funnels retain their pinned recipe semantics, photo flow, signup attribution and browser progress rules. Studio conversations and jobs are private to their author; saved step revisions form a shared library for authorized funnel editors.

Open the interactive field guide for a system map, prompt walkthrough, visitor routes, searchable architecture and operator instructions, and an offline source browser. This page is the canonical architecture reference. For operator instructions, see Onboarding Funnels.

Layer Responsibility Main implementation
Recipe catalog Published recipes, families and anonymous member selection Sirloin character recipe services and storage
Parser Project supported creation inputs and pin their origin funnelparser.go: parseSources, parseFunnelRecipe
Builder Edit presentation, ordering and enabled steps Flank funnel editor; authenticated server functions
Step Studio Bounded generation, compilation and preview; private conversation UI Flank step-studio server helpers and custom-step components
Step library and jobs Immutable revisions, owner-scoped sessions, leases and replay Sirloin studioservices.go and storage/studio*.go
Persistence Revisions, request replay, drafts and version snapshots storage/funnel.go
Public delivery Published-only access, strict-mode projection and media signing GetPublishedFunnel
Visitor runtime Branch selection, controls, photo checks and local progress Brisket funnel state machine and renderer
Signup attribution Record conversion before account-dependent initialization Brisket server attribution and Sirloin tracking storage
flowchart LR
  R[Published recipe or family] --> P[Sirloin source parser]
  P --> D[Funnel draft with pinned branches]
  D --> E[Flank presentation edits]
  E --> V[Saved published version]
  V --> B[Brisket visitor runtime]
  B --> I[IndexedDB progress and resolved snapshot]
  B --> S[Signup]
  S --> A[Server attribution]
  A --> H["/hop validates saved progress"]
  H --> Q[Main-layout welcome quiz unless skipped or completed]
  Q --> F[Resume Explore; character adapter follows separately]

Character funnels currently fall back to Explore with an explicit funnel/publication reference. The separate character adapter will consume compatible saved inputs; /hop does not create a character or mark recipe stages complete. General funnels go to Explore or the existing RI/VI character chooser according to their configured destination, without character-prefill handoff. New character-creation deep links belong to V3. Source: continuation routes.

How does Step Studio generate and save a step?

Section titled “How does Step Studio generate and save a step?”

Each Studio opening starts a fresh conversation. There is no saved-session browser; authors keep reusable work by adding it to a funnel draft or the shared step library. An authenticated Flank editor still creates a private backend session to coordinate generation and idempotent mutations. The session document stores conversation turns, component selection, author media, settings, layout, text overrides, title and the selected library revision, including authoring choices made before the first prompt. The last successful built result is stored separately with its job ID. Mutations use request IDs and expected session revisions. A lost admission response retains the exact request for reconnecting; a later rejection before admission does not erase that uncertainty. Polling only reads job state and never invokes a model.

Generation admits one active job per author and session in Sirloin. The worker claims a fenced 90-second lease, renews it every 30 seconds, and stops when ownership is lost. The UI waits three seconds after each response before polling again, so slow reads cannot overlap. An abandoned queued job or expired worker becomes interrupted; a process restart does not automatically repeat a possibly charged model request. Polling within the open Studio retrieves the same terminal result. Closing Studio discards the conversation UI; it does not cancel an already admitted backend job or provide a way to reopen it. Retrying is an explicit new author request, and a failed attempt leaves the last successful result intact.

The entire generation has a ten-minute deadline, including asset signing, model requests, compilation and up to three total generation/repair attempts. Each provider request is capped at seven minutes. Recent conversation is bounded to eight turns and 16,000 UTF-8 bytes; older decisions use a bounded summary. Requests bound prompt, context, source, assets and response size, and send an explicit output-token allowance. Current limits are 256 KiB of TSX plus authored CSS, 2 MiB of bundled JS plus CSS, 12 generation assets and 16 KiB of settings. Sources: request contracts, generation, worker, durable jobs.

The component picker lists all 33 public Foxy DS UI entrypoints, one per row with a rendered DS example and 11 selected by default. Search matches names and descriptions without changing the selected imports. Menu, select and tooltip examples show their triggers so preview overlays cannot cover the editor. Selecting Icon also makes the shared icon artwork available. Only selected component APIs are included in the prompt and accepted by the build. React, local ./step.css and motion/react are also supported. Generated CSS cannot import external resources or filesystem files. Motion instructions require reduced-motion behavior. Flank compiles and repairs before showing a result, then recompiles the exact source and selected imports when saving a library revision; the browser’s supplied bundle is not trusted. Sources: prompt, build, revision save.

Flank sends a chat-completions request from the server. The browser does not receive FLANK_OPENROUTER_API_KEY. The model comes from FLANK_STEP_STUDIO_MODEL, falling back to STEP_DEFAULT_MODEL; the current default is anthropic/claude-sonnet-5.5. The request sets temperature: 0.4 and max_tokens: 24000. Source: provider request and message assembly.

Messages appear in this order:

Message Contents
System Runtime/import restrictions, StepProps, answer and navigation contracts, written design rules, selected DS declarations, and the required response format.
Older decisions, when present The supplied conversation summary, or bounded excerpts of older turns. Building this summary does not call another model.
Recent conversation At most eight user/assistant turns, with a combined 16,000 UTF-8 byte limit.
Current author request FUNNEL CONTEXT, current TSX/CSS when refining, an explicitly supplied preview error, and AUTHOR REQUEST. Context contains eligible recipe fields, finalized author media, instance settings and layout.
Repair pair, after a failed attempt The latest candidate response, capped at 80,000 characters, followed by validation/build errors capped at 4,000 characters. Earlier repair pairs are replaced.

The prompt requests three fenced blocks: JSON metadata, complete TSX, and complete CSS. The parser requires JSON and TSX, accepts omitted CSS as empty, and extracts the first matching blocks. Metadata declares the title, summary, recipe reads/writes, generic outputs and allowed interaction events. Parsing and compilation must succeed before the result replaces the working preview. The three-attempt repair loop handles metadata/build failures; provider HTTP errors, invalid provider responses, timeouts and missing credentials fail immediately. Sources: system prompt, reply parser.

The system prompt does not include DS implementation files, stylesheets, Storybook pages or picker screenshots. Generation receives no visitor answers, visitor photos, signup credentials or private recipe execution stages. Author-supplied prompt/history and attached author-media URLs do cross the provider boundary. Every message is text; media URLs are JSON strings, without multimodal image/video blocks. The model is instructed to refer to media by context name/type rather than hard-code expiring URLs. Full chat messages are limited to 384 KiB before each provider call; the provider response is limited to 768 KiB. DS declaration context has its own 256 KiB ceiling. Exceeding a limit fails explicitly. Sources: request schema, context limits.

How do DS components reach the prompt and runtime?

Section titled “How do DS components reach the prompt and runtime?”

The author selects component groups in Components. STEP_DS_CATALOG defines the picker and accepted IDs; STEP_DS_COMPONENTS defines the eleven initial selections. Selecting button, for example, permits @foxy/ds/button and includes that entry’s exported names, props, allowed variants and declaration comments. This teaches the model how to use the existing component. It does not ask the model to recreate Button. Sources: catalog and defaults, picker.

dsComponentTypes resolves each selected entry’s types path from the built @foxy/ds/package.json and follows referenced local .d.ts files. Select and Dropdown Menu also include their explicitly registered Radix prop declarations. Supporting declarations do not grant additional imports. Selecting Icon adds @foxy/ds/icons and its artwork names; other components receive the shared icon type without that artwork list. The DESIGN instructions name theme, color, spacing, radius and shadow tokens manually. Source: declaration loader and design instructions.

After generation, the compiler bundles the step with React, the SDK, imported DS components and their CSS, plus Motion when used. Selection authorizes imports; unused components are not all bundled. The declarations guide generation; esbuild compiles TSX without checking every component prop type. Preview remains a separate check. The shared token stylesheet and font are included even when no components are selected. Published artifacts retain this compiled result, so a later DS deployment does not restyle an existing revision automatically. Sources: compiler, DS build in Flank deployment.

DS change Studio maintenance
Existing component gains props or variants Rebuild DS and deploy Flank. The loader reads the new declarations for future generation; no copied prop documentation needs updating. Review preview examples if their usage changes.
New public component group Add its ID/title/description to STEP_DS_CATALOG, a real STUDIO_COMPONENT_PREVIEWS example, and its ID plus the component-count limit in Sirloin validation. Add it to STEP_DS_COMPONENTS only if it should start selected.
New icon artwork Rebuild DS and deploy Flank. Future prompts that select Icon read the new exported artwork names automatically.
New tokens or design conventions Rebuild for runtime CSS, and update the written DESIGN guidance when the generator must know the new names or rules.
New external prop-type dependency Check whether its declarations are included. The loader follows local DS references; extra external packages need explicit registration, as Radix does today.
New DS peer dependency Include it in Flank’s runtime dependencies so deployed compilation can resolve it. The catalog tests check this dependency boundary.

Catalog tests compare public DS exports, picker IDs and Sirloin’s allowlist/count. Prompt tests check selected and supporting types. Run them with compiler tests after a catalog change; rebuilding DS alone does not register a new component in Studio.

The migration separates private authoring state from shared reusable steps. Here, canonical_* columns contain complete protobuf-JSON messages stored as bytes. The name does not mean these payloads undergo an extra canonicalization pass. SQL columns expose IDs, status and timing for constraints and lookups. Sources: migration, session serialization, job serialization.

Record Purpose
studio_sessions An author’s current document and last successful result. Its increasing revision detects edits based on stale state. The (owner_id, id) index supports owner-scoped listing; the primary key separately identifies one session.
studio_jobs One admitted generation job, which can contain up to three model attempts, linked to its session and owner. Request IDs prevent duplicate admission; a request hash identifies the submitted contents. Status records queued/running/terminal state.
Job worker, fence and times worker_id identifies the process. fence is a claim version checked on heartbeat/finish. lease_expires_at requires renewed ownership; deadline_at caps the whole attempt. Unique partial indexes enforce one active job per owner and session.
studio_requests Mutation replay: the owner/request ID, request fingerprint and original response allow an identical retry without repeating the mutation. Reusing an ID for different input is rejected.
step_library, step_revisions, assets and revision-asset links Shared item metadata, immutable reusable snapshots, finalized media and retained references. A revision’s digest identifies its saved contents.

How do card previews, layout and text edits work?

Section titled “How do card previews, layout and text edits work?”

Builder cards load the exact customStepRevision pin, verify its library/revision IDs and digest, then render its built artifact through the existing sandbox and scaled preview viewport. Declared recipe fields and author assets use signed media; settings and copy come from the funnel instance. Cards mount near the viewport, ignore late responses after a reference changes, and never commit answers or analytics. Studio and cards share the same preview shell. Sources: card loader and context, card preview.

props.customStep.layout holds optional hideBack, hideProgress, hideSkip and fullScreen booleans. Missing or false values preserve the funnel’s settings. Full screen removes the host heading and padded content shell; Back/progress become overlays when enabled, and Skip is visible unless Hide Skip is enabled. Hiding Skip does not make the step required: failures still advance automatically. props.customStep.textOverrides maps text IDs to { original, value }. These values belong to the funnel instance, not its library artifact; publication pins them with the rest of the funnel. Sources: layout controls, preview shell, visitor shell.

The trusted Studio overlay provides Edit text → pencil → Apply/Reset without calling the model. A host-injected adapter changes plain HTML/SVG text nodes inside the sandbox, including older artifacts; it does not edit images, videos, canvas, markup or answer values. IDs combine the original text with its DOM location, and an override also checks the original before applying. Regeneration or dynamic content can invalidate that match. Text and target bounds are checked on both sides of the frame; Sirloin validates instance props before saving. Sources: text adapter, instance validation.

Each custom step has a distinct custom:<uuid> instance ID, an immutable customStepRevision pin containing library/revision IDs, digest and manifest, and per-instance settings. The manifest declares recipe fields it reads, recipe keys it writes, named events, selected components and typed generic outputs. Recipe writes must also be declared reads and must refer to non-photo fields in that branch. Generic output keys are separate from recipe writes; reserved prototype keys are rejected. Generic outputs support strings, numbers, booleans and string arrays, with optional required flags and fixed choices. Recipe writes and generic outputs together may declare at most 32 answer keys. Payloads permit only bounded flat values: strings up to 500 characters, arrays up to 32 items and 16 KiB per message. Recipe option values preserve scalar, null and flat-array semantics.

Brisket loads the published artifact and runs it in an opaque-origin iframe with sandbox="allow-scripts". The host transfers a private MessageChannel after the frame reports loaded, bounds and validates messages, and enforces a ready timeout. CSP permits the exact bundled script/style and declared media origins, with fetch connections and form submission disabled. The frame receives declared non-photo field metadata, author assets, settings, theme and saved values. It receives no visitor photos, cookies or parent DOM access. Sources: frame contract, runtime.

The SDK exposes change, complete, track, back and skip. These are requests to the host; generated code does not choose funnel URLs, signup routes or branch transitions. The host validates the complete proposed recipe write before committing it, applies Back only when allowed, and owns Continue and Skip. Every custom step is optional; load, ready or runtime failure reports a bounded error and advances past the step. An input or promo-code step captures a local value only; it does not redeem a code, alter a price or create a payment integration.

How are library pins and author assets retained?

Section titled “How are library pins and author assets retained?”

Saving creates an immutable FunnelStepRevision with source, built artifact, manifest and asset references. Adopting that revision changes only a funnel draft. Existing instances and publications retain their prior pins until an editor explicitly adopts another revision and publishes. Public artifact requests require a currently published funnel, identify the requested publication version and step instance, verify the pin/digest, and return built JS/CSS, manifest and signed author media. An explicitly requested retained historical publication can still serve while that funnel remains published. Author conversation and source stay private. Archiving a library item hides it from the active library; it does not delete revisions needed by publications. Sources: public artifact delivery, library storage, protobuf contract.

Author images/videos upload to a staging key and are finalized after owner, byte count and media-type checks. Finalization creates a new final key; a staging upload URL cannot overwrite a published asset. Stable storage-media:recipe-media/funnel-assets/... references and asset IDs are retained with revisions. After the database confirms the immutable winning key, finalization best-effort deletes its staging object and any losing upload from that request within a five-second cleanup budget. A repeated finalize retries staging cleanup; deletion failures are logged with asset and object IDs. An uncertain database result retains the upload unless a finalized row proves which key won; failed losing-object cleanup and unresolved writes require reconciliation rather than risking a retained revision. Pending deployment configuration: apply a seven-day object expiry only to the studio-staging/ prefix, beyond the existing 72-hour PUT URL expiry, to cover abandoned uploads, failed cleanup and staging rewrites. This rule has not been configured or verified by this change. Never apply that expiry to recipe-media/funnel-assets/, whose objects may be retained by immutable revisions. Preview, generation and public delivery obtain fresh signed URLs instead of persisting expiring URLs. Shared-library refinement may reuse only assets retained by the requested immutable revision; unrelated newly uploaded assets remain owner-scoped. Supported uploads are JPEG, PNG, WebP, AVIF, MP4 and WebM, up to 50 MiB each. Visitor photos remain exclusively in the existing recipe photo flow. Sources: asset finalization, generation asset authorization.

Migration 189_funnel_studio.sql adds private sessions/jobs/replays, shared library revisions, finalized assets and revision-asset retention. Apply main’s migrations through 188 first, then 189, and deploy matching Sirloin, Flank and Brisket contracts. Previews that already applied Studio as migration 185 or 186 need schema and migration-history reconciliation before startup: main uses 185 for renewal payment operations and 186 for character-chain retries. Apply missing main migrations and reconcile the existing Studio tables with 189; do not advance the recorded version alone or discard existing data.

The funnel route loads the registry and composes the toolbar, table and dialogs. It keeps the server-function imports and passes them to useFunnelEditor, which coordinates editor state and the action, catalog, source and autosave hooks.

The steps component arranges branch steps. The step editor exposes editing and preview controls. Rendering is split into:

Shared pure functions live in lib/funnel/helper-functions.ts, with recipe media prefixes, known field/property keys, step kinds and copy-path rules in the sibling constants.ts. The visitor journey has a separate Brisket renderer; Flank previews do not run signup or record visitor conversions.

Signup presentation and the app’s authentication pages share SpiralBackground from @foxy/ds/spiral-background. It is a decorative component usable outside authentication, with light/dark colors and reduced-motion support. Flank includes @foxy/ds/spiral-background.css in its isolated preview stylesheet.

Brisket’s session renderer composes the state machine, persistence and screen effects. Its sibling use-unsaved-continuation.ts carries the continue-without-inputs decision across the authentication remount; use-signup-departure.ts handles the final save, attribution preparation and navigation. The signup component owns email, verification and sign-in handlers. Fixed redirect destinations come from URLS in src/lib/constants.ts; continuation/helper-functions.ts parses the explicit funnel/publication reference and builds URLs with query parameters.

  1. Choose one RI source, one VI source, or one of each. Each source specifies exactly one routeKey or familyKey.
  2. Both synthesizes a character_type entry step. Direct recipes cannot carry family selector settings.
  3. A family must be published. Funnel authors can select published families even when they are disabled or hidden in the character catalog; using one in a funnel does not enable it there. Draft-only families are excluded. Every selector must be either asked or fixed, exactly once. Asked selectors become option-grid entry steps; fixed values must belong to the selector.
  4. The parser expands selector combinations into branch candidates. Each tuple must resolve to exactly one onboarding member, or exactly one default member when no onboarding member matches. It does not perform account-based or weighted allocation.
  5. Each selected route must have a published recipe matching the source’s character type. Each branch pins routeKey, versionId, recipe digest, and, for a family source, familyKey and familyDigest.

Any reported issue rejects the source rebuild without replacing the existing draft. Examples include ambiguous family members, unpublished families, selector/field key collisions, and character-type mismatches.

parseFunnelRecipe locates presentation.creationFlowKey and projects that flow. It does not walk arbitrary recipe flows or execute transitions.

Recipe element Funnel result
Unsubscribed-only stage Stage ID recorded in paywallStageIds; not rendered anonymously
Other non-everyone visibility Unsupported-visibility issue
Hidden field (props.hidden) Omitted
Form/upload field, or text control in another stage Candidate input; first occurrence of each field key wins
Option grid, chips, color picker, multi-select, text field step, ID field:<key>, cloned field contract and original stageId
Media upload photos step with the same field contract
Everyone-visible stage with no projected fields Stage ID recorded in sessionStageIds
Conditional projected stage/field, unsupported control, or content blocks on a projected stage Issue; rebuild rejected

Session and paywall stage IDs are metadata. They are not executable stage objects in the public funnel.

Photo steps use stage copy and the primary action’s label. The parser copies confirmation presentation from the first stage whose props.sourceFieldKey matches the photo field, including action labels. Copying an action label does not execute that action or its validation workflow.

The parser validates the supported field-rule shape and public media references. Reserved keys such as character_type, __proto__, constructor, and prototype cannot become recipe answer fields. Authenticated media paths cannot be exposed in the public definition.

Photo contracts require contentTypes, maxBytes, minWidth, minHeight, and maxPixels; count bounds are optional. Brisket uses those rules through the existing character-upload validation helpers. Missing or malformed required policy is rejected, not replaced with an invented browser default. Omitted counts default to one minimum and a maximum equal to that minimum.

HEIC/HEIF, AVIF, and WebP conversion is a browser input convenience when the recipe allows JPEG. Brisket’s shared src/lib/image-upload/helper-functions.ts converter serves both Create and the funnel: browser decoding runs first, with a dynamically loaded HEIC decoder as fallback. WebP is kept unchanged when the recipe accepts it. Conversion input has a 10 MiB safety cap; the converted JPEG must satisfy the recipe’s byte and dimension limits. The recipe remains authoritative; missing or unsafe policies are rejected rather than replaced with a fixed size allowance. The checking/accepted screen only reflects local file checks. It is not server-side identity, adult-likeness, or workflow approval.

An editor can change supported copy, reorder or disable branch steps, configure choice randomization and presentation settings, and maintain quick links. Field semantics, source pins and branch structure remain protected. Entry and signup steps cannot be dropped. Changing the underlying source requires a source rebuild.

Strict mode defaults on and applies only to RI. Face & Chest remains available; other RI settings and photo steps are locked in the builder and dropped from the public projection. Their saved configuration is retained. VI, routing and signup remain available. Disabling strict mode unlocks RI editing; save and publish applies it to visitors.

General funnels contain ordered custom entry steps and native signup, with no recipe sources or branches. A custom interaction does not rewrite native recipe definitions. In Character funnels it may use declared non-photo fields, including fields whose native presentation was dropped, while publication validation preserves the pinned recipe contract.

A draft is an editable funnel, a publication is a saved visitor definition, and a recipe pin identifies the recipe version used to build a branch. They are separate version boundaries.

Publishing clones the saved draft into a new funnel version. It does not silently reparse newer recipes. Public delivery reads the stored publication, applies strict mode and presentation overrides, and signs allowed media slots. Later draft edits do not alter that publication. Unpublishing or archiving disables public access; reactivation returns to draft and requires another publish.

Sirloin stores canonical protobuf JSON and SHA-256 digests in onboarding.funnels and onboarding.funnel_versions. Storage writes published versions by insertion. A 32-bit revision counter increments on each saved mutation and protects concurrent edits; it is separate from the published version counter. Flank passes revisions as JavaScript numbers. Request replay is scoped by the editor user ID (funnel_requests.user_id). No custom SQL function or trigger is required by the current migrations. Existing preview databases can retain earlier triggers until explicitly reconciled; see the migration notes.

Migration 179_funnels.sql creates storage, tracking and profile attribution together, after main’s migration 178. Its revision, published version and conversion columns are INTEGER; impressions remain BIGINT. Existing stored string revisions and request replay hashes remain readable.

Preview databases that already applied an earlier funnel migration 173, 174, 175 or 177 need schema and migration-history reconciliation. These numbers now belong to main. Confirm that main’s migrations 173 through 178 have run, and reconcile the existing funnel schema with migration 179 before startup. Advancing the recorded version alone can skip main’s changes; running 179 against existing funnel tables fails. Rename the request-replay column from account_id to user_id before deploying the updated queries. Check existing revision, published version and conversion values before converting their old BIGINT columns to INTEGER; preserve funnel data and publication history. No hosted database is reset automatically. After migration 179, profile foreign-key validation runs separately as described in the migration notes.

How does the visitor’s route get selected?

Section titled “How does the visitor’s route get selected?”

activeSteps builds the ordered route used by Next and Back. It is the eligible path, including answered steps, rather than a list of remaining questions. Character funnels resolve a source first: one source selects automatically; two sources use the valid character_type chooser answer. A branch matches that source’s type and all selector values, taking a fixed source selector before a visitor answer. Source: source, branch and route helpers.

Condition Why the entry step remains
step.kind === "character_type" The visitor needs the chooser before a source exists. This is the step kind and answer key, not a type restriction.
Custom step without props.characterType A shared landing or intro can run before selection.
Source selected, no props.characterType An ordinary shared entry can run after source selection.
Source selected, matching props.characterType A type-specific entry belongs to the selected real/virtual source.

The helper appends the selected branch’s steps, then signup only when that branch exists. It removes dropped steps and failing field showWhen conditions. General funnels take the shorter path: ordered entry steps plus signup, removing dropped steps without source/branch resolution. Their branch-specific field conditions do not apply. Runtime filtering does not authorize authoring unsupported definitions; Sirloin still validates step placement and custom-step props before publication.

Native recipe answers and photos belong to the selected branch. Custom change values stay under the custom instance ID. On custom completion, the host validates the full proposal before accepting declared non-photo recipe writes; generic outputs remain instance-local. General steps cannot declare recipe writes. Changing source or branch removes incompatible recipe answers and photos. Sources: custom answer validation, answer transitions, progress mapping.

Brisket stores one record per funnel in IndexedDB database foxy-onboarding-funnels, database version 1, store progress, key funnelId. The record schema is 2; changing record properties does not require a database upgrade. Files stay as File[], without base64 conversion or a second copy in localStorage.

type FunnelProgress = {
schemaVersion: 2;
funnelId: string;
version: string; // Answered publication, not a recipe version.
ownerUserId: string | null;
expiresAt: number;
writeToken: string;
stepId: string | null;
screen: "answering" | "verifyingCode";
characterType: "RI" | "VI" | null;
gender: "female" | "male" | null;
recipe: {
routeKey: string;
versionId: string;
digest: string;
steps: JsonValue[];
} | null;
stages: Record<string, Record<string, JsonValue>>;
customInputs?: Record<string, Record<string, JsonValue>>;
filesByStage: Record<string, Record<string, File[]>>;
};

recipe, characterType, gender, stages and filesByStage are the character inputs. They are saved as the visitor progresses, before signup. The other fields select the right record, resume the screen and prevent cross-account or stale writes. Recipe IDs, stage IDs, field keys and native values remain unchanged: for example, stages.questionnaire.age = "25-29" or filesByStage["upload-face-create"].face_chest = [file]. These are fixture-based illustrations, not captured production records.

V2 adds optional customInputs[instanceId] for generic answers and in-progress custom interaction values. The record remains schema 2 and IndexedDB version 1. Generic inputs never become recipe stage values automatically. Accepted recipe writes still map to their original native stage and field; visitor photos remain File[]. Repeated uses of the same library revision have distinct instance keys. A General record has recipe, character type and gender set to null, empty recipe stages/files, and only its own instance inputs. These generic values are not handed to the character adapter. Source: progress mapping.

The recipe snapshot is the selected branch’s public projection, not its private workflow or billing instructions. routeKey, versionId and digest pin the exact native recipe for the later adapter. There are no duplicate answer maps, photo-binding indices or stage-completion flags. RI/VI and gender are checked against the matching publication. Fixed recipe gender is carried by FunnelBranch.genderConstraint; unknown gender stays null. A family selector uses its original key, even when it is not named gender.

Changing type or gender replaces the recipe and incompatible inputs together. Validation uses stable identities and value semantics; renewed signed media URLs do not invalidate a saved pin. A different publication, unavailable source or unsupported record schema never silently repins old answers. /hop retains recoverable data and reaches Explore. Anonymous /f entry atomically clears unusable anonymous progress from an older publication or a recognized legacy format, including expired or malformed drafts, before starting fresh. It preserves account-owned records, unknown future formats and records from a newer publication than the reader. Storage failure is not treated as corrupt data. A failed restore starts the funnel with empty in-memory inputs, without a recovery screen or overwriting the protected record. A failed final save continues signup without saved inputs: Character uses Explore, while General keeps its configured destination.

The seven-day expiry renews only on edits. Claim, save, reset and consume operations check ownership and the observed writeToken inside the IndexedDB transaction. Every successful write changes that token. A stale tab cannot overwrite a newer record; a delayed adapter cannot delete newer answers. No arbitrary “latest funnel” lookup is used.

Storage responsibilities are separate: progress mapping builds the stored record from form inputs and restores those inputs; publication validation checks compatibility with the pinned funnel. Progress comparison provides shared equality helpers. IndexedDB transactions remain in the storage helpers.

The character adapter is pending as TODO(FOXY-1546) in /hop. Its intended reader API is readOwnedFunnelProgress(reference, userId, publication, signal?); after durable application through the character draft pipeline, it can call consumeFunnelProgress(reference, userId, writeToken). These storage APIs exist, but /hop does not transfer inputs or create a character today. Entering Explore does not consume anything. Authenticated character storage (characters-v2-recipe-drafts) and the existing VI localStorage prefill bridge remain separate. Sources: current hop, stored type, transaction helpers, and recipe projection.

Flank shows a saved-draft recipe-status chip in the table only for updates or an unavailable recipe, alongside Edit source. The editor header also shows current recipes in green and loading, missing-source or failed-check states in neutral. “Unavailable” includes deleted, unpublished and invalid sources. Recipe details provides status, diagnostics, rechecking and access to source editing. The chip does not describe whether draft edits have been published.

Source editing rebuilds the draft from explicitly selected sources through SetFunnelSources. Rebuilding discards step customizations. The reviewed source-refresh workflow, edit reconciliation, refresh undo and their RPCs have been removed. Source inspection remains read-only and returns statuses and issues only.

The checkpoint-table migration has also been removed. This does not drop tables in databases where it previously ran or rewrite draft, publication or request-replay JSON.

The editor’s top-right Viewing dropdown switches between the editable draft and a read-only publication in the main view. Browsing preserves unsaved draft edits. Publication mode hides navigation and quick links, prevents step changes, and offers return-to-draft or confirmed restoration in the bottom panel.

History reads immutable rows in onboarding.funnel_versions by funnel ID and version. Restore clones a stored definition and validates its exact recipe pins, without reparsing today’s source or requiring today’s family digest. Restoration preserves quick links, counters and the live publication. A later explicit publish creates the next version number. See source rebuild, history, and transactional storage.

FunnelDefinition.continuation (field 10) describes type and destination. FunnelBranch.genderConstraint (field 8) carries source-owned gender. The deprecated post_signup_redirect descriptor (field 4) remains readable with a safe /explore value; new clients do not offer arbitrary URLs.

Sirloin canonical-row and replay readers and Flank’s protobuf JSON reader reject unknown fields. Deploy matching schema-aware Sirloin, Flank and Brisket releases before authoring or sharing funnels. Remove the refresh UI and server-function calls before retiring the Sirloin endpoints. Reload old editor tabs; old inspection responses may contain the removed preview or checkpoint fields and fail strict decoding. Drain older instances and reload authoring and public-preview tabs; a new client rejects an old server’s JSON response if it still includes the removed capability field. There is no runtime rollout flag: editing, version restoration, public rendering and progress saving work immediately.

After new fields or schema-2 browser records exist, the schema-aware release is the rollback floor. Revert feature behavior while retaining descriptors, strict JSON readers, storage shape and ownership/token checks. Do not globally discard unknown JSON or rewrite immutable publications for an older binary. Removing field 4 later requires its own persisted-JSON compatibility plan.

The unused capability fields have been removed from API responses. Replay rows contain Funnel snapshots, not response wrappers, so this removal does not change stored JSON. Version restoration still requires author permissions, confirmation and a matching revision; changing the draft never publishes it.

The Events dialog in the table derives a reference from the live publication, or the current draft when unpublished. It mirrors public step eligibility, including RI strict mode, and lists funnel_step_viewed, funnel_step_completed and custom skip/error/interaction events with copyable filters. Both use stable step IDs; branch-owned steps also include branch_id. Completion records the selected recipe option value or photo count only when advancing succeeds. Draft filters omit the publication version. See events and dashboard filters for payloads and counting rules.

Copy dashboard instructions includes a vertical step-impressions column chart: every eligible step in journey order, readable labels, unique-user counts and zero-count steps retained. Keep branch paths and publication versions separate. The prompt also requests an ordered funnel for required milestones, with drop-off counts and percentages from the previous step, counting entrants at the first step so restored visits do not inflate downstream reach. These client events describe step activity; the server conversion ledger remains authoritative for signup totals. Internal states such as answering and checkingPhoto are not separate analytics events. funnel_step_completed can include valid declared choices in answer, photo_count, or skipped: true; it excludes typed text, file contents and credentials, and signup emits a view only.

An opening event records an impression. Signup preparation stores the recorded visit in an HTTP-only cookie. After authentication, server attribution verifies the identity and signup timing, then atomically records the first eligible conversion and users.profile.funnel_id before account-dependent initialization. PostHog consumes that profile value; it does not own attribution.

There are three distinct failure boundaries:

Boundary Current behavior
Final local save Continue signup without saved inputs. Character uses Explore; General keeps its configured Explore or character chooser destination.
Pre-signup attribution preparation useFunnelOpening retries the preparation mutation twice. If preparation still rejects, useSignupDeparture reports a best-effort funnel_signup_preparation_failed browser telemetry warning and continues email signup, OAuth or sign-in. Saved inputs retain the /hop destination; failed storage uses Explore for Character or the configured destination for General. Reporting failures also cannot block departure. The warning includes the funnel ID and version, without raw errors, answers or credentials. Conversion attribution may be missing for that signup.
Server account initialization A timeout or SERVICE_UNAVAILABLE failure logs, clears pending attribution and continues to Explore. Other errors propagate.

The normal signup departure, account-initialization and /hop waiting states use the shared Foxy logo and spinner. There is no saved-progress recovery screen, storage-warning banner or departure retry screen. Actual authentication failures still show their authentication error. Cancellation and account changes stop the old departure instead of allowing late navigation. The ten-second initialization and /hop limits below do not apply to pre-signup preparation in useSignupDeparture.

Account initialization has a ten-second deadline. A timeout or transient initialization failure logs a sanitized funnel_initialization_failed warning through the server Pino/OpenTelemetry pipeline to Axiom, clears the pending attribution cookie through /api/funnel/initialization-fallback, and redirects to Explore without a message or retry screen. This prevents an initialization loop while retaining saved browser answers and photos. The cleanup endpoint needs no account data or auth lookup; Explore still requires authentication. Failed attribution may leave the conversion unrecorded. Only after initialization succeeds does /hop begin its separate ten-second budget for auth readiness, storage and publication lookup. Storage read failures continue automatically to Explore without saved inputs. Once authentication is ready, that deadline also falls back to Explore automatically. If authentication is still unavailable at the deadline, or an unexpected continuation error occurs, the loader stays visible while the browser returns to foxy.ai. This deadline limits the wait; it does not expire authentication or the saved progress, whose lifetime remains seven days. Late callbacks cannot navigate after timeout or account changes.

Already signed-in arrivals at /f go directly to Explore (/) before the funnel query, opening event or renderer starts. Authentication loading mounts no funnel content. Once an anonymous visitor has entered the funnel, completing authentication keeps the existing final-save and signup-preparation path through /hop. Email, OAuth and sign-in departures attempt the last IndexedDB transaction. If it fails, they continue automatically without saved inputs; signup attribution still runs. Auth carries only the explicit funnel/publication reference. /hop checks the server publication and resolves a fixed internal destination; the URL cannot choose an arbitrary redirect. For Character funnels, both destination choices reach Explore until the character adapter exists. General funnels also use /hop to bind usable saved inputs to the signed-in account before any publication lookup. A matching General publication selects the fixed Explore or existing chooser destination without passing a funnel reference or generic inputs to character creation. Photos and inputs remain local.

The main app layout temporarily restores its existing IntroFunnelRedirect: signed-in users without a subscription or completed quiz enter one simplified /welcome quiz and skippable paywall. Returning sign-ins are eligible too; email and OAuth callbacks keep their existing destinations. /hop resolves before the user reaches this gate. skip-post-signup-quiz ON bypasses the quiz; OFF, or an unavailable flag after the grace period, keeps it. The gate captures the current app path and query in a sanitized return_to, preserving the funnel reference through paywall close. Skipping neither marks quiz completion nor consumes saved inputs. At funnel launch, remove the main-layout gate and temporary welcome entry, skip flag and continuation helpers; there are no quiz-specific auth wrappers. See signup destinations. The app tour remains available after existing character-completion and first-purchase flows; funnel continuation does not start it automatically.

Where does the funnel hand control to the app?

Section titled “Where does the funnel hand control to the app?”

Funnel type and destination are separate choices. Character means recipe-aware questions; General means a journey without a character recipe. Both expose destination configuration. General’s current destination helper maps the enum to a fixed internal route; it does not accept arbitrary redirect URLs. Sources: destination editor, destination mapping.

Visitor state Current destination and retained data
Already signed in on arrival at /f/{id} Explore before funnel loading, opening events or progress reads.
Anonymous completion with usable saved progress Authenticate, then /hop?funnel={id}&version={version}. /hop claims the browser record for the account before fetching the matching publication.
Character, either configured destination Validate owned progress and reach /?funnel={id}&version={version}. Inputs stay local for the pending adapter. No character creation or photo upload starts.
General → Explore Reach /, without passing generic inputs or a funnel-prefill reference into character creation.
General → Character creation Reach /characters?chooser=all. The normal chooser owns the next creation flow; generic inputs are not prefills.
Final save fails or progress saving is bypassed Skip /hop: Character uses Explore, while General uses its configured destination. Attribution preparation still runs.
/hop cannot claim progress, validate the publication or finish within its authenticated deadline Fall back to Explore. Failed publication lookup can retain the explicit reference; no arbitrary/latest saved funnel is substituted.

Sources: public entry, signup destination selection, final-save fallback, ownership and publication checks.

The main-layout welcome quiz gate can still intercept the resulting app destination. It preserves the path/query in a safe return_to; subscription, completion and skip-flag rules decide whether it runs. Confirmed account-owned creation sessions are exempt, but a creation-shaped URL alone does not grant that exemption. Sources: quiz gate, authentication flow.

Launch prerequisite: expose funnels only after characters-v2 reaches 100% rollout. This is the launch constraint agreed for this release, not a new guard added by the funnel runtime. The character route still selects the legacy or new flow using its existing flag. Keep General’s configurable destination for future destinations; enabling it does not implement the pending Character adapter. Source: flag-dependent character route.

This parser is character-recipe-aware: it uses CharacterControlKind, character stages and recipe pins. The funnel definition is separately stored, but the parser is not a general flow engine. A new domain should supply an explicit adapter and renderer contract rather than pretending its stages are character stages.

For a new character control or rule, update parser support, server edit validation and the Brisket control/validation path together. Test source rejection, branch selection, publication isolation, progress restoration and the user-facing behavior. Keep execution-only stages behind the authenticated character runtime.

  • funnelparser_test.go: projection, sources, pins and unsupported recipe inputs.
  • funneltracking_test.go (migration): metric defaults, history and profile foreign key.
  • funnelintegration_test.go (storage): publication isolation, replay and concurrent revisions.
  • Brisket funnel recipe/storage/photo tests: snapshot, restore and file-policy behavior.
  • Brisket renderer/signup, initialization/fallback and hop tests: final-save fallback, departure preparation, cookie cleanup, deadlines and cancellation.
  • Flank funnel editor tests: supported authoring changes and archived-state behavior.
  • Flank Studio tests: provider mocks, import restrictions, repair/deadline limits, lease loss, repeated job reads and owner-scoped assets.
  • Sirloin Studio tests: SQL session/job replay, immutable revisions, publication artifact access and asset finalization.
  • Brisket custom-step tests: host messages, recipe/generic validation, per-instance state and failure navigation.

These are verification entrypoints, not evidence of a live provider request, browser session, deployment or end-to-end rollout. Production builds and focused tests should be reported with the delivery evidence; live generation and browser journeys require their own explicit witness.

The interactive guide is generated from this architecture page and the operator flow. It embeds a working-tree source snapshot with a SHA-256 digest per file. Modified source files are labeled as local changes relative to the recorded base commit. Live repository links can differ from that snapshot; local changes are not presented as shipped code.

The funnel docs owner maintains the guide inputs under docs/guides/. After updating either canonical page or its cited implementation, run node scripts/docs-build-funnel-guide.mjs, then make docs-build. Use node scripts/docs-build-funnel-guide.mjs --check to detect stale output. The generator uses the installed docs renderer, refreshes source contents/digests, and embeds the walkthroughs without external scripts, fonts or analytics. The HTML asset is docs/public/guides/funnel-recipe-parser.html.