Skip to content

Growth lifecycle hard cutover: acquisition forms commit to Neon - #968

Merged
blove merged 19 commits into
mainfrom
blove/growth-hard-cutover
Sep 3, 2026
Merged

Growth lifecycle hard cutover: acquisition forms commit to Neon#968
blove merged 19 commits into
mainfrom
blove/growth-hard-cutover

Conversation

@blove

@blove blove commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Replaces the website's legacy lead and whitepaper delivery with the Neon/Dawn/Resend lifecycle merged in #952. Forms now commit durably before returning success, the legacy provider-scheduled path is gone, and the stop, webhook, reply, and cron surfaces are in place.

Production remains inert: cron, delivery, enrollment, and campaign switches are all off, and no provider effect happens as a result of this merge. The controlled cutover is a separate, gated operation.

What changes

Acquisition forms are Neon-only. /api/whitepaper-signup, /api/newsletter, and /api/leads validate one server-owned growth_v1 policy, commit the contact, approval, activity, and jobs in a single Neon transaction, close the connection, then best-effort nudge the lifecycle service. Success is returned only after the commit. A failed commit or close returns a retryable 503; it never falls back to legacy.

Every rendered form submits an immutable envelope. Submission UUID, acquisition session, and policy version travel with the declared facts. An uncertain retry reuses the same UUID; changing the facts mints a new one. A stale form gets 409 with a refresh instruction rather than a success message. Each form renders the server-owned disclosure and points its submit control at it with aria-describedby.

Stop surfaces are honest. The unsubscribe route no longer reports success when the durable write fails. A legacy raw-email GET and a signed one-click POST both answer a stop failure or a database close failure with the same retryable 503, while token and body failures keep their closed 400. Known and unknown addresses still share one success shape, so the link reveals nothing. Newly generated links use opaque signed tokens; old raw-email links stay accepted for inbound compatibility.

New adapters. Signed founder stop, Google mailbox reply intake, verified Resend webhooks, and a lifecycle cron endpoint that stays disabled unless LIFECYCLE_CRON_ENABLED is exactly true. vercel.json registers exactly one cron.

Legacy deletion. The drip scheduler, Loops client, legacy Resend transport wrapper, email preview route, and all legacy templates are removed. A boundary spec fails if any of them returns, or if an acquisition route reaches for the filesystem, a provider, or request-time marketing analytics. The Resend SDK stays a website dependency — the verified-webhook route uses it for Svix signature checks — and the spec pins that to its one importer.

Imported legacy contacts cannot enter the new campaign. Enforced in the SQL eligibility predicate, with a real-database case proving a later approving form event does not change that.

Testing

  • nx run-many -t test lint build --projects=growth,lifecycle,website — pass
  • nx run lifecycle:check, growth:test-operator-cli — pass
  • nx test/nx lint google-mailbox-poller — pass
  • nx e2e website — 85/85, including the four form flows asserting policy version and UUID-shaped submission ids with no provider call
  • CI meta-tests: ci-scope 112, ci-workflow 29 — pass
  • Production nx build website — pass; no legacy module or NDJSON path in any acquisition function

Not covered by this PR

  • growth:test-integration did not run. It needs a disposable Neon TEST_DATABASE_URL, which is intentionally not in the repository and is not wired into any workflow. No automated lane executes this suite today.
  • Preview deployment, canary submission, Resend reconciliation and cancellation, Google authorization, sender verification, and every effect switch stay gated in the cutover runbook.
  • The canonical privacy policy and public-output coverage belong to a separate plan and are not included here.

Operational note

Server pages read the growth form policy while rendering, so the build needs GROWTH_FORM_POLICY=growth_v1. It is set for the unit suite, the Playwright web server, and the CI website job. The Vercel project needs the same variable before the next deployment, or the build will fail closed.

🤖 Generated with Claude Code

blove and others added 12 commits September 2, 2026 16:00
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every rendered acquisition form now sends the immutable growth envelope —
submission UUID, acquisition session, and policy version — and shows the
server-owned disclosure beside its submit control. A 409 stops the flow with a
refresh instruction rather than reporting success.

