Skip to main content

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.md on the next Workspace sync.

What's wired up

GapWhat it doesPR / status
G1Landing prompt + URL hand-offever-works-website#37
G2Anonymous (TTL) users + cleanup taskplatform #756
G3Claim-account flowplatform #757 (stacked on G2)
G4Wizard "Generate now" + /api/works/quick-createplatform #758
G5*.ever.works subdomain + Cloudflare DNSplatform #759
G6Default deployProvider = 'ever-works'platform #752
G7Captcha + global capstracked, follow-up
G8Funnel telemetry + this runbookplatform (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 for data-testid="landing-prompt-form".

Stage 2 — anon user (G2)

  • POST /api/auth/anonymous should return 201 with { access_token, user.isAnonymous: true }.
  • 429 → the per-IP throttle (5/hour) tripped. Log in or wait.
  • 500 → check AnonymousAuthService logs; common cause is the users table not yet having is_anonymous / anonymous_expires_at columns. Run the G2 migration step or set database.autoMigrate=true in dev.

Stage 3 — wizard finished (G4)

  • The wizard reads the URL fragment on mount via the EverWorksOnboardingWizard effect. If the prompt doesn't pre-fill, the most likely cause is a hash-fragment URL encoding issue — open DevTools and inspect window.location.hash before any redirect.

Stage 4 — work created (G4)

  • POST /api/works/quick-create returns 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 WorkLifecycleService logs for EverWorksGitProvider.createRepository lines. Three repos should be created in ever-works-cloud.

Stage 6 — deploy started (G5)

  • DeployService.deploy logs the workflow dispatch. The K8S_INGRESS_HOST Action variable should be set to ${slug}.ever.works.
  • Cloudflare CNAME provisioning: search CloudflareDns CNAME created/updated in EverWorksDnsService logs.

Stage 7 — deploy ready

  • DeploymentVerifierService polls the cluster for Ready. The site should respond on https://<slug>.ever.works/ within ~2 min after the workflow finishes.

Stage 8 — claim account (G3)

  • Authenticated POST /api/auth/claim with { email, password }. 409 → email already taken by a different user. 403 → the caller's session is already non-anonymous.

Rolling back per gap

GapRoll-back
G1Revert 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.
G2Revert platform #756. Existing anonymous rows orphan in the DB; safe to leave (the nightly cleanup task still runs once the schema columns exist).
G3Revert platform #757. Anonymous users stay anonymous; they can sign up normally via /register.
G4Revert platform #758. The wizard falls back to the legacy /works/new hand-off automatically (the "Generate now" button only renders when prompt is set).
G5Unset CLOUDFLARE_API_TOKEN (or rotate it). EverWorksDnsService.getProvider() returns null and the deploy pipeline reverts to the LB hostname fallback.
G6Revert platform #752. New rows go back to defaulting deployProvider='vercel'; existing rows are untouched.
G7(Not yet wired.)
G8Revert this PR. [zero-friction] log lines disappear; nothing else breaks.

Configuration cheat-sheet

Env varOwned byNotes
ANONYMOUS_USER_TTL_DAYSG2Default 7. Garbage values fall back to 7.
CAPTCHA_PROVIDERG7turnstile / hcaptcha / recaptcha or empty (disabled).
CAPTCHA_SECRETG7Server-side secret for the provider's /siteverify.
CLOUDFLARE_API_TOKENG5Scoped: DNS:Edit on the ever.works zone only. Never the global key.
CLOUDFLARE_ZONE_IDG532-char hex.
EVER_WORKS_DEPLOY_LB_HOSTNAMEG5Cluster ingress LB DNS name. Required for DNS automation.
EVER_WORKS_DOMAING5Defaults to ever.works.
DEPLOY_EVER_WORKS_ENABLEDEW-608Gates the ever-works deploy provider end-to-end.
NEXT_PUBLIC_APP_URLG1Where the landing page sends users. Defaults to https://app.ever.works.

Live values (dev / stage / prod, as of 2026-05-15)

Env varValue (or source)
DEPLOY_EVER_WORKS_ENABLEDtrue (inlined in deploy-do-{dev,stage,prod}.yml)
EVER_WORKS_DOMAINever.works (inlined in workflows)
EVER_WORKS_DEPLOY_LB_HOSTNAMElb.ever.works → A 157.230.74.11 (k8s-works ingress LB)
CLOUDFLARE_API_TOKENGH secret CLOUDFLARE_API_TOKEN (Zone:DNS edit on ever.works)
CLOUDFLARE_ZONE_IDGH secret CLOUDFLARE_ZONE_ID14b28d7fd73db5679d4280e4278ec6cf
CAPTCHA_PROVIDERGH secret CAPTCHA_PROVIDERempty until Turnstile widget + client wiring ship
CAPTCHA_SECRETGH secret CAPTCHA_SECRETempty 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 → A 157.230.74.11 is the anchor that all customer {slug}.ever.works CNAMEs point at. Created via the Cloudflare API 2026-05-15 (PR ${{TBD}}). To rotate the LB IP, update this A record once; every existing CNAME inherits the new target. The k8s-works LoadBalancer Service (ingress-nginx/ingress-nginx-controller) does not expose a hostname, only an IP, which is why the anchor record exists in the first place.

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.works zone → DNS records: shows the CNAME pool.
  • k8s-works → ingress → *.ever.works cert: 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_anonymous column from being created may end up with null email but isAnonymous=false (the default). Detection query: SELECT id FROM users WHERE email IS NULL AND is_anonymous = false. Repair: set is_anonymous=true and seed an anonymous_expires_at in the past so the nightly cleanup picks them up.