Zero-friction flow runbook (EW-617)
Companion to the EW-617 spec series. Lives in the platform repo so it ships with the code; mirrored to
Workspace/knowledge/runbooks/EVER_WORKS_ZERO_FRICTION_FLOW.mdon the next Workspace sync.
What's wired up
| Gap | What it does | PR / status |
|---|---|---|
| G1 | Landing prompt + URL hand-off | ever-works-website#37 |
| G2 | Anonymous (TTL) users + cleanup task | platform #756 |
| G3 | Claim-account flow | platform #757 (stacked on G2) |
| G4 | Wizard "Generate now" + /api/works/quick-create | platform #758 |
| G5 | *.ever.works subdomain + Cloudflare DNS | platform #759 |
| G6 | Default deployProvider = 'ever-works' | platform #752 |
| G7 | Captcha + global caps | tracked, follow-up |
| G8 | Funnel telemetry + this runbook | platform (this PR) |
End-to-end demo
1. Open https://ever.works/ in a fresh-incognito window.
2. Type a directory description into the hero textarea.
3. Click Generate.
→ Browser redirects to https://app.ever.works/onboarding#prompt=…
4. Wizard mounts:
- Reads the prompt out of the URL fragment.
- Mints a temporary user via POST /api/auth/anonymous (G2).
- Jumps to the final "Generate now" step.
5. Click Generate now.
→ POST /api/works/quick-create (G4) creates the Work and starts
generation. Response: { work, generation.historyId }.
6. Background: the agent pushes 3 repos to ever-works-cloud (EW-614),
the deploy workflow runs (G5), Cloudflare CNAME is provisioned, and
the site comes up at https://<slug>.ever.works/.
7. Optional: post-deploy banner offers Claim Account
(POST /api/auth/claim, G3).
Debugging by stage
Each stage emits a [zero-friction] JSON log line via
ZeroFrictionFunnelService.emit (G8). All eight events share a
correlationId so a single correlationId=… filter follows one user
across services. Until the emit call sites land in their respective
PRs, fall back to the per-component logs below.
Stage 1 — landing prompt (G1)
- Browser console: form submit posts no XHR; it just sets
window.location.href. Open Network → Doc to see the redirect. - If the form doesn't appear: confirm the website build deployed
LandingPromptForm— check the rendered HTML in DevTools fordata-testid="landing-prompt-form".
Stage 2 — anon user (G2)
POST /api/auth/anonymousshould return 201 with{ access_token, user.isAnonymous: true }.- 429 → the per-IP throttle (5/hour) tripped. Log in or wait.
- 500 → check
AnonymousAuthServicelogs; common cause is theuserstable not yet havingis_anonymous/anonymous_expires_atcolumns. Run the G2 migration step or setdatabase.autoMigrate=truein dev.
Stage 3 — wizard finished (G4)
- The wizard reads the URL fragment on mount via the
EverWorksOnboardingWizardeffect. If the prompt doesn't pre-fill, the most likely cause is a hash-fragment URL encoding issue — open DevTools and inspectwindow.location.hashbefore any redirect.
Stage 4 — work created (G4)
POST /api/works/quick-createreturns 202 with{ status: 'pending', work, generation }. The work id from the response is what to use for downstream polling.- 400 → slug regex failed. The wizard derives slug from the prompt; if it produced an invalid slug log a bug.
Stage 5 — repos pushed (EW-614)
- Search
WorkLifecycleServicelogs forEverWorksGitProvider.createRepositorylines. Three repos should be created inever-works-cloud.
Stage 6 — deploy started (G5)
DeployService.deploylogs the workflow dispatch. TheK8S_INGRESS_HOSTAction variable should be set to${slug}.ever.works.- Cloudflare CNAME provisioning: search
CloudflareDns CNAME created/updatedinEverWorksDnsServicelogs.
Stage 7 — deploy ready
DeploymentVerifierServicepolls the cluster forReady. The site should respond onhttps://<slug>.ever.works/within ~2 min after the workflow finishes.
Stage 8 — claim account (G3)
- Authenticated
POST /api/auth/claimwith{ email, password }. 409 → email already taken by a different user. 403 → the caller's session is already non-anonymous.
Rolling back per gap
| Gap | Roll-back |
|---|---|
| G1 | Revert ever-works-website#37 and redeploy the website. The platform side keeps working — wizard hash-hydration is a no-op when no prompt is in the URL. |
| G2 | Revert platform #756. Existing anonymous rows orphan in the DB; safe to leave (the nightly cleanup task still runs once the schema columns exist). |
| G3 | Revert platform #757. Anonymous users stay anonymous; they can sign up normally via /register. |
| G4 | Revert platform #758. The wizard falls back to the legacy /works/new hand-off automatically (the "Generate now" button only renders when prompt is set). |
| G5 | Unset CLOUDFLARE_API_TOKEN (or rotate it). EverWorksDnsService.getProvider() returns null and the deploy pipeline reverts to the LB hostname fallback. |
| G6 | Revert platform #752. New rows go back to defaulting deployProvider='vercel'; existing rows are untouched. |
| G7 | (Not yet wired.) |
| G8 | Revert this PR. [zero-friction] log lines disappear; nothing else breaks. |
Configuration cheat-sheet
| Env var | Owned by | Notes |
|---|---|---|
ANONYMOUS_USER_TTL_DAYS | G2 | Default 7. Garbage values fall back to 7. |
CAPTCHA_PROVIDER | G7 | turnstile / hcaptcha / recaptcha or empty (disabled). |
CAPTCHA_SECRET | G7 | Server-side secret for the provider's /siteverify. |
CLOUDFLARE_API_TOKEN | G5 | Scoped: DNS:Edit on the ever.works zone only. Never the global key. |
CLOUDFLARE_ZONE_ID | G5 | 32-char hex. |
EVER_WORKS_DEPLOY_LB_HOSTNAME | G5 | Cluster ingress LB DNS name. Required for DNS automation. |
EVER_WORKS_DOMAIN | G5 | Defaults to ever.works. |
DEPLOY_EVER_WORKS_ENABLED | EW-608 | Gates the ever-works deploy provider end-to-end. |
NEXT_PUBLIC_APP_URL | G1 | Where the landing page sends users. Defaults to https://app.ever.works. |
Live values (dev / stage / prod, as of 2026-05-15)
| Env var | Value (or source) |
|---|---|
DEPLOY_EVER_WORKS_ENABLED | true (inlined in deploy-do-{dev,stage,prod}.yml) |
EVER_WORKS_DOMAIN | ever.works (inlined in workflows) |
EVER_WORKS_DEPLOY_LB_HOSTNAME | lb.ever.works → proxied CNAME → Cloudflare Tunnel (see "DNS anchor record") |
CLOUDFLARE_API_TOKEN | GH secret CLOUDFLARE_API_TOKEN (Zone:DNS edit on ever.works) |
CLOUDFLARE_ZONE_ID | GH secret CLOUDFLARE_ZONE_ID → 14b28d7fd73db5679d4280e4278ec6cf |
CAPTCHA_PROVIDER | GH secret CAPTCHA_PROVIDER — empty until Turnstile widget + client wiring ship |
CAPTCHA_SECRET | GH secret CAPTCHA_SECRET — empty until Turnstile widget + client wiring ship |
Deploy mechanism
Env vars land in pods via the envsubst step in
.github/workflows/deploy-do-{dev,stage,prod}.yml against the
.deploy/k8s/k8s-manifest.{dev,stage,prod}.yaml placeholders. Each
workflow's env: map maps GH secrets / inline values → $VAR →
container env. Adding a new env var = (a) declare - name: VAR / value: "$VAR" in the 3 manifest YAMLs, (b) pass VAR: ${{ secrets.VAR }} (or
inline) in the 3 workflow YAMLs, (c) gh secret set VAR --repo ever-works/ever-works.
DNS anchor record
lb.ever.works is the anchor that customer {slug}.ever.works CNAMEs
point at. Created via the Cloudflare API 2026-05-15; repointed
2026-07-29.
Current shape: lb.ever.works → proxied CNAME →
5a1c27a6-7d93-4f25-a329-3154995e16db.cfargotunnel.com, i.e. the same
Cloudflare Tunnel every other ever.works hostname uses. The tunnel has
a catch-all rule to ingress-nginx-controller.ingress-nginx on the
self-hosted ever-k8s cluster.
🛑 It must stay proxied. *.cfargotunnel.com only resolves through
the Cloudflare proxy; a grey-cloud (unproxied) record pointing at the
tunnel will not resolve.
History: it previously pointed at A 157.230.74.11, the DigitalOcean
k8s-works ingress LB. That droplet was decommissioned, so the anchor
resolved to a dead origin (Cloudflare HTTP 521) until it was repointed.
Any customer subdomain CNAMEd to it during that window would have been
unreachable.
Note on current practice: the customer Work subdomains that exist
today (chairs, dir, startup-books, timetrack, vectordb,
compliance-automation, mcpserver) are CNAMEd directly at the
tunnel rather than at this anchor, so the anchor currently has zero
dependents. It is kept working because
EVER_WORKS_DEPLOY_LB_HOSTNAME still defaults to it in
managed-subdomain.service.
Rotating: because the target is now a tunnel rather than an IP, there is normally nothing to rotate — changing which cluster serves a hostname is done in the tunnel's ingress rules / the k8s Ingress, not in DNS.
⚠️ A subdomain will still 404 unless a matching k8s Ingress rule exists. Pointing DNS at the tunnel is necessary but not sufficient: the tunnel catch-all forwards to ingress-nginx, which returns 404 for a Host it has no server block for.
Cloudflare token security note
The token currently in GH secrets has broader scope than strictly
needed (Zone:DNS edit on ever.works is the only operation
EverWorksDnsService performs, but the token currently used can also
read other zones in the account). Follow-up: rotate to a
zone-scoped token via the Cloudflare dashboard → My Profile → API
Tokens → "Create Token" → "Edit zone DNS" template scoped to
ever.works only. Update the CLOUDFLARE_API_TOKEN GH secret with
the new value; the platform code path is unchanged.
Dashboards
- PostHog board for the funnel: TBD (G8 wires emit calls; dashboard follow-up).
- Cloudflare →
ever.workszone → DNS records: shows the CNAME pool. - k8s-works → ingress →
*.ever.workscert: verify cert-manager is serving the wildcard.
Known sharp edges
- The 6-char random suffix on quick-create slugs is meant to dodge
collisions across users typing the same prompt — but two users
hitting the same suffix is still ~one-in-a-million per attempt. The
Cloudflare PUT path handles that gracefully (idempotent update) but
the platform write hits a unique-constraint error first; ops will
see a 409 from
POST /api/works/quick-create. Resolve by retrying; long-term fix is a stronger suffix or a server-side UUID slug. - Anonymous users created during a DB outage that prevented the
users.is_anonymouscolumn from being created may end up with null email butisAnonymous=false(the default). Detection query:SELECT id FROM users WHERE email IS NULL AND is_anonymous = false. Repair: setis_anonymous=trueand seed ananonymous_expires_atin the past so the nightly cleanup picks them up.