Publishing
What a funnel is checked for before it goes live, which findings block a publish and which only warn, and what visitors see while you keep editing.
Editing a funnel edits its draft. Visitors keep seeing the last published version until you publish again, so an unfinished screen is never live.
What publishing checks
The draft is validated as a whole. Findings come back in two kinds: blockers
stop the publish, warnings do not. A blocker is a finding whose severity
is error; a warning is severity warning. The tables below list every code
the publish path can emit — the Rust judge plus the product gates the publish
route adds on top — with the meaning from the message at the site that mints
it. A script checks the tables against those sites, so a code nobody emits
cannot linger here and a code the judge can emit cannot go undocumented.
Blockers
Both targets
The document and its screens
| Finding | What it means | Source |
|---|---|---|
EMPTY_FLOW | The funnel has no screens at all | packages/funnel-core-rs/src/validate.rs:247 |
MALFORMED_CONFIG | The draft does not decode; the message is the decoder's own error, and nothing else is judged until it parses | packages/funnel-core-rs/src/validate.rs:292 |
DUPLICATE_PAGE_ID | Two screens share one id | packages/funnel-core-rs/src/validate.rs:305 |
ORDER_NAMES_MISSING_PAGE | The screen order names a screen that does not exist | packages/funnel-core-rs/src/validate.rs:320 |
PAGE_MISSING_FROM_ORDER | A screen is missing from the screen order, so only an explicit jump can ever reach it | packages/funnel-core-rs/src/validate.rs:333 |
DUPLICATE_BLOCK_ID | Two elements on one screen share one id | packages/funnel-core-rs/src/validate.rs:353 |
DUPLICATE_QUESTION_ID | Two inputs on different screens write the same answer key | packages/funnel-core-rs/src/validate.rs:537 |
QUESTION_WITHOUT_OPTIONS | A choice input with no options — it would render unanswerable | packages/funnel-core-rs/src/validate.rs:370 |
BLOCK_TOO_DEEP | Stacks nested more than 3 deep | packages/funnel-core-rs/src/validate.rs:1069 |
UNKNOWN_PAGE_REF | A jump or branch points at a screen that does not exist | packages/funnel-core-rs/src/validate.rs:556 |
Questions, variables and text
| Finding | What it means | Source |
|---|---|---|
UNKNOWN_QUESTION_REF | A rule reads an answer key no screen asks for | packages/funnel-core-rs/src/validate.rs:586 |
CLAUSE_INVALID_OP | A rule uses an operator nothing can compare, so it would publish and never match | packages/funnel-core-rs/src/validate.rs:602 |
CLAUSE_NEVER_MATCHES | A rule compares a Yes/No answer with anything else — it can never match; compare it with true or false | packages/funnel-core-rs/src/validate.rs:614 |
UNKNOWN_TEXT_REF | Copy interpolates a key no question, variable or app value supplies, so it renders as an empty string | packages/funnel-core-rs/src/validate.rs:482 |
VARIABLE_INVALID_NAME | A variable whose name is not lower_snake_case | packages/funnel-core-rs/src/validate.rs:1014 |
VARIABLE_INVALID_VALUE | A variable with an invalid value | packages/funnel-core-rs/src/validate.rs:1052 |
VARIABLE_SHADOWS_QUESTION | A variable reusing an answer key — it would overwrite the answer | packages/funnel-core-rs/src/validate.rs:1025 |
VARIABLE_SHADOWS_EXTERNAL | A variable reusing an app value's name | packages/funnel-core-rs/src/validate.rs:767 |
VARIABLE_ANSWER_WITHOUT_QUESTION | "Copy the answer" on a screen that asks nothing | packages/funnel-core-rs/src/validate.rs:1038 |
EXTERNAL_INVALID_NAME | An app value with an invalid name | packages/funnel-core-rs/src/validate.rs:737 |
EXTERNAL_DUPLICATE | An app value declared more than once | packages/funnel-core-rs/src/validate.rs:743 |
EXTERNAL_COLLIDES_WITH_QUESTION | An app value sharing its name with an answer key | packages/funnel-core-rs/src/validate.rs:749 |
EXTERNAL_TOO_MANY | More app values declared than the 20 allowed | packages/funnel-core-rs/src/validate.rs:725 |
Actions, routing and timing
| Finding | What it means | Source |
|---|---|---|
ACTION_EMPTY_CHAIN | A trigger with an empty action chain — it does nothing | packages/funnel-core-rs/src/validate.rs:810 |
ACTION_AFTER_TERMINAL | An action below one that leaves the screen — it can never run | packages/funnel-core-rs/src/validate.rs:847 |
ACTION_SELF_TARGET | A screen that navigates to itself | packages/funnel-core-rs/src/validate.rs:862 |
ACTION_INVALID_URL | An open-link action with no URL | packages/funnel-core-rs/src/validate.rs:872 |
ACTION_INVALID_ID | A custom action with an invalid action id | packages/funnel-core-rs/src/validate.rs:882 |
ELEMENT_UNKNOWN_TARGET | A show/hide action naming an element that is not on the screen | packages/funnel-core-rs/src/validate.rs:899 |
CONDITIONAL_NESTED | A rule nested inside another rule | packages/funnel-core-rs/src/validate.rs:917 |
CONDITIONAL_EMPTY | A rule with no condition to test, or nothing to run | packages/funnel-core-rs/src/validate.rs:926 |
ROUTING_NO_FALLBACK | A branching chain with no Otherwise step for when no rule matches | packages/funnel-core-rs/src/validate.rs:691 |
WAIT_OUT_OF_BOUNDS | "Advance automatically after" is set but is not a whole number of milliseconds inside 400–15000 (0.4–15s) | packages/funnel-core-rs/src/validate.rs:401 |
AUTO_ADVANCE_WITH_INPUT | A screen that advances on a timer but also holds something the timer would skip or pull the visitor away from | packages/funnel-core-rs/src/validate.rs:426 |
Selling and legacy rules
A web funnel sells through a checkout action on a button, which buys the
package the visitor picked in a packageList on the same screen. See
Checkout. A web funnel with nothing to sell —
a quiz or a lead-gen funnel — still publishes, with a MISSING_CHECKOUT
warning (see Warnings).
A native funnel sells through the app store, so it needs no checkout — but
the offering rules below apply to both targets: a native package list with
no offering, or one of several, would draw nothing on the device.
| Finding | What it means | Source |
|---|---|---|
PACKAGE_LIST_WITHOUT_OFFERING | A package list naming no offering; every list must name one, on web and native | packages/shared/src/funnel/checkout.ts:188 |
MULTIPLE_OFFERINGS | The package lists name more than one offering; a funnel sells one, on web and native | packages/shared/src/funnel/checkout.ts:202 |
OFFERING_NOT_FOUND | The named offering does not exist in this project; the editor's drawer reports it as you edit, and publish checks it again against the catalog | packages/shared/src/funnel/checkout.ts:236 |
STALE_LEGACY_RULES | A screen with a routing chain and legacy rules at once; the rules are invisible in the builder but would still fire | packages/shared/src/funnel/document.ts:328 |
LEGACY_PAYWALL_RULE_UNRESOLVED | An older branching rule going to "the paywall", but the funnel has no paywall screen — point the rule at a screen | packages/shared/src/funnel/document.ts:355 |
Paywall blocks, store values and project links
Store values ({{product.price}}, {{product.savings_percent}}, {{product.trial_days}}, …)
and project links ({{project.terms_url}}, {{project.privacy_url}}) are filled at run time,
beside the document — see Values.
| Finding | What it means | Source |
|---|---|---|
KEY_HAS_DOT | An answer key, external or variable name contains a . — the dot separates a namespace from its value (product.price), so the name could never be referenced unambiguously | packages/funnel-core-rs/src/validate.rs:150 |
PRODUCT_REF_WITHOUT_LIST | {{product.…}} on a screen with no package list — there is no package for it to describe | packages/funnel-core-rs/src/validate.rs:153 |
PRODUCT_REF_IN_LABEL | {{product.…}} in copy that is always drawn (a button, option, table cell or feature title); only headings, texts, package badges/captions and feature bodies can hide when the store has no value — put price and trial disclosure in a text line | packages/funnel-core-rs/src/validate.rs:159 |
PROJECT_LINK_UNSET | The funnel links to the project's Terms or Privacy URL, but the project has not set it — add it under Project settings → Legal links | packages/funnel-core-rs/src/validate.rs:168 |
DEFAULT_PACKAGE_EMPTY | A package list's "selected by default" package is set to an empty name | packages/funnel-core-rs/src/validate.rs:170 |
TABLE_SHAPE | A comparison table outside 1–3 value columns or 1–12 rows, or a row whose cells do not match the columns | packages/funnel-core-rs/src/validate.rs:174 |
HIGHLIGHT_OUT_OF_RANGE | A comparison table highlights a column it does not have | packages/funnel-core-rs/src/validate.rs:176 |
FEATURE_LIST_EMPTY | A feature list with no items | packages/funnel-core-rs/src/validate.rs:178 |
FEATURE_LIST_TOO_LONG | A feature list with more than 8 items | packages/funnel-core-rs/src/validate.rs:180 |
FEATURE_ICON_INVALID | A feature's emoji icon that is not exactly one emoji | packages/funnel-core-rs/src/validate.rs:182 |
APPEARANCE_COLOR_INVALID | A colour or badge tone that is neither #RRGGBB nor a theme role (accent, onAccent, text, muted, card, border, success, onSuccess) | packages/funnel-core-rs/src/validate.rs:185 |
LAYOUT_NOT_SUPPORTED | A grid layout or column count on a question kind that has no grid (only picture choice does), or a value outside list/grid and 2–3 columns | packages/funnel-core-rs/src/validate.rs:188 |
Web only
The checkout gates are web rules: their whole argument is a paid page that
sells through Stripe, and the sellers they ask for — a checkout action or
an expressCheckout block — are web-only capabilities the crate refuses on
native. Likewise the publish route demands a Stripe account that can take a
card only of a web funnel with a checkout, never of a native funnel whose
purchase goes through the store.
A paywall screen does not count as a checkout. It's a display screen: it
shows an offer and never takes a payment.
| Finding | What it means | Source |
|---|---|---|
CHECKOUT_WITHOUT_PACKAGE_LIST | A screen runs a checkout but has no package list of its own; the payment reads the offering from the checkout's own screen | packages/shared/src/funnel/checkout.ts:206 |
EXPRESS_WITHOUT_PACKAGE_LIST | A screen carries an express checkout block but has no package list of its own; the block buys through the same endpoint, so the same rule applies | packages/shared/src/funnel/checkout.ts:215 |
NO_WAY_FORWARD | A screen that is not the last one has nothing that could ever move the visitor on: no button that goes forward, jumps, closes the funnel or checks out, no answer option that does, and no "Advance automatically after" wait. Add a button, or end the screen's routing in Close funnel to make it a final screen. Native funnels are exempt: a page with no control at all is their documented Continue fallback | packages/funnel-core-rs/src/validate.rs:448 |
STRIPE_NOT_CONNECTED | The funnel has a checkout, but this project cannot take a card yet: finish Stripe onboarding | apps/api/src/routes/dashboard/funnels.ts:835 |
Depends on the target
Whether a block or action may be carried at all depends on the funnel's target. These two findings name the capability the target lacks and say why in the message; the capability table lists what each target may carry.
| Finding | What it means | Source |
|---|---|---|
BLOCK_NOT_ON_TARGET | An element the funnel's target cannot render — see the capability table for what each target allows | packages/funnel-core-rs/src/validate.rs:652 |
ACTION_NOT_ON_TARGET | An action the funnel's target cannot run — see the capability table for what each target allows | packages/funnel-core-rs/src/validate.rs:666 |
Warnings
| Finding | What it means | Source |
|---|---|---|
UNREACHABLE_PAGE | Nothing navigates to this screen — usually a leftover, sometimes deliberate | packages/funnel-core-rs/src/validate.rs:1206 |
PAGE_AFTER_PAYMENT | Follows a payment, so it is never shown — a payment ends the funnel | packages/funnel-core-rs/src/validate.rs:1196 |
MISSING_CHECKOUT | A web funnel where no button runs a checkout action and no screen carries an express checkout block, so nothing takes a payment. A quiz or lead-gen funnel publishes as it is; to take payment, add a button whose action is Checkout | packages/shared/src/funnel/checkout.ts:190 |
OFFER_REF_UNGUARDED | Trial or intro-offer copy ({{product.trial_days}}, {{product.offer_price}}, …) in an element whose visibility never reads product.has_trial. It already hides for a visitor without the offer; pair it with a has_trial = false sibling so that visitor reads something | packages/funnel-core-rs/src/validate.rs:164 |
MEDIA_WITHOUT_SOURCE | An image, video or gallery with no source, so it draws nothing — normal while an asset is still being uploaded, a gap if it ships | packages/funnel-core-rs/src/validate.rs:1262 |
A screen after a payment is never shown: on the web a paid visitor sees the runner's success screen with the app hand-off; in the app your purchase handler takes over.
Why an unknown reference is a blocker
{{frist_name}} renders as an empty string, not as an error. "Hi " ships and
nobody notices until a visitor reads it, which is why a reference nothing
declares stops the publish rather than warning.
Versions
Publishing snapshots the draft: screens, theme, settings and the resolved routing. A session that is already running stays on the version it started with, so a visitor mid-funnel never jumps into a different funnel underneath them.
A published funnel is served at its slug — see Hosting.