Growth lifecycle hard cutover: acquisition forms commit to Neon - #968
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
…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>
Contributor
blove
enabled auto-merge (squash)
September 2, 2026 23:26
…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>
Contributor
Contributor
…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>
Contributor
Contributor
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/leadsvalidate one server-ownedgrowth_v1policy, 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 retryable503; 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
409with a refresh instruction rather than a success message. Each form renders the server-owned disclosure and points its submit control at it witharia-describedby.Stop surfaces are honest. The unsubscribe route no longer reports success when the durable write fails. A legacy raw-email
GETand a signed one-clickPOSTboth answer a stop failure or a database close failure with the same retryable503, while token and body failures keep their closed400. 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_ENABLEDis exactlytrue.vercel.jsonregisters 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— passnx run lifecycle:check,growth:test-operator-cli— passnx test/nx lint google-mailbox-poller— passnx e2e website— 85/85, including the four form flows asserting policy version and UUID-shaped submission ids with no provider callci-scope112,ci-workflow29 — passnx build website— pass; no legacy module or NDJSON path in any acquisition functionNot covered by this PR
growth:test-integrationdid not run. It needs a disposable NeonTEST_DATABASE_URL, which is intentionally not in the repository and is not wired into any workflow. No automated lane executes this suite today.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