SURF-1890 / SURF-1891 shipped per-form privacy controls, but each category could only be fully on or fully off. The customer driving that work embeds Surface forms on pages that already run a consent banner, so neither option fits: leaving tracking on loads GTM, GA4, the Meta Pixel and HubSpot before the visitor has consented, and turning it off loses the tracking they configured and pay for. What they actually need is "off until my banner says yes".
There was also no way for a consent answer to reach a form at all. Forms render in a cross-origin iframe, so the host page cannot reach into them, and the Surface tag had no consent API — meaning even a customer willing to wire it up had nothing to call.
Separately, while scoping this we found the existing ad-tracking gate was incomplete: it covered loading the vendor scripts and the Conversion Events rules, but not the form-event pushes those tags exist to receive (SurfaceFormStarted / SurfaceFormStepSubmitted / SurfaceFormSubmitEvent to GTM, Meta Pixel and GA4). Because both sinks queue and replay — window.dataLayer is drained when the GTM container loads, and pendingMetaPixelCustomEvents is handed back on pixel init — events collected while consent was denied would have reached both vendors retroactively the moment consent arrived.
Replace the two disable booleans with one mode per category, so "disabled but awaiting consent" cannot be expressed:
settings.privacyControls { adTracking?, surfaceAnalytics?: "allowed" | "consent" | "disabled" } — still a JSON blob, no migration, absent key still means allow-all.
{ type: "surface:consent", consent: { adTracking, surfaceAnalytics } }, accepted only from window.parent. The Surface tag relays it to every Surface iframe via a new window.SurfaceSetConsent(...) (trysurface/scripts PR #73), and a plain-iframe host can post it directly. Nothing is granted until it arrives, so a standalone /s/<formId> link never runs a consent-mode category.useSyncExternalStore) feeds the existing ThirdPartyPolicy gate. Every gated call site reads the policy during render, so scripts start the moment consent lands — mid-session, no reload. A single store rather than per-hook state matters because the renderer mounts behind the prefill fetch, after the page shell's gates are already live.surface.form_* postMessages the host integrates against, and the Surface event emitter, still fire.Still to do in a follow-up: the surfaceAnalytics category currently gates PostHog and Speed Insights but not Surface's own tracking calls — callPushEventApi (form_viewed / started / step_submitted / completed / dropoff), useIdentifyLead, and useGetBrowserFingerprint. Those should be blocked too, with response collection left working (a plain form that collects responses without identifying a lead). The Surface tag's own identify + user-journey calls also fire from the customer's page on load, and since the tag is site-wide it cannot read a per-form setting — it needs either its own opt-in switch or for the customer's CMP to block the script tag.
PRs: trysurface/surface_forms #5631 (stacked on #5625 → #5624) and trysurface/scripts #73. The scripts PR also adds test/consent.html, an end-to-end harness with a stand-in cookie banner that drives a real embedded form through both delivery paths.