Also carries the switch the pages now read at render time into the unit suite,
the Playwright web server, and the CI website job, and fixes the module-boundary
lint on the routes landed in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lands the signed stop, Google reply, Resend webhook, and lifecycle cron
adapters, and registers the single every-minute Vercel cron.

The unsubscribe route no longer reports success when the durable write fails:
a legacy raw-email GET and a signed one-click POST both answer a stop failure
or a database close failure with the same retryable 503, while token and body
failures keep their closed 400. Known and unknown addresses still share one
success shape, so the link reveals nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deletes the drip scheduler, Loops client, legacy Resend transport, the email
preview route, and every legacy template, and drops the now-dead isolation
mocks from the acquisition route specs. The boundary spec fails if any of them
returns.

The Resend SDK stays a website dependency: the verified-webhook route uses it
to check Svix signatures, and the boundary spec pins that to the one importer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cancellation operator landed with its spec wired into the dedicated
operator-CLI target, but the CI scope meta-test still pinned exactly three
suites and would have failed the required check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 3, 2026 2:49am UTC

Request Review

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

Comment thread apps/website/src/app/api/growth/stop/route.ts Fixed
Comment thread apps/website/src/app/api/unsubscribe/route.ts Fixed
…forms

CodeQL flagged reflected XSS on both confirmation pages: the token from the
query string was interpolated straight into a hidden input value. A valid HMAC
is still required to reach that branch, so exploitability was low, but the
interpolation was unescaped.

Both routes now escape the value, and each spec mocks only the verifier so a
hostile token reaches the form — the guard fails if the escaping is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

blove and others added 2 commits September 2, 2026 16:36
…over

# Conflicts:
#	apps/website/playwright.config.ts
Merging #963 brought two collisions:

- its playwright config rewrite dropped the growth form-policy switch from the
  local web server, restored here on the rewritten entry;
- its Cockpit-retirement scan used emails/newsletter-welcome.ts and
  lib/resend.ts as canaries proving the scan descends past the top level, and
  both were removed with the legacy email pipeline. Replaced with two nested
  files that still exist.

Its new SiteFooter retirement case also needs the real Footer, so the policy
forwarding is now asserted through the rendered disclosure rather than a
module mock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

blove and others added 2 commits September 2, 2026 17:16
…nse shape

The rollback control was installed against production, verified, and removed.
The runbook now carries the exact rule, the install/remove API calls, and the
observed probe results so Task 11 does not have to rediscover them.

It also corrects a promise the platform cannot keep: the plan and design said
the block returns a retryable maintenance response, but Vercel's WAF only
offers log, challenge, deny, bypass, and rate_limit. Deny returns 403, which
a well-behaved client will not retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit 3e9f203 into main Sep 3, 2026
75 checks passed
blove added a commit that referenced this pull request Sep 3, 2026
The cutover plan told the operator to prepare the website deployment without
promoting it, and sequenced Task 11 around blocking forms before the hard
boundary went live. Neither is possible here: threadplane promotes every main
deployment automatically and main is the only branch that has ever reached
production. Merging #968 promoted the boundary seconds later, ahead of the
firewall block, drain, snapshot, import, and cancellation.

Annotates both affected steps and states the constraint in the runbook so a
future cutover either disables auto-promotion first or reconciles provider
state before the code merges.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
blove added a commit that referenced this pull request Sep 3, 2026
The deploy job's `npx vercel build` fails on main:

    Error: GROWTH_FORM_POLICY must be growth_v1
    Error: Command "npx nx build website" exited with 1

so the Website preview is never built, every later step skips, and production
has not promoted since the control-plane arc landed.

#968 added the assertion in apps/website/src/lib/growth/form-policy.ts and set
GROWTH_FORM_POLICY for the `website` job, but not for the deploy job's build.
It cannot come from `vercel pull`: the project marks GROWTH_FORM_POLICY
sensitive for preview and production, and Vercel never returns a sensitive
value, so the pulled .env omits it. A Vercel-side cloud build would have it;
our prebuilt-on-runner build does not.

Set it on the build step, mirroring the `website` job. It is not a secret, and
runtime is unaffected — Vercel still injects the sensitive value into the
deployed function.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants