From d2497aec5eafe5c6c1f9cac0f4d12bb6e6eb0414 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 10:18:53 -0700
Subject: [PATCH 001/194] Add APS render fix and TSJS resilience design spec
Synthesizes three audits of the full rc/july merged state (APS
end-to-end trace, TSJS architecture audit, GPT integration map) into a
phased design: client-to-server disposition telemetry first, APS
admission/identity/render fixes second, then the kernel/adapters/
services restructuring of TSJS with a single window.tsjs namespace,
performance budgets, and restoration of the render attribution lost in
the #922 merge.
---
...s-render-fix-and-tsjs-resilience-design.md | 591 ++++++++++++++++++
1 file changed, 591 insertions(+)
create mode 100644 docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
new file mode 100644
index 000000000..d8550b05a
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -0,0 +1,591 @@
+# APS Render Fix and TSJS Resilience Architecture — Design
+
+- **Status:** draft for review
+- **Date:** 2026-08-04
+- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged
+ from `main` plus every rc-only merge), not just the delta pending against
+ `main`.
+- **Inputs:** three code audits performed against this baseline (APS end-to-end
+ trace, TSJS architecture audit, GPT integration map); open issues #926, #941,
+ #944, #962, #964, #977, #983, #989, #993; open PR #997.
+
+---
+
+## 1. Problem statement
+
+APS (Amazon Publisher Services) demand is fully integrated server-side — the
+edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer
+descriptor to the page — yet APS creatives still do not appear for real users.
+Every previous fix (the `bid.meta` carrier so Prebid does not strip the
+descriptor, the decoupled prebid shim, the `hb_adid` fallback to the OpenRTB bid
+id) addressed a real defect, and APS still does not render. That pattern — serial
+single-cause fixes that each survive review and still do not produce ads — is
+itself the finding: the APS pipeline has **multiple independent failure points,
+most of which fail silently**, and the client library has **no way to tell the
+server (or the operator) which one fired**.
+
+At the same time, the TSJS client library has grown organically to 56 files /
+~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by
+hand in two languages, inverted layering, and roughly one hundred `catch` blocks
+that discard failures. The APS outage and the library's shape are the same
+problem seen from two sides: a delivery pipeline whose failure modes are
+invisible and whose components cannot be reasoned about independently.
+
+This design covers both: (a) the specific fixes that make APS render, and (b)
+the target architecture that makes TSJS a clean, resilient library so the next
+integration does not reproduce this failure class.
+
+### Non-goals
+
+- No change to the APS OpenRTB endpoint contract or Amazon-side configuration.
+- No rewrite of Prebid.js integration strategy (the decoupled shim stays).
+- No visual/behavioral change for publishers whose pages work today.
+
+---
+
+## 2. Why APS still does not render — the evidence
+
+The audit traced all four delivery flows: (a) SSAT server-side ad template via
+`window.tsjs.bids`, (b) GAM + client-side `trustedServer` Prebid adapter, (c)
+SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`.
+Only flow (d) — the demo path nobody runs in production — can render an APS
+descriptor without GAM's cooperation.
+
+The failure points, ranked by likelihood and blast radius:
+
+### 2.1 Admission: APS bids are eliminated before they can win
+
+| # | Failure | Where |
+| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
+| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` |
+| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` |
+| A3 | **Strict per-bid gates**: exact `w`×`h` match against configured formats (a 300×600 answer on a `[[300,250],[728,90]]` slot dies), required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` |
+| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
+
+### 2.2 Identity: the `hb_adid` contract with GAM is unproven
+
+| # | Failure | Where |
+| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
+| B1 | **GAM key-value values are capped at 40 characters.** The new fallback emits the raw APS OpenRTB bid `id` (a long opaque string) as `hb_adid`. If GAM truncates or rejects it, `%%PATTERN:hb_adid%%` comes back different, the bridge's equality check fails, and it bails **with no log**. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` |
+| B2 | **Two id universes for the same bid.** SSAT keys the bridge on the APS bid id; the client-side Prebid adapter keys on Prebid's generated `adId`. A page running both paths registers the same slot under different ids. | `publisher.rs:3366`, `prebid/index.ts:982` |
+
+### 2.3 Render: the client has one narrow happy path and no fallback
+
+| # | Failure | Where |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
+| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` |
+| C2 | **The `/integrations/aps/renderer` route registers only when `[integrations.aps]` is enabled on that origin.** A 404/401 there means the sandboxed iframe waits 10 s and dies silently. | `aps.rs:1188-1245`, `aps/render.ts:404` |
+| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` |
+| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` |
+| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` |
+| C6 | **The renderer CSP may kill creatives after success is reported** (`object-src`, workers, `blob:`/`data:` frames are blocked; `renderer-ready` fires before the creative actually paints). | `aps.rs:49` |
+| C7 | **The renderer branches record nothing**: no `recordRender`, no win/billing beacons, no `stampCreativeTrace` — an APS win looks "never rendered" in every trace whether or not it painted. | `gpt/index.ts:1558-1632` |
+
+### 2.4 Observability: the common factor
+
+There is **zero client→server reporting**. Server telemetry marks `is_win=1` at
+auction time and goes quiet; a bid that never painted is byte-identical to one
+that painted perfectly. Client-side evidence (`window.tsjs.renders`, console
+warnings, `data-ts-*` attributes) dies with the tab. This is why "APS still does
+not work" has taken weeks instead of a dashboard row saying
+`render_fail{renderer_endpoint_404}`.
+
+---
+
+## 3. The GPT reality this design must respect
+
+The GPT integration is a **bootstrap-first hybrid**: the server injects a
+495-line ES5 `gpt_bootstrap.js` inline in `
` before the TSJS bundle, and
+the two coordinate through shared monkeypatch sentinels, with document order
+deciding the winner. The audit's key facts:
+
+1. **The bundle's handoff and initial-load code is dead in production.** The
+ bootstrap installs its wrappers first and sets the same sentinels the bundle
+ checks; ~200 lines of the TypeScript the test suite exercises most heavily
+ never run on a real page. A fix landed only in `gpt/index.ts` has no
+ production effect.
+2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a
+ dead element** — and the orphan-recovery watcher built to repair exactly that
+ was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc
+ side). `updateRender` (one impression, one row) now has no production
+ caller, `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every
+ bridge-served impression double-counts in the trace. Open PR #997 appears to
+ be the reworked replacement; restoring this is a correctness prerequisite,
+ not a refactor.
+3. **TS refreshes never pass `changeCorrelator: false`**, so every TS-driven
+ refresh starts a new GAM page-view correlator — silently changing roadblock,
+ competitive-exclusion, and frequency-capping behavior.
+4. **`enableSingleRequest()` is called blind** after the publisher's own
+ `enableServices()` has almost always run (post-#945 deferral), so SRA intent
+ is asserted but not real — and on pages where TS wins the race, it forces
+ SRA onto publishers who chose otherwise.
+5. Responsive resolution is a DOM-element-selection ladder (not GPT size
+ mapping); ambiguity silently skips the slot for the whole pass.
+6. Three independent wrappers on `pubads().refresh` (bootstrap, bundle, prebid)
+ coordinate via window-global booleans that async wrappers observe already
+ reset.
+
+Any APS fix that adds more targeting keys, more refresh calls, or more
+postMessage traffic has to land inside this reality, which is why the design
+couples the APS fix to the library restructuring instead of adding a sixth
+patch to the pile.
+
+---
+
+## 4. Design overview
+
+Three workstreams, ordered by dependency:
+
+1. **See the failures** — client→server disposition telemetry plus surfacing
+ the server's existing drop counters. Without this, every subsequent fix is
+ another blind patch.
+2. **Fix APS delivery** — admission, identity, render chain, schema, and
+ bridge fixes, each verifiable by the new telemetry and by contract tests.
+3. **Restructure TSJS** — the kernel/adapters/services architecture that makes
+ the fixes durable and the next integration cheap.
+
+---
+
+## 5. Workstream 1 — Observability first
+
+### 5.1 Client disposition beacon
+
+A new kernel module batches disposition events and posts them to a new
+`POST /_ts/client-events` ingest route:
+
+```
+{ v: 1, page: {auctionId, navGen}, events: [
+ { t: "bid_received", slot, bidder, source }
+ { t: "targeting_set", slot, hbAdid }
+ { t: "bridge_request", slot, adId, matched: bool }
+ { t: "render_attempt", slot, source: "renderer"|"adm"|"pbs-cache" }
+ { t: "render_ok", slot, source }
+ { t: "render_fail", slot, source, reason } // reason is a closed enum
+] }
+```
+
+- Transport: `navigator.sendBeacon` with `fetch` keepalive fallback; batched
+ (flush on `visibilitychange`/`pagehide` and every 5 s); capped payload.
+- Server side: a bounded, sampled log/telemetry row per event class, joining on
+ the auction id the server already logs. No KV writes, no PII, no cookies.
+- The existing `recordRender` funnel becomes a producer for this beacon, so the
+ in-page trace overlay and the server see the same stream.
+
+`reason` enums are the contract: `renderer_endpoint_404`,
+`renderer_ready_timeout`, `descriptor_invalid`, `bridge_id_mismatch`,
+`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, and so on.
+Every silent `return` found by the audit gets a reason code.
+
+### 5.2 Surface the server's own drop counters
+
+- Log `drop_reasons` at `warn` when an APS response yields zero admitted bids.
+- Add `drop_reasons` to auction telemetry rows and to the `ts-debug` comment
+ allowlist (SSAT and page-bids paths).
+- Startup validation warning when `[integrations.aps]` is enabled while
+ `allow_script_creatives = false`: "script-type APS demand will be dropped."
+- Startup validation warning when APS (or any direct provider) is configured
+ alongside a mediator, until Workstream 2 makes that combination meaningful.
+
+**Exit criterion:** an operator can answer "which of the failure points in
+section 2 is firing on this page" from server logs alone, with one page load.
+
+---
+
+## 6. Workstream 2 — APS delivery fixes
+
+### 6.1 Admission
+
+- **Mediated auctions must not discard direct-provider winners (A1).** New
+ winner-merge policy: after the mediator responds, direct-provider bids
+ compete per slot by decoded CPM against mediator bids under a configurable
+ strategy: `mediator_only` (today's behavior, explicit), `merge_highest_cpm`
+ (new default). The delivery report gains
+ `dropped_winner_reasons["mediator_superseded"]` so the loser is visible.
+- **Dimension tolerance (A3).** Replace exact `w`×`h` equality with a
+ containment rule: an APS bid is admitted when its size fits within any
+ configured format for the slot (never larger on either axis); the served size
+ is reported in targeting. Exact match stays preferred when available.
+- **Script creatives (A2).** Keep the secure default (`false`) but make the
+ consequence loud (5.2) and document the enablement path for TAM-heavy
+ publishers. The renderer sandbox already isolates script tag types; this is a
+ policy toggle, not new machinery.
+
+### 6.2 Render identity: one short token
+
+Introduce a server-generated **render token** — 12 chars, `[a-z0-9]`, unique per
+(auction, slot) — emitted as `hb_adid` for every SSAT bid and used as the key in
+every registry and bridge branch:
+
+- Well inside GAM's 40-char value limit and charset rules (B1).
+- The bid map carries `{ hb_adid: token, bid_id, renderer, … }`; the bridge
+ matches on the token; billing/win URLs keep using the real bid id.
+- The client-side Prebid adapter path keeps Prebid's generated `adId` (that
+ contract is Prebid's own), but registration for both paths lands in **one**
+ registry keyed by whichever token the path will observe (B2).
+- Property test: every emitted `hb_adid` matches `^[a-z0-9-]{1,40}$`.
+
+### 6.3 Render source chain with a GAM-claim timeout
+
+A winning bid becomes an ordered list of render sources:
+`renderer → inline adm → pbs-cache`. The render engine walks the chain, emitting
+`render_attempt` / `render_ok` / `render_fail{reason}` per step.
+
+For flow (a)/(c), add the missing fallback (C1): when targeting was set for a
+slot and **no bridge request arrives within N seconds of `slotRenderEnded`
+(empty) or within M seconds of refresh**, and the config opts in
+(`[auction].client_render_fallback = "renderer"`), render the descriptor
+directly into the slot container via the existing `renderApsCreative` path. The
+fallback is opt-in because it changes GAM reporting semantics; the beacon makes
+the "GAM never asked" case visible either way.
+
+### 6.4 One descriptor schema
+
+The Rust `ApsRendererV1` struct becomes the single source of truth:
+
+- `build.rs` (or a checked-in generation step) exports JSON Schema from the
+ serde model; the TS types and validators in `aps/render.ts` and the inline
+ renderer-document validator are **generated** from it.
+- Validation becomes versioned-envelope tolerant: known fields validated
+ strictly, unknown fields ignored, `version` gates behavior (C4).
+- A conformance test round-trips a Rust-serialized descriptor through the TS
+ validator and the renderer-document validator in CI.
+
+### 6.5 Renderer endpoint availability
+
+- Register the `/integrations/aps/renderer` route whenever the server can emit
+ renderer bids (auction-level concern), not only when the APS integration is
+ enabled on the serving origin (C2).
+- The renderer iframe failure path (10 s timeout, load error) emits
+ `render_fail{renderer_endpoint_404 | renderer_ready_timeout}` instead of
+ dying silently.
+- CSP audit (C6): extend `APS_RENDERER_CSP` with the minimum additional sources
+ observed in real Amazon creative traffic (candidates: `frame-src data: blob:`,
+ `worker-src blob:`), each addition justified in a comment and covered by the
+ browser spec.
+
+### 6.6 Bridge hardening
+
+- Delete the dead duplicate renderer branch (C5) and move its dedup +
+ debug-log into the live branch.
+- Renderer branches call the same `fireWinBillingBeacons` +
+ `recordGptBridgeRender` as the adm and cache branches (C7).
+- Blanket source validation at the top of the bridge listener: parse and
+ ownership-check before any branch logic; new branches inherit protection.
+- SafeFrame-aware attribution (C3): resolve the slot by the MessageChannel port
+ and the `hb_adid` token first (the token is already unique per slot), using
+ the DOM walk only as a fallback.
+
+### 6.7 Tests that pin the contract
+
+1. Browser spec for flow (a): real GPT + PUC handshake driven from
+ `window.tsjs.bids` with a renderer-only bid (the region the dead code hid).
+2. Mediator + APS orchestration test asserting `merge_highest_cpm` admits the
+ APS winner and `mediator_only` reports the drop.
+3. `build_bid_map` tests: renderer emission, token-form `hb_adid`, adm and
+ cache-coordinate suppression for renderer bids.
+4. Cross-schema conformance (6.4).
+5. Page-bids JSON carries `renderer`; SPA hook delivers it to the bridge.
+
+---
+
+## 7. Workstream 3 — TSJS target architecture
+
+### 7.1 Layering
+
+```
+kernel/ boot, config, command queue, event bus, log, telemetry beacon
+adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access
+services/ slots (registry+handoff), auction client, render engine, consent
+integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services)
+```
+
+Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`):
+
+- `kernel` imports nothing above it; `adapters` import kernel only; `services`
+ import kernel + adapters; `integrations` import kernel + services, **never
+ each other**.
+- This dissolves today's inversions: `core/auction.ts` and `core/request.ts`
+ importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and
+ `prebid` owning the GPT refresh wrapper.
+- `aps/` gains a real module boundary (an `index.ts`), ending the triple
+ inlining that gives three bundles three private copies of the frame-tracking
+ WeakMaps (today two paths can each mount a live APS iframe on one container
+ without seeing each other's cancel bookkeeping).
+
+### 7.2 Adapters: explicit absence
+
+Every external global is wrapped once with a tri-state
+(`present | pending | absent`), a queue for `pending`, and a resolution
+timeout that emits telemetry on `absent`. No other file touches
+`window.googletag` / `window.pbjs`. This converts today's silent hangs (GPT
+stub whose `cmd` never drains, `adInit` bare-returning without googletag) into
+recorded, reasoned outcomes.
+
+### 7.3 Slot registry service
+
+One registry owns all slot knowledge: publisher-defined vs TS-defined,
+adoption, handoff claims, responsive element resolution, refresh generation,
+targeting-key history — keyed by `WeakMap` plus a
+div-id index. Expando properties on live GPT objects are eliminated. The GPT
+integration feeds events in and executes registry decisions; the prebid refresh
+handler consumes the same registry instead of re-deriving slot resolution.
+
+### 7.4 Global namespace policy: everything under `window.tsjs`
+
+Today the library sprawls across the window: ten-plus `window.__tsjs_*` /
+`window.__ts*` flags, `globalThis.tscreative` / `tsCreativeConfig`, a
+symbol-keyed dispatcher, and expando properties stamped onto foreign objects
+(`__tsPushed` on GPT's command queue, `__tsSlotHandoffPatched` on wrapped
+functions, `__tsRenderGeneration` / `__tsRenderBid` on live GPT slot objects,
+sentinels on `pbjs`). The policy going forward:
+
+- **One owned global: `window.tsjs`**, split internally into `tsjs` (public,
+ versioned API) and `tsjs._internal` (coordination state, explicitly not a
+ contract). Server-injected boot flags (`__tsjs_gpt_enabled`,
+ `__tsjs_slim_prebid_url`, bundle manifests) become fields the boot script
+ sets via the same command-queue pattern (`window.tsjs = window.tsjs ||
+{cmd: []}`), so early inline scripts and the bundle share one namespace.
+- **No expandos on objects we do not own.** Per-slot state
+ (`__tsRenderGeneration`, `__tsRenderBid`) moves into the slot registry's
+ `WeakMap`; wrap-idempotence sentinels on foreign
+ functions are replaced by a kernel-held `WeakSet` of wrapped targets.
+- **Immediate cleanup, independent of the refactor:** `__tsRenderGeneration`
+ and `__tsRenderBid` are dead writes on this baseline — written at
+ `gpt/index.ts:1086-1091`, read by nothing (their consumer was lost in the
+ #922 merge). Delete the writes now; when Phase 2 restores attribution, the
+ captured bid/generation lives in `SlotRecord`.
+- Third-party globals (`googletag`, `pbjs`, CMP APIs) are read only through
+ adapters (7.2); integration-owned config globals (`didomiConfig`,
+ `permutive.config`) are written only inside that integration's adapter
+ boundary.
+
+### 7.5 Messaging module
+
+All `postMessage` traffic goes through one module: versioned envelopes, message
+name constants (today `'Prebid Request'` appears as a bare literal at six
+sites, and the APS handshake exists in three hand-synced copies), source and —
+where origins are non-opaque — origin validation, and one audit point.
+
+### 7.6 Lifecycle discipline
+
+- **`install()` entry points instead of import-time side effects.** The server
+ boot script calls `tsjs.install(['gpt', 'prebid', …])`; modules stop
+ self-executing at module bottom. This kills the double-injection class
+ (today: `beacon_guard` double-wraps `window.fetch` unrecoverably, a second
+ `creative` copy silently disarms `setConfig`, `didomi` can throw during
+ module evaluation and halt the concatenated bundle).
+- One shared window-level install sentinel helper (the pattern
+ `gpt_diagnostics` already got right), applied to every integration.
+- A `PageSession` object owns all per-page mutable state; SPA navigation
+ disposes and recreates it (fixing the leaked observers, listeners, and
+ unbounded maps the audit enumerated).
+- Error policy: no empty `catch` — every catch either handles, logs with
+ context, or emits a disposition reason. The auction fetch gets a timeout +
+ `AbortController`, and `requestAds` surfaces failure to its caller.
+- **Console logging is retained, not replaced.** The disposition beacon is
+ additive: every condition that surfaces an issue keeps (or gains) a
+ `log.warn` with enough context to debug from an open DevTools console,
+ because the console is the tool available on a publisher's page when no
+ server access exists. Concretely: existing warnings survive the refactor
+ verbatim or strengthened; failure paths currently logged at `debug` — which
+ is invisible at the default `warn` level (for example the creative
+ `dynamic_src_guard` and click-guard rejection paths) — are promoted to
+ `warn` when they indicate a delivery or security-relevant failure; and every
+ new `render_fail` / `absent`-dependency disposition emits a paired `warn`
+ carrying the same reason code, so console and beacon tell one story.
+
+### 7.7 The bootstrap problem
+
+`gpt_bootstrap.js` duplicates ~400 lines of the hardest logic (handoff,
+initial-load detection, hydration deferral) in hand-written ES5, always wins
+the sentinel race, and has one live divergence (its simpler `adInit` can run
+first and permanently suppress the bundle's `slotRenderEnded` listener).
+
+Target: shrink the inline bootstrap to a **queue-and-flags stub only** (create
+`googletag.cmd` interception points, record early publisher calls, expose
+`__tsjs_gpt_enabled`), and move all behavior into the bundle, which replays the
+recorded early calls on install. If a no-bundle fallback must keep rendering
+ads (today's pinned behavior), that fallback is **generated from the same
+TypeScript source** at build time, never hand-maintained.
+
+### 7.8 GPT correctness fixes carried with the restructure
+
+- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify
+ against open PR #997; land whichever is canonical) — fixes the dead-element
+ handoff alias (section 3.2) and trace double-counting.
+- Pass `changeCorrelator: false` on TS-initiated refreshes; make correlator
+ behavior a documented, configurable decision.
+- `enableSingleRequest()` only when GPT services are not already enabled;
+ otherwise adopt the publisher's mode and record it.
+- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` instead
+ of only a console warning.
+
+### 7.9 Decomposition targets
+
+| Today | Target |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
+| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
+| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics |
+| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory the other six integrations already use |
+| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
+| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state |
+
+---
+
+### 7.10 Performance
+
+Resilience must not cost speed; several parts of this design make the library
+faster, and the rest are held to explicit budgets:
+
+**Where the design is a speedup:**
+
+- **Smaller synchronous bundle.** Consolidating `gpt/script_guard.ts` (634
+ lines) onto the shared factory, un-inlining `aps/render.ts` from three
+ bundles into one, deleting the dead bridge branch and dead expando writes,
+ and splitting the trace overlay UI out of `core` all shrink the head-blocking
+ `tsjs-unified.js`. Target: measurably smaller than today's bundle, tracked in
+ CI (size report per PR).
+- **Fewer repeated DOM walks.** Today slot resolution
+ (`findSlotElementByDivId`'s five-step ladder, iframe walks in the bridge,
+ prebid's independent re-derivation) runs per feature per pass. The slot
+ registry resolves once per slot per navigation and everyone reads the record.
+- **Bounded waits instead of blind ones.** The 10 s silent renderer timeout and
+ the "queued forever on a GPT that never loads" cases become short, telemetered
+ timeouts with fallbacks — failures surface in hundreds of milliseconds, and
+ the render-source chain moves to the next source instead of waiting.
+
+**Where the design must not regress, and how that is enforced:**
+
+- **Ad request timing is untouched.** The critical path (bids script →
+ targeting → display/refresh) gains no network calls and no awaits; adapter
+ indirection is one property read and a queue check.
+- **Telemetry is off the critical path by construction.** `sendBeacon` /
+ keepalive fetch, batched, flushed on `visibilitychange` — never awaited by
+ render code, capped in size and event count.
+- **No new long tasks.** The kernel boots synchronously in microseconds
+ (queue + registry creation); integration `install()` bodies do what their
+ import-time footers do today, just at a controlled moment.
+- **Budgets in CI:** bundle byte size per module, and a browser-spec assertion
+ that time-from-bids-script-to-first-`display()` on the reference page does
+ not regress against the recorded baseline.
+
+### 7.11 Toolchain and dependency currency
+
+The refactor starts from a current toolchain rather than dragging old versions
+through it:
+
+- **TypeScript to latest stable.** The library pins `typescript ^5.5.4` while
+ the rest of the stack (vite 7, vitest 4, typescript-eslint 8) is current;
+ upgrade TS first and adopt the newer strictness the refactor wants anyway
+ (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+ `verbatimModuleSyntax`) — these directly serve the typing goals in 7.9
+ (killing `as unknown as` escapes and the wrong `global.d.ts` declaration).
+- **Dev toolchain to latest stable:** eslint (+ plugins), prettier, jsdom,
+ `@playwright/test` in the browser-test package, and `@types/node` aligned to
+ the pinned Node in `.tool-versions`. Each bump lands as its own mechanical PR
+ gated by the full CI matrix, with changelog review — this library
+ monkeypatches globals (`fetch`, `sendBeacon`, DOM prototypes), so jsdom and
+ Playwright behavior changes are real risks, not formalities.
+- **`prebid.js` is deliberately excluded from casual bumps.** The runtime
+ Prebid is the external R2 bundle, version-locked by manifest hash and SRI;
+ the npm `prebid.js` dependency exists for tests and type
+ references. Upgrading Prebid is its own coordinated deploy (bundle + config
+ sha + server), per the decoupled-shim process — the spec only requires that
+ the npm pin and the deployed bundle version stay documented together so
+ tests exercise the version production runs.
+- **Standing policy:** dependencies are reviewed on a monthly cadence and
+ before each phase of this migration begins; a phase never starts on a
+ toolchain more than one minor behind latest stable. Version floors live in
+ `package.json` (exact or caret pins as today) and CI runs on the pinned
+ Node/npm from `.tool-versions`.
+
+## 8. Migration plan (phased, each phase independently shippable)
+
+- **Phase 0 — Observability and toolchain.** Beacon + ingest route + server
+ drop-reason surfacing + reason codes on today's silent returns. Toolchain
+ currency (7.11): TypeScript and dev-dependency upgrades land here, before
+ any structural change, so every later phase type-checks against the compiler
+ it will ship with. No runtime behavior change.
+- **Phase 1 — APS correctness.** Sections 6.1, 6.2, 6.5, 6.6 (admission,
+ token, endpoint, bridge). Verified by Phase 0 data and the new tests. This
+ phase alone should make APS render wherever configuration permits.
+- **Phase 2 — GPT correctness.** Restore #922 attribution/orphan recovery
+ (with #997), correlator, SRA guard. Render-source chain + opt-in direct
+ fallback (6.3).
+- **Phase 3 — Structure.** Layering + boundary lint, `install()` lifecycle,
+ adapters, slot registry, messaging module, schema generation (6.4).
+- **Phase 4 — Decomposition.** File splits, script-guard consolidation,
+ bootstrap shrink. Pure moves under the existing vitest + browser specs,
+ landed one module per PR.
+
+Each phase gates on: all existing CI (Rust + JS + browser specs) green, plus
+its own new tests; no phase depends on a later one.
+
+---
+
+## 9. Alternatives considered
+
+1. **Keep patching APS point-failures without telemetry.** Rejected: three
+ consecutive correct fixes have not produced ads; without disposition data
+ the next fix is another guess.
+2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but
+ changes GAM reporting/pacing semantics unilaterally; kept as the opt-in
+ fallback (6.3) instead.
+3. **Full library rewrite in one branch.** Rejected: the browser-spec safety
+ net is thin in exactly the areas being changed; phased extraction under
+ tests is slower but survivable.
+4. **Drop the ES5 bootstrap entirely (bundle-only).** Cleanest, but loses the
+ pinned "ads still render if the bundle fails" guarantee; the
+ generated-fallback approach (7.6) keeps that guarantee without the dual
+ maintenance.
+
+---
+
+## 10. Risks
+
+- **Mediator merge policy (6.1)** changes auction economics where a mediator is
+ configured; mitigated by the explicit `mediator_only` strategy and the
+ delivery-report visibility.
+- **Beacon volume**: bounded by batching, sampling, and closed enums; the
+ ingest route is fire-and-forget and cannot block rendering.
+- **Schema generation** adds a build step; mitigated by checking generated
+ artifacts into the tree and diffing them in CI.
+- **Bootstrap shrink** touches the most load-order-sensitive code in the
+ product; it is deliberately last (Phase 4) and behind the browser specs.
+
+## 11. Success criteria
+
+1. APS creatives render on a reference page in each configured flow (SSAT,
+ Prebid adapter, page-bids), proven by browser specs and by disposition
+ telemetry from a staged deployment.
+2. Every failure point in section 2 maps to a distinct, observable signal
+ (server log, telemetry row, or beacon reason).
+3. `eslint` boundary rules pass with zero exceptions; no integration imports
+ another integration; `core`/`kernel` imports no integration.
+4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
+ generated.
+5. Trace counts are per-impression (no double counting), and orphaned-slot
+ recovery is covered by a non-vacuous test.
+6. The only TSJS-owned global is `window.tsjs`; no expando properties on GPT
+ slots, GPT functions, or `pbjs`; the dead `__tsRenderGeneration` /
+ `__tsRenderBid` writes are gone.
+7. The synchronous bundle is no larger than today's (target: smaller), and the
+ reference-page time-from-bids-script-to-first-`display()` does not regress.
+8. No existing warning is lost: every issue-surfacing condition logs at `warn`
+ or above in the console, with the same reason code the beacon carries.
+9. TypeScript and the dev toolchain are on latest stable (with the new
+ strictness flags enabled), `prebid.js`'s npm pin matches the documented
+ deployed bundle version, and the monthly review policy is in CI docs.
+
+## 12. Open questions
+
+1. Is a mediator configured in the affected production deployment? (Decides
+ whether A1 is the primary cause or a latent one.)
+2. What share of live APS demand is `tagtype: "script"`? (Decides how urgent
+ the `allow_script_creatives` enablement guidance is.)
+3. Should the direct-render fallback (6.3) ever become default-on for
+ publishers without GAM line items for `hb_bidder=aps`?
+4. Is PR #997 the intended restoration of the lost #922 attribution core, or
+ should the original be re-merged?
+5. Beacon endpoint naming and retention: `/_ts/client-events` vs folding into
+ the existing telemetry namespace.
From 4f817d02799a76886cddc50bed5c3cb9413e7cdf Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:01:40 -0700
Subject: [PATCH 002/194] Rework design spec after review: settle the five
architectural contracts
Revision 2 addresses every blocking finding from the design review:
adds a client-born random trace_id envelope and drops the EC-derived
auction id from ingestion, scopes the render token to renderer-only
bids so the PBS Cache uuid contract is untouched, replaces the aps
index.ts assumption with a versioned window.tsjs._internal runtime ABI
for the IIFE build, specifies an exactly-once render state machine with
honest accepted-vs-confirmed semantics and nonempty-GAM protection,
keeps mediator_only as the default with merge as defined opt-in,
withdraws the containment dimension rule for operator-declared size
maps, renames the unobservable 404 reason to renderer_no_ready, moves
schema generation to a separate wire-schema crate with semantic
validators retained, keeps ownership-first bridge validation over
port-plus-token trust, and adds the ingest/deployment contracts
(credentials-omit transport, four-adapter routing, content-addressed
asset fix), a test acceptance matrix, and reordered phases with flags
and rollback criteria.
---
...s-render-fix-and-tsjs-resilience-design.md | 936 +++++++++++-------
1 file changed, 566 insertions(+), 370 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index d8550b05a..6415d36d8 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,13 +1,16 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** draft for review
+- **Status:** revision 2 — reworked after design review (review verdict:
+ request changes). The five architectural contracts the review required are
+ settled in section 4.
- **Date:** 2026-08-04
-- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything merged
- from `main` plus every rc-only merge), not just the delta pending against
- `main`.
+- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything
+ merged from `main` plus every rc-only merge), not just the delta pending
+ against `main`.
- **Inputs:** three code audits performed against this baseline (APS end-to-end
- trace, TSJS architecture audit, GPT integration map); open issues #926, #941,
- #944, #962, #964, #977, #983, #989, #993; open PR #997.
+ trace, TSJS architecture audit, GPT integration map); design review of
+ revision 1; open issues #926, #941, #944, #962, #964, #977, #983, #989,
+ #993; open PR #997.
---
@@ -39,7 +42,9 @@ integration does not reproduce this failure class.
- No change to the APS OpenRTB endpoint contract or Amazon-side configuration.
- No rewrite of Prebid.js integration strategy (the decoupled shim stays).
-- No visual/behavioral change for publishers whose pages work today.
+- No behavior change for publishers whose pages work today. Where this design
+ must migrate a public surface (section 7.4), it does so behind a bounded
+ compatibility window, never by immediate removal.
---
@@ -59,7 +64,7 @@ The failure points, ranked by likelihood and blast radius:
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` |
| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` |
-| A3 | **Strict per-bid gates**: exact `w`×`h` match against configured formats (a 300×600 answer on a `[[300,250],[728,90]]` slot dies), required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` |
+| A3 | **Strict per-bid gates**: exact `w`×`h` membership in the slot's configured formats, required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` |
| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
### 2.2 Identity: the `hb_adid` contract with GAM is unproven
@@ -74,7 +79,7 @@ The failure points, ranked by likelihood and blast radius:
| # | Failure | Where |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` |
-| C2 | **The `/integrations/aps/renderer` route registers only when `[integrations.aps]` is enabled on that origin.** A 404/401 there means the sandboxed iframe waits 10 s and dies silently. | `aps.rs:1188-1245`, `aps/render.ts:404` |
+| C2 | **A renderer endpoint that never answers is a silent 10-second death.** The sandboxed iframe cannot read an HTTP status from its opaque origin; a 404/401/misrouted document simply never posts `renderer-ready`. | `aps.rs:1188-1245`, `aps/render.ts:384-404` |
| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` |
| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` |
| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` |
@@ -86,9 +91,7 @@ The failure points, ranked by likelihood and blast radius:
There is **zero client→server reporting**. Server telemetry marks `is_win=1` at
auction time and goes quiet; a bid that never painted is byte-identical to one
that painted perfectly. Client-side evidence (`window.tsjs.renders`, console
-warnings, `data-ts-*` attributes) dies with the tab. This is why "APS still does
-not work" has taken weeks instead of a dashboard row saying
-`render_fail{renderer_endpoint_404}`.
+warnings, `data-ts-*` attributes) dies with the tab.
---
@@ -125,166 +128,355 @@ deciding the winner. The audit's key facts:
coordinate via window-global booleans that async wrappers observe already
reset.
-Any APS fix that adds more targeting keys, more refresh calls, or more
-postMessage traffic has to land inside this reality, which is why the design
-couples the APS fix to the library restructuring instead of adding a sixth
-patch to the pile.
-
---
-## 4. Design overview
-
-Three workstreams, ordered by dependency:
-
-1. **See the failures** — client→server disposition telemetry plus surfacing
- the server's existing drop counters. Without this, every subsequent fix is
- another blind patch.
-2. **Fix APS delivery** — admission, identity, render chain, schema, and
- bridge fixes, each verifiable by the new telemetry and by contract tests.
-3. **Restructure TSJS** — the kernel/adapters/services architecture that makes
- the fixes durable and the next integration cheap.
+## 4. Design gates — the five contracts settled before implementation
+
+The design review identified five architectural contracts every later section
+depends on. This revision settles them; changing any of these later reopens the
+review.
+
+### G1 — Trace identity and event envelope
+
+**The client-visible auction id must never be ingested.** It is EC-derived by
+construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists), and server
+telemetry already uses a deliberately unrelated fresh UUID
+(`telemetry.rs:94-97`), so a join on it is both a privacy violation and
+impossible.
+
+Contract:
+
+- A **`trace_id`** is minted client-side per navigation: 128-bit CSPRNG
+ (`crypto.getRandomValues`), hex-encoded, held in `PageSession`, never
+ persisted, never derived from any identity.
+- Every event carries the envelope `{trace_id, nav_gen, render_gen, seq}`.
+ `seq` is a per-trace monotonic counter (ordering + deduplication);
+ `nav_gen`/`render_gen` scope events to a navigation and a refresh cycle, so a
+ batch that spans SPA navigations remains attributable per event, not per
+ batch.
+- The **server** stamps the same `trace_id` into its own telemetry rows by
+ reading it from the beacon, never the reverse: server auction rows keep their
+ independent telemetry UUID, and correlation happens only where the client
+ reported a `trace_id` alongside the events it observed. Failures with no
+ winning bid therefore still have a trace: the trace is client-born, not
+ auction-born.
+- **Sampling is sticky per trace** (decided once at trace mint, recorded in the
+ envelope), never per event — a sampled trace is complete or absent.
+
+### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID
+
+**The PBS Cache contract stays untouched.** Today `hb_adid` deliberately
+prefers the Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache
+coordinates and the bridge's cache fetch assume `?uuid=`
+(`publisher.rs:3450`, `gpt/index.ts:1700`) — that is the Prebid Universal
+Creative's documented contract. Revision 1's "token for every SSAT bid" would
+have broken every cache-backed render and is withdrawn.
+
+Contract:
+
+- Bids with a cache id: `hb_adid` = cache UUID, exactly as today.
+- Bids with markup and no cache id: `hb_adid` = existing fallback chain
+ (`ad_id`, then bid id), exactly as today.
+- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**,
+ format `^[a-z0-9]{12}$` (12 chars exactly), CSPRNG-generated with collision
+ retry within the auction, unique across slots and auctions, one-time
+ consumption in the bridge registry, TTL-bounded. Twelve characters sit well
+ inside GAM's documented 40-character targeting-value limit.
+- The client-side Prebid adapter path keeps Prebid's generated `adId` (that is
+ Prebid's own contract); both paths register into one bridge registry keyed by
+ whichever id that path will observe.
+- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid`
+ and cache coordinates; the token property test asserts `^[a-z0-9]{12}$`,
+ uniqueness, one-time consumption, and TTL expiry.
+
+### G3 — Runtime ABI: how code shares state under the IIFE build
+
+Every entry point is built as a self-contained IIFE with dynamic imports
+inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs
+(`bundle.rs:23`). **A module `import` therefore never shares state across
+bundles** — an `aps/index.ts` alone would just mint another private copy. This
+is already a live defect beyond APS: `core/context.ts:11` holds a private
+context-provider `Map` while `permutive/index.ts:102` registers into its own
+independently bundled copy.
+
+Contract — **a versioned registration ABI on `window.tsjs._internal`**:
+
+- The kernel ships **only** in `tsjs-core` (always first in the concatenated
+ unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly
+ once, guarded by a window-level sentinel.
+- All **stateful** services (slot registry, render state machine, APS renderer
+ registry, context providers, event bus, beacon queue) exist once, owned by
+ the kernel, and are reached **only** through
+ `tsjs._internal.registry.get(name, minVersion)` at call time — never through
+ imported module state. Pure stateless helpers may still be imported and
+ inlined freely.
+- Deferred bundles (prebid) and later scripts interact through the same ABI;
+ `abi` majors are checked at lookup and a mismatch is a logged, telemetered
+ refusal, not a silent no-op.
+- The alternative (single module graph / shared chunks emitted by the build and
+ loaded as real modules) is recorded as the long-term option; the ABI is
+ chosen now because it works under today's concatenation and deferred
+ loading without changing the delivery pipeline.
+- This gate precedes and unblocks: the event bus, the slot registry, the
+ install registry, `PageSession`, the messaging service, and fixes the
+ context-provider split as its first proof.
+
+### G4 — Exactly-once render state machine, and honest success semantics
+
+**Absence of a bridge request is not proof GAM failed** — GAM may legitimately
+serve a different, higher-priority creative without ever invoking the TS
+bridge. And **`renderer-ready` means Amazon's runner script loaded, not that an
+ad painted** (`aps.rs:105`); the current direct renderer returns `true`
+immediately while the real outcome settles asynchronously for up to ten
+seconds (`render.ts:318`).
+
+Contract:
+
+- One render state machine per placement instance, keyed by
+ `(trace_id, nav_gen, slot, refresh_gen, render_token)`, with exactly-once
+ terminal transition. States:
+ `targeting_set → bridge_claimed | gam_filled_other | fallback_running →
+render_accepted → render_confirmed | render_failed(reason)`.
+- **The fallback never replaces a nonempty GAM render.** Any bridge claim or
+ nonempty `slotRenderEnded` cancels a pending fallback; an **empty**
+ `slotRenderEnded` may trigger it; navigation, refresh, handoff, and slot
+ destruction invalidate stale attempts.
+- The renderer API becomes awaitable with cancellation and an explicit
+ terminal reason; no fire-and-forget `true`.
+- Event taxonomy replaces the single `render_ok`:
+ - `bridge_response_sent` — the bridge handed a payload to the PUC;
+ - `render_accepted` — the renderer document loaded Amazon's runner
+ (today's "ready");
+ - `render_confirmed` — observed evidence of a paint (nonempty
+ `slotRenderEnded` for GAM fills; for the sandboxed renderer, iframe
+ load + non-collapsed geometry where observable).
+- **Billing/win callbacks fire no earlier than `render_accepted`, and
+ `render_confirmed` is the only state reported as success.** Where APS
+ provides no paint acknowledgement, the design says so plainly: telemetry can
+ distinguish "runner loaded" from "nothing loaded," but **cannot distinguish
+ a painted ad from a blank one** inside the opaque frame — rows terminate at
+ `render_accepted` and are labeled as such, not upgraded.
+
+### G5 — Deployment contracts
+
+- **Config rollback:** every new auction/config field is default-valued and
+ **omitted from serialization at its default** (`AuctionConfig` denies
+ unknown fields, `auction_config_types.rs:7`), so blobs written by a new
+ binary remain readable by the previous one unless an operator opts in.
+- **Ingest routing:** the beacon route exists in **all four adapters**
+ (Fastly, Axum, Cloudflare, Spin) as an early, EC-free, filter-free route.
+ Only Fastly has a real telemetry sink today; the no-sink behavior elsewhere
+ is "accept, count, drop" — defined, not accidental.
+- **Storage:** a new dedicated datasource for client events (the existing
+ auction datasource cannot hold the shape without migration); schema,
+ retention, and sampling documented with the datasource definition.
+- **Asset content-addressing is fixed, not assumed.** Script URLs embed a
+ content hash but the handler ignores the query and serves current registry
+ bytes from the path alone (`tsjs.rs:3`, `publisher.rs:294`) — during a
+ rolling deploy an old `?v=A` request can receive bytes `B` and cache them
+ under `A`. The handler must validate the requested hash and serve the
+ matching immutable artifact (or answer with the current hash's redirect);
+ rolling-deploy cache tests pin this.
+- **Phase ordering** follows section 9; every phase ships behind a feature
+ flag with canary thresholds and rollback criteria.
---
-## 5. Workstream 1 — Observability first
+## 5. Workstream 1 — Observability
### 5.1 Client disposition beacon
-A new kernel module batches disposition events and posts them to a new
-`POST /_ts/client-events` ingest route:
+A kernel module batches disposition events to `POST /_ts/client-events`:
```
-{ v: 1, page: {auctionId, navGen}, events: [
- { t: "bid_received", slot, bidder, source }
- { t: "targeting_set", slot, hbAdid }
- { t: "bridge_request", slot, adId, matched: bool }
- { t: "render_attempt", slot, source: "renderer"|"adm"|"pbs-cache" }
- { t: "render_ok", slot, source }
- { t: "render_fail", slot, source, reason } // reason is a closed enum
+{ v: 1, trace: {trace_id, sampled}, events: [
+ { seq, nav_gen, render_gen, t: "bid_received", slot, bidder, source }
+ { seq, nav_gen, render_gen, t: "targeting_set", slot, hbAdid }
+ { seq, nav_gen, render_gen, t: "bridge_request", slot, adId, matched }
+ { seq, nav_gen, render_gen, t: "bridge_response_sent", slot, source }
+ { seq, nav_gen, render_gen, t: "render_attempt", slot, source }
+ { seq, nav_gen, render_gen, t: "render_accepted", slot, source }
+ { seq, nav_gen, render_gen, t: "render_confirmed", slot, source }
+ { seq, nav_gen, render_gen, t: "render_fail", slot, source, reason }
] }
```
-- Transport: `navigator.sendBeacon` with `fetch` keepalive fallback; batched
- (flush on `visibilitychange`/`pagehide` and every 5 s); capped payload.
-- Server side: a bounded, sampled log/telemetry row per event class, joining on
- the auction id the server already logs. No KV writes, no PII, no cookies.
-- The existing `recordRender` funnel becomes a producer for this beacon, so the
- in-page trace overlay and the server see the same stream.
-
-`reason` enums are the contract: `renderer_endpoint_404`,
-`renderer_ready_timeout`, `descriptor_invalid`, `bridge_id_mismatch`,
-`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`, and so on.
-Every silent `return` found by the audit gets a reason code.
-
-### 5.2 Surface the server's own drop counters
-
-- Log `drop_reasons` at `warn` when an APS response yields zero admitted bids.
-- Add `drop_reasons` to auction telemetry rows and to the `ts-debug` comment
- allowlist (SSAT and page-bids paths).
-- Startup validation warning when `[integrations.aps]` is enabled while
- `allow_script_creatives = false`: "script-type APS demand will be dropped."
-- Startup validation warning when APS (or any direct provider) is configured
- alongside a mediator, until Workstream 2 makes that combination meaningful.
-
-**Exit criterion:** an operator can answer "which of the failure points in
-section 2 is firing on this page" from server logs alone, with one page load.
+- **Transport:** `fetch(..., {keepalive: true, credentials: "omit"})` is the
+ primary transport, because `sendBeacon` always sends credentials and its
+ `true` return only means "queued," not "received." `sendBeacon` remains the
+ documented last-resort fallback on `pagehide` where keepalive is
+ unavailable, and the ingest handler ignores credentials in all cases.
+- **Batching:** flush on `visibilitychange`/`pagehide` and every 5 s; each
+ event self-describes its navigation via the envelope, so batches spanning
+ SPA navigations stay attributable.
+- **Reasons are a closed enum**, structurally serialized (never interpolated
+ into log lines): `renderer_no_ready`, `descriptor_invalid`,
+ `bridge_id_mismatch`, `gam_empty`, `gam_filled_other`, `no_render_source`,
+ `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`,
+ `fallback_cancelled`, `timeout`. `renderer_no_ready` (not
+ `renderer_endpoint_404`) is deliberate: the opaque iframe cannot read an
+ HTTP status, so the observable fact is "no ready message before timeout."
+
+### 5.2 Ingest contract
+
+- Registered in all four adapters before auth/EC/filters (G5); same-origin
+ enforced via `Origin`/`Sec-Fetch-Site` checks; hard caps on body bytes,
+ event count, and string lengths **enforced before parsing or logging**;
+ malformed rows dropped and counted; per-IP rate limiting; responds
+ `204 Cache-Control: no-store`; touches no KV and mints no identity.
+- Server logs a bounded, structured summary per batch; the Fastly sink writes
+ to the new datasource (G5); other adapters count-and-drop until a sink
+ exists.
+
+### 5.3 Two modes, honestly separated
+
+A sampled, best-effort beacon **cannot** guarantee a complete diagnosis from
+one page load — so the design stops claiming it:
+
+- **Production telemetry:** sticky-sampled traces, SLO "a delivery failure
+ mode occurring on ≥ N% of impressions is visible in the datasource within
+ one hour."
+- **Diagnostic mode:** explicitly enabled (tester cookie / query flag),
+ unsampled, full event stream plus console mirroring — this is the "one page
+ load tells you which failure fired" tool.
+
+### 5.4 Server-side drop-reason surfacing
+
+- Emit a bounded structured summary **whenever any bid is dropped** (not only
+ when zero survive): per-slot reason counts, capped.
+- Add `drop_reasons` to auction telemetry rows.
+- The initial-HTML `ts-debug` comment gains the drop summary; `/_ts/page-bids`
+ returns JSON and **cannot carry an HTML comment**, so it gains a gated
+ structured `debug` field instead, enabled by the same tester gate.
+- Startup validation warnings: APS enabled while `allow_script_creatives =
+false` ("script-type APS demand will be dropped"); any direct provider
+ configured alongside a mediator without the merge strategy of 6.1 ("provider
+ bids cannot win as configured").
---
## 6. Workstream 2 — APS delivery fixes
-### 6.1 Admission
-
-- **Mediated auctions must not discard direct-provider winners (A1).** New
- winner-merge policy: after the mediator responds, direct-provider bids
- compete per slot by decoded CPM against mediator bids under a configurable
- strategy: `mediator_only` (today's behavior, explicit), `merge_highest_cpm`
- (new default). The delivery report gains
- `dropped_winner_reasons["mediator_superseded"]` so the loser is visible.
-- **Dimension tolerance (A3).** Replace exact `w`×`h` equality with a
- containment rule: an APS bid is admitted when its size fits within any
- configured format for the slot (never larger on either axis); the served size
- is reported in targeting. Exact match stays preferred when available.
-- **Script creatives (A2).** Keep the secure default (`false`) but make the
- consequence loud (5.2) and document the enablement path for TAM-heavy
- publishers. The renderer sandbox already isolates script tag types; this is a
- policy toggle, not new machinery.
-
-### 6.2 Render identity: one short token
-
-Introduce a server-generated **render token** — 12 chars, `[a-z0-9]`, unique per
-(auction, slot) — emitted as `hb_adid` for every SSAT bid and used as the key in
-every registry and bridge branch:
-
-- Well inside GAM's 40-char value limit and charset rules (B1).
-- The bid map carries `{ hb_adid: token, bid_id, renderer, … }`; the bridge
- matches on the token; billing/win URLs keep using the real bid id.
-- The client-side Prebid adapter path keeps Prebid's generated `adId` (that
- contract is Prebid's own), but registration for both paths lands in **one**
- registry keyed by whichever token the path will observe (B2).
-- Property test: every emitted `hb_adid` matches `^[a-z0-9-]{1,40}$`.
-
-### 6.3 Render source chain with a GAM-claim timeout
-
-A winning bid becomes an ordered list of render sources:
-`renderer → inline adm → pbs-cache`. The render engine walks the chain, emitting
-`render_attempt` / `render_ok` / `render_fail{reason}` per step.
-
-For flow (a)/(c), add the missing fallback (C1): when targeting was set for a
-slot and **no bridge request arrives within N seconds of `slotRenderEnded`
-(empty) or within M seconds of refresh**, and the config opts in
-(`[auction].client_render_fallback = "renderer"`), render the descriptor
-directly into the slot container via the existing `renderApsCreative` path. The
-fallback is opt-in because it changes GAM reporting semantics; the beacon makes
-the "GAM never asked" case visible either way.
-
-### 6.4 One descriptor schema
-
-The Rust `ApsRendererV1` struct becomes the single source of truth:
-
-- `build.rs` (or a checked-in generation step) exports JSON Schema from the
- serde model; the TS types and validators in `aps/render.ts` and the inline
- renderer-document validator are **generated** from it.
-- Validation becomes versioned-envelope tolerant: known fields validated
- strictly, unknown fields ignored, `version` gates behavior (C4).
-- A conformance test round-trips a Rust-serialized descriptor through the TS
- validator and the renderer-document validator in CI.
-
-### 6.5 Renderer endpoint availability
-
-- Register the `/integrations/aps/renderer` route whenever the server can emit
- renderer bids (auction-level concern), not only when the APS integration is
- enabled on the serving origin (C2).
-- The renderer iframe failure path (10 s timeout, load error) emits
- `render_fail{renderer_endpoint_404 | renderer_ready_timeout}` instead of
- dying silently.
-- CSP audit (C6): extend `APS_RENDERER_CSP` with the minimum additional sources
- observed in real Amazon creative traffic (candidates: `frame-src data: blob:`,
- `worker-src blob:`), each addition justified in a comment and covered by the
- browser spec.
-
-### 6.6 Bridge hardening
-
-- Delete the dead duplicate renderer branch (C5) and move its dedup +
- debug-log into the live branch.
-- Renderer branches call the same `fireWinBillingBeacons` +
- `recordGptBridgeRender` as the adm and cache branches (C7).
-- Blanket source validation at the top of the bridge listener: parse and
- ownership-check before any branch logic; new branches inherit protection.
-- SafeFrame-aware attribution (C3): resolve the slot by the MessageChannel port
- and the `hb_adid` token first (the token is already unique per slot), using
- the DOM walk only as a fallback.
-
-### 6.7 Tests that pin the contract
-
-1. Browser spec for flow (a): real GPT + PUC handshake driven from
- `window.tsjs.bids` with a renderer-only bid (the region the dead code hid).
-2. Mediator + APS orchestration test asserting `merge_highest_cpm` admits the
- APS winner and `mediator_only` reports the drop.
-3. `build_bid_map` tests: renderer emission, token-form `hb_adid`, adm and
- cache-coordinate suppression for renderer bids.
-4. Cross-schema conformance (6.4).
-5. Page-bids JSON carries `renderer`; SPA hook delivers it to the bridge.
+### 6.1 Mediation: opt-in merge, `mediator_only` stays the default
+
+The configured contract today is explicit — the mediator is the final
+decision-maker (`auction_config_types.rs:48`) — and changing that default
+silently would be an economic breaking change with a rollback hazard. So:
+
+- `[auction].winner_selection = "mediator_only"` (default, today's behavior,
+ now explicit) or `"merge_highest_cpm"` (opt-in). The field is omitted from
+ serialized blobs at its default (G5).
+- `merge_highest_cpm` semantics, defined up front: comparison in the auction's
+ decoded CPM currency; slot floors apply to both populations; a bid present
+ in both (a provider bid the mediator also returned) counts once, by
+ provenance `mediator`; deals outrank open bids regardless of CPM; ties break
+ to the mediator; a mediator timeout degrades to direct-provider selection
+ (today's short-circuit) and is reported as such.
+- **One candidate-selection helper serves both mediation lifecycles** — the
+ ordinary/page-bids path (`orchestrator.rs:412-431`) and the initial-SSAT
+ split dispatch/collect path (`orchestrator.rs:1320`) — with tests for each.
+- A **selection report** (`winner_source`, `mediator_superseded` counts) is
+ emitted separately from the delivery-conversion drop reasons, so "lost the
+ merge" is never conflated with "failed to serialize."
+
+### 6.2 Dimensions: exact membership stays; flexibility is operator-declared
+
+Revision 1's containment rule ("never larger on either axis") would still
+reject its own motivating example (a 300×600 answer on a 300×250/728×90 slot)
+while admitting pathological 1×1 sizes — withdrawn. Instead:
+
+- Exact size membership (`aps.rs:657-668`) remains the default; it is
+ consistent with discrete GAM slot formats.
+- An operator may declare flexibility per slot:
+ `accept_sizes = [[300, 600], …]` (an explicit allow-list of additional
+ creative sizes, with documentation that GAM line items must accept them) —
+ no inference, no aspect heuristics in v1. Admitted alternate sizes set
+ `hb_size` targeting (set and cleared with the other `hb_*` keys) and the
+ served size is reported in the selection report.
+
+### 6.3 Script creatives
+
+Keep the secure default (`allow_script_creatives = false`) but make the
+consequence loud (5.4) and document the enablement path for TAM-heavy
+publishers. The renderer sandbox already isolates script tag types; this is a
+policy toggle, not new machinery.
+
+### 6.4 Render identity
+
+Implemented exactly as G2: cache UUID untouched, render token only for
+renderer-only bids, one registry, token property tests
+(`^[a-z0-9]{12}$`, CSPRNG, collision retry, TTL, one-time consumption,
+cross-slot/auction uniqueness), and a non-APS cache-path regression test.
+
+### 6.5 Fallback rendering
+
+Implemented exactly as G4: opt-in
+(`[auction].client_render_fallback = "renderer"`), driven by the render state
+machine, triggered only by an **empty** GAM render or a bridge-claim timeout
+with no fill evidence, cancelled by any bridge claim or nonempty render,
+invalidated by navigation/refresh/destruction, and reported with the G4 event
+taxonomy. The direct renderer is converted to an awaitable API first; the
+fallback lands only after that conversion.
+
+### 6.6 Renderer endpoint
+
+- Document the topology: within one deployment the renderer route and the APS
+ provider share the same config gate (`aps.rs:1224`, `:1244`), so "server
+ emits descriptors but lacks the route" is a **cross-deployment or stale-CDN
+ problem**, and a 401 most plausibly comes from broad `[[handlers]]` auth
+ patterns matching `/integrations/*` before route dispatch.
+- Therefore: validate at startup that no configured auth handler pattern
+ covers `/integrations/aps/renderer`; make the static renderer document
+ config-independent **iff** the deployment serves multiple origins from one
+ config (recorded as an open question with the operator); version the
+ renderer document and define its cache headers.
+- The client reports `renderer_no_ready` (5.1) — no status probe is added,
+ because a probe would violate the no-new-critical-path-request budget.
+- CSP audit (C6) unchanged from revision 1: extend `APS_RENDERER_CSP` only
+ with sources observed in real Amazon traffic, each justified in a comment
+ and covered by the browser spec.
+
+### 6.7 One descriptor schema — structural generation, semantic validators kept
+
+- The wire truth is the **tagged enum** `BidRenderer` (the `type: "aps"`
+ discriminator lives there, not on `ApsRendererV1` — `types.rs:188-211`), so
+ the generated schema is the full tagged envelope.
+- Generation lives in a **separate wire-schema crate** (or a host-side
+ xtask) — it cannot live in `trusted-server-js`'s build because core already
+ depends on that crate (`Cargo.toml:45`) and the reverse edge would be a
+ cycle. Generated TS artifacts are checked in; CI fails on staleness.
+- Generation covers **structure only** (fields, types, discriminator,
+ version). The semantic security checks stay hand-written on both sides:
+ URL/origin policy, canonical base64, length bounds, the exact one-bid
+ envelope projection, cross-field equality. **Unknown-field tolerance applies
+ only to the outer versioned descriptor; the decoded AAX envelope remains an
+ exact projection** so `adm`, notification URLs, or sibling fields can never
+ slip through unexamined.
+- A shared corpus — positive cases plus an adversarial set (extra fields,
+ wrong versions, oversized payloads, URL smuggling, non-canonical base64) —
+ runs through the Rust validator, the TS validator, and the inline renderer
+ document in CI.
+
+### 6.8 Bridge hardening
+
+- **Ownership proof stays source-first.** The bridge continues to resolve the
+ message source to a slot and only then compares that slot's expected id
+ (`gpt/index.ts:1599` order) — a MessageChannel port plus a token is not
+ ownership proof, because an inbound port has no pre-established slot
+ identity and the token is visible in `window.tsjs.bids`. For SafeFrame,
+ source resolution is extended to walk nested browsing contexts via
+ `window.frames` containment checks rather than DOM `querySelectorAll` only;
+ where the source is unresolvable the bridge refuses (with
+ `bridge_id_mismatch`) instead of trusting the token.
+- Adversarial tests are part of the contract: wrong-slot tokens, nested
+ foreign frames, replayed and duplicated messages, stolen tokens, and
+ previous-navigation tokens — not only the positive SafeFrame case.
+- Blanket top-of-listener hygiene: parse and ownership-check before any
+ branch logic; new branches inherit protection.
+- Delete the dead duplicate renderer branch (C5); move its dedup and debug log
+ into the live branch.
+- Renderer branches emit the same trace records as the adm and cache branches,
+ under G4's event taxonomy and billing rules (C7).
---
@@ -304,95 +496,87 @@ Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`):
- `kernel` imports nothing above it; `adapters` import kernel only; `services`
import kernel + adapters; `integrations` import kernel + services, **never
each other**.
+- Stateful services are reached through the G3 registration ABI, never through
+ imported module state; stateless helpers may be imported and inlined.
- This dissolves today's inversions: `core/auction.ts` and `core/request.ts`
importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and
`prebid` owning the GPT refresh wrapper.
-- `aps/` gains a real module boundary (an `index.ts`), ending the triple
- inlining that gives three bundles three private copies of the frame-tracking
- WeakMaps (today two paths can each mount a live APS iframe on one container
- without seeing each other's cancel bookkeeping).
-### 7.2 Adapters: explicit absence
+### 7.2 Adapters: explicit absence, without giving up on late loaders
-Every external global is wrapped once with a tri-state
-(`present | pending | absent`), a queue for `pending`, and a resolution
-timeout that emits telemetry on `absent`. No other file touches
-`window.googletag` / `window.pbjs`. This converts today's silent hangs (GPT
-stub whose `cmd` never drains, `adInit` bare-returning without googletag) into
-recorded, reasoned outcomes.
+Every external global is wrapped once with a state machine
+`present | pending | timed_out`, a queue for `pending`, and per-operation
+bounds. `timed_out` is **not terminal**: publishers legitimately lazy-load
+GPT, Prebid, and CMPs, so a later arrival transitions the adapter to `present`
+and drains what is still valid; individual queued operations carry their own
+timeouts and expire with a disposition reason rather than the adapter
+permanently disabling itself.
### 7.3 Slot registry service
One registry owns all slot knowledge: publisher-defined vs TS-defined,
adoption, handoff claims, responsive element resolution, refresh generation,
targeting-key history — keyed by `WeakMap` plus a
-div-id index. Expando properties on live GPT objects are eliminated. The GPT
-integration feeds events in and executes registry decisions; the prebid refresh
-handler consumes the same registry instead of re-deriving slot resolution.
-
-### 7.4 Global namespace policy: everything under `window.tsjs`
-
-Today the library sprawls across the window: ten-plus `window.__tsjs_*` /
-`window.__ts*` flags, `globalThis.tscreative` / `tsCreativeConfig`, a
-symbol-keyed dispatcher, and expando properties stamped onto foreign objects
-(`__tsPushed` on GPT's command queue, `__tsSlotHandoffPatched` on wrapped
-functions, `__tsRenderGeneration` / `__tsRenderBid` on live GPT slot objects,
-sentinels on `pbjs`). The policy going forward:
-
-- **One owned global: `window.tsjs`**, split internally into `tsjs` (public,
- versioned API) and `tsjs._internal` (coordination state, explicitly not a
- contract). Server-injected boot flags (`__tsjs_gpt_enabled`,
- `__tsjs_slim_prebid_url`, bundle manifests) become fields the boot script
- sets via the same command-queue pattern (`window.tsjs = window.tsjs ||
-{cmd: []}`), so early inline scripts and the bundle share one namespace.
-- **No expandos on objects we do not own.** Per-slot state
- (`__tsRenderGeneration`, `__tsRenderBid`) moves into the slot registry's
- `WeakMap`; wrap-idempotence sentinels on foreign
- functions are replaced by a kernel-held `WeakSet` of wrapped targets.
-- **Immediate cleanup, independent of the refactor:** `__tsRenderGeneration`
- and `__tsRenderBid` are dead writes on this baseline — written at
- `gpt/index.ts:1086-1091`, read by nothing (their consumer was lost in the
- #922 merge). Delete the writes now; when Phase 2 restores attribution, the
- captured bid/generation lives in `SlotRecord`.
-- Third-party globals (`googletag`, `pbjs`, CMP APIs) are read only through
- adapters (7.2); integration-owned config globals (`didomiConfig`,
- `permutive.config`) are written only inside that integration's adapter
- boundary.
+div-id index, owned by the kernel via the G3 ABI. Expando properties on live
+GPT objects are eliminated. The GPT integration feeds events in and executes
+registry decisions; the prebid refresh handler consumes the same registry.
+
+### 7.4 Global namespace policy — with a compatibility window
+
+One owned global, `window.tsjs`, public API versioned, coordination state
+under `tsjs._internal` (G3). But the migration must not break published
+contracts:
+
+- **Inventory first:** every current global is classified public
+ (`globalThis.tscreative`, `tsCreativeConfig` — documented, settable
+ pre-load) or private (`__tsjs_*` flags, expandos, sentinels).
+- **Public globals get a bounded dual-read/write window:** old and new names
+ both work for a stated deprecation period, with pre-init compatibility tests
+ (config set before the bundle loads must keep working); removal is its own
+ later, announced change.
+- Private globals migrate immediately: per-slot expandos
+ (`__tsRenderGeneration`, `__tsRenderBid` — dead writes today, delete now)
+ into `SlotRecord`; function-object sentinels into a kernel-held `WeakSet`;
+ boot flags into the `window.tsjs = window.tsjs || {cmd: []}` pattern.
### 7.5 Messaging module
All `postMessage` traffic goes through one module: versioned envelopes, message
name constants (today `'Prebid Request'` appears as a bare literal at six
-sites, and the APS handshake exists in three hand-synced copies), source and —
-where origins are non-opaque — origin validation, and one audit point.
-
-### 7.6 Lifecycle discipline
-
-- **`install()` entry points instead of import-time side effects.** The server
- boot script calls `tsjs.install(['gpt', 'prebid', …])`; modules stop
- self-executing at module bottom. This kills the double-injection class
- (today: `beacon_guard` double-wraps `window.fetch` unrecoverably, a second
- `creative` copy silently disarms `setConfig`, `didomi` can throw during
- module evaluation and halt the concatenated bundle).
-- One shared window-level install sentinel helper (the pattern
- `gpt_diagnostics` already got right), applied to every integration.
-- A `PageSession` object owns all per-page mutable state; SPA navigation
- disposes and recreates it (fixing the leaked observers, listeners, and
- unbounded maps the audit enumerated).
-- Error policy: no empty `catch` — every catch either handles, logs with
- context, or emits a disposition reason. The auction fetch gets a timeout +
+sites, and the APS handshake exists in three hand-synced copies), source
+validation per 6.8, and one audit point.
+
+### 7.6 Plugin lifecycle
+
+`install()` replaces import-time side effects, with the semantics the review
+demanded:
+
+- `tsjs.definePlugin(id, version, install, dispose)` registers; the kernel
+ resolves install requests against registrations, so **a deferred bundle that
+ registers after `tsjs.install([...])` was requested is installed on
+ arrival** (pending-install queue), bounded by a missing-module timeout that
+ emits `bundle_partial`.
+- Per-plugin exception isolation: a plugin that throws during install is
+ quarantined and reported; it cannot halt the bundle (today `didomi` can
+ throw during module evaluation and stop everything after it).
+- Duplicate registration of the same `(id, version)` is a no-op; a different
+ version for a registered id follows a declared policy (first-wins + loud
+ telemetry).
+- A `PageSession` object owns an **enumerable** set of listeners, timers,
+ observers, and slot records — registered at creation, disposed on
+ navigation. A `WeakMap` alone cannot dispose anything; the owned-set is the
+ disposal inventory.
+- Error policy: no empty `catch` — every catch handles, logs with context, or
+ emits a disposition reason. The auction fetch gets a timeout +
`AbortController`, and `requestAds` surfaces failure to its caller.
-- **Console logging is retained, not replaced.** The disposition beacon is
- additive: every condition that surfaces an issue keeps (or gains) a
- `log.warn` with enough context to debug from an open DevTools console,
- because the console is the tool available on a publisher's page when no
- server access exists. Concretely: existing warnings survive the refactor
- verbatim or strengthened; failure paths currently logged at `debug` — which
- is invisible at the default `warn` level (for example the creative
- `dynamic_src_guard` and click-guard rejection paths) — are promoted to
- `warn` when they indicate a delivery or security-relevant failure; and every
- new `render_fail` / `absent`-dependency disposition emits a paired `warn`
- carrying the same reason code, so console and beacon tell one story.
+- **Console logging is retained, not replaced.** The beacon is additive: every
+ issue-surfacing condition keeps (or gains) a `log.warn` debuggable from an
+ open DevTools console. Existing warnings survive verbatim or strengthened;
+ failure paths currently at `debug` (invisible at the default `warn` level —
+ the creative `dynamic_src_guard` and click-guard rejection paths) are
+ promoted to `warn` when they indicate a delivery or security-relevant
+ failure; every `render_fail` / dependency-timeout disposition emits a paired
+ `warn` carrying the same reason code, so console and beacon tell one story.
### 7.7 The bootstrap problem
@@ -401,24 +585,25 @@ initial-load detection, hydration deferral) in hand-written ES5, always wins
the sentinel race, and has one live divergence (its simpler `adInit` can run
first and permanently suppress the bundle's `slotRenderEnded` listener).
-Target: shrink the inline bootstrap to a **queue-and-flags stub only** (create
-`googletag.cmd` interception points, record early publisher calls, expose
-`__tsjs_gpt_enabled`), and move all behavior into the bundle, which replays the
-recorded early calls on install. If a no-bundle fallback must keep rendering
-ads (today's pinned behavior), that fallback is **generated from the same
-TypeScript source** at build time, never hand-maintained.
+Target: shrink the inline bootstrap to a queue-and-flags stub (create
+`googletag.cmd` interception points, record early publisher calls, expose the
+enable flag), with the bundle replaying recorded calls on install. This is
+**not a pure move** — replay changes observable ordering — so it ships behind
+its own flag with the browser specs extended to cover replay timing, and the
+no-bundle fallback ("ads still render if the bundle fails," pinned by
+`gpt.rs:1174-1179`) is **generated from the same TypeScript source** at build
+time, never hand-maintained.
### 7.8 GPT correctness fixes carried with the restructure
- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify
- against open PR #997; land whichever is canonical) — fixes the dead-element
- handoff alias (section 3.2) and trace double-counting.
-- Pass `changeCorrelator: false` on TS-initiated refreshes; make correlator
- behavior a documented, configurable decision.
+ against open PR #997; land whichever is canonical).
+- Pass `changeCorrelator: false` on TS-initiated refreshes; correlator
+ behavior becomes a documented, configurable decision.
- `enableSingleRequest()` only when GPT services are not already enabled;
otherwise adopt the publisher's mode and record it.
-- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` instead
- of only a console warning.
+- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` in
+ addition to its console warning.
### 7.9 Decomposition targets
@@ -430,162 +615,173 @@ TypeScript source** at build time, never hand-maintained.
| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state |
----
-
### 7.10 Performance
-Resilience must not cost speed; several parts of this design make the library
-faster, and the rest are held to explicit budgets:
-
-**Where the design is a speedup:**
-
-- **Smaller synchronous bundle.** Consolidating `gpt/script_guard.ts` (634
- lines) onto the shared factory, un-inlining `aps/render.ts` from three
- bundles into one, deleting the dead bridge branch and dead expando writes,
- and splitting the trace overlay UI out of `core` all shrink the head-blocking
- `tsjs-unified.js`. Target: measurably smaller than today's bundle, tracked in
- CI (size report per PR).
-- **Fewer repeated DOM walks.** Today slot resolution
- (`findSlotElementByDivId`'s five-step ladder, iframe walks in the bridge,
- prebid's independent re-derivation) runs per feature per pass. The slot
- registry resolves once per slot per navigation and everyone reads the record.
-- **Bounded waits instead of blind ones.** The 10 s silent renderer timeout and
- the "queued forever on a GPT that never loads" cases become short, telemetered
- timeouts with fallbacks — failures surface in hundreds of milliseconds, and
- the render-source chain moves to the next source instead of waiting.
-
-**Where the design must not regress, and how that is enforced:**
-
-- **Ad request timing is untouched.** The critical path (bids script →
- targeting → display/refresh) gains no network calls and no awaits; adapter
- indirection is one property read and a queue check.
-- **Telemetry is off the critical path by construction.** `sendBeacon` /
- keepalive fetch, batched, flushed on `visibilitychange` — never awaited by
- render code, capped in size and event count.
-- **No new long tasks.** The kernel boots synchronously in microseconds
- (queue + registry creation); integration `install()` bodies do what their
- import-time footers do today, just at a controlled moment.
-- **Budgets in CI:** bundle byte size per module, and a browser-spec assertion
- that time-from-bids-script-to-first-`display()` on the reference page does
- not regress against the recorded baseline.
+Client-side, the design is a net speedup with enforced budgets:
+
+- **Smaller synchronous bundle:** script-guard consolidation, single APS
+ module (via the ABI), dead-code deletion, trace-overlay extraction.
+- **Fewer repeated DOM walks:** slot resolution once per navigation in the
+ registry.
+- **Bounded waits instead of blind ones:** the 10 s silent renderer timeout
+ and forever-queued GPT cases become short, telemetered timeouts.
+- **Budgets in CI, precisely specified:** per-bundle byte sizes measured raw,
+ gzip, and Brotli for an exact named module set, compared against a
+ checked-in baseline artifact with a stated tolerance; the browser-spec
+ timing assertion (bids-script-to-first-`display()`) runs N times and gates
+ on a percentile, not a single sample.
+
+Server-side (new in this revision): each injected page currently concatenates
+and hashes the full immediate bundle, and the asset request concatenates it
+again (`bundle.rs:51`). Precompute bundle bytes + hash per registry module set
+(they change only at deploy/config time), and benchmark server CPU/heap before
+and after.
### 7.11 Toolchain and dependency currency
-The refactor starts from a current toolchain rather than dragging old versions
-through it:
-
-- **TypeScript to latest stable.** The library pins `typescript ^5.5.4` while
- the rest of the stack (vite 7, vitest 4, typescript-eslint 8) is current;
- upgrade TS first and adopt the newer strictness the refactor wants anyway
- (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
- `verbatimModuleSyntax`) — these directly serve the typing goals in 7.9
- (killing `as unknown as` escapes and the wrong `global.d.ts` declaration).
-- **Dev toolchain to latest stable:** eslint (+ plugins), prettier, jsdom,
- `@playwright/test` in the browser-test package, and `@types/node` aligned to
- the pinned Node in `.tool-versions`. Each bump lands as its own mechanical PR
- gated by the full CI matrix, with changelog review — this library
- monkeypatches globals (`fetch`, `sendBeacon`, DOM prototypes), so jsdom and
- Playwright behavior changes are real risks, not formalities.
-- **`prebid.js` is deliberately excluded from casual bumps.** The runtime
- Prebid is the external R2 bundle, version-locked by manifest hash and SRI;
- the npm `prebid.js` dependency exists for tests and type
- references. Upgrading Prebid is its own coordinated deploy (bundle + config
- sha + server), per the decoupled-shim process — the spec only requires that
- the npm pin and the deployed bundle version stay documented together so
- tests exercise the version production runs.
-- **Standing policy:** dependencies are reviewed on a monthly cadence and
- before each phase of this migration begins; a phase never starts on a
- toolchain more than one minor behind latest stable. Version floors live in
- `package.json` (exact or caret pins as today) and CI runs on the pinned
- Node/npm from `.tool-versions`.
-
-## 8. Migration plan (phased, each phase independently shippable)
-
-- **Phase 0 — Observability and toolchain.** Beacon + ingest route + server
- drop-reason surfacing + reason codes on today's silent returns. Toolchain
- currency (7.11): TypeScript and dev-dependency upgrades land here, before
- any structural change, so every later phase type-checks against the compiler
- it will ship with. No runtime behavior change.
-- **Phase 1 — APS correctness.** Sections 6.1, 6.2, 6.5, 6.6 (admission,
- token, endpoint, bridge). Verified by Phase 0 data and the new tests. This
- phase alone should make APS render wherever configuration permits.
-- **Phase 2 — GPT correctness.** Restore #922 attribution/orphan recovery
- (with #997), correlator, SRA guard. Render-source chain + opt-in direct
- fallback (6.3).
-- **Phase 3 — Structure.** Layering + boundary lint, `install()` lifecycle,
- adapters, slot registry, messaging module, schema generation (6.4).
-- **Phase 4 — Decomposition.** File splits, script-guard consolidation,
- bootstrap shrink. Pure moves under the existing vitest + browser specs,
- landed one module per PR.
-
-Each phase gates on: all existing CI (Rust + JS + browser specs) green, plus
-its own new tests; no phase depends on a later one.
+- **TypeScript to latest stable** (library pins `^5.5.4` while vite 7 /
+ vitest 4 / typescript-eslint 8 are current), adopting
+ `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+ `verbatimModuleSyntax` — these directly serve killing the `as unknown as`
+ escapes and the wrong `global.d.ts` declaration.
+- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom,
+ `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its
+ own mechanical CI-gated PR with changelog review — this library
+ monkeypatches `fetch`, `sendBeacon`, and DOM prototypes, so jsdom and
+ Playwright behavior changes are real risks.
+- **`prebid.js` is excluded from casual bumps:** the runtime Prebid is the
+ external R2 bundle locked by manifest hash and SRI; upgrading it is its own
+ coordinated deploy. The npm pin and the deployed bundle version stay
+ documented together so tests exercise the version production runs.
+- **Standing policy:** monthly dependency review; no migration phase starts
+ more than one minor behind latest stable.
---
-## 9. Alternatives considered
-
-1. **Keep patching APS point-failures without telemetry.** Rejected: three
- consecutive correct fixes have not produced ads; without disposition data
- the next fix is another guess.
-2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but
- changes GAM reporting/pacing semantics unilaterally; kept as the opt-in
- fallback (6.3) instead.
-3. **Full library rewrite in one branch.** Rejected: the browser-spec safety
- net is thin in exactly the areas being changed; phased extraction under
- tests is slower but survivable.
-4. **Drop the ES5 bootstrap entirely (bundle-only).** Cleanest, but loses the
- pinned "ads still render if the bundle fails" guarantee; the
- generated-fallback approach (7.6) keeps that guarantee without the dual
- maintenance.
+## 8. Migration plan
+
+Reordered so every phase's prerequisites precede it; each phase ships behind a
+feature flag with canary thresholds and rollback criteria.
+
+- **Phase 0 — Contracts and toolchain.** Settle G1–G5 in code-adjacent docs;
+ toolchain upgrades (7.11); the trace envelope + beacon + four-adapter ingest
+ (accept-count-drop outside Fastly) behind a flag; server drop-reason
+ surfacing (5.4); reason codes on today's silent returns; delete the dead
+ expando writes. No runtime behavior change for pages with the flag off.
+- **Phase 1 — Runtime ABI + APS admission/identity.** G3 kernel registry in
+ `tsjs-core` (context-provider fix is the proof); wire-schema crate + shared
+ corpus (6.7); mediation selection helper + opt-in merge (6.1); render token
+ for renderer-only bids (6.4); renderer-endpoint startup validation (6.6);
+ bridge hardening minus fallback (6.8). APS renders after this phase wherever
+ GAM line items and configuration permit — the no-GAM fallback is explicitly
+ Phase 2.
+- **Phase 2 — Render state machine + GPT correctness.** Minimal `SlotRecord`
+ core (just enough for the state machine keys; full registry lands in
+ Phase 3); awaitable renderer conversion; the exactly-once fallback (6.5,
+ G4); restore #922/#997 attribution and orphan recovery; correlator and SRA
+ fixes (7.8).
+- **Phase 3 — Structure.** Full layering + boundary lint, plugin lifecycle +
+ `PageSession` (7.6), adapters (7.2), full slot registry (7.3), messaging
+ module (7.5), namespace migration with its compatibility window (7.4),
+ asset content-addressing fix + server bundle precompute (G5, 7.10).
+- **Phase 4 — Decomposition.** File splits (7.9), script-guard consolidation,
+ bootstrap shrink behind its own flag with replay-timing specs (7.7), and
+ the end of the public-global compatibility window.
---
-## 10. Risks
+## 9. Test acceptance matrix
+
+Blocking CI is hermetic (the deterministic PUC/message harness); a separate
+staged smoke suite covers real GAM line items and is release-gating, not
+PR-gating.
+
+| Area | Must cover |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Mediation | both lifecycles (ordinary + SSAT split dispatch/collect); ties, floors, deals, duplicate demand; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) |
+| Cache identity | non-APS cache-backed bids: byte-identical `hb_adid` + cache coordinates (regression); PUC `?uuid=` fetch path |
+| Render token | `^[a-z0-9]{12}$`; CSPRNG source; collision retry; TTL; one-time consumption; cross-slot/auction uniqueness |
+| Fallback | races vs bridge claim; nonempty-GAM protection; repeated refresh; SPA navigation cancellation; slot destruction; exactly-once terminal transition |
+| Render semantics | no billing after runner-load failure; `render_accepted` vs `render_confirmed` labeling; opaque-frame honesty (no painted/blank claim) |
+| Bridge security | wrong-slot tokens; nested foreign frames; replayed + duplicate messages; stolen tokens; previous-navigation tokens; SafeFrame positive case |
+| Beacon | trace joins; ordering/dedup via `(trace_id, nav_gen, seq)`; batching across navigations; loss tolerance; ingest abuse (oversize, malformed, cross-origin); all-adapter routing |
+| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection |
+| Runtime ABI | one kernel instance under concatenation; deferred registration after install request; per-plugin failure isolation; abi-version mismatch refusal |
+| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); `PageSession` disposal inventory; pre-init `tsCreativeConfig` compatibility; dual-name global window |
+| Delivery | immutable-cache behavior across a simulated rolling deploy; deterministic raw/gzip/Brotli bundle budgets vs baseline artifact |
+| Observability | drop-reason summary on any partial drop; page-bids structured `debug` field gating; diagnostic mode unsampled completeness |
+
+---
-- **Mediator merge policy (6.1)** changes auction economics where a mediator is
- configured; mitigated by the explicit `mediator_only` strategy and the
- delivery-report visibility.
-- **Beacon volume**: bounded by batching, sampling, and closed enums; the
- ingest route is fire-and-forget and cannot block rendering.
-- **Schema generation** adds a build step; mitigated by checking generated
- artifacts into the tree and diffing them in CI.
-- **Bootstrap shrink** touches the most load-order-sensitive code in the
- product; it is deliberately last (Phase 4) and behind the browser specs.
+## 10. Alternatives considered
-## 11. Success criteria
+1. **Keep patching APS point-failures without telemetry.** Rejected: three
+ consecutive correct fixes have not produced ads; without disposition data
+ the next fix is another guess.
+2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but
+ changes GAM reporting/pacing semantics unilaterally; kept as the opt-in,
+ state-machine-guarded fallback instead.
+3. **Single module graph / shared chunks instead of the registration ABI.**
+ Cleaner long-term, but changes the delivery pipeline (chunk loading) now;
+ recorded as the successor option behind the same ABI surface.
+4. **Full library rewrite in one branch.** Rejected: the browser-spec safety
+ net is thin in exactly the areas being changed.
+5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the
+ bundle fails" guarantee; the generated-fallback approach keeps it without
+ dual maintenance.
+
+## 11. Risks
+
+- **Merge strategy misconfiguration** changes auction economics; mitigated by
+ keeping `mediator_only` the default, the selection report, and omitted
+ serialization at defaults.
+- **Beacon abuse/volume:** bounded by pre-parse caps, origin checks, rate
+ limits, sticky sampling, and closed enums.
+- **ABI freeze risk:** `tsjs._internal.registry` becomes load-bearing;
+ versioned from day one, majors checked at lookup.
+- **Bootstrap replay** changes observable ordering; own flag, replay-timing
+ specs, staged rollout.
+- **Schema generation** adds a build step; checked-in artifacts + staleness CI.
+
+## 12. Success criteria
1. APS creatives render on a reference page in each configured flow (SSAT,
- Prebid adapter, page-bids), proven by browser specs and by disposition
- telemetry from a staged deployment.
-2. Every failure point in section 2 maps to a distinct, observable signal
- (server log, telemetry row, or beacon reason).
+ Prebid adapter, page-bids), proven hermetically in CI and by the staged
+ smoke suite against real GAM line items.
+2. Every failure point in section 2 maps to a distinct observable signal, and
+ **diagnostic mode** yields the failing reason from one page load; production
+ telemetry meets the stated SLO (5.3).
3. `eslint` boundary rules pass with zero exceptions; no integration imports
- another integration; `core`/`kernel` imports no integration.
+ another integration; stateful sharing goes through the versioned ABI.
4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
generated.
5. Trace counts are per-impression (no double counting), and orphaned-slot
recovery is covered by a non-vacuous test.
-6. The only TSJS-owned global is `window.tsjs`; no expando properties on GPT
- slots, GPT functions, or `pbjs`; the dead `__tsRenderGeneration` /
- `__tsRenderBid` writes are gone.
-7. The synchronous bundle is no larger than today's (target: smaller), and the
- reference-page time-from-bids-script-to-first-`display()` does not regress.
+6. The only TSJS-owned global is `window.tsjs` (public globals only inside
+ their announced compatibility window); no expandos on GPT slots, GPT
+ functions, or `pbjs`.
+7. Per-bundle raw/gzip/Brotli sizes are at or below the checked-in baseline
+ within stated tolerance, and the percentile-based
+ bids-script-to-first-`display()` assertion does not regress; server-side
+ per-request bundle concatenation/hashing is precomputed.
8. No existing warning is lost: every issue-surfacing condition logs at `warn`
- or above in the console, with the same reason code the beacon carries.
-9. TypeScript and the dev toolchain are on latest stable (with the new
- strictness flags enabled), `prebid.js`'s npm pin matches the documented
- deployed bundle version, and the monthly review policy is in CI docs.
+ or above with the same reason code the beacon carries.
+9. TypeScript and the dev toolchain are on latest stable with the new
+ strictness flags; `prebid.js`'s npm pin matches the documented deployed
+ bundle version; the monthly review policy is in CI docs.
+10. Rolling-deploy cache tests pass: a `?v=A` request never caches bytes other
+ than `A`.
-## 12. Open questions
+## 13. Open questions
1. Is a mediator configured in the affected production deployment? (Decides
whether A1 is the primary cause or a latent one.)
-2. What share of live APS demand is `tagtype: "script"`? (Decides how urgent
- the `allow_script_creatives` enablement guidance is.)
-3. Should the direct-render fallback (6.3) ever become default-on for
- publishers without GAM line items for `hb_bidder=aps`?
+2. What share of live APS demand is `tagtype: "script"`?
+3. Should `client_render_fallback` ever become default-on for publishers
+ without GAM line items for `hb_bidder=aps`?
4. Is PR #997 the intended restoration of the lost #922 attribution core, or
should the original be re-merged?
-5. Beacon endpoint naming and retention: `/_ts/client-events` vs folding into
- the existing telemetry namespace.
+5. Does any deployment serve descriptors for an origin whose config disables
+ APS (decides whether the renderer route becomes config-independent, 6.6)?
+6. Datasource naming/retention for client events, and whether Axum/Cloudflare/
+ Spin get real sinks or keep accept-count-drop.
From 196f0b788d9503bc301197c446a59cf767c851b1 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:59:44 -0700
Subject: [PATCH 003/194] Revise design spec for the second review round
Settles the correlation gap by minting the trace server-side for the
initial navigation and echoing client-minted traces through the
page-bids request, adds the per-attempt acknowledgement-nonce protocol
so render_accepted is an authenticated kernel-observed event, renames
observations to what the browser can actually see and terminates APS
at render_accepted, splits nurl and burl into independent idempotent
transitions with a declared billing policy, restricts fallback to an
attributed terminal empty GPT event via a per-slot request-cycle
protocol, moves the content hash into the asset pathname with retained
artifacts and 410-on-unknown, reorders the migration so asset identity
and the kernel ABI precede the beacon, drops the deal-priority rule
the bid model cannot express, replaces accept_sizes with request-what-
you-accept, minimizes the beacon payload with id kinds and a signed
diagnostic capability, makes the renderer document route unconditional
and versioned with a two-stage ack, bounds SafeFrame traversal to a
parent-chain walk, fixes the tsjs.que and TypeScript-5.9 facts, and
names the concrete numbers the review asked for.
---
...s-render-fix-and-tsjs-resilience-design.md | 1081 +++++++++--------
1 file changed, 556 insertions(+), 525 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 6415d36d8..144b7ca36 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,16 +1,17 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 2 — reworked after design review (review verdict:
- request changes). The five architectural contracts the review required are
- settled in section 4.
+- **Status:** revision 3 — reworked after the second design review round.
- **Date:** 2026-08-04
- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything
merged from `main` plus every rc-only merge), not just the delta pending
against `main`.
- **Inputs:** three code audits performed against this baseline (APS end-to-end
- trace, TSJS architecture audit, GPT integration map); design review of
- revision 1; open issues #926, #941, #944, #962, #964, #977, #983, #989,
- #993; open PR #997.
+ trace, TSJS architecture audit, GPT integration map); design reviews of
+ revisions 1 and 2; open issues #926, #941, #944, #962, #964, #977, #983,
+ #989, #993; open PR #997.
+- **Terminology:** the per-refresh counter is `refresh_gen` everywhere in this
+ document (revision 2 used `render_gen` in some places; that name is
+ retired).
---
@@ -56,8 +57,6 @@ SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`.
Only flow (d) — the demo path nobody runs in production — can render an APS
descriptor without GAM's cooperation.
-The failure points, ranked by likelihood and blast radius:
-
### 2.1 Admission: APS bids are eliminated before they can win
| # | Failure | Where |
@@ -105,253 +104,291 @@ deciding the winner. The audit's key facts:
1. **The bundle's handoff and initial-load code is dead in production.** The
bootstrap installs its wrappers first and sets the same sentinels the bundle
checks; ~200 lines of the TypeScript the test suite exercises most heavily
- never run on a real page. A fix landed only in `gpt/index.ts` has no
- production effect.
+ never run on a real page.
2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a
dead element** — and the orphan-recovery watcher built to repair exactly that
was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc
- side). `updateRender` (one impression, one row) now has no production
- caller, `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every
+ side). `updateRender` now has no production caller,
+ `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every
bridge-served impression double-counts in the trace. Open PR #997 appears to
- be the reworked replacement; restoring this is a correctness prerequisite,
- not a refactor.
-3. **TS refreshes never pass `changeCorrelator: false`**, so every TS-driven
- refresh starts a new GAM page-view correlator — silently changing roadblock,
- competitive-exclusion, and frequency-capping behavior.
+ be the reworked replacement.
+3. **TS refreshes never pass `changeCorrelator: false`**, silently changing
+ roadblock, competitive-exclusion, and frequency-capping behavior.
4. **`enableSingleRequest()` is called blind** after the publisher's own
- `enableServices()` has almost always run (post-#945 deferral), so SRA intent
- is asserted but not real — and on pages where TS wins the race, it forces
- SRA onto publishers who chose otherwise.
-5. Responsive resolution is a DOM-element-selection ladder (not GPT size
- mapping); ambiguity silently skips the slot for the whole pass.
-6. Three independent wrappers on `pubads().refresh` (bootstrap, bundle, prebid)
- coordinate via window-global booleans that async wrappers observe already
- reset.
+ `enableServices()` has almost always run.
+5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently
+ skips the slot for the whole pass.
+6. Three independent wrappers on `pubads().refresh` coordinate via
+ window-global booleans that async wrappers observe already reset.
+7. **GPT refresh is asynchronous and offers no request-cancellation
+ primitive** (`gpt/index.ts:1080`): once a refresh is issued, a response can
+ arrive arbitrarily late. Any fallback design must treat an issued GPT
+ request as uncancelable.
---
-## 4. Design gates — the five contracts settled before implementation
-
-The design review identified five architectural contracts every later section
-depends on. This revision settles them; changing any of these later reopens the
-review.
+## 4. Design gates — the five contracts
-### G1 — Trace identity and event envelope
+### G1 — Trace identity and correlation
**The client-visible auction id must never be ingested.** It is EC-derived by
-construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists), and server
-telemetry already uses a deliberately unrelated fresh UUID
-(`telemetry.rs:94-97`), so a join on it is both a privacy violation and
-impossible.
-
-Contract:
-
-- A **`trace_id`** is minted client-side per navigation: 128-bit CSPRNG
- (`crypto.getRandomValues`), hex-encoded, held in `PageSession`, never
- persisted, never derived from any identity.
-- Every event carries the envelope `{trace_id, nav_gen, render_gen, seq}`.
- `seq` is a per-trace monotonic counter (ordering + deduplication);
- `nav_gen`/`render_gen` scope events to a navigation and a refresh cycle, so a
- batch that spans SPA navigations remains attributable per event, not per
- batch.
-- The **server** stamps the same `trace_id` into its own telemetry rows by
- reading it from the beacon, never the reverse: server auction rows keep their
- independent telemetry UUID, and correlation happens only where the client
- reported a `trace_id` alongside the events it observed. Failures with no
- winning bid therefore still have a trace: the trace is client-born, not
- auction-born.
-- **Sampling is sticky per trace** (decided once at trace mint, recorded in the
- envelope), never per event — a sampled trace is complete or absent.
+construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists). Server
+telemetry uses an independent fresh UUID (`telemetry.rs:94-97`) — and, decisive
+for the design: **initial-HTML auction telemetry is emitted before page
+JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so a
+client-minted trace can never retroactively join it.
+
+Contract — correlation is minted by whoever acts first:
+
+- **Initial navigation (`nav_gen = 0`):** the **server** mints a
+ `trace_id` — 128-bit CSPRNG, hex (`^[0-9a-f]{32}$`), derived from nothing —
+ per HTML response, writes it into that response's auction telemetry rows at
+ emit time, and injects it into the page as a `tsjs` boot field alongside the
+ sampling decision. The client's `NavigationSession` adopts it. It is a new
+ value, never `AuctionRequest.id`.
+- **SPA navigations (`nav_gen > 0`):** the **client** mints the `trace_id` for
+ the navigation and sends it in the `/_ts/page-bids` request body; the server
+ records it contemporaneously in that auction's telemetry rows. No
+ retroactive joining anywhere.
+- **Envelope:** every event carries
+ `{trace_id, sampled, nav_gen, refresh_gen, seq}` — per event, not per batch,
+ so a transport batch may span navigations without ambiguity. `seq` is a
+ per-trace monotonic counter for ordering and deduplication.
+- **Sampling** is decided once per trace (server-decided for `nav_gen 0`,
+ client-decided from the injected rate for later navigations) and recorded in
+ the envelope; a sampled trace is complete or absent.
### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID
-**The PBS Cache contract stays untouched.** Today `hb_adid` deliberately
-prefers the Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache
-coordinates and the bridge's cache fetch assume `?uuid=`
-(`publisher.rs:3450`, `gpt/index.ts:1700`) — that is the Prebid Universal
-Creative's documented contract. Revision 1's "token for every SSAT bid" would
-have broken every cache-backed render and is withdrawn.
+**The PBS Cache contract stays untouched.** `hb_adid` deliberately prefers the
+Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache coordinates
+and the bridge's cache fetch assume `?uuid=` (`publisher.rs:3450`,
+`gpt/index.ts:1700`).
Contract:
-- Bids with a cache id: `hb_adid` = cache UUID, exactly as today.
-- Bids with markup and no cache id: `hb_adid` = existing fallback chain
- (`ad_id`, then bid id), exactly as today.
+- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. Bids with
+ markup and no cache id: existing fallback chain, exactly as today.
- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**,
- format `^[a-z0-9]{12}$` (12 chars exactly), CSPRNG-generated with collision
- retry within the auction, unique across slots and auctions, one-time
- consumption in the bridge registry, TTL-bounded. Twelve characters sit well
- inside GAM's documented 40-character targeting-value limit.
-- The client-side Prebid adapter path keeps Prebid's generated `adId` (that is
- Prebid's own contract); both paths register into one bridge registry keyed by
- whichever id that path will observe.
+ format `^[a-z0-9]{12}$` (12 chars exactly, 36¹² ≈ 4.7 × 10¹⁸ values),
+ CSPRNG-generated. Collision handling is honest about its scope: retry on
+ collision **within the minting auction** (the only scope the server can
+ check without storage); cross-auction uniqueness is **probabilistic**, with
+ the birthday bound documented (at 10⁶ live tokens the collision probability
+ is ~10⁻⁷) and made harmless by scoping: the client registry keys tokens per
+ `(trace_id, nav_gen)`, so a cross-page collision cannot cross wires.
+- Token lifecycle: TTL **15 minutes** from mint, one-time consumption in the
+ bridge registry, invalidated by navigation.
+- The client-side Prebid adapter path keeps Prebid's generated `adId`; both
+ paths register into one bridge registry keyed by whichever id that path
+ observes.
- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid`
- and cache coordinates; the token property test asserts `^[a-z0-9]{12}$`,
- uniqueness, one-time consumption, and TTL expiry.
+ and cache coordinates.
### G3 — Runtime ABI: how code shares state under the IIFE build
Every entry point is built as a self-contained IIFE with dynamic imports
inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs
-(`bundle.rs:23`). **A module `import` therefore never shares state across
-bundles** — an `aps/index.ts` alone would just mint another private copy. This
-is already a live defect beyond APS: `core/context.ts:11` holds a private
-context-provider `Map` while `permutive/index.ts:102` registers into its own
-independently bundled copy.
+(`bundle.rs:23`) — a module `import` never shares state across bundles. Live
+proof: `core/context.ts:11` holds a private context-provider `Map` while
+`permutive/index.ts:102` registers into its own copy.
Contract — **a versioned registration ABI on `window.tsjs._internal`**:
- The kernel ships **only** in `tsjs-core` (always first in the concatenated
unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly
once, guarded by a window-level sentinel.
-- All **stateful** services (slot registry, render state machine, APS renderer
- registry, context providers, event bus, beacon queue) exist once, owned by
- the kernel, and are reached **only** through
- `tsjs._internal.registry.get(name, minVersion)` at call time — never through
- imported module state. Pure stateless helpers may still be imported and
- inlined freely.
-- Deferred bundles (prebid) and later scripts interact through the same ABI;
- `abi` majors are checked at lookup and a mismatch is a logged, telemetered
- refusal, not a silent no-op.
-- The alternative (single module graph / shared chunks emitted by the build and
- loaded as real modules) is recorded as the long-term option; the ABI is
- chosen now because it works under today's concatenation and deferred
- loading without changing the delivery pipeline.
-- This gate precedes and unblocks: the event bus, the slot registry, the
- install registry, `PageSession`, the messaging service, and fixes the
- context-provider split as its first proof.
-
-### G4 — Exactly-once render state machine, and honest success semantics
-
-**Absence of a bridge request is not proof GAM failed** — GAM may legitimately
-serve a different, higher-priority creative without ever invoking the TS
-bridge. And **`renderer-ready` means Amazon's runner script loaded, not that an
-ad painted** (`aps.rs:105`); the current direct renderer returns `true`
-immediately while the real outcome settles asynchronously for up to ten
-seconds (`render.ts:318`).
-
-Contract:
-
-- One render state machine per placement instance, keyed by
- `(trace_id, nav_gen, slot, refresh_gen, render_token)`, with exactly-once
- terminal transition. States:
- `targeting_set → bridge_claimed | gam_filled_other | fallback_running →
-render_accepted → render_confirmed | render_failed(reason)`.
-- **The fallback never replaces a nonempty GAM render.** Any bridge claim or
- nonempty `slotRenderEnded` cancels a pending fallback; an **empty**
- `slotRenderEnded` may trigger it; navigation, refresh, handoff, and slot
- destruction invalidate stale attempts.
-- The renderer API becomes awaitable with cancellation and an explicit
- terminal reason; no fire-and-forget `true`.
-- Event taxonomy replaces the single `render_ok`:
- - `bridge_response_sent` — the bridge handed a payload to the PUC;
- - `render_accepted` — the renderer document loaded Amazon's runner
- (today's "ready");
- - `render_confirmed` — observed evidence of a paint (nonempty
- `slotRenderEnded` for GAM fills; for the sandboxed renderer, iframe
- load + non-collapsed geometry where observable).
-- **Billing/win callbacks fire no earlier than `render_accepted`, and
- `render_confirmed` is the only state reported as success.** Where APS
- provides no paint acknowledgement, the design says so plainly: telemetry can
- distinguish "runner loaded" from "nothing loaded," but **cannot distinguish
- a painted ad from a blank one** inside the opaque frame — rows terminate at
- `render_accepted` and are labeled as such, not upgraded.
+- **Construction ownership:** the kernel constructs and registers the core
+ service instances (event bus, beacon queue, session objects, slot registry,
+ render state machine) during its own boot; integrations construct only
+ integration-scoped services and register them during their `install()`.
+- All **stateful** services are reached only through
+ `tsjs._internal.registry.get(name, minVersion)` at call time. Pure stateless
+ helpers may be imported and inlined freely.
+- `abi` majors are checked at lookup; a mismatch is a logged, telemetered
+ refusal, not a silent no-op. Mixed-version delivery (old deferred bundle,
+ new core) is a tested scenario, not an accident.
+- The single-module-graph build is recorded as the successor option behind the
+ same ABI surface.
+
+### G4 — Render lifecycle: cycles, acknowledgements, and honest states
+
+Four sub-contracts, each fixing a hole the reviews identified.
+
+**G4a — GPT request-cycle protocol.** `slotRenderEnded` identifies a slot, not
+a request, and the bridge currently reads live `window.tsjs.bids`
+(`gpt/index.ts:1606`) while the generation snapshots are dead writes
+(`gpt/index.ts:1085`). Contract: every TS-issued `display()`/`refresh()` opens
+a **cycle** `(slot, refresh_gen)` pushed onto a per-slot pending-cycle queue;
+`slotRequested` confirms it; GPT fires slot events in order per slot, so
+`slotRenderEnded` is attributed to the oldest confirmed pending cycle for the
+slot. Each bridge token and each render attempt binds to exactly one cycle. If
+attribution is ambiguous (overlapping cycles the queue cannot separate, or an
+event with no pending cycle), the state machine for that slot **fails closed**:
+no fallback, `render_fail{cycle_unattributable}`, console warning.
+
+**G4b — Acknowledgement path.** Today the renderer document posts ready only
+to its immediate parent (`aps.rs:105`); in the PUC path that parent is the
+nested renderer frame, which resolves a local promise (`render.ts:423`) the
+top-level kernel cannot observe — and callbacks currently fire right after the
+bridge posts its response (`gpt/index.ts:1572`, `:1620`). Contract: the bridge
+response carries a **per-attempt CSPRNG acknowledgement nonce**; the dynamic
+renderer posts versioned `render_accepted` / `render_failed{reason}` messages
+**to the kernel** (top window), carrying the nonce; the kernel validates
+source ownership, nonce, token, `nav_gen`, and `refresh_gen` before any state
+transition or callback. This protocol is pinned by tests for all three flows:
+SSAT, client-Prebid, and nested SafeFrame.
+
+**G4c — Honest observation names.** The browser cannot see inside an opaque
+APS frame, and the iframe's geometry is assigned by our own renderer — it
+proves nothing about content. A nonempty `slotRenderEnded` proves GAM
+delivered a creative container, not that the nested runner painted. The state
+machine therefore records observations under accurate names —
+`gam_nonempty`, `gam_empty`, `renderer_document_loaded`, `runner_loaded`,
+`runner_failed` — and **APS attempts terminate at `render_accepted`**
+(= authenticated `runner_loaded` ack) unless Amazon provides a real completion
+acknowledgement. `render_confirmed` exists only for paths with same-origin
+observable content (inline adm frames TS itself writes); it is never derived
+from geometry or from PUC container delivery. Tests: accepted-but-blank, and
+nonempty-`slotRenderEnded`-before-bridge-claim.
+
+**G4d — `nurl`/`burl` are separate business events.** OpenRTB 2.6
+distinguishes them: `nurl` is the win notice (implies neither delivery nor
+billability); `burl` is the billable-event notice under exchange policy.
+Today both fire together (`gpt/index.ts:459`). Contract — independent,
+idempotent transitions:
+
+1. winner selection → fire `nurl`;
+2. `render_accepted` (authenticated) → fire `burl` — this is the **declared
+ commercial policy** for APS given no paint acknowledgement exists, recorded
+ here explicitly rather than implied;
+3. terminal failure after acceptance → no un-firing; the row is labeled
+ `billed_then_failed` so the policy's cost is measurable.
+
+**G4e — Fallback trigger.** GPT offers no cancellation (section 3.7), so a
+timeout can race a late fill that arrives after a fallback has rendered and
+billed. Contract: the opt-in direct fallback
+(`[auction].client_render_fallback = "renderer"`) renders **only after an
+explicit terminal empty event for the bound cycle** (`gam_empty` from G4a
+attribution). A timeout emits diagnostics (`render_fail{bridge_claim_timeout}`)
+and **never renders**. For publisher-owned (adopted) slots, fallback is
+disabled entirely. TS-owned-slot timeout rendering is admitted only as a
+possible future extension that must first destroy the slot to retire the
+request, and is out of scope here.
### G5 — Deployment contracts
- **Config rollback:** every new auction/config field is default-valued and
- **omitted from serialization at its default** (`AuctionConfig` denies
- unknown fields, `auction_config_types.rs:7`), so blobs written by a new
- binary remain readable by the previous one unless an operator opts in.
-- **Ingest routing:** the beacon route exists in **all four adapters**
- (Fastly, Axum, Cloudflare, Spin) as an early, EC-free, filter-free route.
- Only Fastly has a real telemetry sink today; the no-sink behavior elsewhere
- is "accept, count, drop" — defined, not accidental.
-- **Storage:** a new dedicated datasource for client events (the existing
- auction datasource cannot hold the shape without migration); schema,
- retention, and sampling documented with the datasource definition.
-- **Asset content-addressing is fixed, not assumed.** Script URLs embed a
- content hash but the handler ignores the query and serves current registry
- bytes from the path alone (`tsjs.rs:3`, `publisher.rs:294`) — during a
- rolling deploy an old `?v=A` request can receive bytes `B` and cache them
- under `A`. The handler must validate the requested hash and serve the
- matching immutable artifact (or answer with the current hash's redirect);
- rolling-deploy cache tests pin this.
-- **Phase ordering** follows section 9; every phase ships behind a feature
- flag with canary thresholds and rollback criteria.
+ omitted from serialization at its default (`auction_config_types.rs:7`
+ denies unknown fields), so blobs written by a new binary remain readable by
+ the previous one unless an operator opts in.
+- **Asset identity is path-based and retained.** A query hash the handler
+ ignores (`tsjs.rs:3`, `publisher.rs:294`) is not content addressing, and
+ redirecting an old hash to current bytes just executes new code under old
+ HTML, bootstrap flags, and ABI expectations. Contract: the content hash
+ moves into the **pathname** (`/static/tsjs//.js`); the server
+ serves through a hash→bytes manifest that **retains prior artifacts** beyond
+ the maximum HTML cache lifetime plus the deferred-load window (retention
+ floor: 7 days); `Cache-Control: immutable` only on exact hash matches;
+ unknown hashes answer `410 Gone` with `no-store` — never a redirect to
+ different bytes. Precomputed concatenations are keyed by the **ordered
+ module-ID vector** (order affects side effects), not the set.
+- **Ingest routing:** the beacon route exists in all four adapters (Fastly,
+ Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. Only Fastly
+ has a real sink today; the others accept-count-drop by explicit contract.
+- **Storage:** a new dedicated datasource named `ts_client_events`; retention
+ **30 days**; production sampling default **10%** (operator-tunable);
+ schema versioned with the event enum.
+- **Phase ordering** is section 8's; each phase ships behind a feature flag
+ with the named canary thresholds and rollback criteria in section 8.
---
## 5. Workstream 1 — Observability
-### 5.1 Client disposition beacon
+### 5.1 Event payload — minimized by design
-A kernel module batches disposition events to `POST /_ts/client-events`:
+High-cardinality identifiers stay out of the beacon: no raw `hb_adid`, no raw
+Prebid `adId`, no free-form slot strings.
```
-{ v: 1, trace: {trace_id, sampled}, events: [
- { seq, nav_gen, render_gen, t: "bid_received", slot, bidder, source }
- { seq, nav_gen, render_gen, t: "targeting_set", slot, hbAdid }
- { seq, nav_gen, render_gen, t: "bridge_request", slot, adId, matched }
- { seq, nav_gen, render_gen, t: "bridge_response_sent", slot, source }
- { seq, nav_gen, render_gen, t: "render_attempt", slot, source }
- { seq, nav_gen, render_gen, t: "render_accepted", slot, source }
- { seq, nav_gen, render_gen, t: "render_confirmed", slot, source }
- { seq, nav_gen, render_gen, t: "render_fail", slot, source, reason }
+{ v: 1, events: [
+ { trace_id, sampled, nav_gen, refresh_gen, seq,
+ t: "bid_received" | "targeting_set" | "bridge_request" |
+ "bridge_response_sent" | "render_attempt" | "render_accepted" |
+ "render_confirmed" | "render_fail",
+ slot, // configured slot id if in the injected slot set, else "s"
+ id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none"
+ matched, // bridge_request only: token/id equality result
+ source, // "renderer" | "adm" | "pbs-cache" | "gam"
+ reason } // render_fail only: closed enum below
] }
```
-- **Transport:** `fetch(..., {keepalive: true, credentials: "omit"})` is the
- primary transport, because `sendBeacon` always sends credentials and its
- `true` return only means "queued," not "received." `sendBeacon` remains the
- documented last-resort fallback on `pagehide` where keepalive is
- unavailable, and the ingest handler ignores credentials in all cases.
-- **Batching:** flush on `visibilitychange`/`pagehide` and every 5 s; each
- event self-describes its navigation via the envelope, so batches spanning
- SPA navigations stay attributable.
-- **Reasons are a closed enum**, structurally serialized (never interpolated
- into log lines): `renderer_no_ready`, `descriptor_invalid`,
- `bridge_id_mismatch`, `gam_empty`, `gam_filled_other`, `no_render_source`,
- `slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`,
- `fallback_cancelled`, `timeout`. `renderer_no_ready` (not
- `renderer_endpoint_404`) is deliberate: the opaque iframe cannot read an
- HTTP status, so the observable fact is "no ready message before timeout."
-
-### 5.2 Ingest contract
-
-- Registered in all four adapters before auth/EC/filters (G5); same-origin
- enforced via `Origin`/`Sec-Fetch-Site` checks; hard caps on body bytes,
- event count, and string lengths **enforced before parsing or logging**;
- malformed rows dropped and counted; per-IP rate limiting; responds
- `204 Cache-Control: no-store`; touches no KV and mints no identity.
-- Server logs a bounded, structured summary per batch; the Fastly sink writes
- to the new datasource (G5); other adapters count-and-drop until a sink
- exists.
-
-### 5.3 Two modes, honestly separated
-
-A sampled, best-effort beacon **cannot** guarantee a complete diagnosis from
-one page load — so the design stops claiming it:
-
-- **Production telemetry:** sticky-sampled traces, SLO "a delivery failure
- mode occurring on ≥ N% of impressions is visible in the datasource within
- one hour."
-- **Diagnostic mode:** explicitly enabled (tester cookie / query flag),
- unsampled, full event stream plus console mirroring — this is the "one page
- load tells you which failure fired" tool.
-
-### 5.4 Server-side drop-reason surfacing
-
-- Emit a bounded structured summary **whenever any bid is dropped** (not only
- when zero survive): per-slot reason counts, capped.
-- Add `drop_reasons` to auction telemetry rows.
-- The initial-HTML `ts-debug` comment gains the drop summary; `/_ts/page-bids`
- returns JSON and **cannot carry an HTML comment**, so it gains a gated
- structured `debug` field instead, enabled by the same tester gate.
-- Startup validation warnings: APS enabled while `allow_script_creatives =
-false` ("script-type APS demand will be dropped"); any direct provider
- configured alongside a mediator without the merge strategy of 6.1 ("provider
- bids cannot win as configured").
+- Every stored string is either a member of a server-known allowlist (slot ids
+ from the injected config, enum members) or a bounded ordinal — nothing free
+ .form is persisted.
+- Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`,
+ `descriptor_invalid`, `bridge_id_mismatch`, `cycle_unattributable`,
+ `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`,
+ `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`,
+ `abi_mismatch`. (`renderer_no_ready` from revision 2 is split by the G4b/6.6
+ protocol into document-load vs runner-load failures.)
+
+### 5.2 Transport
+
+`fetch(..., {keepalive: true, credentials: "omit"})` primary; `sendBeacon` as
+the documented last-resort `pagehide` fallback (credentialed by platform
+design, and its `true` means queued, not received — the handler ignores
+credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s.
+
+### 5.3 Ingest wire contract (numeric, complete)
+
+- Route: `POST /_ts/client-events`, registered in all four adapters before
+ auth/EC/filters. Content type: `application/json` only (no
+ `Content-Encoding`; compressed bodies rejected). Responds
+ `204 Cache-Control: no-store`.
+- Limits enforced **before parse or log**: body ≤ **16 KiB**; ≤ **64** events
+ per batch; any string field ≤ **64** chars; `trace_id` must match
+ `^[0-9a-f]{32}$`; `nav_gen`/`refresh_gen`/`seq` are integers in
+ `[0, 2³¹)`. Violation → `204` (accepted-and-dropped) + abuse counter; the
+ endpoint never echoes input.
+- Same-origin enforcement: `Sec-Fetch-Site: same-origin` when present;
+ otherwise `Origin` must match the serving host; **absent both → reject**
+ (drop-and-count). All strings are structurally serialized (never
+ interpolated into log lines).
+- Client IP for rate limiting is derived per adapter from its documented
+ trusted source (Fastly: the platform client IP; Axum: configured trusted
+ proxy header; Cloudflare/Spin: platform equivalents). Rate limiting uses the
+ platform limiter where one exists (Fastly); portable adapters ship a
+ best-effort in-memory limiter and the policy is **fail-open with an abuse
+ counter** (dropping telemetry must never block ad delivery).
+- **Diagnostic mode is a server-injected capability, not a query flag.** The
+ tester gate (cookie) is evaluated server-side at HTML render; the page
+ receives a short-lived signed capability token (HMAC over
+ `trace_id + expiry`, ≤ 15 minutes) which the client echoes in the batch.
+ The ingest handler verifies the signature — this works with
+ `credentials: "omit"` because the capability travels in the payload, and a
+ public query flag alone can never switch a session to unsampled.
+
+### 5.4 Two modes, honestly separated
+
+- **Production telemetry:** sticky-sampled (default 10%), SLO: **a delivery
+ failure mode affecting ≥ 1% of impressions is visible in `ts_client_events`
+ within one hour**.
+- **Diagnostic mode:** capability-gated, unsampled, full event stream plus
+ console mirroring — the "one page load names the failing reason" tool.
+
+### 5.5 Server-side drop-reason surfacing
+
+- Emit a bounded structured summary **whenever any bid is dropped** (per-slot
+ reason counts, capped), not only when zero survive.
+- Add `drop_reasons` to auction telemetry rows; add the drop summary to the
+ initial-HTML `ts-debug` comment; `/_ts/page-bids` (JSON) gains a gated
+ structured `debug` field under the same tester gate.
+- Startup validation warnings: APS enabled while
+ `allow_script_creatives = false`; any direct provider configured alongside a
+ mediator without the 6.1 merge strategy.
---
@@ -359,124 +396,128 @@ false` ("script-type APS demand will be dropped"); any direct provider
### 6.1 Mediation: opt-in merge, `mediator_only` stays the default
-The configured contract today is explicit — the mediator is the final
-decision-maker (`auction_config_types.rs:48`) — and changing that default
-silently would be an economic breaking change with a rollback hazard. So:
-
- `[auction].winner_selection = "mediator_only"` (default, today's behavior,
- now explicit) or `"merge_highest_cpm"` (opt-in). The field is omitted from
- serialized blobs at its default (G5).
-- `merge_highest_cpm` semantics, defined up front: comparison in the auction's
- decoded CPM currency; slot floors apply to both populations; a bid present
- in both (a provider bid the mediator also returned) counts once, by
- provenance `mediator`; deals outrank open bids regardless of CPM; ties break
- to the mediator; a mediator timeout degrades to direct-provider selection
- (today's short-circuit) and is reported as such.
-- **One candidate-selection helper serves both mediation lifecycles** — the
- ordinary/page-bids path (`orchestrator.rs:412-431`) and the initial-SSAT
- split dispatch/collect path (`orchestrator.rs:1320`) — with tests for each.
-- A **selection report** (`winner_source`, `mediator_superseded` counts) is
- emitted separately from the delivery-conversion drop reasons, so "lost the
- merge" is never conflated with "failed to serialize."
-
-### 6.2 Dimensions: exact membership stays; flexibility is operator-declared
-
-Revision 1's containment rule ("never larger on either axis") would still
-reject its own motivating example (a 300×600 answer on a 300×250/728×90 slot)
-while admitting pathological 1×1 sizes — withdrawn. Instead:
-
-- Exact size membership (`aps.rs:657-668`) remains the default; it is
- consistent with discrete GAM slot formats.
-- An operator may declare flexibility per slot:
- `accept_sizes = [[300, 600], …]` (an explicit allow-list of additional
- creative sizes, with documentation that GAM line items must accept them) —
- no inference, no aspect heuristics in v1. Admitted alternate sizes set
- `hb_size` targeting (set and cleared with the other `hb_*` keys) and the
- served size is reported in the selection report.
+ now explicit) or `"merge_highest_cpm"` (opt-in); omitted from serialized
+ blobs at the default (G5).
+- `merge_highest_cpm` semantics: comparison in decoded CPM; **currency
+ mismatch is a rejection** (the mismatched bid is dropped with a selection
+ reason; no conversion in v1); slot floors apply to both populations; ties
+ break to the mediator; a mediator timeout degrades to direct-provider
+ selection and is reported.
+- **Deduplication key:** the server constructs the mediator's input, so it
+ records provenance at forwarding time — `(provider_name, upstream_bid_id)`
+ per candidate — and carries a provenance map keyed by the id it sent.
+ A mediator bid whose id maps back to a forwarded candidate counts once, as
+ provenance `mediator`. A mediator bid whose id was **transformed beyond the
+ map** is treated as distinct mediator demand (documented limitation).
+- **Deal priority is out of scope for v1.** The internal `Bid`
+ (`types.rs:231`) carries no deal identity; inventing a priority rule the
+ model cannot express would be fiction. Extending the bid model with
+ `deal_id`/deal type and a deal-first rule is recorded as follow-up work;
+ until then deals compete by CPM like everything else and the limitation is
+ documented in the config reference.
+- One candidate-selection helper serves **both** mediation lifecycles
+ (ordinary/page-bids, `orchestrator.rs:412-431`; initial-SSAT split
+ dispatch/collect, `orchestrator.rs:1320`), with tests for each.
+- A **selection report** (`winner_source`, `mediator_superseded`,
+ `currency_rejected`, dedup hits) is emitted separately from
+ delivery-conversion drop reasons.
+
+### 6.2 Dimensions: the contract is "request what you accept"
+
+Revision 2's operator `accept_sizes` allow-list is withdrawn on the review's
+sharper observation: if an alternate size is acceptable, it belongs in the
+slot's **requested formats** — APS should be asked for it. Accepting an
+unrequested response size would conceal an upstream protocol violation.
+
+- Exact size membership (`aps.rs:657-668`) remains the admission rule,
+ unchanged.
+- The fix is configuration plus visibility: the drop summary (5.5) names the
+ rejected size per slot (`invalid_dimensions{300x600}`), so an operator sees
+ exactly which format to add to the slot's `formats` if they want that
+ demand. Documentation gains a "sizing your slots for APS" section.
+- No `hb_size` key, no admission relaxation, no new config.
### 6.3 Script creatives
Keep the secure default (`allow_script_creatives = false`) but make the
-consequence loud (5.4) and document the enablement path for TAM-heavy
-publishers. The renderer sandbox already isolates script tag types; this is a
-policy toggle, not new machinery.
+consequence loud (5.5) and document the enablement path for TAM-heavy
+publishers.
### 6.4 Render identity
-Implemented exactly as G2: cache UUID untouched, render token only for
-renderer-only bids, one registry, token property tests
-(`^[a-z0-9]{12}$`, CSPRNG, collision retry, TTL, one-time consumption,
-cross-slot/auction uniqueness), and a non-APS cache-path regression test.
+Implemented exactly as G2 (token scope, format, TTL, per-navigation registry
+keying, one-time consumption, cache-path regression tests).
### 6.5 Fallback rendering
-Implemented exactly as G4: opt-in
-(`[auction].client_render_fallback = "renderer"`), driven by the render state
-machine, triggered only by an **empty** GAM render or a bridge-claim timeout
-with no fill evidence, cancelled by any bridge claim or nonempty render,
-invalidated by navigation/refresh/destruction, and reported with the G4 event
-taxonomy. The direct renderer is converted to an awaitable API first; the
-fallback lands only after that conversion.
-
-### 6.6 Renderer endpoint
-
-- Document the topology: within one deployment the renderer route and the APS
- provider share the same config gate (`aps.rs:1224`, `:1244`), so "server
- emits descriptors but lacks the route" is a **cross-deployment or stale-CDN
- problem**, and a 401 most plausibly comes from broad `[[handlers]]` auth
- patterns matching `/integrations/*` before route dispatch.
-- Therefore: validate at startup that no configured auth handler pattern
- covers `/integrations/aps/renderer`; make the static renderer document
- config-independent **iff** the deployment serves multiple origins from one
- config (recorded as an open question with the operator); version the
- renderer document and define its cache headers.
-- The client reports `renderer_no_ready` (5.1) — no status probe is added,
- because a probe would violate the no-new-critical-path-request budget.
-- CSP audit (C6) unchanged from revision 1: extend `APS_RENDERER_CSP` only
- with sources observed in real Amazon traffic, each justified in a comment
- and covered by the browser spec.
+Implemented exactly as G4e: renders only on an attributed terminal
+`gam_empty`; timeouts are diagnostics-only; disabled for adopted slots. The
+direct renderer is converted to an awaitable API with cancellation and a
+terminal reason first; the fallback lands only after that conversion.
+
+### 6.6 Renderer endpoint — unconditional, versioned, observable
+
+Topology is resolved now rather than left conditional:
+
+- **The static renderer document route registers unconditionally in every
+ adapter.** It contains no configuration, no secrets, and validates its input
+ client-side; serving it cannot leak anything, and conditional registration
+ is exactly what created the silent cross-deployment failure class. (The APS
+ _provider_ stays config-gated; only the static document is unconditional.)
+- The document is **versioned in its path**
+ (`/integrations/aps/renderer/v1`) and served `Cache-Control: no-store`;
+ descriptor compatibility across N/N−1 is guaranteed by the outer-tolerant
+ validation of 6.7. The client pins the version it targets.
+- Startup validation fails loudly if any configured auth handler pattern
+ covers `/integrations/aps/renderer`.
+- **Two-stage acknowledgement (with G4b):** the document first posts an
+ authenticated `document_loaded` (proving route + auth + CSP allowed the
+ document itself), then the separate runner-load result. This splits the old
+ blind timeout into `renderer_document_no_load` (route/auth/stale-CDN/network)
+ vs `runner_no_load` / `runner_failed` (Amazon script or CSP) — distinct
+ signals, as the success criteria require.
+- Server-side: route status/version counters (requests, unknown-version,
+ auth-blocked) join the telemetry rows.
+- **CSP changes ship report-only first** (`Content-Security-Policy-Report-Only`
+ canary with a bounded report endpoint), then enforce; each added source is
+ justified in a comment and covered by the browser spec. Tests cover broad
+ auth patterns, stale versions, and CSP failures on all adapters.
### 6.7 One descriptor schema — structural generation, semantic validators kept
-- The wire truth is the **tagged enum** `BidRenderer` (the `type: "aps"`
- discriminator lives there, not on `ApsRendererV1` — `types.rs:188-211`), so
- the generated schema is the full tagged envelope.
-- Generation lives in a **separate wire-schema crate** (or a host-side
- xtask) — it cannot live in `trusted-server-js`'s build because core already
- depends on that crate (`Cargo.toml:45`) and the reverse edge would be a
- cycle. Generated TS artifacts are checked in; CI fails on staleness.
-- Generation covers **structure only** (fields, types, discriminator,
- version). The semantic security checks stay hand-written on both sides:
- URL/origin policy, canonical base64, length bounds, the exact one-bid
- envelope projection, cross-field equality. **Unknown-field tolerance applies
- only to the outer versioned descriptor; the decoded AAX envelope remains an
- exact projection** so `adm`, notification URLs, or sibling fields can never
- slip through unexamined.
-- A shared corpus — positive cases plus an adversarial set (extra fields,
- wrong versions, oversized payloads, URL smuggling, non-canonical base64) —
- runs through the Rust validator, the TS validator, and the inline renderer
- document in CI.
+- The wire truth is the tagged enum `BidRenderer` (discriminator lives there,
+ not on `ApsRendererV1` — `types.rs:188-211`); the generated schema is the
+ full tagged envelope.
+- Generation lives in a **separate wire-schema crate** (or host-side xtask) —
+ core already depends on `trusted-server-js` (`Cargo.toml:45`), so the
+ reverse edge would be a cycle. Generated TS artifacts are checked in; CI
+ fails on staleness.
+- Generation covers structure only; the semantic security checks stay
+ hand-written on both sides (URL/origin policy, canonical base64, length
+ bounds, the exact one-bid envelope projection, cross-field equality).
+ **Unknown-field tolerance applies only to the outer versioned descriptor;
+ the decoded AAX envelope remains an exact projection.**
+- A shared positive + adversarial corpus runs through the Rust validator, the
+ TS validator, and the inline renderer document in CI.
### 6.8 Bridge hardening
-- **Ownership proof stays source-first.** The bridge continues to resolve the
- message source to a slot and only then compares that slot's expected id
- (`gpt/index.ts:1599` order) — a MessageChannel port plus a token is not
- ownership proof, because an inbound port has no pre-established slot
- identity and the token is visible in `window.tsjs.bids`. For SafeFrame,
- source resolution is extended to walk nested browsing contexts via
- `window.frames` containment checks rather than DOM `querySelectorAll` only;
- where the source is unresolvable the bridge refuses (with
- `bridge_id_mismatch`) instead of trusting the token.
-- Adversarial tests are part of the contract: wrong-slot tokens, nested
- foreign frames, replayed and duplicated messages, stolen tokens, and
- previous-navigation tokens — not only the positive SafeFrame case.
-- Blanket top-of-listener hygiene: parse and ownership-check before any
- branch logic; new branches inherit protection.
-- Delete the dead duplicate renderer branch (C5); move its dedup and debug log
- into the live branch.
-- Renderer branches emit the same trace records as the adm and cache branches,
- under G4's event taxonomy and billing rules (C7).
+- **Ownership proof stays source-first**, and the SafeFrame extension is
+ bounded: the kernel maintains a map of known slot-root `WindowProxy` objects
+ (the iframes GPT created under each slot element); on a message, it walks
+ the **sender's own parent chain** (`event.source.parent`, …) up to depth
+ **5**, looking for a known root — it never enumerates or recursively scans
+ an attacker-controllable frame tree. Unresolvable source → refuse with
+ `bridge_id_mismatch`.
+- Adversarial tests: wrong-slot tokens, nested foreign frames, replayed and
+ duplicated messages, stolen tokens, previous-navigation tokens, plus the
+ positive SafeFrame case.
+- Blanket top-of-listener hygiene: parse and ownership-check before branch
+ logic.
+- Delete the dead duplicate renderer branch (C5); renderer branches emit the
+ same trace records as adm/cache branches under G4's taxonomy and the G4d
+ `nurl`/`burl` split (C7).
---
@@ -485,125 +526,113 @@ fallback lands only after that conversion.
### 7.1 Layering
```
-kernel/ boot, config, command queue, event bus, log, telemetry beacon
+kernel/ boot, config, queue, event bus, log, beacon, sessions
adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access
services/ slots (registry+handoff), auction client, render engine, consent
integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services)
```
-Rules, enforced by an eslint boundary rule in CI (`import/no-restricted-paths`):
-
-- `kernel` imports nothing above it; `adapters` import kernel only; `services`
- import kernel + adapters; `integrations` import kernel + services, **never
- each other**.
-- Stateful services are reached through the G3 registration ABI, never through
- imported module state; stateless helpers may be imported and inlined.
-- This dissolves today's inversions: `core/auction.ts` and `core/request.ts`
- importing `integrations/aps/render`, `gpt` and `prebid` importing `aps`, and
- `prebid` owning the GPT refresh wrapper.
+Boundary lint in CI (`import/no-restricted-paths`): `kernel` imports nothing
+above it; `adapters` import kernel only; `services` import kernel + adapters;
+`integrations` import kernel + services, never each other. Stateful services
+via the G3 ABI only.
### 7.2 Adapters: explicit absence, without giving up on late loaders
-Every external global is wrapped once with a state machine
-`present | pending | timed_out`, a queue for `pending`, and per-operation
-bounds. `timed_out` is **not terminal**: publishers legitimately lazy-load
-GPT, Prebid, and CMPs, so a later arrival transitions the adapter to `present`
-and drains what is still valid; individual queued operations carry their own
-timeouts and expire with a disposition reason rather than the adapter
-permanently disabling itself.
+`present | pending | timed_out` per external global; `timed_out` is
+non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and drains
+what is still valid); individual queued operations carry their own timeouts
+and expire with a disposition reason.
### 7.3 Slot registry service
-One registry owns all slot knowledge: publisher-defined vs TS-defined,
-adoption, handoff claims, responsive element resolution, refresh generation,
-targeting-key history — keyed by `WeakMap` plus a
-div-id index, owned by the kernel via the G3 ABI. Expando properties on live
-GPT objects are eliminated. The GPT integration feeds events in and executes
-registry decisions; the prebid refresh handler consumes the same registry.
+One registry owns slot knowledge (publisher- vs TS-defined, adoption, handoff
+claims, responsive resolution, pending request cycles per G4a, targeting-key
+history), keyed by `WeakMap` plus a div-id index,
+kernel-owned via the ABI. Expandos on live GPT objects are eliminated.
### 7.4 Global namespace policy — with a compatibility window
-One owned global, `window.tsjs`, public API versioned, coordination state
-under `tsjs._internal` (G3). But the migration must not break published
-contracts:
-
-- **Inventory first:** every current global is classified public
- (`globalThis.tscreative`, `tsCreativeConfig` — documented, settable
- pre-load) or private (`__tsjs_*` flags, expandos, sentinels).
-- **Public globals get a bounded dual-read/write window:** old and new names
- both work for a stated deprecation period, with pre-init compatibility tests
- (config set before the bundle loads must keep working); removal is its own
- later, announced change.
-- Private globals migrate immediately: per-slot expandos
- (`__tsRenderGeneration`, `__tsRenderBid` — dead writes today, delete now)
- into `SlotRecord`; function-object sentinels into a kernel-held `WeakSet`;
- boot flags into the `window.tsjs = window.tsjs || {cmd: []}` pattern.
+- One owned global, `window.tsjs`; public API versioned; coordination state
+ under `tsjs._internal` (G3). **The public queue keeps its existing name:
+ `window.tsjs.que`** (`types.ts:259`, drained at `core/index.ts:25`) —
+ revision 2's `cmd` was an error; renaming a public surface silently would
+ violate this very section.
+- Inventory first: every current global classified public
+ (`globalThis.tscreative`, `tsCreativeConfig`, `tsjs.que`) or private
+ (`__tsjs_*` flags, expandos, sentinels).
+- **Public globals: dual-read/write for a bounded window — two release
+ cycles, minimum 60 days — ending only after an adoption gate: beacon-observed
+ old-name usage below 0.1% of traces for 14 consecutive days.** Pre-init
+ compatibility tests pin that config set before the bundle loads keeps
+ working.
+- Private globals migrate immediately: dead expando writes deleted now; slot
+ state into `SlotRecord`; function sentinels into a kernel `WeakSet`; boot
+ flags into `tsjs` boot fields.
+- `requestAds` keeps its void signature; failure surfacing arrives as a **new
+ versioned async API** (`tsjs.requestAdsAsync(...): Promise`)
+ rather than changing the existing contract.
### 7.5 Messaging module
-All `postMessage` traffic goes through one module: versioned envelopes, message
-name constants (today `'Prebid Request'` appears as a bare literal at six
-sites, and the APS handshake exists in three hand-synced copies), source
-validation per 6.8, and one audit point.
-
-### 7.6 Plugin lifecycle
-
-`install()` replaces import-time side effects, with the semantics the review
-demanded:
-
-- `tsjs.definePlugin(id, version, install, dispose)` registers; the kernel
- resolves install requests against registrations, so **a deferred bundle that
- registers after `tsjs.install([...])` was requested is installed on
- arrival** (pending-install queue), bounded by a missing-module timeout that
- emits `bundle_partial`.
+All `postMessage` traffic through one module: versioned envelopes, message
+name constants, the G4b acknowledgement nonces, source validation per 6.8, one
+audit point.
+
+### 7.6 Plugin lifecycle and session model
+
+- **Activation:** Rust owns integration selection today and continues to — the
+ server injects a **versioned install manifest** (enabled plugin ids +
+ expected versions, in injection order) into the pre-core `tsjs.que`. The
+ kernel executes the manifest on boot; nobody else calls install in
+ production (the API remains callable for tests).
+- `tsjs.definePlugin(id, version, install, dispose)`: synchronous `install`
+ by default; a plugin may return a promise, but anything `adInit` depends on
+ (gpt, prebid shim registration) must complete synchronously and is listed as
+ such in the manifest. Late registration after the manifest requested the id
+ installs on arrival (pending-install queue) bounded by a missing-module
+ timeout emitting `bundle_partial`. A stale async completion (arriving after
+ its `RuntimeSession` was disposed) is discarded. Duplicate `(id, version)`
+ is a no-op; a different version for a registered id: first-wins + loud
+ telemetry. Disposal runs in reverse install order.
+- **Session model, split as the review required:**
+ - `RuntimeSession` (page lifetime): bridge listener, history hook, pbjs
+ subscriptions, adapters, beacon queue.
+ - `NavigationSession` (per SPA navigation): `trace_id`, render attempts,
+ slot aliases, targeting history, navigation-scoped timers/observers.
+ - `RenderAttempt` (per G4a cycle): state machine instance, ack nonce.
+ Each owns an **enumerable** disposal inventory; navigation disposes
+ `NavigationSession` children only.
- Per-plugin exception isolation: a plugin that throws during install is
- quarantined and reported; it cannot halt the bundle (today `didomi` can
- throw during module evaluation and stop everything after it).
-- Duplicate registration of the same `(id, version)` is a no-op; a different
- version for a registered id follows a declared policy (first-wins + loud
- telemetry).
-- A `PageSession` object owns an **enumerable** set of listeners, timers,
- observers, and slot records — registered at creation, disposed on
- navigation. A `WeakMap` alone cannot dispose anything; the owned-set is the
- disposal inventory.
-- Error policy: no empty `catch` — every catch handles, logs with context, or
- emits a disposition reason. The auction fetch gets a timeout +
- `AbortController`, and `requestAds` surfaces failure to its caller.
-- **Console logging is retained, not replaced.** The beacon is additive: every
- issue-surfacing condition keeps (or gains) a `log.warn` debuggable from an
- open DevTools console. Existing warnings survive verbatim or strengthened;
- failure paths currently at `debug` (invisible at the default `warn` level —
- the creative `dynamic_src_guard` and click-guard rejection paths) are
- promoted to `warn` when they indicate a delivery or security-relevant
- failure; every `render_fail` / dependency-timeout disposition emits a paired
- `warn` carrying the same reason code, so console and beacon tell one story.
+ quarantined and reported; it cannot halt the bundle.
+- Error policy: no empty `catch`; every catch handles, logs with context, or
+ emits a disposition reason. The auction fetch gets timeout +
+ `AbortController`.
+- **Console logging is retained, not replaced.** Every issue-surfacing
+ condition keeps (or gains) a `log.warn` debuggable from DevTools; existing
+ warnings survive verbatim or strengthened; `debug`-level failure paths that
+ indicate delivery or security-relevant failures are promoted to `warn`;
+ every `render_fail`/dependency-timeout disposition emits a paired `warn`
+ with the same reason code.
### 7.7 The bootstrap problem
-`gpt_bootstrap.js` duplicates ~400 lines of the hardest logic (handoff,
-initial-load detection, hydration deferral) in hand-written ES5, always wins
-the sentinel race, and has one live divergence (its simpler `adInit` can run
-first and permanently suppress the bundle's `slotRenderEnded` listener).
-
-Target: shrink the inline bootstrap to a queue-and-flags stub (create
-`googletag.cmd` interception points, record early publisher calls, expose the
-enable flag), with the bundle replaying recorded calls on install. This is
-**not a pure move** — replay changes observable ordering — so it ships behind
-its own flag with the browser specs extended to cover replay timing, and the
-no-bundle fallback ("ads still render if the bundle fails," pinned by
-`gpt.rs:1174-1179`) is **generated from the same TypeScript source** at build
-time, never hand-maintained.
+Target: shrink the inline `gpt_bootstrap.js` to a queue-and-flags stub with
+the bundle replaying recorded calls on install. This is **not a pure move** —
+replay changes observable ordering — so it ships behind its own flag with
+browser specs extended to cover replay timing, and the no-bundle fallback
+("ads still render if the bundle fails", pinned by `gpt.rs:1174-1179`) is
+**generated from the same TypeScript source** at build time.
### 7.8 GPT correctness fixes carried with the restructure
- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify
against open PR #997; land whichever is canonical).
-- Pass `changeCorrelator: false` on TS-initiated refreshes; correlator
- behavior becomes a documented, configurable decision.
-- `enableSingleRequest()` only when GPT services are not already enabled;
- otherwise adopt the publisher's mode and record it.
-- Ambiguous responsive resolution emits `render_fail{slot_unresolved}` in
- addition to its console warning.
+- `changeCorrelator: false` on TS-initiated refreshes; correlator behavior
+ becomes a documented, configurable decision.
+- `enableSingleRequest()` only when GPT services are not already enabled.
+- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`
+ alongside its console warning.
### 7.9 Decomposition targets
@@ -611,48 +640,38 @@ time, never hand-maintained.
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics |
-| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory the other six integrations already use |
+| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state |
### 7.10 Performance
-Client-side, the design is a net speedup with enforced budgets:
-
-- **Smaller synchronous bundle:** script-guard consolidation, single APS
- module (via the ABI), dead-code deletion, trace-overlay extraction.
-- **Fewer repeated DOM walks:** slot resolution once per navigation in the
- registry.
-- **Bounded waits instead of blind ones:** the 10 s silent renderer timeout
- and forever-queued GPT cases become short, telemetered timeouts.
-- **Budgets in CI, precisely specified:** per-bundle byte sizes measured raw,
- gzip, and Brotli for an exact named module set, compared against a
- checked-in baseline artifact with a stated tolerance; the browser-spec
- timing assertion (bids-script-to-first-`display()`) runs N times and gates
- on a percentile, not a single sample.
-
-Server-side (new in this revision): each injected page currently concatenates
-and hashes the full immediate bundle, and the asset request concatenates it
-again (`bundle.rs:51`). Precompute bundle bytes + hash per registry module set
-(they change only at deploy/config time), and benchmark server CPU/heap before
-and after.
+Client-side speedups with enforced budgets: smaller synchronous bundle
+(script-guard consolidation, single APS module via the ABI, dead-code
+deletion, trace-overlay extraction); slot resolution once per navigation;
+bounded telemetered waits. Budgets, concretely: per-bundle raw/gzip/Brotli
+bytes for the exact ordered module vector of the reference config, compared
+to a checked-in baseline with **+5% tolerance**; the
+bids-script-to-first-`display()` browser assertion runs **20 iterations** and
+gates on **p90**. Server-side: precompute bundle bytes + hash per ordered
+module vector (deploy/config-time), benchmark server CPU/heap before/after.
### 7.11 Toolchain and dependency currency
-- **TypeScript to latest stable** (library pins `^5.5.4` while vite 7 /
- vitest 4 / typescript-eslint 8 are current), adopting
+- **TypeScript:** the manifest floor is `^5.5.4` but the lockfile already
+ resolves **5.9.3** (`package-lock.json`), so the code compiles on 5.9 today.
+ Action: raise the manifest floor to the resolved 5.9 line (making the floor
+ honest), then evaluate the next major as its own gated PR; adopt
`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
- `verbatimModuleSyntax` — these directly serve killing the `as unknown as`
- escapes and the wrong `global.d.ts` declaration.
+ `verbatimModuleSyntax`.
- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom,
`@playwright/test`, `@types/node` aligned to the pinned Node), each bump its
- own mechanical CI-gated PR with changelog review — this library
- monkeypatches `fetch`, `sendBeacon`, and DOM prototypes, so jsdom and
- Playwright behavior changes are real risks.
-- **`prebid.js` is excluded from casual bumps:** the runtime Prebid is the
- external R2 bundle locked by manifest hash and SRI; upgrading it is its own
- coordinated deploy. The npm pin and the deployed bundle version stay
- documented together so tests exercise the version production runs.
+ own mechanical CI-gated PR with changelog review — this library monkeypatches
+ `fetch`, `sendBeacon`, and DOM prototypes, so jsdom/Playwright behavior
+ changes are real risks.
+- **`prebid.js` is excluded from casual bumps** (runtime Prebid is the
+ manifest-locked external R2 bundle); the npm pin and deployed bundle version
+ stay documented together.
- **Standing policy:** monthly dependency review; no migration phase starts
more than one minor behind latest stable.
@@ -660,33 +679,39 @@ and after.
## 8. Migration plan
-Reordered so every phase's prerequisites precede it; each phase ships behind a
-feature flag with canary thresholds and rollback criteria.
-
-- **Phase 0 — Contracts and toolchain.** Settle G1–G5 in code-adjacent docs;
- toolchain upgrades (7.11); the trace envelope + beacon + four-adapter ingest
- (accept-count-drop outside Fastly) behind a flag; server drop-reason
- surfacing (5.4); reason codes on today's silent returns; delete the dead
- expando writes. No runtime behavior change for pages with the flag off.
-- **Phase 1 — Runtime ABI + APS admission/identity.** G3 kernel registry in
- `tsjs-core` (context-provider fix is the proof); wire-schema crate + shared
- corpus (6.7); mediation selection helper + opt-in merge (6.1); render token
- for renderer-only bids (6.4); renderer-endpoint startup validation (6.6);
- bridge hardening minus fallback (6.8). APS renders after this phase wherever
- GAM line items and configuration permit — the no-GAM fallback is explicitly
- Phase 2.
-- **Phase 2 — Render state machine + GPT correctness.** Minimal `SlotRecord`
- core (just enough for the state machine keys; full registry lands in
- Phase 3); awaitable renderer conversion; the exactly-once fallback (6.5,
- G4); restore #922/#997 attribution and orphan recovery; correlator and SRA
- fixes (7.8).
-- **Phase 3 — Structure.** Full layering + boundary lint, plugin lifecycle +
- `PageSession` (7.6), adapters (7.2), full slot registry (7.3), messaging
- module (7.5), namespace migration with its compatibility window (7.4),
- asset content-addressing fix + server bundle precompute (G5, 7.10).
-- **Phase 4 — Decomposition.** File splits (7.9), script-guard consolidation,
- bootstrap shrink behind its own flag with replay-timing specs (7.7), and
- the end of the public-global compatibility window.
+Ordered so every phase's prerequisites precede it (the review's required
+order). Every phase ships behind a feature flag; shared canary criteria: no
+increase in `render_fail` rate beyond **+0.5% absolute** on canary traffic
+over 24 h, no new console errors in the browser-spec run, rollback = flag off
+(phases 0–2 are additive; later phases keep dual paths until their gate).
+
+- **Phase 0 — Asset identity, contracts, toolchain.** Path-based immutable
+ asset identity + manifest retention + rolling-deploy tests (G5) — this lands
+ **before** anything makes ABI compatibility load-bearing. Toolchain floors
+ (7.11). Contracts G1–G5 recorded as code-adjacent docs. Delete the dead
+ expando writes. Server drop-reason surfacing (5.5) — server-only, no client
+ dependency.
+- **Phase 1 — Kernel ABI and sessions.** Minimal kernel in `tsjs-core`
+ publishing the versioned registry (G3); `RuntimeSession` /
+ `NavigationSession` scopes (7.6); context-provider fix as the ABI proof;
+ install manifest injection.
+- **Phase 2 — Trace and beacon.** Server correlation nonce for `nav_gen 0` +
+ page-bids trace echo (G1); beacon service on the Phase-1 kernel; ingest
+ route in all four adapters (5.3); diagnostic capability; `ts_client_events`
+ datasource.
+- **Phase 3 — APS delivery.** Wire-schema crate + corpus (6.7); mediation
+ helper + opt-in merge (6.1); render token (6.4); unconditional versioned
+ renderer route + two-stage ack (6.6, G4b); bridge hardening (6.8); GPT
+ request-cycle protocol + render state machine + awaitable renderer +
+ `nurl`/`burl` split (G4a–G4d); the opt-in fallback (G4e); restore #922/#997
+ attribution; correlator and SRA fixes (7.8). APS renders after this phase
+ wherever GAM line items and configuration permit.
+- **Phase 4 — Structure.** Full layering + boundary lint, plugin lifecycle
+ completion, adapters, full slot registry, messaging module, namespace
+ migration window (7.4), server bundle precompute (7.10).
+- **Phase 5 — Decomposition.** File splits (7.9), script-guard consolidation,
+ bootstrap shrink behind its own flag with replay-timing specs (7.7), end of
+ the public-global compatibility window (gated per 7.4).
---
@@ -696,92 +721,98 @@ Blocking CI is hermetic (the deterministic PUC/message harness); a separate
staged smoke suite covers real GAM line items and is release-gating, not
PR-gating.
-| Area | Must cover |
-| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Mediation | both lifecycles (ordinary + SSAT split dispatch/collect); ties, floors, deals, duplicate demand; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) |
-| Cache identity | non-APS cache-backed bids: byte-identical `hb_adid` + cache coordinates (regression); PUC `?uuid=` fetch path |
-| Render token | `^[a-z0-9]{12}$`; CSPRNG source; collision retry; TTL; one-time consumption; cross-slot/auction uniqueness |
-| Fallback | races vs bridge claim; nonempty-GAM protection; repeated refresh; SPA navigation cancellation; slot destruction; exactly-once terminal transition |
-| Render semantics | no billing after runner-load failure; `render_accepted` vs `render_confirmed` labeling; opaque-frame honesty (no painted/blank claim) |
-| Bridge security | wrong-slot tokens; nested foreign frames; replayed + duplicate messages; stolen tokens; previous-navigation tokens; SafeFrame positive case |
-| Beacon | trace joins; ordering/dedup via `(trace_id, nav_gen, seq)`; batching across navigations; loss tolerance; ingest abuse (oversize, malformed, cross-origin); all-adapter routing |
-| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection |
-| Runtime ABI | one kernel instance under concatenation; deferred registration after install request; per-plugin failure isolation; abi-version mismatch refusal |
-| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); `PageSession` disposal inventory; pre-init `tsCreativeConfig` compatibility; dual-name global window |
-| Delivery | immutable-cache behavior across a simulated rolling deploy; deterministic raw/gzip/Brotli bundle budgets vs baseline artifact |
-| Observability | drop-reason summary on any partial drop; page-bids structured `debug` field gating; diagnostic mode unsampled completeness |
+| Area | Must cover |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Mediation | both lifecycles; ties, floors, currency rejection; duplicate demand via provenance map; transformed mediator ids; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) |
+| Cache identity | non-APS cache-backed bids byte-identical (`hb_adid` + coordinates); PUC `?uuid=` fetch path |
+| Render token | `^[a-z0-9]{12}$`; CSPRNG source; in-auction collision retry; 15-min TTL; one-time consumption; per-`(trace, nav_gen)` registry scoping |
+| Request cycles | pending-cycle attribution; overlapping refreshes; late `slotRenderEnded`; nonempty-before-bridge-claim; `cycle_unattributable` fail-closed |
+| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame flows; stale/replayed acks |
+| Render semantics | no `burl` before authenticated `render_accepted`; `nurl` at selection independent of delivery; `billed_then_failed` labeling; accepted-but-blank; no `render_confirmed` from geometry or PUC container |
+| Fallback | renders only on attributed `gam_empty`; timeout is diagnostics-only; adopted-slot fallback disabled; SPA cancellation; slot destruction; exactly-once terminal transition |
+| Bridge security | wrong-slot tokens; nested foreign frames; replay + duplicates; stolen tokens; previous-navigation tokens; bounded parent-chain walk (depth 5); SafeFrame positive case |
+| Beacon | initial-nav server nonce join; page-bids trace echo; per-event envelope across navigation-spanning batches; `(trace_id, nav_gen, seq)` dedup; ingest abuse (oversize, malformed, cross-origin, absent Origin); capability sig |
+| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection |
+| Runtime ABI | one kernel under concatenation; deferred registration after manifest request; per-plugin failure isolation; **mixed-version delivery (old deferred bundle + new core)**; abi-mismatch refusal |
+| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); RuntimeSession vs NavigationSession disposal inventories; stale async install completion; pre-init `tsCreativeConfig` and `tsjs.que` compatibility; dual-name global window |
+| Delivery | rolling-deploy simulation (old HTML + retained old assets); unknown hash → 410 `no-store`; immutable only on exact match; ordered-module-vector keying; deterministic raw/gzip/Brotli budgets vs baseline |
+| Renderer endpoint | route present in all four adapters; auth-pattern startup failure; stale version behavior; CSP report-only canary; `document_loaded` vs runner-result split |
+| Adapter parity | ingest + renderer routes + drop-reason surfacing behave equivalently on Fastly/Viceroy vs Axum vs Cloudflare vs Spin |
+| Policy | script-creative startup warning; `invalid_dimensions{WxH}` drop naming; page-bids `debug` field gating; diagnostic-mode unsampled completeness |
---
## 10. Alternatives considered
1. **Keep patching APS point-failures without telemetry.** Rejected: three
- consecutive correct fixes have not produced ads; without disposition data
- the next fix is another guess.
-2. **Direct-render APS always (skip GAM/PUC).** Simplest render path, but
- changes GAM reporting/pacing semantics unilaterally; kept as the opt-in,
- state-machine-guarded fallback instead.
+ consecutive correct fixes have not produced ads.
+2. **Direct-render APS always (skip GAM/PUC).** Changes GAM reporting/pacing
+ semantics unilaterally; kept as the opt-in, `gam_empty`-gated fallback.
3. **Single module graph / shared chunks instead of the registration ABI.**
- Cleaner long-term, but changes the delivery pipeline (chunk loading) now;
- recorded as the successor option behind the same ABI surface.
-4. **Full library rewrite in one branch.** Rejected: the browser-spec safety
- net is thin in exactly the areas being changed.
+ Cleaner long-term; changes the delivery pipeline now; successor option
+ behind the same ABI surface.
+4. **Full library rewrite in one branch.** Rejected: thin browser-spec safety
+ net in exactly the changing areas.
5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the
- bundle fails" guarantee; the generated-fallback approach keeps it without
- dual maintenance.
+ bundle fails" guarantee; generated fallback keeps it.
+6. **Timeout-triggered fallback rendering.** Rejected (G4e): GPT requests
+ cannot be cancelled, so timeout rendering races late fills; only an
+ attributed terminal empty event may trigger rendering.
## 11. Risks
-- **Merge strategy misconfiguration** changes auction economics; mitigated by
- keeping `mediator_only` the default, the selection report, and omitted
- serialization at defaults.
-- **Beacon abuse/volume:** bounded by pre-parse caps, origin checks, rate
- limits, sticky sampling, and closed enums.
-- **ABI freeze risk:** `tsjs._internal.registry` becomes load-bearing;
- versioned from day one, majors checked at lookup.
-- **Bootstrap replay** changes observable ordering; own flag, replay-timing
- specs, staged rollout.
-- **Schema generation** adds a build step; checked-in artifacts + staleness CI.
+- **Merge strategy misconfiguration:** mitigated by `mediator_only` default,
+ the selection report, omitted-default serialization.
+- **Beacon abuse/volume:** pre-parse caps, origin checks, per-adapter rate
+ limiting, sticky sampling, closed enums, capability-gated diagnostics.
+- **ABI freeze risk:** versioned from day one; mixed-version delivery tested.
+- **Ack protocol adds a message round-trip before `burl`:** bounded by the
+ existing renderer timeout; the `billed_then_failed` label measures the
+ policy.
+- **Bootstrap replay:** own flag, replay-timing specs, staged rollout.
+- **Schema generation:** checked-in artifacts + staleness CI.
## 12. Success criteria
1. APS creatives render on a reference page in each configured flow (SSAT,
- Prebid adapter, page-bids), proven hermetically in CI and by the staged
- smoke suite against real GAM line items.
-2. Every failure point in section 2 maps to a distinct observable signal, and
- **diagnostic mode** yields the failing reason from one page load; production
- telemetry meets the stated SLO (5.3).
-3. `eslint` boundary rules pass with zero exceptions; no integration imports
- another integration; stateful sharing goes through the versioned ABI.
+ Prebid adapter, page-bids), hermetically in CI and via the staged smoke
+ suite.
+2. Every failure point in section 2 maps to a distinct observable signal;
+ diagnostic mode names the failing reason from one page load; production
+ telemetry meets the 5.4 SLO (≥ 1% failure modes visible within one hour).
+3. Boundary lint passes with zero exceptions; stateful sharing only via the
+ versioned ABI; mixed-version delivery behaves as specified.
4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
generated.
-5. Trace counts are per-impression (no double counting), and orphaned-slot
- recovery is covered by a non-vacuous test.
+5. Trace counts are per-impression; orphaned-slot recovery has a non-vacuous
+ test; `refresh_gen` attribution follows the G4a cycle protocol.
6. The only TSJS-owned global is `window.tsjs` (public globals only inside
- their announced compatibility window); no expandos on GPT slots, GPT
- functions, or `pbjs`.
-7. Per-bundle raw/gzip/Brotli sizes are at or below the checked-in baseline
- within stated tolerance, and the percentile-based
- bids-script-to-first-`display()` assertion does not regress; server-side
- per-request bundle concatenation/hashing is precomputed.
-8. No existing warning is lost: every issue-surfacing condition logs at `warn`
- or above with the same reason code the beacon carries.
-9. TypeScript and the dev toolchain are on latest stable with the new
- strictness flags; `prebid.js`'s npm pin matches the documented deployed
- bundle version; the monthly review policy is in CI docs.
-10. Rolling-deploy cache tests pass: a `?v=A` request never caches bytes other
- than `A`.
+ their announced window, which closes per the 7.4 adoption gate); no
+ expandos on GPT slots, GPT functions, or `pbjs`.
+7. Bundle budgets (raw/gzip/Brotli, +5% tolerance vs baseline) and the p90
+ 20-run timing assertion hold; server-side per-request concatenation is
+ precomputed.
+8. No existing warning is lost; every issue-surfacing condition logs at `warn`
+ or above with the beacon's reason code.
+9. TypeScript floor matches the resolved 5.9 line with the strictness flags
+ on; `prebid.js` npm pin matches the documented deployed bundle; monthly
+ review policy in CI docs.
+10. Rolling-deploy tests: old HTML always receives its exact old assets during
+ the retention window; unknown hashes 410 with `no-store`; immutable
+ caching only on exact matches.
+11. `nurl` and `burl` fire on their distinct G4d transitions, idempotently,
+ and never before their triggering state.
## 13. Open questions
-1. Is a mediator configured in the affected production deployment? (Decides
- whether A1 is the primary cause or a latent one.)
+1. Is a mediator configured in the affected production deployment?
2. What share of live APS demand is `tagtype: "script"`?
3. Should `client_render_fallback` ever become default-on for publishers
without GAM line items for `hb_bidder=aps`?
4. Is PR #997 the intended restoration of the lost #922 attribution core, or
should the original be re-merged?
-5. Does any deployment serve descriptors for an origin whose config disables
- APS (decides whether the renderer route becomes config-independent, 6.6)?
-6. Datasource naming/retention for client events, and whether Axum/Cloudflare/
- Spin get real sinks or keep accept-count-drop.
+5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep
+ accept-count-drop? (Datasource `ts_client_events`, 30-day retention, and
+ 10% default sampling are proposed values pending operator sign-off.)
+6. Does Amazon expose any creative-completion acknowledgement we could adopt
+ to move APS beyond `render_accepted` (G4c)?
From d92f801567054d288dd9597baebab48863facee7 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 12:43:41 -0700
Subject: [PATCH 004/194] Revise design spec for the third review round
Moves the SPA trace onto a validated X-TSJS-Trace-Id header because
page-bids is a GET, adds the cache-privacy invariant for trace-bearing
HTML, rebuilds cycle attribution without ordering assumptions (ts-vs-
publisher classification, one outstanding TS cycle per slot, overlap
fails closed, real-GAM release-gating test), scopes nurl/burl to bid
paths that carry them with attempt-scoped idempotency and leaves the
APS runner lifecycle untouched, removes render_confirmed after the
sandboxed-srcdoc correction, pulls minimal messaging, the cycle
registry, and unconditional GPT subscriptions forward so Phase 3 has
its dependencies, defines mixed-version ABI verdicts (major/minMinor
ranges, quarantine, first-wins among compatible), makes retained
assets realizable via two-stage publishing to shared immutable
storage with both rolling directions tested, groups beacon events per
trace with per-trace capabilities and page-bids capability issuance,
fails telemetry rate limiting closed with per-adapter trusted-address
definitions, parameterizes the SLO and phase-specific gates, corrects
the renderer /v1 to immutable caching with an aggregate-only counter
claim and a fully specified CSP report route, allows fallback on
attributed gam_empty regardless of slot ownership, and tightens the
remaining bounded-schema, provenance, and performance-method edges.
---
...s-render-fix-and-tsjs-resilience-design.md | 1112 ++++++++---------
1 file changed, 501 insertions(+), 611 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 144b7ca36..9c53fca6a 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,17 +1,15 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 3 — reworked after the second design review round.
+- **Status:** revision 4 — reworked after the third design review round.
- **Date:** 2026-08-04
- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything
merged from `main` plus every rc-only merge), not just the delta pending
against `main`.
-- **Inputs:** three code audits performed against this baseline (APS end-to-end
- trace, TSJS architecture audit, GPT integration map); design reviews of
- revisions 1 and 2; open issues #926, #941, #944, #962, #964, #977, #983,
- #989, #993; open PR #997.
-- **Terminology:** the per-refresh counter is `refresh_gen` everywhere in this
- document (revision 2 used `render_gen` in some places; that name is
- retired).
+- **Inputs:** three code audits performed against this baseline; design
+ reviews of revisions 1–3; open issues #926, #941, #944, #962, #964, #977,
+ #983, #989, #993; open PR #997.
+- **Terminology:** the per-refresh counter is `refresh_gen` everywhere. The
+ event previously named `render_confirmed` is removed (see G4c).
---
@@ -20,32 +18,29 @@
APS (Amazon Publisher Services) demand is fully integrated server-side — the
edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer
descriptor to the page — yet APS creatives still do not appear for real users.
-Every previous fix (the `bid.meta` carrier so Prebid does not strip the
-descriptor, the decoupled prebid shim, the `hb_adid` fallback to the OpenRTB bid
-id) addressed a real defect, and APS still does not render. That pattern — serial
-single-cause fixes that each survive review and still do not produce ads — is
-itself the finding: the APS pipeline has **multiple independent failure points,
-most of which fail silently**, and the client library has **no way to tell the
-server (or the operator) which one fired**.
+Every previous fix (the `bid.meta` carrier, the decoupled prebid shim, the
+`hb_adid` fallback) addressed a real defect, and APS still does not render.
+That pattern is itself the finding: the APS pipeline has **multiple independent
+failure points, most of which fail silently**, and the client library has **no
+way to tell the server (or the operator) which one fired**.
At the same time, the TSJS client library has grown organically to 56 files /
~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by
-hand in two languages, inverted layering, and roughly one hundred `catch` blocks
-that discard failures. The APS outage and the library's shape are the same
-problem seen from two sides: a delivery pipeline whose failure modes are
-invisible and whose components cannot be reasoned about independently.
+hand in two languages, inverted layering, and roughly one hundred `catch`
+blocks that discard failures. The APS outage and the library's shape are the
+same problem seen from two sides.
This design covers both: (a) the specific fixes that make APS render, and (b)
-the target architecture that makes TSJS a clean, resilient library so the next
-integration does not reproduce this failure class.
+the target architecture that makes TSJS a clean, resilient library.
### Non-goals
-- No change to the APS OpenRTB endpoint contract or Amazon-side configuration.
+- No change to the APS OpenRTB endpoint contract or Amazon-side configuration
+ (including its deliberate absence of `nurl`/`burl` — see G4d).
- No rewrite of Prebid.js integration strategy (the decoupled shim stays).
-- No behavior change for publishers whose pages work today. Where this design
- must migrate a public surface (section 7.4), it does so behind a bounded
- compatibility window, never by immediate removal.
+- No behavior change for publishers whose pages work today. Public-surface
+ migrations happen behind a bounded compatibility window (7.4), never by
+ immediate removal.
---
@@ -87,43 +82,38 @@ descriptor without GAM's cooperation.
### 2.4 Observability: the common factor
-There is **zero client→server reporting**. Server telemetry marks `is_win=1` at
-auction time and goes quiet; a bid that never painted is byte-identical to one
-that painted perfectly. Client-side evidence (`window.tsjs.renders`, console
-warnings, `data-ts-*` attributes) dies with the tab.
+There is **zero client→server reporting**. Server telemetry marks `is_win=1`
+at auction time and goes quiet; a bid that never painted is byte-identical to
+one that painted perfectly. Client-side evidence dies with the tab.
---
## 3. The GPT reality this design must respect
-The GPT integration is a **bootstrap-first hybrid**: the server injects a
-495-line ES5 `gpt_bootstrap.js` inline in `` before the TSJS bundle, and
-the two coordinate through shared monkeypatch sentinels, with document order
-deciding the winner. The audit's key facts:
-
-1. **The bundle's handoff and initial-load code is dead in production.** The
- bootstrap installs its wrappers first and sets the same sentinels the bundle
- checks; ~200 lines of the TypeScript the test suite exercises most heavily
- never run on a real page.
-2. **The slot handoff can alias a publisher's new div to a GPT slot bound to a
- dead element** — and the orphan-recovery watcher built to repair exactly that
- was **lost in the #922 merge** (`0dc9b19a9` resolved `gpt/index.ts` to the rc
- side). `updateRender` now has no production caller,
- `__tsRenderGeneration` / `__tsRenderBid` are dead writes, and every
- bridge-served impression double-counts in the trace. Open PR #997 appears to
- be the reworked replacement.
-3. **TS refreshes never pass `changeCorrelator: false`**, silently changing
- roadblock, competitive-exclusion, and frequency-capping behavior.
+1. **Bootstrap-first hybrid:** the server injects a 495-line ES5
+ `gpt_bootstrap.js` before the bundle; shared monkeypatch sentinels mean the
+ bundle's handoff and initial-load code is dead in production.
+2. **The #922 merge loss:** orphan-slot recovery and `updateRender` enrichment
+ are gone (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead
+ writes; bridge-served impressions double-count. PR #997 appears to be the
+ reworked replacement.
+3. **TS refreshes never pass `changeCorrelator: false`.**
4. **`enableSingleRequest()` is called blind** after the publisher's own
`enableServices()` has almost always run.
5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently
- skips the slot for the whole pass.
+ skips the slot.
6. Three independent wrappers on `pubads().refresh` coordinate via
- window-global booleans that async wrappers observe already reset.
-7. **GPT refresh is asynchronous and offers no request-cancellation
- primitive** (`gpt/index.ts:1080`): once a refresh is issued, a response can
- arrive arbitrarily late. Any fallback design must treat an issued GPT
- request as uncancelable.
+ window-global booleans.
+7. **GPT offers no request cancellation** (`gpt/index.ts:1080`), and its event
+ contract identifies only the slot: Google documents no per-refresh
+ identifier and no completion-order guarantee for overlapping requests, and
+ `slotRenderEnded` means creative code was injected, not that its resources
+ loaded. Publisher code can refresh an adopted slot while a TS cycle is
+ pending. Any attribution scheme must survive all of that (G4a).
+8. **The bundle's `slotRenderEnded` registration is gated behind
+ `!ts.servicesEnabled`** (`gpt/index.ts:1017`), so bootstrap-first or
+ services-enabled pages can miss the listener entirely — G4a requires
+ unconditional early subscription.
---
@@ -131,264 +121,285 @@ deciding the winner. The audit's key facts:
### G1 — Trace identity and correlation
-**The client-visible auction id must never be ingested.** It is EC-derived by
-construction (`publisher.rs:3237`: `ts-{ec_id}` when an EC exists). Server
-telemetry uses an independent fresh UUID (`telemetry.rs:94-97`) — and, decisive
-for the design: **initial-HTML auction telemetry is emitted before page
-JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so a
-client-minted trace can never retroactively join it.
-
-Contract — correlation is minted by whoever acts first:
-
-- **Initial navigation (`nav_gen = 0`):** the **server** mints a
- `trace_id` — 128-bit CSPRNG, hex (`^[0-9a-f]{32}$`), derived from nothing —
- per HTML response, writes it into that response's auction telemetry rows at
- emit time, and injects it into the page as a `tsjs` boot field alongside the
- sampling decision. The client's `NavigationSession` adopts it. It is a new
- value, never `AuctionRequest.id`.
-- **SPA navigations (`nav_gen > 0`):** the **client** mints the `trace_id` for
- the navigation and sends it in the `/_ts/page-bids` request body; the server
- records it contemporaneously in that auction's telemetry rows. No
- retroactive joining anywhere.
+**The client-visible auction id must never be ingested** (EC-derived:
+`publisher.rs:3237`). **Initial-HTML auction telemetry is emitted before page
+JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so correlation
+is minted by whoever acts first:
+
+- **Initial navigation (`nav_gen = 0`):** the server mints `trace_id`
+ (128-bit CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction
+ telemetry rows at emit time (a new nullable `trace_id` column on the
+ existing auction datasource; the independent telemetry `auction_id` UUID
+ remains and remains separate), and injects it into the page as a `tsjs`
+ boot field with the sampling decision and, when the tester gate is active,
+ the diagnostic capability (5.3).
+- **Cache-privacy invariant:** a trace or capability is injected **only** into
+ responses that ran a per-request auction, and any trace-bearing HTML MUST be
+ shared-cache-ineligible: `Cache-Control: private, no-store` and no
+ validators that could revalidate a shared copy. This is enforced by
+ construction (the injection site is the auction-bearing render path) and by
+ test. A shared-cached page would have no per-visitor auction to correlate
+ anyway; the invariant makes that alignment explicit.
+- **SPA navigations (`nav_gen > 0`):** `/_ts/page-bids` **stays GET** (it is
+ GET in `publisher.rs:3815` and the client issues GET at
+ `gpt/index.ts:1152`; a browser GET cannot carry a body). The client mints
+ the `trace_id` and sends it in a validated **`X-TSJS-Trace-Id`** request
+ header — the request already carries a non-simple TSJS header, so CORS
+ preflight behavior is unchanged. The server records it contemporaneously in
+ that auction's telemetry rows and the JSON response **echoes the accepted
+ trace id** and returns a capability bound to it when the tester gate is
+ active.
- **Envelope:** every event carries
- `{trace_id, sampled, nav_gen, refresh_gen, seq}` — per event, not per batch,
- so a transport batch may span navigations without ambiguity. `seq` is a
- per-trace monotonic counter for ordering and deduplication.
-- **Sampling** is decided once per trace (server-decided for `nav_gen 0`,
- client-decided from the injected rate for later navigations) and recorded in
- the envelope; a sampled trace is complete or absent.
+ `{trace_id, sampled, nav_gen, refresh_gen, seq}`; `seq` is per-trace
+ monotonic.
+- **Sampling is trace-sticky** (decided once per trace; server-decided for
+ `nav_gen 0`, client-decided from the injected rate afterwards). Transport is
+ best-effort, so a sampled trace may still arrive partial; partiality is
+ detectable via `seq` gaps and is not treated as a contract violation.
### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID
-**The PBS Cache contract stays untouched.** `hb_adid` deliberately prefers the
-Prebid Cache UUID (`publisher.rs:3355`), and both the emitted cache coordinates
-and the bridge's cache fetch assume `?uuid=` (`publisher.rs:3450`,
-`gpt/index.ts:1700`).
-
-Contract:
-
-- Bids with a cache id: `hb_adid` = cache UUID, exactly as today. Bids with
- markup and no cache id: existing fallback chain, exactly as today.
-- **Renderer-only bids (APS): `hb_adid` = a server-minted render token**,
- format `^[a-z0-9]{12}$` (12 chars exactly, 36¹² ≈ 4.7 × 10¹⁸ values),
- CSPRNG-generated. Collision handling is honest about its scope: retry on
- collision **within the minting auction** (the only scope the server can
- check without storage); cross-auction uniqueness is **probabilistic**, with
- the birthday bound documented (at 10⁶ live tokens the collision probability
- is ~10⁻⁷) and made harmless by scoping: the client registry keys tokens per
- `(trace_id, nav_gen)`, so a cross-page collision cannot cross wires.
-- Token lifecycle: TTL **15 minutes** from mint, one-time consumption in the
- bridge registry, invalidated by navigation.
-- The client-side Prebid adapter path keeps Prebid's generated `adId`; both
- paths register into one bridge registry keyed by whichever id that path
- observes.
-- Regression tests: non-APS cache-backed bids keep byte-identical `hb_adid`
- and cache coordinates.
+Unchanged from revision 3 (review-accepted): cache-backed bids keep the cache
+UUID as `hb_adid` byte-for-byte; renderer-only bids get a server-minted token
+`^[a-z0-9]{12}$` (CSPRNG; in-auction collision retry; cross-auction uniqueness
+probabilistic with the birthday bound documented; harmless via per-
+`(trace_id, nav_gen)` registry scoping); TTL 15 minutes; one-time consumption;
+client-Prebid keeps Prebid's `adId`; non-APS cache-path regression tests.
### G3 — Runtime ABI: how code shares state under the IIFE build
-Every entry point is built as a self-contained IIFE with dynamic imports
-inlined (`build-all.mjs:46`), and the server concatenates already-closed IIFEs
-(`bundle.rs:23`) — a module `import` never shares state across bundles. Live
-proof: `core/context.ts:11` holds a private context-provider `Map` while
-`permutive/index.ts:102` registers into its own copy.
-
-Contract — **a versioned registration ABI on `window.tsjs._internal`**:
-
-- The kernel ships **only** in `tsjs-core` (always first in the concatenated
- unified bundle) and publishes `tsjs._internal = { abi: 1, registry }` exactly
- once, guarded by a window-level sentinel.
-- **Construction ownership:** the kernel constructs and registers the core
- service instances (event bus, beacon queue, session objects, slot registry,
- render state machine) during its own boot; integrations construct only
- integration-scoped services and register them during their `install()`.
-- All **stateful** services are reached only through
- `tsjs._internal.registry.get(name, minVersion)` at call time. Pure stateless
- helpers may be imported and inlined freely.
-- `abi` majors are checked at lookup; a mismatch is a logged, telemetered
- refusal, not a silent no-op. Mixed-version delivery (old deferred bundle,
- new core) is a tested scenario, not an accident.
-- The single-module-graph build is recorded as the successor option behind the
- same ABI surface.
-
-### G4 — Render lifecycle: cycles, acknowledgements, and honest states
-
-Four sub-contracts, each fixing a hole the reviews identified.
-
-**G4a — GPT request-cycle protocol.** `slotRenderEnded` identifies a slot, not
-a request, and the bridge currently reads live `window.tsjs.bids`
-(`gpt/index.ts:1606`) while the generation snapshots are dead writes
-(`gpt/index.ts:1085`). Contract: every TS-issued `display()`/`refresh()` opens
-a **cycle** `(slot, refresh_gen)` pushed onto a per-slot pending-cycle queue;
-`slotRequested` confirms it; GPT fires slot events in order per slot, so
-`slotRenderEnded` is attributed to the oldest confirmed pending cycle for the
-slot. Each bridge token and each render attempt binds to exactly one cycle. If
-attribution is ambiguous (overlapping cycles the queue cannot separate, or an
-event with no pending cycle), the state machine for that slot **fails closed**:
-no fallback, `render_fail{cycle_unattributable}`, console warning.
-
-**G4b — Acknowledgement path.** Today the renderer document posts ready only
-to its immediate parent (`aps.rs:105`); in the PUC path that parent is the
-nested renderer frame, which resolves a local promise (`render.ts:423`) the
-top-level kernel cannot observe — and callbacks currently fire right after the
-bridge posts its response (`gpt/index.ts:1572`, `:1620`). Contract: the bridge
-response carries a **per-attempt CSPRNG acknowledgement nonce**; the dynamic
-renderer posts versioned `render_accepted` / `render_failed{reason}` messages
-**to the kernel** (top window), carrying the nonce; the kernel validates
-source ownership, nonce, token, `nav_gen`, and `refresh_gen` before any state
-transition or callback. This protocol is pinned by tests for all three flows:
-SSAT, client-Prebid, and nested SafeFrame.
-
-**G4c — Honest observation names.** The browser cannot see inside an opaque
-APS frame, and the iframe's geometry is assigned by our own renderer — it
-proves nothing about content. A nonempty `slotRenderEnded` proves GAM
-delivered a creative container, not that the nested runner painted. The state
-machine therefore records observations under accurate names —
-`gam_nonempty`, `gam_empty`, `renderer_document_loaded`, `runner_loaded`,
-`runner_failed` — and **APS attempts terminate at `render_accepted`**
-(= authenticated `runner_loaded` ack) unless Amazon provides a real completion
-acknowledgement. `render_confirmed` exists only for paths with same-origin
-observable content (inline adm frames TS itself writes); it is never derived
-from geometry or from PUC container delivery. Tests: accepted-but-blank, and
-nonempty-`slotRenderEnded`-before-bridge-claim.
-
-**G4d — `nurl`/`burl` are separate business events.** OpenRTB 2.6
-distinguishes them: `nurl` is the win notice (implies neither delivery nor
-billability); `burl` is the billable-event notice under exchange policy.
-Today both fire together (`gpt/index.ts:459`). Contract — independent,
-idempotent transitions:
-
-1. winner selection → fire `nurl`;
-2. `render_accepted` (authenticated) → fire `burl` — this is the **declared
- commercial policy** for APS given no paint acknowledgement exists, recorded
- here explicitly rather than implied;
-3. terminal failure after acceptance → no un-firing; the row is labeled
- `billed_then_failed` so the policy's cost is measurable.
-
-**G4e — Fallback trigger.** GPT offers no cancellation (section 3.7), so a
-timeout can race a late fill that arrives after a fallback has rendered and
-billed. Contract: the opt-in direct fallback
-(`[auction].client_render_fallback = "renderer"`) renders **only after an
-explicit terminal empty event for the bound cycle** (`gam_empty` from G4a
-attribution). A timeout emits diagnostics (`render_fail{bridge_claim_timeout}`)
-and **never renders**. For publisher-owned (adopted) slots, fallback is
-disabled entirely. TS-owned-slot timeout rendering is admitted only as a
-possible future extension that must first destroy the slot to retire the
-request, and is out of scope here.
+IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`)
+means module imports never share state across bundles (live proof:
+`core/context.ts:11` vs `permutive/index.ts:102`).
+
+Contract — a versioned registration ABI on `window.tsjs._internal`:
+
+- Kernel ships only in `tsjs-core`, publishes
+ `tsjs._internal = { abi: 1, registry }` once (window sentinel). The kernel
+ constructs and registers core service instances during boot; integrations
+ register integration-scoped services during `install()`.
+- **Version semantics (settling the mixed-version gap):** every service
+ registers with `(major, minor)`. `registry.get(name, {major, minMinor})`
+ succeeds iff an implementation with the same `major` and `minor ≥ minMinor`
+ is registered. The install manifest's plugin versions are **ranges with the
+ same semantics** (required major, minimum minor), not exact pins.
+ An incompatible service registration is **quarantined** — recorded, not
+ installed — and surfaced as `abi_mismatch`; an incompatible plugin as
+ `bundle_partial`. **First-wins applies only among compatible
+ registrations.** The old-deferred-bundle + new-core scenario therefore has a
+ deterministic verdict: the old plugin either satisfies the manifest range
+ and runs, or is quarantined loudly.
+- Stateful services only via the registry at call time; stateless helpers may
+ be imported and inlined. Single-module-graph builds remain the successor
+ option behind the same surface.
+
+### G4 — Render lifecycle
+
+**G4a — Request-cycle protocol (no ordering assumptions).** GPT documents no
+per-refresh identity and no completion-order guarantee, so the protocol
+assumes neither:
+
+- Every observable request initiation is classified `ts | publisher`: TS's own
+ `display()`/`refresh()` calls open TS cycles; the wrapped publisher
+ entry-points and `slotRequested` events that match no TS cycle are recorded
+ as publisher-initiated.
+- **TS serializes itself to at most one outstanding cycle per slot** — a new
+ TS refresh for a slot with a pending cycle waits or supersedes explicitly;
+ it is never concurrently pending.
+- With ≤1 TS cycle outstanding, a `slotRenderEnded` is attributable iff no
+ untracked or publisher-initiated request overlaps it. **Any overlap marks
+ the slot `cycle_unattributable` and fails closed** (no fallback, no state
+ transition, console warning + disposition).
+- `slotRenderEnded` is treated as "creative code injected", not "resources
+ loaded" — it can confirm delivery, never paint.
+- The deterministic PUC/message harness exercises the protocol in CI, and a
+ **release-gating real-GAM overlap test** (publisher refresh racing a TS
+ cycle) validates the contract against actual GPT, since a FIFO-assuming
+ stub proves nothing.
+
+**G4b — Acknowledgement path.** Unchanged from revision 3 (review-accepted):
+per-attempt CSPRNG nonce in the bridge response; the dynamic renderer posts
+versioned accepted/failed messages to the kernel; the kernel validates source
+ownership, nonce, token, `nav_gen`, `refresh_gen` before any transition or
+callback; pinned for SSAT, client-Prebid, and nested SafeFrame flows.
+
+**G4c — Honest observations, `render_confirmed` removed.** The inline-adm
+frames are sandboxed `srcdoc` documents with `allow-same-origin` deliberately
+omitted (`gpt/index.ts:358`) — their origins are opaque, so revision 3's
+"same-origin observable" premise was false. The event is removed entirely.
+The taxonomy is now: `gam_nonempty`, `gam_empty`, `renderer_document_loaded`,
+`runner_loaded`, `runner_failed`, `adm_document_loaded` (the iframe `load`
+event for TS-written adm frames — document delivery, not paint). **Every
+render path terminates at `render_accepted`** (authenticated per G4b where
+the renderer protocol exists; `adm_document_loaded` for adm frames). No
+observation claims paint. A future trusted completion acknowledgement (open
+question 6) may reintroduce a confirmed state under a new name.
+
+**G4d — Win/billing notifications, scoped to paths that have them.** APS
+**intentionally carries neither** `nurl` nor `burl` (`aps.rs:812` sets both
+`None`; the minimized AAX envelope excludes notifications; the integration
+guide documents that generic win/billing beacons are not fired for APS). APS
+billing runs entirely inside the Amazon runner lifecycle, and this design
+does not change the APS wire contract.
+
+For bid paths that do carry the URLs (PBS and other OpenRTB providers):
+
+- **Trigger semantics, published explicitly:** `nurl` fires when the render
+ attempt binds the bid to a cycle (Trusted Server's selection produced the
+ candidate GAM will render — the earliest point at which "win" is
+ meaningful for this pipeline); `burl` fires at the attempt's
+ `render_accepted`. Both are **attempt-scoped**, keyed by
+ `(trace_id, nav_gen, slot, refresh_gen, hb_adid)` as the idempotency key —
+ fired at most once per attempt, not page-wide.
+- **Owner and mechanics:** the client render pipeline owns firing (as today,
+ `gpt/index.ts:459`), via `sendBeacon`/`no-cors fetch`, no retries (a beacon
+ either queues or is lost; retrying risks double-billing).
+- Terminal failure after acceptance is labeled `billed_then_failed`; no
+ un-firing.
+
+**G4e — Fallback trigger.** The opt-in fallback
+(`[auction].client_render_fallback = "renderer"`) renders only after a
+**terminal `gam_empty` unambiguously attributed to a TS-initiated cycle**
+(G4a). Timeouts are diagnostics-only and never render. Revision 3 disabled
+fallback for adopted slots entirely, which — as the review noted — excludes
+the common production path (pre-existing publisher slots are adopted and
+refreshed, `gpt/index.ts:925`). Revised: **ownership does not gate the
+fallback; attribution does.** An adopted slot whose attributed TS cycle ends
+in `gam_empty` may fall back; any publisher-initiated or unattributable cycle
+never triggers it. The success criteria and browser specs cover the adopted
+case explicitly.
### G5 — Deployment contracts
-- **Config rollback:** every new auction/config field is default-valued and
- omitted from serialization at its default (`auction_config_types.rs:7`
- denies unknown fields), so blobs written by a new binary remain readable by
- the previous one unless an operator opts in.
-- **Asset identity is path-based and retained.** A query hash the handler
- ignores (`tsjs.rs:3`, `publisher.rs:294`) is not content addressing, and
- redirecting an old hash to current bytes just executes new code under old
- HTML, bootstrap flags, and ABI expectations. Contract: the content hash
- moves into the **pathname** (`/static/tsjs//.js`); the server
- serves through a hash→bytes manifest that **retains prior artifacts** beyond
- the maximum HTML cache lifetime plus the deferred-load window (retention
- floor: 7 days); `Cache-Control: immutable` only on exact hash matches;
- unknown hashes answer `410 Gone` with `no-store` — never a redirect to
- different bytes. Precomputed concatenations are keyed by the **ordered
- module-ID vector** (order affects side effects), not the set.
-- **Ingest routing:** the beacon route exists in all four adapters (Fastly,
- Axum, Cloudflare, Spin) as an early, EC-free, filter-free route. Only Fastly
- has a real sink today; the others accept-count-drop by explicit contract.
-- **Storage:** a new dedicated datasource named `ts_client_events`; retention
- **30 days**; production sampling default **10%** (operator-tunable);
- schema versioned with the event enum.
-- **Phase ordering** is section 8's; each phase ships behind a feature flag
- with the named canary thresholds and rollback criteria in section 8.
+- **Config rollback:** new config fields are default-valued and omitted from
+ serialization at defaults. **Rollback runbook rule:** after an operator has
+ opted into a new field, rolling the binary back requires restoring the
+ default and pushing the default-compatible blob first (this mirrors the
+ project's existing rollback guidance).
+- **Asset identity and the artifact source (settling "not realizable"):**
+ hash in the pathname; and artifacts are **published to shared immutable
+ platform storage (KV/config store) as deploy stage 1, before any HTML
+ references them** — binaries serve the current vector from embedded bytes
+ (fast path) and everything else by hash lookup in shared storage. This
+ answers all four skew cases: new HTML hash `B` reaching an old instance
+ (lookup serves `B` from storage), a miss for retained `A` after only `B` is
+ embedded (lookup), already-issued legacy query-hash URLs (the legacy path
+ keeps serving current bytes with short-TTL, non-immutable caching through a
+ documented sunset), and renderer `/v2` reaching a `/v1`-era instance
+ (versioned renderer documents are published to the same storage in
+ stage 1). Two-stage deployment is the contract: **stage 1 publish
+ artifacts, stage 2 roll binaries/HTML.** Both rolling directions and the
+ legacy URL are tested. Retention: ≥ 7 days, which must exceed the HTML
+ cache lifetime — itself now bounded by contract (auction-bearing HTML is
+ `no-store` per G1; any cacheable non-auction HTML referencing tsjs sets
+ `max-age ≤ 300`). `Cache-Control: immutable` only on exact hash matches;
+ unknown hashes → `410 Gone`, `no-store`. Concatenations are keyed by the
+ **ordered module-ID vector**, precomputed in Phase 0 (which owns asset
+ identity).
+- **Ingest routing:** the beacon route exists in all four adapters as an
+ early, EC-free, filter-free route; only Fastly has a real sink; others
+ accept-count-drop by explicit contract.
+- **Storage:** datasource `ts_client_events`; retention 30 days; production
+ sampling 10%. These are **adopted defaults** (operator-tunable), no longer
+ open questions; the remaining open question is only whether non-Fastly
+ adapters get sinks (OQ5).
+- **Phase gates are phase-specific** (section 8) — the render-fail canary
+ applies only from Phase 3 onward, because earlier phases don't create that
+ metric.
---
## 5. Workstream 1 — Observability
-### 5.1 Event payload — minimized by design
-
-High-cardinality identifiers stay out of the beacon: no raw `hb_adid`, no raw
-Prebid `adId`, no free-form slot strings.
+### 5.1 Event payload — minimized, grouped per trace
```
-{ v: 1, events: [
- { trace_id, sampled, nav_gen, refresh_gen, seq,
- t: "bid_received" | "targeting_set" | "bridge_request" |
- "bridge_response_sent" | "render_attempt" | "render_accepted" |
- "render_confirmed" | "render_fail",
- slot, // configured slot id if in the injected slot set, else "s"
- id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none"
- matched, // bridge_request only: token/id equality result
- source, // "renderer" | "adm" | "pbs-cache" | "gam"
- reason } // render_fail only: closed enum below
+{ v: 1, traces: [
+ { trace_id, sampled, capability?, // capability: only in diagnostic mode
+ events: [
+ { nav_gen, refresh_gen, seq,
+ t: "bid_received" | "targeting_set" | "bridge_request" |
+ "bridge_response_sent" | "render_attempt" | "render_accepted" |
+ "render_fail",
+ slot, // configured slot id if in the injected set, else "s"
+ id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none"
+ matched, // bridge_request only
+ source, // "renderer" | "adm" | "pbs-cache" | "gam"
+ reason, // render_fail only: closed enum below
+ width, height } // invalid_dimensions context only: bounded ints [0, 8192]
+ ] }
] }
```
-- Every stored string is either a member of a server-known allowlist (slot ids
- from the injected config, enum members) or a bounded ordinal — nothing free
- .form is persisted.
-- Reason enum: `renderer_document_no_load`, `runner_no_load`, `runner_failed`,
- `descriptor_invalid`, `bridge_id_mismatch`, `cycle_unattributable`,
- `bridge_claim_timeout`, `gam_empty`, `no_render_source`, `slot_unresolved`,
- `gpt_absent`, `pbjs_absent`, `bundle_partial`, `fallback_cancelled`,
- `abi_mismatch`. (`renderer_no_ready` from revision 2 is split by the G4b/6.6
- protocol into document-load vs runner-load failures.)
+- **Events are grouped per trace, and the diagnostic capability is a per-trace
+ field** — a navigation-spanning batch carries one group per trace, so one
+ batch-level capability can never be ambiguous, and an initial-navigation
+ capability never authorizes a client-minted SPA trace (that trace's
+ capability comes from the page-bids response, G1).
+- Reason enum (closed; no interpolation — dimension context travels in the
+ bounded numeric fields): `renderer_document_no_load`, `runner_no_load`,
+ `runner_failed`, `descriptor_invalid`, `invalid_dimensions`,
+ `bridge_id_mismatch`, `cycle_unattributable`, `bridge_claim_timeout`,
+ `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`,
+ `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`.
### 5.2 Transport
-`fetch(..., {keepalive: true, credentials: "omit"})` primary; `sendBeacon` as
-the documented last-resort `pagehide` fallback (credentialed by platform
-design, and its `true` means queued, not received — the handler ignores
-credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s.
-
-### 5.3 Ingest wire contract (numeric, complete)
-
-- Route: `POST /_ts/client-events`, registered in all four adapters before
- auth/EC/filters. Content type: `application/json` only (no
- `Content-Encoding`; compressed bodies rejected). Responds
- `204 Cache-Control: no-store`.
-- Limits enforced **before parse or log**: body ≤ **16 KiB**; ≤ **64** events
- per batch; any string field ≤ **64** chars; `trace_id` must match
- `^[0-9a-f]{32}$`; `nav_gen`/`refresh_gen`/`seq` are integers in
- `[0, 2³¹)`. Violation → `204` (accepted-and-dropped) + abuse counter; the
- endpoint never echoes input.
-- Same-origin enforcement: `Sec-Fetch-Site: same-origin` when present;
- otherwise `Origin` must match the serving host; **absent both → reject**
- (drop-and-count). All strings are structurally serialized (never
- interpolated into log lines).
-- Client IP for rate limiting is derived per adapter from its documented
- trusted source (Fastly: the platform client IP; Axum: configured trusted
- proxy header; Cloudflare/Spin: platform equivalents). Rate limiting uses the
- platform limiter where one exists (Fastly); portable adapters ship a
- best-effort in-memory limiter and the policy is **fail-open with an abuse
- counter** (dropping telemetry must never block ad delivery).
-- **Diagnostic mode is a server-injected capability, not a query flag.** The
- tester gate (cookie) is evaluated server-side at HTML render; the page
- receives a short-lived signed capability token (HMAC over
- `trace_id + expiry`, ≤ 15 minutes) which the client echoes in the batch.
- The ingest handler verifies the signature — this works with
- `credentials: "omit"` because the capability travels in the payload, and a
- public query flag alone can never switch a session to unsampled.
+`fetch(..., {keepalive: true, credentials: "omit"})` primary. The `pagehide`
+fallback is `navigator.sendBeacon(url, new Blob([json], {type:
+"application/json"}))` — the Blob type satisfies the ingest media-type
+contract (a bare string would arrive as text); it is credentialed by platform
+design and its `true` means queued, not received; the handler ignores
+credentials either way. Flush on `visibilitychange`/`pagehide` and every 5 s.
+
+### 5.3 Ingest wire contract
+
+- `POST /_ts/client-events` in all four adapters, before auth/EC/filters.
+ `Content-Type: application/json` only; no `Content-Encoding`. Responds
+ `204 Cache-Control: no-store`; never echoes input.
+- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars;
+ `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`; width/height in
+ `[0, 8192]`. Violation → drop-and-count with `204`.
+- Same-origin: `Sec-Fetch-Site: same-origin` when present, else `Origin`
+ matching the serving host; **absent both → drop-and-count**.
+- **Rate limiting fails closed for telemetry:** when the limiter denies, or
+ when a portable adapter's best-effort limiter is unavailable or errors, the
+ request is dropped early with `204` (count only, no parse, no sink). Ad
+ delivery is unaffected by construction because this route serves nothing.
+- **Trusted client address, per adapter, concretely:** Fastly — the
+ platform's client IP API; Axum — the rightmost `X-Forwarded-For` entry
+ beyond `trusted_proxy_hops` (a required config value when the beacon is
+ enabled; without it, the socket peer address is used and forwarded headers
+ are ignored); Cloudflare — `CF-Connecting-IP`; Spin — the platform client
+ address. Spoofable headers are never trusted beyond the configured hop
+ count.
+- **Diagnostic capability:** server-issued, HMAC over
+ `trace_id + expiry` (≤ 15 min), delivered via the G1 boot field (initial
+ trace) or the page-bids response (SPA traces), echoed per trace group.
+ Signature verification is what switches a trace to unsampled — a public
+ query flag alone never does.
### 5.4 Two modes, honestly separated
-- **Production telemetry:** sticky-sampled (default 10%), SLO: **a delivery
- failure mode affecting ≥ 1% of impressions is visible in `ts_client_events`
- within one hour**.
-- **Diagnostic mode:** capability-gated, unsampled, full event stream plus
- console mirroring — the "one page load names the failing reason" tool.
+- **Production telemetry** (sink-backed deployments only — today Fastly):
+ sticky-sampled (10%). SLO, fully parameterized: a failure mode affecting
+ ≥ 1% of **sampled render attempts** is visible in `ts_client_events` within
+ one hour, evaluated only when the deployment produced ≥ 10,000 sampled
+ render attempts in that hour, with sink ingestion freshness ≤ 5 minutes;
+ sink outages pause the SLO clock and are alarmed separately. Non-sink
+ adapters are explicitly out of SLO scope, and no global gate depends on
+ their beacon data.
+- **Diagnostic mode:** capability-gated, unsampled, full stream + console
+ mirroring — the "one page load names the failing reason" tool.
### 5.5 Server-side drop-reason surfacing
-- Emit a bounded structured summary **whenever any bid is dropped** (per-slot
- reason counts, capped), not only when zero survive.
-- Add `drop_reasons` to auction telemetry rows; add the drop summary to the
- initial-HTML `ts-debug` comment; `/_ts/page-bids` (JSON) gains a gated
- structured `debug` field under the same tester gate.
-- Startup validation warnings: APS enabled while
- `allow_script_creatives = false`; any direct provider configured alongside a
- mediator without the 6.1 merge strategy.
+- Bounded structured summary whenever **any** bid is dropped (per-slot reason
+ counts, capped); `drop_reasons` added to auction telemetry rows; drop
+ summary in the initial-HTML `ts-debug` comment; `/_ts/page-bids` gains a
+ tester-gated structured `debug` field.
+- Startup warnings: APS enabled with `allow_script_creatives = false`; direct
+ provider configured alongside a mediator without 6.1's merge strategy.
---
@@ -396,128 +407,77 @@ credentials either way). Flush on `visibilitychange`/`pagehide` and every 5 s.
### 6.1 Mediation: opt-in merge, `mediator_only` stays the default
-- `[auction].winner_selection = "mediator_only"` (default, today's behavior,
- now explicit) or `"merge_highest_cpm"` (opt-in); omitted from serialized
- blobs at the default (G5).
-- `merge_highest_cpm` semantics: comparison in decoded CPM; **currency
- mismatch is a rejection** (the mismatched bid is dropped with a selection
- reason; no conversion in v1); slot floors apply to both populations; ties
- break to the mediator; a mediator timeout degrades to direct-provider
- selection and is reported.
-- **Deduplication key:** the server constructs the mediator's input, so it
- records provenance at forwarding time — `(provider_name, upstream_bid_id)`
- per candidate — and carries a provenance map keyed by the id it sent.
- A mediator bid whose id maps back to a forwarded candidate counts once, as
- provenance `mediator`. A mediator bid whose id was **transformed beyond the
- map** is treated as distinct mediator demand (documented limitation).
-- **Deal priority is out of scope for v1.** The internal `Bid`
- (`types.rs:231`) carries no deal identity; inventing a priority rule the
- model cannot express would be fiction. Extending the bid model with
- `deal_id`/deal type and a deal-first rule is recorded as follow-up work;
- until then deals compete by CPM like everything else and the limitation is
- documented in the config reference.
-- One candidate-selection helper serves **both** mediation lifecycles
- (ordinary/page-bids, `orchestrator.rs:412-431`; initial-SSAT split
- dispatch/collect, `orchestrator.rs:1320`), with tests for each.
-- A **selection report** (`winner_source`, `mediator_superseded`,
- `currency_rejected`, dedup hits) is emitted separately from
- delivery-conversion drop reasons.
+As revision 3 (review-accepted), with one tightening: a mediator bid whose id
+the provenance map cannot resolve, **for a slot where the same provider had
+forwarded candidates**, is counted under a dedicated
+`mediator_provenance_unresolved` metric and logged at `warn` — surfacing
+possible self-competition instead of silently treating it as distinct demand.
+Deal priority remains out of scope (the `Bid` model carries no deal identity;
+recorded as follow-up). Currency mismatch remains rejection. One selection
+helper serves both mediation lifecycles, with tests for each.
### 6.2 Dimensions: the contract is "request what you accept"
-Revision 2's operator `accept_sizes` allow-list is withdrawn on the review's
-sharper observation: if an alternate size is acceptable, it belongs in the
-slot's **requested formats** — APS should be asked for it. Accepting an
-unrequested response size would conceal an upstream protocol violation.
-
-- Exact size membership (`aps.rs:657-668`) remains the admission rule,
- unchanged.
-- The fix is configuration plus visibility: the drop summary (5.5) names the
- rejected size per slot (`invalid_dimensions{300x600}`), so an operator sees
- exactly which format to add to the slot's `formats` if they want that
- demand. Documentation gains a "sizing your slots for APS" section.
-- No `hb_size` key, no admission relaxation, no new config.
+Exact size membership stays. The fix is visibility plus configuration: the
+drop summary names the rejected size per slot via
+`reason: invalid_dimensions` with bounded numeric `width`/`height` fields
+(closed enum preserved), and documentation gains "sizing your slots for APS."
### 6.3 Script creatives
-Keep the secure default (`allow_script_creatives = false`) but make the
-consequence loud (5.5) and document the enablement path for TAM-heavy
-publishers.
+Secure default kept; consequence made loud (5.5); enablement path documented.
### 6.4 Render identity
-Implemented exactly as G2 (token scope, format, TTL, per-navigation registry
-keying, one-time consumption, cache-path regression tests).
+As G2.
### 6.5 Fallback rendering
-Implemented exactly as G4e: renders only on an attributed terminal
-`gam_empty`; timeouts are diagnostics-only; disabled for adopted slots. The
-direct renderer is converted to an awaitable API with cancellation and a
-terminal reason first; the fallback lands only after that conversion.
+As G4e — attribution-gated, not ownership-gated; timeouts never render; the
+renderer is converted to an awaitable API with cancellation and terminal
+reasons before the fallback lands.
### 6.6 Renderer endpoint — unconditional, versioned, observable
-Topology is resolved now rather than left conditional:
-
-- **The static renderer document route registers unconditionally in every
- adapter.** It contains no configuration, no secrets, and validates its input
- client-side; serving it cannot leak anything, and conditional registration
- is exactly what created the silent cross-deployment failure class. (The APS
- _provider_ stays config-gated; only the static document is unconditional.)
-- The document is **versioned in its path**
- (`/integrations/aps/renderer/v1`) and served `Cache-Control: no-store`;
- descriptor compatibility across N/N−1 is guaranteed by the outer-tolerant
- validation of 6.7. The client pins the version it targets.
-- Startup validation fails loudly if any configured auth handler pattern
- covers `/integrations/aps/renderer`.
-- **Two-stage acknowledgement (with G4b):** the document first posts an
- authenticated `document_loaded` (proving route + auth + CSP allowed the
- document itself), then the separate runner-load result. This splits the old
- blind timeout into `renderer_document_no_load` (route/auth/stale-CDN/network)
- vs `runner_no_load` / `runner_failed` (Amazon script or CSP) — distinct
- signals, as the success criteria require.
-- Server-side: route status/version counters (requests, unknown-version,
- auth-blocked) join the telemetry rows.
-- **CSP changes ship report-only first** (`Content-Security-Policy-Report-Only`
- canary with a bounded report endpoint), then enforce; each added source is
- justified in a comment and covered by the browser spec. Tests cover broad
- auth patterns, stale versions, and CSP failures on all adapters.
-
-### 6.7 One descriptor schema — structural generation, semantic validators kept
-
-- The wire truth is the tagged enum `BidRenderer` (discriminator lives there,
- not on `ApsRendererV1` — `types.rs:188-211`); the generated schema is the
- full tagged envelope.
-- Generation lives in a **separate wire-schema crate** (or host-side xtask) —
- core already depends on `trusted-server-js` (`Cargo.toml:45`), so the
- reverse edge would be a cycle. Generated TS artifacts are checked in; CI
- fails on staleness.
-- Generation covers structure only; the semantic security checks stay
- hand-written on both sides (URL/origin policy, canonical base64, length
- bounds, the exact one-bid envelope projection, cross-field equality).
- **Unknown-field tolerance applies only to the outer versioned descriptor;
- the decoded AAX envelope remains an exact projection.**
-- A shared positive + adversarial corpus runs through the Rust validator, the
- TS validator, and the inline renderer document in CI.
+- The static renderer document route registers unconditionally in every
+ adapter (the APS provider stays config-gated). Startup validation fails
+ loudly if an auth handler pattern covers it.
+- **Caching matches immutability:** `/integrations/aps/renderer/v1` is an
+ immutable artifact — its bytes change only by shipping `/v2` — so it is
+ served with `Cache-Control: immutable` (long max-age), published to shared
+ artifact storage in deploy stage 1 like every versioned asset (G5), which
+ also answers version-skew (`/v2` requests reaching older instances are
+ served from storage). Revision 3's `no-store` contradicted the versioning
+ and is corrected.
+- Two-stage acknowledgement (G4b): authenticated `document_loaded`, then the
+ runner-load result — splitting `renderer_document_no_load` from
+ `runner_no_load`/`runner_failed`.
+- **Server route counters are aggregate** (requests, unknown-version,
+ auth-blocked): the document request carries no trace (the nonce travels in
+ the URL fragment, which never reaches the server), so no row-level join is
+ claimed.
+- **CSP report-only canary, fully specified:** reports go to a dedicated
+ `POST /_ts/csp-reports` route (same pre-parse caps and same-origin rules as
+ 5.3; credentials ignored; rate-limited fail-closed); stored as **aggregate
+ counters only** (directive, blocked-origin **host only** — full URLs
+ redacted) on sink-backed adapters, count-and-drop elsewhere; enforcement
+ follows only after a clean canary window.
+
+### 6.7 One descriptor schema
+
+As revision 3 (review-accepted): tagged-envelope schema generated from a
+separate wire-schema crate/xtask; semantic validators hand-written on both
+sides; outer-tolerance only, exact AAX projection; shared
+positive + adversarial corpus across Rust, TS, and the inline document;
+staleness CI.
### 6.8 Bridge hardening
-- **Ownership proof stays source-first**, and the SafeFrame extension is
- bounded: the kernel maintains a map of known slot-root `WindowProxy` objects
- (the iframes GPT created under each slot element); on a message, it walks
- the **sender's own parent chain** (`event.source.parent`, …) up to depth
- **5**, looking for a known root — it never enumerates or recursively scans
- an attacker-controllable frame tree. Unresolvable source → refuse with
- `bridge_id_mismatch`.
-- Adversarial tests: wrong-slot tokens, nested foreign frames, replayed and
- duplicated messages, stolen tokens, previous-navigation tokens, plus the
- positive SafeFrame case.
-- Blanket top-of-listener hygiene: parse and ownership-check before branch
- logic.
-- Delete the dead duplicate renderer branch (C5); renderer branches emit the
- same trace records as adm/cache branches under G4's taxonomy and the G4d
- `nurl`/`burl` split (C7).
+As revision 3 (review-accepted): source-first ownership with the bounded
+parent-chain SafeFrame walk (depth 5, known slot-root `WindowProxy` map, no
+tree scans); adversarial test set; top-of-listener hygiene; dead branch
+deleted; renderer branches emit trace records under G4's taxonomy, with G4d
+notifications only where the bid path carries them (never APS).
---
@@ -525,283 +485,211 @@ Topology is resolved now rather than left conditional:
### 7.1 Layering
-```
-kernel/ boot, config, queue, event bus, log, beacon, sessions
-adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access
-services/ slots (registry+handoff), auction client, render engine, consent
-integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services)
-```
-
-Boundary lint in CI (`import/no-restricted-paths`): `kernel` imports nothing
-above it; `adapters` import kernel only; `services` import kernel + adapters;
-`integrations` import kernel + services, never each other. Stateful services
-via the G3 ABI only.
+Kernel / adapters / services / integrations exactly as revision 3, with the
+boundary lint in CI. Stateful services via the G3 ABI only.
-### 7.2 Adapters: explicit absence, without giving up on late loaders
+### 7.2 Adapters
-`present | pending | timed_out` per external global; `timed_out` is
-non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and drains
-what is still valid); individual queued operations carry their own timeouts
-and expire with a disposition reason.
+`present | pending | timed_out` with non-terminal `timed_out` (late loaders
+transition to `present`); per-operation timeouts with disposition reasons.
### 7.3 Slot registry service
-One registry owns slot knowledge (publisher- vs TS-defined, adoption, handoff
-claims, responsive resolution, pending request cycles per G4a, targeting-key
-history), keyed by `WeakMap` plus a div-id index,
-kernel-owned via the ABI. Expandos on live GPT objects are eliminated.
+Kernel-owned registry (`WeakMap` + div-id index)
+holding ownership, adoption, handoff claims, responsive resolution, pending
+request cycles (G4a), and targeting-key history. No expandos on GPT objects.
### 7.4 Global namespace policy — with a compatibility window
-- One owned global, `window.tsjs`; public API versioned; coordination state
- under `tsjs._internal` (G3). **The public queue keeps its existing name:
- `window.tsjs.que`** (`types.ts:259`, drained at `core/index.ts:25`) —
- revision 2's `cmd` was an error; renaming a public surface silently would
- violate this very section.
-- Inventory first: every current global classified public
- (`globalThis.tscreative`, `tsCreativeConfig`, `tsjs.que`) or private
- (`__tsjs_*` flags, expandos, sentinels).
-- **Public globals: dual-read/write for a bounded window — two release
- cycles, minimum 60 days — ending only after an adoption gate: beacon-observed
- old-name usage below 0.1% of traces for 14 consecutive days.** Pre-init
- compatibility tests pin that config set before the bundle loads keeps
- working.
-- Private globals migrate immediately: dead expando writes deleted now; slot
- state into `SlotRecord`; function sentinels into a kernel `WeakSet`; boot
- flags into `tsjs` boot fields.
-- `requestAds` keeps its void signature; failure surfacing arrives as a **new
- versioned async API** (`tsjs.requestAdsAsync(...): Promise`)
- rather than changing the existing contract.
+As revision 3 (review-accepted): `window.tsjs` + `tsjs._internal`; the public
+queue keeps its real name **`tsjs.que`**; public globals
+(`tscreative`, `tsCreativeConfig`, `tsjs.que`) get dual-read/write for two
+release cycles / ≥ 60 days, closing only on the adoption gate (old-name usage
+< 0.1% of traces for 14 days, measured on sink-backed deployments);
+`requestAds` keeps its void signature; `requestAdsAsync` is the new versioned
+API; private globals migrate immediately.
### 7.5 Messaging module
-All `postMessage` traffic through one module: versioned envelopes, message
-name constants, the G4b acknowledgement nonces, source validation per 6.8, one
-audit point.
+All `postMessage` through one module: versioned envelopes, name constants,
+G4b nonces, 6.8 source validation. A **minimal** messaging module (envelope +
+constants + validation helpers used by the bridge) lands early (Phase 1) so
+Phase 3 does not depend on Phase-4 structure; the full migration of every
+legacy call site completes in Phase 4.
### 7.6 Plugin lifecycle and session model
-- **Activation:** Rust owns integration selection today and continues to — the
- server injects a **versioned install manifest** (enabled plugin ids +
- expected versions, in injection order) into the pre-core `tsjs.que`. The
- kernel executes the manifest on boot; nobody else calls install in
- production (the API remains callable for tests).
-- `tsjs.definePlugin(id, version, install, dispose)`: synchronous `install`
- by default; a plugin may return a promise, but anything `adInit` depends on
- (gpt, prebid shim registration) must complete synchronously and is listed as
- such in the manifest. Late registration after the manifest requested the id
- installs on arrival (pending-install queue) bounded by a missing-module
- timeout emitting `bundle_partial`. A stale async completion (arriving after
- its `RuntimeSession` was disposed) is discarded. Duplicate `(id, version)`
- is a no-op; a different version for a registered id: first-wins + loud
- telemetry. Disposal runs in reverse install order.
-- **Session model, split as the review required:**
- - `RuntimeSession` (page lifetime): bridge listener, history hook, pbjs
- subscriptions, adapters, beacon queue.
- - `NavigationSession` (per SPA navigation): `trace_id`, render attempts,
- slot aliases, targeting history, navigation-scoped timers/observers.
- - `RenderAttempt` (per G4a cycle): state machine instance, ack nonce.
- Each owns an **enumerable** disposal inventory; navigation disposes
- `NavigationSession` children only.
-- Per-plugin exception isolation: a plugin that throws during install is
- quarantined and reported; it cannot halt the bundle.
-- Error policy: no empty `catch`; every catch handles, logs with context, or
- emits a disposition reason. The auction fetch gets timeout +
- `AbortController`.
-- **Console logging is retained, not replaced.** Every issue-surfacing
- condition keeps (or gains) a `log.warn` debuggable from DevTools; existing
- warnings survive verbatim or strengthened; `debug`-level failure paths that
- indicate delivery or security-relevant failures are promoted to `warn`;
- every `render_fail`/dependency-timeout disposition emits a paired `warn`
- with the same reason code.
+As revision 3 (review-accepted), with G3's sharpened version semantics:
+manifest versions are ranges (major + minMinor); quarantine on
+incompatibility; first-wins only among compatible. Sessions:
+`RuntimeSession` / `NavigationSession` / `RenderAttempt` with enumerable
+disposal inventories. Error policy: no empty `catch`; auction fetch gets
+timeout + `AbortController`. **Console logging retained, not replaced**
+(paired `warn` with the beacon's reason code; `debug`-level
+delivery/security failures promoted to `warn`).
### 7.7 The bootstrap problem
-Target: shrink the inline `gpt_bootstrap.js` to a queue-and-flags stub with
-the bundle replaying recorded calls on install. This is **not a pure move** —
-replay changes observable ordering — so it ships behind its own flag with
-browser specs extended to cover replay timing, and the no-bundle fallback
-("ads still render if the bundle fails", pinned by `gpt.rs:1174-1179`) is
-**generated from the same TypeScript source** at build time.
+As revision 3: queue-and-flags stub + bundle replay behind its own flag with
+replay-timing specs; the no-bundle fallback generated from the TypeScript
+source.
### 7.8 GPT correctness fixes carried with the restructure
-- Restore the #922 orphan-slot recovery and `updateRender` enrichment (verify
- against open PR #997; land whichever is canonical).
-- `changeCorrelator: false` on TS-initiated refreshes; correlator behavior
- becomes a documented, configurable decision.
+- **Unconditional early GPT event subscription:** the `slotRenderEnded`
+ (and `slotRequested`) listeners register on the command queue at install,
+ no longer gated behind `!ts.servicesEnabled` (`gpt/index.ts:1017`) — G4a
+ cannot work on bootstrap-first pages otherwise. Recording is idempotent so
+ double-registration cannot double-count.
+- Restore #922/#997 attribution and orphan recovery.
+- `changeCorrelator: false` on TS-initiated refreshes (configurable).
- `enableSingleRequest()` only when GPT services are not already enabled.
-- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`
- alongside its console warning.
+- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`.
### 7.9 Decomposition targets
-| Today | Target |
-| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
-| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
-| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (moves onto slot registry), eids, diagnostics |
-| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
-| `core/trace.ts` (record model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
-| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split into public API vs internal coordination state |
+As revision 3 (gpt/prebid splits, script-guard consolidation, trace model/UI
+split, `global.d.ts` fix).
### 7.10 Performance
-Client-side speedups with enforced budgets: smaller synchronous bundle
-(script-guard consolidation, single APS module via the ABI, dead-code
-deletion, trace-overlay extraction); slot resolution once per navigation;
-bounded telemetered waits. Budgets, concretely: per-bundle raw/gzip/Brotli
-bytes for the exact ordered module vector of the reference config, compared
-to a checked-in baseline with **+5% tolerance**; the
-bids-script-to-first-`display()` browser assertion runs **20 iterations** and
-gates on **p90**. Server-side: precompute bundle bytes + hash per ordered
-module vector (deploy/config-time), benchmark server CPU/heap before/after.
+Budgets tightened per review: per-bundle raw/gzip/Brotli for the exact
+ordered module vector vs a checked-in baseline, **+5% byte tolerance**;
+browser timing assertion (bids-script-to-first-`display()`) on a **pinned CI
+runner class and pinned browser version**, **5 warm-up runs discarded, 50
+measured samples, gate on p90 with +10% latency tolerance**; server bench
+(precomputed concatenation) gates CPU and heap at **±10% vs baseline** with
+pinned tool versions. Precompute lands in Phase 0 with asset identity (G5).
### 7.11 Toolchain and dependency currency
-- **TypeScript:** the manifest floor is `^5.5.4` but the lockfile already
- resolves **5.9.3** (`package-lock.json`), so the code compiles on 5.9 today.
- Action: raise the manifest floor to the resolved 5.9 line (making the floor
- honest), then evaluate the next major as its own gated PR; adopt
- `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
- `verbatimModuleSyntax`.
-- **Dev toolchain to latest stable** (eslint + plugins, prettier, jsdom,
- `@playwright/test`, `@types/node` aligned to the pinned Node), each bump its
- own mechanical CI-gated PR with changelog review — this library monkeypatches
- `fetch`, `sendBeacon`, and DOM prototypes, so jsdom/Playwright behavior
- changes are real risks.
-- **`prebid.js` is excluded from casual bumps** (runtime Prebid is the
- manifest-locked external R2 bundle); the npm pin and deployed bundle version
- stay documented together.
-- **Standing policy:** monthly dependency review; no migration phase starts
- more than one minor behind latest stable.
+As revision 3 (review-accepted): raise the TypeScript floor to the resolved
+5.9 line, then evaluate the next major separately; strictness flags on; dev
+toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual
+bumps; monthly review policy.
---
## 8. Migration plan
-Ordered so every phase's prerequisites precede it (the review's required
-order). Every phase ships behind a feature flag; shared canary criteria: no
-increase in `render_fail` rate beyond **+0.5% absolute** on canary traffic
-over 24 h, no new console errors in the browser-spec run, rollback = flag off
-(phases 0–2 are additive; later phases keep dual paths until their gate).
-
-- **Phase 0 — Asset identity, contracts, toolchain.** Path-based immutable
- asset identity + manifest retention + rolling-deploy tests (G5) — this lands
- **before** anything makes ABI compatibility load-bearing. Toolchain floors
- (7.11). Contracts G1–G5 recorded as code-adjacent docs. Delete the dead
- expando writes. Server drop-reason surfacing (5.5) — server-only, no client
- dependency.
-- **Phase 1 — Kernel ABI and sessions.** Minimal kernel in `tsjs-core`
- publishing the versioned registry (G3); `RuntimeSession` /
- `NavigationSession` scopes (7.6); context-provider fix as the ABI proof;
- install manifest injection.
-- **Phase 2 — Trace and beacon.** Server correlation nonce for `nav_gen 0` +
- page-bids trace echo (G1); beacon service on the Phase-1 kernel; ingest
- route in all four adapters (5.3); diagnostic capability; `ts_client_events`
- datasource.
-- **Phase 3 — APS delivery.** Wire-schema crate + corpus (6.7); mediation
- helper + opt-in merge (6.1); render token (6.4); unconditional versioned
- renderer route + two-stage ack (6.6, G4b); bridge hardening (6.8); GPT
- request-cycle protocol + render state machine + awaitable renderer +
- `nurl`/`burl` split (G4a–G4d); the opt-in fallback (G4e); restore #922/#997
- attribution; correlator and SRA fixes (7.8). APS renders after this phase
- wherever GAM line items and configuration permit.
-- **Phase 4 — Structure.** Full layering + boundary lint, plugin lifecycle
- completion, adapters, full slot registry, messaging module, namespace
- migration window (7.4), server bundle precompute (7.10).
-- **Phase 5 — Decomposition.** File splits (7.9), script-guard consolidation,
- bootstrap shrink behind its own flag with replay-timing specs (7.7), end of
- the public-global compatibility window (gated per 7.4).
+Each phase ships behind a feature flag with **phase-specific gates** (the
+render-fail canary cannot evaluate phases that predate the metric):
+
+- **Phase 0 — Asset identity, contracts, toolchain.** Two-stage artifact
+ publishing (shared immutable storage) + path-based identity + ordered-vector
+ precompute + rolling-deploy tests in both directions + legacy-URL test;
+ toolchain floors; contracts G1–G5 as code-adjacent docs; delete dead expando
+ writes; server drop-reason surfacing.
+ _Gate:_ zero unexpected `410`s and zero legacy-URL breakage on canary; asset
+ hit/miss counters nominal.
+- **Phase 1 — Kernel ABI, sessions, minimal messaging, minimal cycle
+ registry.** Versioned registry with `(major, minor)` semantics;
+ `RuntimeSession`/`NavigationSession`; install manifest; the minimal
+ messaging module (7.5) and the cycle-aware slot-record core (G4a's queue)
+ land here so Phase 3 has its dependencies; unconditional early GPT
+ subscriptions (7.8).
+ _Gate:_ ABI install-success counters clean; zero `abi_mismatch` on canary;
+ no listener regression in browser specs.
+- **Phase 2 — Trace and beacon.** Server-minted initial trace + page-bids
+ `X-TSJS-Trace-Id` echo + capability issuance (G1, 5.3); beacon service;
+ four-adapter ingest; `ts_client_events`.
+ _Gate:_ ingest acceptance/drop/abuse counters nominal; trace join rate on
+ sink-backed canary ≥ 95% of sampled traces.
+- **Phase 3 — APS delivery.** Wire-schema crate + corpus; mediation helper +
+ opt-in merge; render token; unconditional versioned renderer route +
+ two-stage ack; bridge hardening; request-cycle protocol + render state
+ machine + awaitable renderer + scoped notifications (G4a–G4d); the opt-in
+ fallback (G4e); restore #922/#997; correlator and SRA fixes.
+ _Gate:_ `render_fail` rate within +0.5% absolute of pre-flag baseline over
+ 24 h on canary; fill/latency/billing volume deltas within agreed bounds
+ (billing measured against GAM/server-side reporting, not the beacon);
+ release-gating real-GAM overlap test green.
+- **Phase 4 — Structure.** Full layering + boundary lint; plugin lifecycle
+ completion; adapters; full slot registry; full messaging migration;
+ namespace window (7.4).
+ _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests green.
+- **Phase 5 — Decomposition.** File splits; script-guard consolidation;
+ bootstrap shrink (own flag, replay-timing specs); compatibility-window
+ close (adoption-gated).
+ _Gate:_ bundle budgets and timing assertions hold; adoption gate met before
+ any removal.
---
## 9. Test acceptance matrix
-Blocking CI is hermetic (the deterministic PUC/message harness); a separate
-staged smoke suite covers real GAM line items and is release-gating, not
-PR-gating.
-
-| Area | Must cover |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Mediation | both lifecycles; ties, floors, currency rejection; duplicate demand via provenance map; transformed mediator ids; `mediator_only` default preserved; rollback blob round-trip (omitted defaults) |
-| Cache identity | non-APS cache-backed bids byte-identical (`hb_adid` + coordinates); PUC `?uuid=` fetch path |
-| Render token | `^[a-z0-9]{12}$`; CSPRNG source; in-auction collision retry; 15-min TTL; one-time consumption; per-`(trace, nav_gen)` registry scoping |
-| Request cycles | pending-cycle attribution; overlapping refreshes; late `slotRenderEnded`; nonempty-before-bridge-claim; `cycle_unattributable` fail-closed |
-| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame flows; stale/replayed acks |
-| Render semantics | no `burl` before authenticated `render_accepted`; `nurl` at selection independent of delivery; `billed_then_failed` labeling; accepted-but-blank; no `render_confirmed` from geometry or PUC container |
-| Fallback | renders only on attributed `gam_empty`; timeout is diagnostics-only; adopted-slot fallback disabled; SPA cancellation; slot destruction; exactly-once terminal transition |
-| Bridge security | wrong-slot tokens; nested foreign frames; replay + duplicates; stolen tokens; previous-navigation tokens; bounded parent-chain walk (depth 5); SafeFrame positive case |
-| Beacon | initial-nav server nonce join; page-bids trace echo; per-event envelope across navigation-spanning batches; `(trace_id, nav_gen, seq)` dedup; ingest abuse (oversize, malformed, cross-origin, absent Origin); capability sig |
-| Schema | generated-artifact staleness; adversarial corpus through Rust + TS + inline validator; outer-tolerance vs exact AAX projection |
-| Runtime ABI | one kernel under concatenation; deferred registration after manifest request; per-plugin failure isolation; **mixed-version delivery (old deferred bundle + new core)**; abi-mismatch refusal |
-| Lifecycle | late-loaded GPT/pbjs (`timed_out → present`); RuntimeSession vs NavigationSession disposal inventories; stale async install completion; pre-init `tsCreativeConfig` and `tsjs.que` compatibility; dual-name global window |
-| Delivery | rolling-deploy simulation (old HTML + retained old assets); unknown hash → 410 `no-store`; immutable only on exact match; ordered-module-vector keying; deterministic raw/gzip/Brotli budgets vs baseline |
-| Renderer endpoint | route present in all four adapters; auth-pattern startup failure; stale version behavior; CSP report-only canary; `document_loaded` vs runner-result split |
-| Adapter parity | ingest + renderer routes + drop-reason surfacing behave equivalently on Fastly/Viceroy vs Axum vs Cloudflare vs Spin |
-| Policy | script-creative startup warning; `invalid_dimensions{WxH}` drop naming; page-bids `debug` field gating; diagnostic-mode unsampled completeness |
+Blocking CI is hermetic; the staged smoke suite (real GAM line items,
+including the G4a overlap test) is release-gating.
+
+| Area | Must cover |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Mediation | both lifecycles; ties, floors, currency rejection; provenance dedup; transformed ids → `mediator_provenance_unresolved`; `mediator_only` default; rollback blob round-trip |
+| Cache identity | non-APS cache-backed bids byte-identical; PUC `?uuid=` path |
+| Render token | format/CSPRNG/in-auction retry/TTL/one-time/per-`(trace, nav_gen)` scoping |
+| Request cycles | ts-vs-publisher classification; one-outstanding-TS-cycle serialization; publisher refresh overlapping TS cycle → `cycle_unattributable` fail-closed; late events; **real-GAM overlap (release gate)** |
+| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame; stale/replayed acks |
+| Render semantics | notifications only on carrying paths (never APS); `nurl` at bind, `burl` at accepted, attempt-scoped idempotency; `billed_then_failed`; no paint claims (`adm_document_loaded` labeling); accepted-but-blank |
+| Fallback | renders only on attributed `gam_empty` (adopted **and** TS-owned); publisher-initiated cycles never trigger; timeout diagnostics-only; SPA cancellation; destruction; exactly-once terminal |
+| Bridge security | wrong-slot/stolen/replayed/prior-navigation tokens; nested foreign frames; bounded parent-chain walk; SafeFrame positive |
+| Beacon | initial-trace join; page-bids header echo + response trace/capability; per-trace grouping across navigation-spanning batches; `seq`-gap partial traces; ingest abuse incl. absent Origin; capability signature; sendBeacon Blob |
+| Schema | staleness; adversarial corpus ×3 validators; outer-tolerance vs exact AAX projection |
+| Runtime ABI | one kernel under concatenation; deferred late registration; failure isolation; **mixed-version verdicts (compatible-range install vs quarantine)**; first-wins among compatible only |
+| Lifecycle | `timed_out → present`; session disposal inventories; stale async install; pre-init `tsCreativeConfig` + `tsjs.que`; dual-name window |
+| Delivery | two-stage deploy simulation **both rolling directions**; new-HTML-hash on old instance (storage lookup); retained-hash miss; legacy query-hash sunset path; unknown hash 410 `no-store`; immutable exact-match only; ordered vector |
+| Renderer endpoint | route in all adapters; auth-pattern startup failure; `/v1` immutable caching; version-skew via storage; `document_loaded` vs runner split; CSP report route caps/redaction |
+| Adapter parity | ingest, CSP-report, renderer routes and drop-reason surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
+| Policy | script-creative warning; `invalid_dimensions` + bounded width/height; page-bids `debug` gating; diagnostic unsampled completeness; trace-bearing HTML `private, no-store` invariant |
---
## 10. Alternatives considered
-1. **Keep patching APS point-failures without telemetry.** Rejected: three
- consecutive correct fixes have not produced ads.
-2. **Direct-render APS always (skip GAM/PUC).** Changes GAM reporting/pacing
- semantics unilaterally; kept as the opt-in, `gam_empty`-gated fallback.
-3. **Single module graph / shared chunks instead of the registration ABI.**
- Cleaner long-term; changes the delivery pipeline now; successor option
- behind the same ABI surface.
-4. **Full library rewrite in one branch.** Rejected: thin browser-spec safety
- net in exactly the changing areas.
-5. **Drop the ES5 bootstrap entirely.** Loses the pinned "ads render if the
- bundle fails" guarantee; generated fallback keeps it.
-6. **Timeout-triggered fallback rendering.** Rejected (G4e): GPT requests
- cannot be cancelled, so timeout rendering races late fills; only an
- attributed terminal empty event may trigger rendering.
+Unchanged from revision 3 (patching without telemetry; always-direct-render;
+single module graph; big-bang rewrite; dropping the bootstrap;
+timeout-triggered fallback — all rejected for the recorded reasons).
## 11. Risks
-- **Merge strategy misconfiguration:** mitigated by `mediator_only` default,
- the selection report, omitted-default serialization.
-- **Beacon abuse/volume:** pre-parse caps, origin checks, per-adapter rate
- limiting, sticky sampling, closed enums, capability-gated diagnostics.
-- **ABI freeze risk:** versioned from day one; mixed-version delivery tested.
-- **Ack protocol adds a message round-trip before `burl`:** bounded by the
- existing renderer timeout; the `billed_then_failed` label measures the
- policy.
-- **Bootstrap replay:** own flag, replay-timing specs, staged rollout.
-- **Schema generation:** checked-in artifacts + staleness CI.
+Revision 3's list, plus: **shared-storage dependency for assets** (stage-1
+publish becomes a deploy prerequisite; mitigated by the embedded fast path
+for the current vector and deploy-time verification that storage matches the
+embedded hashes); **notification-trigger semantics** are now a published
+contract for PBS-path demand — changing them later is a breaking change for
+SSP reporting expectations.
## 12. Success criteria
-1. APS creatives render on a reference page in each configured flow (SSAT,
- Prebid adapter, page-bids), hermetically in CI and via the staged smoke
- suite.
+1. APS creatives render on a reference page in each configured flow,
+ hermetically in CI and via the staged smoke suite (including the real-GAM
+ overlap test).
2. Every failure point in section 2 maps to a distinct observable signal;
diagnostic mode names the failing reason from one page load; production
- telemetry meets the 5.4 SLO (≥ 1% failure modes visible within one hour).
-3. Boundary lint passes with zero exceptions; stateful sharing only via the
- versioned ABI; mixed-version delivery behaves as specified.
+ telemetry meets the 5.4 SLO on sink-backed deployments.
+3. Boundary lint zero exceptions; stateful sharing only via the versioned
+ ABI; mixed-version delivery resolves to the G3 verdicts.
4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
generated.
-5. Trace counts are per-impression; orphaned-slot recovery has a non-vacuous
- test; `refresh_gen` attribution follows the G4a cycle protocol.
+5. Trace counts are per-impression; orphan recovery has a non-vacuous test;
+ cycle attribution follows G4a including the publisher-overlap fail-closed
+ rule.
6. The only TSJS-owned global is `window.tsjs` (public globals only inside
- their announced window, which closes per the 7.4 adoption gate); no
- expandos on GPT slots, GPT functions, or `pbjs`.
-7. Bundle budgets (raw/gzip/Brotli, +5% tolerance vs baseline) and the p90
- 20-run timing assertion hold; server-side per-request concatenation is
- precomputed.
-8. No existing warning is lost; every issue-surfacing condition logs at `warn`
- or above with the beacon's reason code.
-9. TypeScript floor matches the resolved 5.9 line with the strictness flags
- on; `prebid.js` npm pin matches the documented deployed bundle; monthly
- review policy in CI docs.
-10. Rolling-deploy tests: old HTML always receives its exact old assets during
- the retention window; unknown hashes 410 with `no-store`; immutable
- caching only on exact matches.
-11. `nurl` and `burl` fire on their distinct G4d transitions, idempotently,
- and never before their triggering state.
+ their window, closed by the 7.4 adoption gate); no expandos on GPT slots,
+ GPT functions, or `pbjs`.
+7. Bundle budgets (+5% bytes) and the pinned-environment p90 timing assertion
+ (50 samples, +10% tolerance) hold; server concatenation is precomputed
+ within ±10% CPU/heap of baseline.
+8. No existing warning is lost; every issue-surfacing condition logs at
+ `warn` or above with the beacon's reason code.
+9. TypeScript floor matches the resolved 5.9 line with strictness flags on;
+ `prebid.js` pin matches the documented deployed bundle; monthly review
+ policy in CI docs.
+10. Rolling-deploy tests pass in both directions; legacy URLs serve through
+ their sunset; unknown hashes 410 `no-store`; immutable only on exact
+ match.
+11. `nurl`/`burl` fire only on carrying paths, on their G4d transitions,
+ attempt-scoped and idempotent; APS fires neither.
+12. Trace-bearing responses are `private, no-store` by test; capabilities are
+ per-trace and never authorize a trace they were not bound to.
## 13. Open questions
@@ -812,7 +700,9 @@ PR-gating.
4. Is PR #997 the intended restoration of the lost #922 attribution core, or
should the original be re-merged?
5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep
- accept-count-drop? (Datasource `ts_client_events`, 30-day retention, and
- 10% default sampling are proposed values pending operator sign-off.)
-6. Does Amazon expose any creative-completion acknowledgement we could adopt
- to move APS beyond `render_accepted` (G4c)?
+ accept-count-drop?
+6. Does Amazon expose any creative-completion acknowledgement that could
+ reintroduce a confirmed state (G4c) under a new name?
+7. Which shared storage backs stage-1 artifact publishing per platform (KV
+ store vs config store vs CDN), and who owns the publish step in the deploy
+ pipeline?
From 42a0bdc3a4e74229e6518b64e8caaec402676af8 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:48:21 -0700
Subject: [PATCH 005/194] Rewrite design spec for the coordinated hard-cutover
policy
Revision 5 adopts the hard-cutover release model (exact release ids,
config format_version, embedded-only artifacts with 410 on unknown
hashes, final namespace at cutover, switch-and-purge runbook) and
removes the N/N-1 machinery. It also makes the document fully
self-contained and addresses the fourth review round: physical
request-cycle protocol opened by slotRequested with intent separation,
RuntimeSession-owned cycle state with quarantine and drain-by-match,
propagation-stopping bridge order that preserves the stolen-capability
defense, per-flow nurl bind definitions, a complete non-GPT direct
/auction lifecycle, a fully inlined mediation algorithm with server-
minted candidate ids and deterministic total ordering, a specified
signed trace authorization (v1.kid.exp.mode.sig, HMAC-SHA-256, key
rotation, server-decided sampling), wire events covering every G4c
observation with concrete Tinybird schemas and bid_drop rows, sink
dedup keys and numeric rate-limit bounds, early isolated internal
routes on all adapters, a CSP rollout that cannot false-pass with both
report media types and three-browser coverage, transactional plugin
install with reverse-order unwind, the final global-surface table,
refresh_gen-scoped token registries with capacity rules, structured
dimension drops, generated inline validators, executable phase gates
with control cohorts, and the expanded acceptance matrix with the
section-2 failure-to-signal mapping.
---
...s-render-fix-and-tsjs-resilience-design.md | 1453 ++++++++++-------
1 file changed, 839 insertions(+), 614 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 9c53fca6a..4cf8c4d4d 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,695 +1,921 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 4 — reworked after the third design review round.
+- **Status:** revision 5 — rewritten for the coordinated hard-cutover policy
+ adopted in the fourth review round, and made fully self-contained (no
+ contract is defined by reference to an earlier revision).
- **Date:** 2026-08-04
-- **Baseline:** `rc/july` @ `541298695` — the full merged state (everything
- merged from `main` plus every rc-only merge), not just the delta pending
- against `main`.
-- **Inputs:** three code audits performed against this baseline; design
- reviews of revisions 1–3; open issues #926, #941, #944, #962, #964, #977,
- #983, #989, #993; open PR #997.
-- **Terminology:** the per-refresh counter is `refresh_gen` everywhere. The
- event previously named `render_confirmed` is removed (see G4c).
-
----
+- **Baseline:** `rc/july` @ `541298695` — the full merged state.
+- **Inputs:** three code audits against this baseline; design reviews of
+ revisions 1–4; open issues #926, #941, #944, #962, #964, #977, #983, #989,
+ #993; open PR #997.
+
+## 0. Release policy: coordinated hard cutover
+
+This design targets a **single coordinated release**. Explicitly:
+
+- Server, TSJS bundles, config format, and page HTML ship together as one
+ release with one **release id** (`release_id`: the git tag / build hash).
+- **No N/N−1 support.** Old pages, old bundles, old config blobs, old
+ globals, and old URLs may stop working at cutover. In-flight clients (pages
+ loaded before the switch) may fail; this is accepted and stated, not
+ mitigated.
+- **Exact release matching only.** The kernel, every service, every plugin,
+ and the install manifest carry the same `release_id`; any mismatch is a
+ refusal, never a negotiation. There are no version ranges.
+- **Config format version:** the config blob gains a top-level
+ `format_version`; the binary rejects any other value. No
+ omit-default-for-rollback serialization rules; rollback means redeploying
+ the previous release with its own config.
+- **Assets:** binaries embed only their own release's artifacts. Hashed
+ pathnames are kept **for cache identity only** (correct caching and
+ dedup), not for retention: an unknown hash answers `410 Gone`,
+ `Cache-Control: no-store`. No shared artifact store — no separate
+ requirement for one was established.
+- **Cutover runbook (normative):** (1) deploy the release to all instances
+ dark (flagged off); (2) verify instance health and config
+ `format_version`; (3) switch traffic atomically at the routing/CDN layer;
+ (4) purge the CDN of prior HTML and assets; (5) monitor the Phase gates
+ (§8); rollback = switch traffic back to the previous release and re-purge.
+
+Everything that revisions 3–4 built for mixed-version tolerance is removed:
+no ABI version ranges, no retained-artifact storage, no legacy query-hash
+sunset, no dual-name global window, no adoption gates.
## 1. Problem statement
-APS (Amazon Publisher Services) demand is fully integrated server-side — the
-edge server runs the APS OpenRTB auction, wins bids, and ships a typed renderer
-descriptor to the page — yet APS creatives still do not appear for real users.
-Every previous fix (the `bid.meta` carrier, the decoupled prebid shim, the
-`hb_adid` fallback) addressed a real defect, and APS still does not render.
-That pattern is itself the finding: the APS pipeline has **multiple independent
-failure points, most of which fail silently**, and the client library has **no
-way to tell the server (or the operator) which one fired**.
-
-At the same time, the TSJS client library has grown organically to 56 files /
-~11,900 lines with two ~1,700-line monoliths, duplicated logic maintained by
-hand in two languages, inverted layering, and roughly one hundred `catch`
-blocks that discard failures. The APS outage and the library's shape are the
-same problem seen from two sides.
+APS demand is fully integrated server-side — the edge runs the APS OpenRTB
+auction, wins bids, and ships a typed renderer descriptor to the page — yet
+APS creatives do not appear for real users. Serial single-cause fixes (the
+`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback) each
+survived review and still did not produce ads. That pattern is the finding:
+the APS pipeline has **multiple independent failure points, most of which
+fail silently**, and the client cannot tell the server which one fired.
-This design covers both: (a) the specific fixes that make APS render, and (b)
-the target architecture that makes TSJS a clean, resilient library.
+The TSJS library (56 files, ~11,900 lines, two ~1,700-line monoliths,
+duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch`
+blocks) is the same problem structurally. This design fixes APS delivery and
+rebuilds TSJS so the next integration cannot reproduce this failure class.
### Non-goals
- No change to the APS OpenRTB endpoint contract or Amazon-side configuration
- (including its deliberate absence of `nurl`/`burl` — see G4d).
-- No rewrite of Prebid.js integration strategy (the decoupled shim stays).
-- No behavior change for publishers whose pages work today. Public-surface
- migrations happen behind a bounded compatibility window (7.4), never by
- immediate removal.
-
----
-
-## 2. Why APS still does not render — the evidence
-
-The audit traced all four delivery flows: (a) SSAT server-side ad template via
-`window.tsjs.bids`, (b) GAM + client-side `trustedServer` Prebid adapter, (c)
-SPA `/_ts/page-bids` re-auction, (d) direct `/auction` via `tsjs.requestAds`.
-Only flow (d) — the demo path nobody runs in production — can render an APS
-descriptor without GAM's cooperation.
-
-### 2.1 Admission: APS bids are eliminated before they can win
-
-| # | Failure | Where |
-| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
-| A1 | **A configured `[auction].mediator` discards every direct-provider bid.** Winners come exclusively from the mediator response; APS bids (with their renderers) are used only as mediator input and reporting. APS shows `status: success, bid_count: N` yet never wins a slot. | `orchestrator.rs:412-431` |
-| A2 | **`allow_script_creatives` defaults to `false`**, dropping every `tagtype: "script"` APS bid — a large share of TAM demand. The drop is counted but invisible (see A4). | `aps.rs:141-143`, `:773-778` |
-| A3 | **Strict per-bid gates**: exact `w`×`h` membership in the slot's configured formats, required `ext.creativeurl`, and any top-level `contextual` key rejects the entire response. | `aps.rs:657-668`, `:745-778`, `:838-846` |
-| A4 | **Drop reasons never reach an operator on the production paths.** `drop_reasons` counters surface only in `/auction` `ext.orchestrator`; the SSAT and page-bids paths discard them, server logs do not carry them, and the `ts-debug` comment allowlist excludes them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
-
-### 2.2 Identity: the `hb_adid` contract with GAM is unproven
-
-| # | Failure | Where |
-| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
-| B1 | **GAM key-value values are capped at 40 characters.** The new fallback emits the raw APS OpenRTB bid `id` (a long opaque string) as `hb_adid`. If GAM truncates or rejects it, `%%PATTERN:hb_adid%%` comes back different, the bridge's equality check fails, and it bails **with no log**. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` |
-| B2 | **Two id universes for the same bid.** SSAT keys the bridge on the APS bid id; the client-side Prebid adapter keys on Prebid's generated `adId`. A page running both paths registers the same slot under different ids. | `publisher.rs:3366`, `prebid/index.ts:982` |
-
-### 2.3 Render: the client has one narrow happy path and no fallback
-
-| # | Failure | Where |
-| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
-| C1 | **If GAM never serves the Prebid Universal Creative, nothing renders and nothing is recorded.** The renderer descriptor sits unused in `window.tsjs.bids`; `renderApsCreative` is reachable only from the unused flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` |
-| C2 | **A renderer endpoint that never answers is a silent 10-second death.** The sandboxed iframe cannot read an HTTP status from its opaque origin; a 404/401/misrouted document simply never posts `renderer-ready`. | `aps.rs:1188-1245`, `aps/render.ts:384-404` |
-| C3 | **SafeFrame breaks slot attribution.** The bridge resolves a message source by walking top-document iframes under the slot div; a nested SafeFrame creative window is invisible to that walk, so the bridge bails silently. | `gpt/index.ts:157-183`, `:1599-1600` |
-| C4 | **Three hand-maintained copies of the descriptor schema** (Rust struct, TS validator, inline renderer-document validator) with exact-key rejection: any server-side field addition instantly blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` |
-| C5 | **A dead duplicate renderer branch in the bridge** contains the debug log and dedup logic people would look for while debugging; it can never execute. | `gpt/index.ts:1643-1674` |
-| C6 | **The renderer CSP may kill creatives after success is reported** (`object-src`, workers, `blob:`/`data:` frames are blocked; `renderer-ready` fires before the creative actually paints). | `aps.rs:49` |
-| C7 | **The renderer branches record nothing**: no `recordRender`, no win/billing beacons, no `stampCreativeTrace` — an APS win looks "never rendered" in every trace whether or not it painted. | `gpt/index.ts:1558-1632` |
-
-### 2.4 Observability: the common factor
-
-There is **zero client→server reporting**. Server telemetry marks `is_win=1`
-at auction time and goes quiet; a bid that never painted is byte-identical to
-one that painted perfectly. Client-side evidence dies with the tab.
-
----
+ (including its deliberate absence of `nurl`/`burl`, §G4d).
+- No rewrite of the decoupled Prebid.js strategy.
+- **No backward compatibility** (§0). Publisher-visible surfaces change at
+ cutover; the replacement shapes are in §7.4.
+
+## 2. Why APS does not render — evidence
+
+Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer`
+Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`
+`tsjs.requestAds`. Only (d) — unused in production — renders an APS
+descriptor without GAM.
+
+### 2.1 Admission
+
+| # | Failure | Where |
+| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
+| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` |
+| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:141-143`, `:773-778` |
+| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:657-668`, `:745-778`, `:838-846` |
+| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
+
+### 2.2 Identity
+
+| # | Failure | Where |
+| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
+| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` |
+| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` |
+
+### 2.3 Render
+
+| # | Failure | Where |
+| --- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
+| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` |
+| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1188-1245`, `aps/render.ts:384-404` |
+| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:157-183`, `:1599-1600` |
+| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` |
+| C5 | A dead duplicate renderer branch holds the debug log and dedup logic; it can never execute. | `gpt/index.ts:1643-1674` |
+| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` |
+| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1558-1632` |
+
+### 2.4 Observability
+
+Zero client→server reporting. Server telemetry marks `is_win=1` at auction
+time; a bid that never painted is byte-identical to one that painted.
+
+### 2.5 Failure → signal mapping (normative)
+
+Every section-2 failure maps to a distinct observable **failure class** (not
+a claim to distinguish unknowable root causes):
+
+| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console |
+| ------- | -------------------------------------------- | -------------------------------------- | ------------ |
+| A1 | — | selection report `mediator_superseded` | startup warn |
+| A2 | — | `bid_drop{script_rendering_disabled}` | startup warn |
+| A3 | — | `bid_drop{invalid_dimensions, w, h}` | warn |
+| A4 | — (fixed by §5.6 itself) | `bid_drop` rows exist on all paths | `ts-debug` |
+| B1/B2 | `bridge_request{matched: false}` | join via `trace_id` | warn |
+| C1 | `gam_empty` then no `bridge_request` | join via `trace_id` | warn |
+| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | warn |
+| C3 | `render_fail{bridge_id_mismatch}` | join via `trace_id` | warn |
+| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | warn |
+| C5 | — (branch deleted) | — | — |
+| C6 | `runner_failed` + CSP report buckets | CSP aggregate counters | warn |
+| C7 | renderer branch emits the full §5.1 sequence | join via `trace_id` | debug/warn |
## 3. The GPT reality this design must respect
-1. **Bootstrap-first hybrid:** the server injects a 495-line ES5
- `gpt_bootstrap.js` before the bundle; shared monkeypatch sentinels mean the
- bundle's handoff and initial-load code is dead in production.
-2. **The #922 merge loss:** orphan-slot recovery and `updateRender` enrichment
- are gone (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead
- writes; bridge-served impressions double-count. PR #997 appears to be the
- reworked replacement.
-3. **TS refreshes never pass `changeCorrelator: false`.**
-4. **`enableSingleRequest()` is called blind** after the publisher's own
- `enableServices()` has almost always run.
-5. Responsive resolution is a DOM-element-selection ladder; ambiguity silently
- skips the slot.
-6. Three independent wrappers on `pubads().refresh` coordinate via
- window-global booleans.
-7. **GPT offers no request cancellation** (`gpt/index.ts:1080`), and its event
- contract identifies only the slot: Google documents no per-refresh
- identifier and no completion-order guarantee for overlapping requests, and
- `slotRenderEnded` means creative code was injected, not that its resources
- loaded. Publisher code can refresh an adopted slot while a TS cycle is
- pending. Any attribution scheme must survive all of that (G4a).
-8. **The bundle's `slotRenderEnded` registration is gated behind
- `!ts.servicesEnabled`** (`gpt/index.ts:1017`), so bootstrap-first or
- services-enabled pages can miss the listener entirely — G4a requires
- unconditional early subscription.
-
----
-
-## 4. Design gates — the five contracts
+1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the
+ sentinel race; the bundle's handoff/initial-load code is dead in
+ production.
+2. The #922 merge loss: orphan recovery and `updateRender` are gone
+ (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead writes;
+ bridge impressions double-count. PR #997 is the apparent replacement.
+3. TS refreshes never pass `changeCorrelator: false`.
+4. `enableSingleRequest()` is called blind after publisher `enableServices()`.
+5. Responsive resolution ambiguity silently skips slots.
+6. Three independent `pubads().refresh` wrappers coordinate via window-global
+ booleans.
+7. **GPT has no request cancellation, no documented per-refresh identity, no
+ overlapping-completion order.** `slotRenderEnded` means creative code was
+ injected, not that resources loaded. The April 2025 `responseIdentifier`
+ identifies the ad **response** — usable for response dedup/drain, not for
+ attributing which caller initiated a request.
+8. **With initial load disabled, `display()` creates no request** — the
+ subsequent `refresh()` does (`gpt/index.ts:1059`, `ad_init.test.ts:1201`).
+ Any cycle protocol must model physical requests, not API calls.
+9. The bundle's `slotRenderEnded` registration is gated behind
+ `!ts.servicesEnabled` (`gpt/index.ts:1017`); G4a needs unconditional early
+ subscription.
+
+## 4. Design gates
### G1 — Trace identity and correlation
-**The client-visible auction id must never be ingested** (EC-derived:
-`publisher.rs:3237`). **Initial-HTML auction telemetry is emitted before page
-JavaScript exists** (`telemetry.rs:148`, `publisher.rs:2452`), so correlation
-is minted by whoever acts first:
-
-- **Initial navigation (`nav_gen = 0`):** the server mints `trace_id`
- (128-bit CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction
- telemetry rows at emit time (a new nullable `trace_id` column on the
- existing auction datasource; the independent telemetry `auction_id` UUID
- remains and remains separate), and injects it into the page as a `tsjs`
- boot field with the sampling decision and, when the tester gate is active,
- the diagnostic capability (5.3).
-- **Cache-privacy invariant:** a trace or capability is injected **only** into
- responses that ran a per-request auction, and any trace-bearing HTML MUST be
- shared-cache-ineligible: `Cache-Control: private, no-store` and no
- validators that could revalidate a shared copy. This is enforced by
- construction (the injection site is the auction-bearing render path) and by
- test. A shared-cached page would have no per-visitor auction to correlate
- anyway; the invariant makes that alignment explicit.
-- **SPA navigations (`nav_gen > 0`):** `/_ts/page-bids` **stays GET** (it is
- GET in `publisher.rs:3815` and the client issues GET at
- `gpt/index.ts:1152`; a browser GET cannot carry a body). The client mints
- the `trace_id` and sends it in a validated **`X-TSJS-Trace-Id`** request
- header — the request already carries a non-simple TSJS header, so CORS
- preflight behavior is unchanged. The server records it contemporaneously in
- that auction's telemetry rows and the JSON response **echoes the accepted
- trace id** and returns a capability bound to it when the tester gate is
- active.
-- **Envelope:** every event carries
- `{trace_id, sampled, nav_gen, refresh_gen, seq}`; `seq` is per-trace
- monotonic.
-- **Sampling is trace-sticky** (decided once per trace; server-decided for
- `nav_gen 0`, client-decided from the injected rate afterwards). Transport is
- best-effort, so a sampled trace may still arrive partial; partiality is
- detectable via `seq` gaps and is not treated as a contract violation.
-
-### G2 — Render identity: `hb_adid`, the APS token, and the PBS Cache UUID
-
-Unchanged from revision 3 (review-accepted): cache-backed bids keep the cache
-UUID as `hb_adid` byte-for-byte; renderer-only bids get a server-minted token
-`^[a-z0-9]{12}$` (CSPRNG; in-auction collision retry; cross-auction uniqueness
-probabilistic with the birthday bound documented; harmless via per-
-`(trace_id, nav_gen)` registry scoping); TTL 15 minutes; one-time consumption;
-client-Prebid keeps Prebid's `adId`; non-APS cache-path regression tests.
-
-### G3 — Runtime ABI: how code shares state under the IIFE build
+The client-visible auction id is EC-derived (`publisher.rs:3237`) and is
+never ingested. Initial-HTML auction telemetry is emitted before page JS
+exists (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by
+whoever acts first:
+
+- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit
+ CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows
+ (§5.6 schema), and injects it into `tsjs.boot` with a **signed trace
+ authorization** (§5.3). It is never `AuctionRequest.id`.
+- **Cache privacy invariant:** traces/authorizations are injected only into
+ responses that ran a per-request auction; such HTML is
+ `Cache-Control: private, no-store` with no validators. Enforced by
+ construction and by test.
+- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints
+ `trace_id` and sends it in a validated `X-TSJS-Trace-Id` header (the
+ request already carries a non-simple TSJS header). The server records it in
+ that auction's rows; the JSON response echoes the accepted trace and
+ returns its signed authorization.
+- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a
+ per-trace group `{trace_id, auth, events[]}` (§5.1). `seq` is per-trace
+ monotonic. Transport is best-effort: gaps (loss) and duplicates
+ (fetch/pagehide races) are both expected; §5.5 defines dedup.
+- **Sampling is server-decided for every trace** — initial via boot, SPA via
+ the page-bids response — and carried **inside the signed authorization**
+ (§5.3 `mode`). The client never asserts its own sampling; an unsigned or
+ missing authorization means the trace group is rejected at ingest.
+
+### G2 — Render identity
+
+- Cache-backed bids: `hb_adid` = the PBS Cache UUID, byte-for-byte as today
+ (`publisher.rs:3355`; the PUC fetches `?uuid=`,
+ `publisher.rs:3450`, `gpt/index.ts:1700`). Markup bids without cache ids:
+ today's fallback chain unchanged.
+- **Renderer-only bids: `hb_adid` = a server-minted render token**,
+ `^[a-z0-9]{12}$` exactly, CSPRNG, collision-retried within the minting
+ auction. Cross-auction uniqueness is probabilistic (36¹² ≈ 4.7×10¹⁸;
+ birthday-bound negligible at realistic volumes) and made harmless by
+ registry scoping.
+- **Registry scope and bounds:** the client bridge registry keys tokens by
+ `(trace_id, nav_gen, refresh_gen)` — refresh auctions within one
+ navigation cannot collide, closing the revision-4 gap. Capacity: 64 live
+ entries per navigation; at capacity a new registration is refused with
+ disposition `registry_full`; unexpired entries are evicted only by
+ navigation disposal. Token TTL 15 minutes; one-time consumption.
+- The client-Prebid path keeps Prebid's generated `adId`; both paths register
+ into the one registry keyed by whichever id that path observes.
+- Regression tests: non-APS cache-backed bids byte-identical.
+
+### G3 — Runtime ABI under the IIFE build (exact-release model)
IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`)
-means module imports never share state across bundles (live proof:
+means imports never share state across bundles (live defect:
`core/context.ts:11` vs `permutive/index.ts:102`).
-Contract — a versioned registration ABI on `window.tsjs._internal`:
-
-- Kernel ships only in `tsjs-core`, publishes
- `tsjs._internal = { abi: 1, registry }` once (window sentinel). The kernel
- constructs and registers core service instances during boot; integrations
- register integration-scoped services during `install()`.
-- **Version semantics (settling the mixed-version gap):** every service
- registers with `(major, minor)`. `registry.get(name, {major, minMinor})`
- succeeds iff an implementation with the same `major` and `minor ≥ minMinor`
- is registered. The install manifest's plugin versions are **ranges with the
- same semantics** (required major, minimum minor), not exact pins.
- An incompatible service registration is **quarantined** — recorded, not
- installed — and surfaced as `abi_mismatch`; an incompatible plugin as
- `bundle_partial`. **First-wins applies only among compatible
- registrations.** The old-deferred-bundle + new-core scenario therefore has a
- deterministic verdict: the old plugin either satisfies the manifest range
- and runs, or is quarantined loudly.
-- Stateful services only via the registry at call time; stateless helpers may
- be imported and inlined. Single-module-graph builds remain the successor
- option behind the same surface.
+- The kernel ships only in `tsjs-core`, publishes
+ `tsjs._internal = { release_id, registry }` once (window sentinel), and
+ **freezes** `_internal` after boot. The kernel constructs and registers
+ core services (event bus, beacon queue, sessions, slot registry, render
+ state machine) during boot; integrations register integration-scoped
+ services during `install()`.
+- **Exact release matching:** every service and plugin registration carries
+ `release_id`; `registry.get(name)` succeeds only when the registrant's
+ `release_id` equals the kernel's. A mismatch quarantines the registration
+ and emits `abi_mismatch` (service) / `bundle_partial` (plugin) with a
+ console error. No ranges, no minors, no first-wins tiers — under §0 a
+ mismatch is a deployment error to surface, not tolerate.
+- Stateful access only through the registry at call time; stateless helpers
+ may be imported and inlined. Single-module-graph builds remain the
+ recorded successor option behind the same surface.
### G4 — Render lifecycle
-**G4a — Request-cycle protocol (no ordering assumptions).** GPT documents no
-per-refresh identity and no completion-order guarantee, so the protocol
-assumes neither:
-
-- Every observable request initiation is classified `ts | publisher`: TS's own
- `display()`/`refresh()` calls open TS cycles; the wrapped publisher
- entry-points and `slotRequested` events that match no TS cycle are recorded
- as publisher-initiated.
-- **TS serializes itself to at most one outstanding cycle per slot** — a new
- TS refresh for a slot with a pending cycle waits or supersedes explicitly;
- it is never concurrently pending.
-- With ≤1 TS cycle outstanding, a `slotRenderEnded` is attributable iff no
- untracked or publisher-initiated request overlaps it. **Any overlap marks
- the slot `cycle_unattributable` and fails closed** (no fallback, no state
- transition, console warning + disposition).
-- `slotRenderEnded` is treated as "creative code injected", not "resources
- loaded" — it can confirm delivery, never paint.
-- The deterministic PUC/message harness exercises the protocol in CI, and a
- **release-gating real-GAM overlap test** (publisher refresh racing a TS
- cycle) validates the contract against actual GPT, since a FIFO-assuming
- stub proves nothing.
-
-**G4b — Acknowledgement path.** Unchanged from revision 3 (review-accepted):
-per-attempt CSPRNG nonce in the bridge response; the dynamic renderer posts
-versioned accepted/failed messages to the kernel; the kernel validates source
-ownership, nonce, token, `nav_gen`, `refresh_gen` before any transition or
-callback; pinned for SSAT, client-Prebid, and nested SafeFrame flows.
-
-**G4c — Honest observations, `render_confirmed` removed.** The inline-adm
-frames are sandboxed `srcdoc` documents with `allow-same-origin` deliberately
-omitted (`gpt/index.ts:358`) — their origins are opaque, so revision 3's
-"same-origin observable" premise was false. The event is removed entirely.
-The taxonomy is now: `gam_nonempty`, `gam_empty`, `renderer_document_loaded`,
-`runner_loaded`, `runner_failed`, `adm_document_loaded` (the iframe `load`
-event for TS-written adm frames — document delivery, not paint). **Every
-render path terminates at `render_accepted`** (authenticated per G4b where
-the renderer protocol exists; `adm_document_loaded` for adm frames). No
-observation claims paint. A future trusted completion acknowledgement (open
-question 6) may reintroduce a confirmed state under a new name.
-
-**G4d — Win/billing notifications, scoped to paths that have them.** APS
-**intentionally carries neither** `nurl` nor `burl` (`aps.rs:812` sets both
-`None`; the minimized AAX envelope excludes notifications; the integration
-guide documents that generic win/billing beacons are not fired for APS). APS
-billing runs entirely inside the Amazon runner lifecycle, and this design
-does not change the APS wire contract.
-
-For bid paths that do carry the URLs (PBS and other OpenRTB providers):
-
-- **Trigger semantics, published explicitly:** `nurl` fires when the render
- attempt binds the bid to a cycle (Trusted Server's selection produced the
- candidate GAM will render — the earliest point at which "win" is
- meaningful for this pipeline); `burl` fires at the attempt's
- `render_accepted`. Both are **attempt-scoped**, keyed by
- `(trace_id, nav_gen, slot, refresh_gen, hb_adid)` as the idempotency key —
- fired at most once per attempt, not page-wide.
-- **Owner and mechanics:** the client render pipeline owns firing (as today,
- `gpt/index.ts:459`), via `sendBeacon`/`no-cors fetch`, no retries (a beacon
- either queues or is lost; retrying risks double-billing).
-- Terminal failure after acceptance is labeled `billed_then_failed`; no
- un-firing.
-
-**G4e — Fallback trigger.** The opt-in fallback
-(`[auction].client_render_fallback = "renderer"`) renders only after a
-**terminal `gam_empty` unambiguously attributed to a TS-initiated cycle**
-(G4a). Timeouts are diagnostics-only and never render. Revision 3 disabled
-fallback for adopted slots entirely, which — as the review noted — excludes
-the common production path (pre-existing publisher slots are adopted and
-refreshed, `gpt/index.ts:925`). Revised: **ownership does not gate the
-fallback; attribution does.** An adopted slot whose attributed TS cycle ends
-in `gam_empty` may fall back; any publisher-initiated or unattributable cycle
-never triggers it. The success criteria and browser specs cover the adopted
-case explicitly.
-
-### G5 — Deployment contracts
-
-- **Config rollback:** new config fields are default-valued and omitted from
- serialization at defaults. **Rollback runbook rule:** after an operator has
- opted into a new field, rolling the binary back requires restoring the
- default and pushing the default-compatible blob first (this mirrors the
- project's existing rollback guidance).
-- **Asset identity and the artifact source (settling "not realizable"):**
- hash in the pathname; and artifacts are **published to shared immutable
- platform storage (KV/config store) as deploy stage 1, before any HTML
- references them** — binaries serve the current vector from embedded bytes
- (fast path) and everything else by hash lookup in shared storage. This
- answers all four skew cases: new HTML hash `B` reaching an old instance
- (lookup serves `B` from storage), a miss for retained `A` after only `B` is
- embedded (lookup), already-issued legacy query-hash URLs (the legacy path
- keeps serving current bytes with short-TTL, non-immutable caching through a
- documented sunset), and renderer `/v2` reaching a `/v1`-era instance
- (versioned renderer documents are published to the same storage in
- stage 1). Two-stage deployment is the contract: **stage 1 publish
- artifacts, stage 2 roll binaries/HTML.** Both rolling directions and the
- legacy URL are tested. Retention: ≥ 7 days, which must exceed the HTML
- cache lifetime — itself now bounded by contract (auction-bearing HTML is
- `no-store` per G1; any cacheable non-auction HTML referencing tsjs sets
- `max-age ≤ 300`). `Cache-Control: immutable` only on exact hash matches;
- unknown hashes → `410 Gone`, `no-store`. Concatenations are keyed by the
- **ordered module-ID vector**, precomputed in Phase 0 (which owns asset
- identity).
-- **Ingest routing:** the beacon route exists in all four adapters as an
- early, EC-free, filter-free route; only Fastly has a real sink; others
- accept-count-drop by explicit contract.
-- **Storage:** datasource `ts_client_events`; retention 30 days; production
- sampling 10%. These are **adopted defaults** (operator-tunable), no longer
- open questions; the remaining open question is only whether non-Fastly
- adapters get sinks (OQ5).
-- **Phase gates are phase-specific** (section 8) — the render-fail canary
- applies only from Phase 3 onward, because earlier phases don't create that
- metric.
-
----
-
-## 5. Workstream 1 — Observability
-
-### 5.1 Event payload — minimized, grouped per trace
+**G4a — Physical request-cycle protocol.** Two separated notions:
+
+- **Intent:** a TS `display()`/`refresh()` call (or an observed publisher
+ entry) targeting a slot. Intents are classified `ts | publisher` at the
+ wrapped entry points. An intent may produce zero physical requests
+ (initial-load-disabled `display()`; `refresh()` on a never-displayed
+ adopted slot); an intent that produces no `slotRequested` within its bound
+ (2 s) expires with disposition `intent_no_request` — diagnostics only.
+- **Cycle (outstanding physical request):** opened **only by
+ `slotRequested`**, matched to the oldest unexpired TS intent for that slot,
+ else classified publisher-initiated. SRA batching yields one `slotRequested`
+ per slot per batch — one cycle each, all matched to the intents of the
+ batch call. A cycle closes on its `slotRenderEnded` (matched by slot; GPT's
+ `responseIdentifier`, where present, deduplicates responses during drain —
+ it never attributes initiation).
+- **Serialization:** TS keeps at most one outstanding TS cycle per slot. A TS
+ intent arriving while a TS cycle is outstanding **queues** (bounded: 1
+ queued replacement; further intents coalesce into it).
+- **Attribution:** a `slotRenderEnded` is attributable iff exactly one
+ TS cycle is outstanding for the slot and no publisher-initiated or
+ untracked request overlaps it. Any overlap → the slot enters
+ **quarantine**: `cycle_unattributable`, fail closed (no fallback, no state
+ transition), and the drain rule applies.
+- **Drain/re-arm (supersession and SPA):** physical cycle state lives in the
+ **RuntimeSession-owned slot record**, not the NavigationSession — adopted
+ slots outlive navigations. On navigation or supersession, outstanding
+ cycles are marked stale; their late events are **matched and discarded**
+ with disposition `stale_navigation` (never misattributed); a quarantined or
+ stale slot re-arms only after every outstanding request/render pair has
+ drained (or its 60 s drain bound elapses, which keeps the slot
+ fallback-ineligible for that navigation). Queued TS intents dispatch only
+ after re-arm. A timeout never makes an old event disappear — drain-by-match
+ does.
+- CI exercises the protocol on the deterministic harness; a **release-gating
+ real-GAM overlap test** (publisher refresh racing a TS cycle;
+ initial-load-disabled cycle formation) validates it against actual GPT.
+
+**G4b — Acknowledgement protocol.** The renderer document currently posts
+"ready" only to its immediate parent (`aps.rs:105`); in the PUC path the
+top-level kernel cannot observe it, and callbacks fire on send
+(`gpt/index.ts:1572`, `:1620`). Contract: the bridge response embeds a
+**per-attempt 128-bit CSPRNG acknowledgement nonce**; the renderer document
+posts versioned `{t: "render_accepted" | "render_failed", nonce, reason?}`
+to the top window; the kernel validates, in order: source ownership (§6.8
+walk), nonce equality, token binding, `nav_gen`, `refresh_gen` — all five —
+before any state transition or notification. Pinned by tests for SSAT,
+client-Prebid, and nested SafeFrame flows, including stale and replayed
+acks.
+
+**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc`
+without `allow-same-origin` (`gpt/index.ts:358`) — opaque origins; geometry
+proves nothing. Observations: `gam_nonempty`, `gam_empty`,
+`renderer_document_loaded`, `runner_loaded`, `runner_failed`,
+`adm_document_loaded`. Every path terminates at `render_accepted`
+(authenticated per G4b where the renderer protocol exists;
+`adm_document_loaded` stands in for adm frames). **No observation claims
+paint**; there is no `render_confirmed`. A future trusted completion ack
+(OQ6) may add a new state under a new name.
+
+**G4d — Win/billing notifications.** APS intentionally carries neither
+`nurl` nor `burl` (`aps.rs:812`; the minimized AAX envelope excludes them;
+the integration guide documents no generic APS beacons). APS billing lives in
+the Amazon runner lifecycle; unchanged.
+
+For carrying paths (PBS and other OpenRTB providers), **bind is defined per
+flow and is never selection or targeting** (targeting-only firing is
+explicitly prevented today, `ad_init.test.ts:1824`):
+
+- PUC/GAM flow: bind = an owned, slot-and-ad-id-matched bridge claim.
+- Direct `/auction` flow: bind = validated render start (slot resolved,
+ descriptor/markup validated, attempt created).
+- Fallback flow: bind = attributed `gam_empty`, immediately before the
+ fallback render starts.
+
+`nurl` fires at bind; `burl` at `render_accepted`; both attempt-scoped
+(idempotency key `(trace_id, nav_gen, slot, refresh_gen, hb_adid)`), fired at
+most once, via `sendBeacon`/`no-cors fetch`, no retries. Terminal failure
+after acceptance → `billed_then_failed` label; no un-firing.
+
+**G4e — Fallback trigger.** Opt-in
+(`[auction].client_render_fallback = "renderer"`). Renders only after a
+terminal `gam_empty` **unambiguously attributed to a TS cycle** (G4a) —
+ownership does not gate it (adopted slots are the common path and are
+eligible); publisher-initiated or unattributable cycles never trigger it;
+timeouts never render. The direct renderer is converted to an awaitable API
+with cancellation and terminal reasons before the fallback lands.
+
+**G4f — Direct `/auction` lifecycle.** The non-GPT path
+(`core/request.ts:52`) gets the same discipline: a `RenderAttempt` keyed
+`(trace_id, nav_gen, refresh_gen, slot)` where `refresh_gen` increments per
+`requestAds` invocation for the same slot within a navigation; exactly-once
+terminal state; G4b acknowledgement validation; G4d direct-flow bind;
+cancellation and disposal on navigation; the same §5.1 event sequence. "Every
+configured flow" in the success criteria includes this one.
+
+### G5 — Deployment contracts (hard cutover)
+
+- **Config:** top-level `format_version`; exact match required; mismatch is a
+ startup error. No default-omission rollback rules.
+- **Assets:** hash-in-pathname (`/static/tsjs//.js`) for cache
+ identity; binaries serve only embedded current-release artifacts; unknown
+ hash → `410 Gone`, `no-store`; `Cache-Control: immutable` on exact matches.
+ Concatenations precomputed per **ordered module-ID vector** at build time.
+ The cutover runbook (§0) owns HTML/asset consistency; the CDN purge step is
+ what retires old references.
+- **Internal route isolation (all adapters):** the renderer, client-events,
+ and CSP-report route families (a) dispatch **before** auth, EC setup, and
+ publisher/integration filters (today the renderer can traverse EC setup and
+ pre-route filters, `app.rs:709` in the Fastly adapter); (b) reserve **all
+ methods and all version prefixes** locally — unsupported method →
+ deterministic `405` with `Allow` and `no-store`; unknown version → `404`
+ `no-store`; never the publisher fall-through some adapters use today
+ (`adapter-spin app.rs:804`); (c) never forward bodies, cookies, or
+ authorization headers to publisher origins; (d) compare origins as
+ normalized scheme + host + port, not host-only.
+- **Ingest routing:** client-events in all four adapters; Fastly has the real
+ sink; others accept-count-drop by contract (OQ5).
+- **Storage:** §5.6 schemas deploy and validate **before** any writer
+ enables.
+
+## 5. Observability
+
+### 5.1 Wire payload
```
{ v: 1, traces: [
- { trace_id, sampled, capability?, // capability: only in diagnostic mode
+ { trace_id, auth, // auth: signed authorization (§5.3)
events: [
{ nav_gen, refresh_gen, seq,
t: "bid_received" | "targeting_set" | "bridge_request" |
"bridge_response_sent" | "render_attempt" | "render_accepted" |
- "render_fail",
- slot, // configured slot id if in the injected set, else "s"
- id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none"
- matched, // bridge_request only
- source, // "renderer" | "adm" | "pbs-cache" | "gam"
- reason, // render_fail only: closed enum below
- width, height } // invalid_dimensions context only: bounded ints [0, 8192]
+ "render_fail" | "gam_nonempty" | "gam_empty" |
+ "renderer_document_loaded" | "runner_loaded" | "runner_failed" |
+ "adm_document_loaded" | "fallback_start",
+ slot, // configured slot id if in the injected set, else "s"
+ id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none"
+ matched, // bridge_request only
+ source, // "renderer" | "adm" | "pbs-cache" | "gam"
+ reason } // render_fail only
] }
] }
```
-- **Events are grouped per trace, and the diagnostic capability is a per-trace
- field** — a navigation-spanning batch carries one group per trace, so one
- batch-level capability can never be ambiguous, and an initial-navigation
- capability never authorizes a client-minted SPA trace (that trace's
- capability comes from the page-bids response, G1).
-- Reason enum (closed; no interpolation — dimension context travels in the
- bounded numeric fields): `renderer_document_no_load`, `runner_no_load`,
- `runner_failed`, `descriptor_invalid`, `invalid_dimensions`,
- `bridge_id_mismatch`, `cycle_unattributable`, `bridge_claim_timeout`,
- `gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`,
- `pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`.
+The `t` enum now **contains every G4c observation**, so Phase-3 stage rates
+(renderer-document load rate, runner load/failure, GAM fill) are queryable.
+Reason enum (closed): `renderer_document_no_load`, `runner_no_load`,
+`runner_failed`, `descriptor_invalid`, `invalid_dimensions`,
+`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`,
+`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, `gam_empty`,
+`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`,
+`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`.
### 5.2 Transport
-`fetch(..., {keepalive: true, credentials: "omit"})` primary. The `pagehide`
-fallback is `navigator.sendBeacon(url, new Blob([json], {type:
-"application/json"}))` — the Blob type satisfies the ingest media-type
-contract (a bare string would arrive as text); it is credentialed by platform
-design and its `true` means queued, not received; the handler ignores
-credentials either way. Flush on `visibilitychange`/`pagehide` and every 5 s.
-
-### 5.3 Ingest wire contract
-
-- `POST /_ts/client-events` in all four adapters, before auth/EC/filters.
- `Content-Type: application/json` only; no `Content-Encoding`. Responds
- `204 Cache-Control: no-store`; never echoes input.
+`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide`
+fallback `navigator.sendBeacon(url, new Blob([json], {type:
+"application/json"}))`. Flush every 5 s and on `visibilitychange`/`pagehide`.
+**Client queue bound:** 256 events; overflow drops oldest, increments a
+counter, and the final flushed batch carries one `render_fail{...}`-class
+overflow marker event so truncation is visible. Duplicates from
+fetch/pagehide races are expected and handled at the sink (§5.5).
+
+### 5.3 Signed trace authorization
+
+Format `v1....`:
+
+- `kid`: key id; **active and previous keys** live in the platform secret
+ store; rotation = introduce new key as active, demote, retire.
+- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future
+ 15 minutes from issuance.
+- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1);
+ diagnostic is a distinct authenticated mode gated by the tester cookie at
+ issuance — not an overloaded "sampling off" bit.
+- `sig`: HMAC-SHA-256 over the **domain-separated, length-prefixed** input
+ `"ts-trace-auth-v1" || len(origin) || origin || len(trace_id) || trace_id
+|| len(mode) || mode || u64(exp)`, where `origin` is the externally visible
+ scheme+host+port. Constant-time comparison.
+- Ingest verifies per trace group; a missing key id, expired, future-dated,
+ or invalid signature → that **group** is dropped-and-counted (other groups
+ in the batch survive). An unsigned `sampled` claim does not exist in the
+ wire format, so it cannot be asserted.
+
+### 5.4 Ingest contract
+
+- `POST /_ts/client-events`; `Content-Type: application/json` only; no
+ `Content-Encoding`; responds `204`, `no-store`; never echoes input.
- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars;
- `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`; width/height in
- `[0, 8192]`. Violation → drop-and-count with `204`.
-- Same-origin: `Sec-Fetch-Site: same-origin` when present, else `Origin`
- matching the serving host; **absent both → drop-and-count**.
-- **Rate limiting fails closed for telemetry:** when the limiter denies, or
- when a portable adapter's best-effort limiter is unavailable or errors, the
- request is dropped early with `204` (count only, no parse, no sink). Ad
- delivery is unaffected by construction because this route serves nothing.
-- **Trusted client address, per adapter, concretely:** Fastly — the
- platform's client IP API; Axum — the rightmost `X-Forwarded-For` entry
- beyond `trusted_proxy_hops` (a required config value when the beacon is
- enabled; without it, the socket peer address is used and forwarded headers
- are ignored); Cloudflare — `CF-Connecting-IP`; Spin — the platform client
- address. Spoofable headers are never trusted beyond the configured hop
- count.
-- **Diagnostic capability:** server-issued, HMAC over
- `trace_id + expiry` (≤ 15 min), delivered via the G1 boot field (initial
- trace) or the page-bids response (SPA traces), echoed per trace group.
- Signature verification is what switches a trace to unsampled — a public
- query flag alone never does.
-
-### 5.4 Two modes, honestly separated
-
-- **Production telemetry** (sink-backed deployments only — today Fastly):
- sticky-sampled (10%). SLO, fully parameterized: a failure mode affecting
- ≥ 1% of **sampled render attempts** is visible in `ts_client_events` within
- one hour, evaluated only when the deployment produced ≥ 10,000 sampled
- render attempts in that hour, with sink ingestion freshness ≤ 5 minutes;
- sink outages pause the SLO clock and are alarmed separately. Non-sink
- adapters are explicitly out of SLO scope, and no global gate depends on
- their beacon data.
-- **Diagnostic mode:** capability-gated, unsampled, full stream + console
- mirroring — the "one page load names the failing reason" tool.
-
-### 5.5 Server-side drop-reason surfacing
-
-- Bounded structured summary whenever **any** bid is dropped (per-slot reason
- counts, capped); `drop_reasons` added to auction telemetry rows; drop
- summary in the initial-HTML `ts-debug` comment; `/_ts/page-bids` gains a
- tester-gated structured `debug` field.
-- Startup warnings: APS enabled with `allow_script_creatives = false`; direct
- provider configured alongside a mediator without 6.1's merge strategy.
-
----
-
-## 6. Workstream 2 — APS delivery fixes
-
-### 6.1 Mediation: opt-in merge, `mediator_only` stays the default
-
-As revision 3 (review-accepted), with one tightening: a mediator bid whose id
-the provenance map cannot resolve, **for a slot where the same provider had
-forwarded candidates**, is counted under a dedicated
-`mediator_provenance_unresolved` metric and logged at `warn` — surfacing
-possible self-competition instead of silently treating it as distinct demand.
-Deal priority remains out of scope (the `Bid` model carries no deal identity;
-recorded as follow-up). Currency mismatch remains rejection. One selection
-helper serves both mediation lifecycles, with tests for each.
-
-### 6.2 Dimensions: the contract is "request what you accept"
-
-Exact size membership stays. The fix is visibility plus configuration: the
-drop summary names the rejected size per slot via
-`reason: invalid_dimensions` with bounded numeric `width`/`height` fields
-(closed enum preserved), and documentation gains "sizing your slots for APS."
+ `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`.
+- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized
+ `Origin` equality; absent both → drop-and-count.
+- **Rate limiting (numeric, fail-closed for telemetry):** token bucket per
+ client address, **10 requests/min, burst 20**; limiter map ≤ 65,536
+ entries, entry TTL 10 min, LRU eviction; limiter unavailable/errored →
+ drop early with `204` (count only). Trusted client address per adapter:
+ Fastly — platform client IP; Axum — rightmost `X-Forwarded-For` entry
+ beyond required `trusted_proxy_hops` (absent config → socket peer only);
+ Cloudflare — `CF-Connecting-IP`; Spin — platform client address.
+
+### 5.5 Sink and deduplication
+
+- Stable event key `(publisher, trace_id, seq)`; deduplication at the sink
+ or query layer (Tinybird: latest-write or `GROUP BY` on the key), covering
+ fetch/pagehide double-delivery.
+- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and
+ cannot observe downstream schema/auth rejection — therefore
+ **datasource-side freshness monitoring is mandatory** (§8 gates alarm on
+ ingestion lag and row-rejection metrics from the datasource side).
+
+### 5.6 Physical schemas (deployed before writers)
+
+- **New datasource `ts_client_events`** (flattened rows):
+ `ts (DateTime64), publisher (LowCardinality String), release_id (String),
+trace_id (FixedString 32), mode (Enum sampled|diagnostic), nav_gen UInt32,
+refresh_gen UInt32, seq UInt32, event (Enum §5.1), slot (String ≤64),
+id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`.
+ Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest
+ token, configured via new `TinybirdSettings` fields
+ (`client_events_dataset`, `client_events_token_secret`) — today's settings
+ configure only the auction dataset (`settings.rs:1752`).
+- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and
+ `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)`
+ and `mode`; add a bounded **`bid_drop` row type**
+ `{provider, slot, reason (Enum), width UInt16?, height UInt16?, count
+UInt32}` with per-auction row cap 32 and an `overflow` bucket row.
+- APS parsing returns a **structured drop observation**
+ `{reason, slot, width?, height?}` instead of a bare reason string
+ (`aps.rs:722`); dimensions above 8192 use `dimensions_out_of_range` with
+ dimensions omitted, never clamped.
+
+### 5.7 Modes and SLOs
+
+- **Production (sink-backed only):** server-decided 10% sampling.
+ Two separated objectives: **pipeline availability** — ingestion freshness
+ ≤ 5 min and datasource rejection rate < 0.1%, alarmed on breach (fails
+ during sink outages, by design); **failure detection** — a failure mode
+ affecting ≥ 1% of sampled render attempts is visible within one hour,
+ evaluated only at ≥ 10,000 sampled render attempts/hour.
+- **Diagnostic:** authenticated mode (§5.3), unsampled, full stream +
+ console mirroring; one page load names the failing class.
+
+### 5.8 Server-side drop surfacing
+
+Bounded structured summary whenever any bid is dropped; `bid_drop` rows
+(§5.6); drop summary in the initial-HTML `ts-debug` comment; page-bids gains
+a tester-gated structured `debug` field. Startup warnings: APS +
+`allow_script_creatives = false`; mediator + direct providers without an
+explicit `winner_selection` (§6.1 makes that a hard error).
+
+## 6. APS delivery fixes
+
+### 6.1 Mediation: complete inline algorithm
+
+Current mediation cannot support merging: it forwards no stable candidate id;
+restores fields via a lossy last-write-wins `(provider, slot, bidder)` index
+(`adserver_mock.rs:95`); breaks equal-price ties by response arrival order
+(`orchestrator.rs:827`); and assigns parsed Prebid bids USD without
+validating response currency (`prebid.rs:2318`). The algorithm below replaces
+that, identically in the synchronous and split dispatch/collect paths, via
+one shared candidate-selection helper.
+
+1. **Candidate registration.** Every direct-provider bid admitted by parsing
+ becomes a candidate with a **server-minted candidate id** (`c` +
+ 11-char CSPRNG, unique per auction). The full candidate (renderer, cache
+ coordinates, notification URLs, currency, provenance
+ `(provider, upstream_bid_id)`) is stored by candidate id. Winners are
+ selected **by candidate id** and their fields read from the stored
+ candidate — the lossy index is deleted.
+2. **Currency.** The auction has one configured currency. A provider response
+ that declares another currency, or a path that cannot prove its currency
+ (the Prebid parse point must validate, not assume USD), rejects that bid
+ at parse with `bid_drop{currency_mismatch}`. No conversion.
+3. **Mediator exchange.** Forwarded candidates carry their candidate id; the
+ mediator is required (wire contract, including `adserver_mock`) to echo it
+ on any bid derived from a forwarded candidate. A mediator bid **without**
+ an echoed id is mediator-native. A mediator bid with an id that does not
+ resolve → **the slot fails closed** for merging
+ (`mediation_provenance_invalid`: mediator-native bids for that slot still
+ compete; unresolvable forwarded claims are discarded and counted — a
+ warning alone is insufficient).
+4. **Floors.** Slot floors filter both populations before selection.
+5. **Dedup.** A mediator bid that echoes candidate id X removes direct
+ candidate X from the pool (it is the same demand, provenance `mediator`).
+6. **Selection.** Per slot, the winner is the maximum under the **total
+ deterministic order**: decoded CPM desc → provenance rank (mediator
+ before direct) → provider name asc → candidate id asc. Response arrival
+ order can never matter.
+7. **Strategy config.** `[auction].winner_selection` is **required whenever a
+ mediator and direct providers coexist** — startup error if absent (no
+ silent default; §0 removes the compatibility rationale for one):
+ `mediator_only` (mediator bids only, direct providers are signal) or
+ `merge_highest_cpm` (the algorithm above). A mediator timeout degrades to
+ direct-only selection and is reported.
+8. **Reporting.** A selection report per auction: `winner_source`,
+ `mediator_superseded`, `currency_mismatch`, `dedup_hits`,
+ `mediation_provenance_invalid` — separate from delivery `bid_drop` rows.
+
+Deal priority remains out of scope: the `Bid` model carries no deal identity
+(`types.rs:231`); a rule the model cannot express would be fiction. Recorded
+as follow-up requiring a bid-model extension.
+
+### 6.2 Dimensions
+
+Exact size membership stays (`aps.rs:657-668`). The fix is visibility
+(structured `bid_drop{invalid_dimensions, w, h}`, §5.6) plus documentation
+("sizing your slots for APS"): if a size is acceptable, request it in the
+slot's `formats` — accepting unrequested sizes would conceal an upstream
+protocol violation.
### 6.3 Script creatives
-Secure default kept; consequence made loud (5.5); enablement path documented.
+`allow_script_creatives` stays default-`false` (defensible sandbox posture);
+the consequence becomes loud (§5.8) and the enablement path documented.
### 6.4 Render identity
-As G2.
-
-### 6.5 Fallback rendering
-
-As G4e — attribution-gated, not ownership-gated; timeouts never render; the
-renderer is converted to an awaitable API with cancellation and terminal
-reasons before the fallback lands.
-
-### 6.6 Renderer endpoint — unconditional, versioned, observable
-
-- The static renderer document route registers unconditionally in every
- adapter (the APS provider stays config-gated). Startup validation fails
- loudly if an auth handler pattern covers it.
-- **Caching matches immutability:** `/integrations/aps/renderer/v1` is an
- immutable artifact — its bytes change only by shipping `/v2` — so it is
- served with `Cache-Control: immutable` (long max-age), published to shared
- artifact storage in deploy stage 1 like every versioned asset (G5), which
- also answers version-skew (`/v2` requests reaching older instances are
- served from storage). Revision 3's `no-store` contradicted the versioning
- and is corrected.
-- Two-stage acknowledgement (G4b): authenticated `document_loaded`, then the
- runner-load result — splitting `renderer_document_no_load` from
- `runner_no_load`/`runner_failed`.
-- **Server route counters are aggregate** (requests, unknown-version,
- auth-blocked): the document request carries no trace (the nonce travels in
- the URL fragment, which never reaches the server), so no row-level join is
- claimed.
-- **CSP report-only canary, fully specified:** reports go to a dedicated
- `POST /_ts/csp-reports` route (same pre-parse caps and same-origin rules as
- 5.3; credentials ignored; rate-limited fail-closed); stored as **aggregate
- counters only** (directive, blocked-origin **host only** — full URLs
- redacted) on sink-backed adapters, count-and-drop elsewhere; enforcement
- follows only after a clean canary window.
-
-### 6.7 One descriptor schema
-
-As revision 3 (review-accepted): tagged-envelope schema generated from a
-separate wire-schema crate/xtask; semantic validators hand-written on both
-sides; outer-tolerance only, exact AAX projection; shared
-positive + adversarial corpus across Rust, TS, and the inline document;
-staleness CI.
+As G2, including the `(trace_id, nav_gen, refresh_gen)` registry scope and
+capacity rules.
+
+### 6.5 Fallback
+
+As G4e/G4a; awaitable renderer first; attribution-gated; timeouts never
+render.
+
+### 6.6 Renderer endpoint
+
+- The static renderer document route registers **unconditionally in every
+ adapter** (the APS provider stays config-gated); startup validation fails
+ if an auth handler pattern covers it; §G5 route-isolation rules apply
+ (early dispatch, all methods reserved, no publisher fall-through).
+- Path `/integrations/aps/renderer/v1`, embedded in the binary, served
+ `Cache-Control: immutable` (its bytes change only by shipping `/v2` in a
+ new release; §0's purge retires the old). Unknown versions → `404`
+ `no-store`.
+- **Two-stage acknowledgement:** authenticated `document_loaded` (proves
+ route + auth + document CSP), then the runner-load result — splitting
+ `renderer_document_no_load` from `runner_no_load`/`runner_failed`.
+- Server route counters (requests, unknown-version, auth-blocked) are
+ **aggregate only** — the document request carries no trace (the nonce
+ rides the URL fragment and never reaches the server).
+- **CSP rollout that cannot false-pass:** discovery uses the **currently
+ enforced** policy with reporting attached (report-only alone cannot reveal
+ what the enforced policy already blocks). Candidate relaxations are tested
+ in a small enforced cohort under a short-lived canary document version;
+ once frozen, a new immutable `/v2` ships with the final policy. Reports:
+ dedicated `POST /_ts/csp-reports` accepting **both**
+ `application/csp-report` (legacy) and `application/reports+json`
+ (Reporting API), each with its own payload validator; §5.4 caps and
+ rate-limit rules; **origin rules account for the renderer's opaque
+ sandbox** (reports may carry `null` origin — validated by document URL /
+ policy version instead of the beacon's same-origin rule); stored as
+ aggregate counters with blocked sources bucketed into
+ `https-host | data | blob | inline | eval | other` (no arbitrary host
+ labels — cardinality abuse is otherwise trivial). Browser coverage for
+ opaque-renderer reports runs on **Chromium, Firefox, and WebKit** (CI is
+ Chromium-only today, `playwright.config.ts:16`; the matrix extends for
+ this suite).
+
+### 6.7 One descriptor schema — generation covers all three implementations
+
+- Wire truth: the tagged `BidRenderer` envelope (discriminator on the enum,
+ `types.rs:188-211`).
+- A wire-schema crate/xtask (separate from `trusted-server-js`; core already
+ depends on that crate, `Cargo.toml:45`) generates: the JSON-Schema
+ artifact, the **TS structural parser**, the **ES5-compatible inline
+ validator fragment** embedded in the renderer document, and shared
+ fixtures — all checked in with staleness CI. Only environment-specific
+ semantic checks (URL/origin policy, canonical base64, length bounds, the
+ exact one-bid AAX projection, cross-field equality) stay handwritten.
+- Tolerance only on the outer versioned descriptor; the decoded AAX envelope
+ remains an exact projection. A shared positive + adversarial corpus runs
+ through the Rust validator, the generated TS parser, and the generated
+ inline fragment in CI.
### 6.8 Bridge hardening
-As revision 3 (review-accepted): source-first ownership with the bounded
-parent-chain SafeFrame walk (depth 5, known slot-root `WindowProxy` map, no
-tree scans); adversarial test set; top-of-listener hygiene; dead branch
-deleted; renderer branches emit trace records under G4's taxonomy, with G4d
-notifications only where the bid path carries them (never APS).
-
----
-
-## 7. Workstream 3 — TSJS target architecture
+Processing order (normative — preserves the existing stolen-capability
+defense that suppresses propagation before source validation,
+`gpt/index.ts:1547`):
+
+1. parse `e.data` (bare `catch` → return);
+2. identify a TS-reserved ad id (registry lookup);
+3. if TS-reserved: `stopImmediatePropagation()` **before** any validation —
+ a rejected foreign frame must not be answerable by Prebid's native
+ handler either;
+4. validate source ownership via the bounded walk: known slot-root
+ `WindowProxy` map, sender's own parent chain (`event.source.parent`, …)
+ to depth 5 — never scanning an attacker-controllable frame tree;
+5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b);
+6. respond, or refuse with `bridge_id_mismatch`.
+
+Non-TS ad ids are untouched (no propagation suppression). The stolen-token
+browser test asserts **neither TS nor the native Prebid listener responds**;
+listener-registration ordering has a real-browser assertion. The dead
+duplicate renderer branch (C5) is deleted; renderer branches emit the full
+§5.1 sequence with G4d notifications only on carrying paths.
+
+## 7. TSJS target architecture
### 7.1 Layering
-Kernel / adapters / services / integrations exactly as revision 3, with the
-boundary lint in CI. Stateful services via the G3 ABI only.
+```
+kernel/ boot, config, queue, event bus, log, beacon, sessions
+adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access
+services/ slots (registry+handoff), auction client, render engine, consent
+integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services)
+```
+
+Boundary lint in CI (`import/no-restricted-paths`): kernel imports nothing
+above it; adapters import kernel only; services import kernel + adapters;
+integrations import kernel + services, never each other. This dissolves the
+audited inversions (`core/auction.ts` and `core/request.ts` importing
+`integrations/aps/render`; `gpt` and `prebid` importing `aps`; `prebid`
+owning the GPT refresh wrapper). Stateful services via the G3 registry only.
### 7.2 Adapters
-`present | pending | timed_out` with non-terminal `timed_out` (late loaders
-transition to `present`); per-operation timeouts with disposition reasons.
+Per external global: `present | pending | timed_out`, `timed_out`
+non-terminal (late loaders transition to `present` and drain what is still
+valid); queued operations carry their own timeouts and expire with
+disposition reasons.
### 7.3 Slot registry service
-Kernel-owned registry (`WeakMap` + div-id index)
-holding ownership, adoption, handoff claims, responsive resolution, pending
-request cycles (G4a), and targeting-key history. No expandos on GPT objects.
-
-### 7.4 Global namespace policy — with a compatibility window
-
-As revision 3 (review-accepted): `window.tsjs` + `tsjs._internal`; the public
-queue keeps its real name **`tsjs.que`**; public globals
-(`tscreative`, `tsCreativeConfig`, `tsjs.que`) get dual-read/write for two
-release cycles / ≥ 60 days, closing only on the adoption gate (old-name usage
-< 0.1% of traces for 14 days, measured on sink-backed deployments);
-`requestAds` keeps its void signature; `requestAdsAsync` is the new versioned
-API; private globals migrate immediately.
+Kernel-owned; `WeakMap` + div-id index; holds
+ownership (ts/publisher/adopted), handoff claims, responsive resolution,
+G4a intent queue + cycle state (RuntimeSession-scoped), targeting-key
+history. No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid`
+deleted).
+
+### 7.4 Final global surface (hard cutover — no dual names)
+
+| Legacy surface (removed at cutover) | Final shape |
+| -------------------------------------------- | ----------------------------------------------------------------------------- |
+| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue |
+| `globalThis.tscreative` (API) | `tsjs.creative.*` (same methods, namespaced) |
+| `globalThis.tsCreativeConfig` (pre-load) | `tsjs.boot.creative` inside the boot container (below) |
+| `requestAds` (void) — and rev-4's dual API | **one** async contract: `tsjs.requestAds(options): Promise` |
+| `window.__tsjs_*` flags, integration configs | `tsjs.boot.*` fields written by server-injected scripts before the bundle |
+| server install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) |
+| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` |
+| `tsjs._internal` | kernel-owned registry (G3), **frozen after boot** |
+
+Boot container lifecycle: pre-core scripts write
+`window.tsjs = window.tsjs || {que: [], boot: {}}` fields; the kernel
+**consumes `boot` at boot, deep-freezes the retained copy, and deletes
+consumed one-shot secrets** (the trace authorization moves into the sealed
+NavigationSession). Old pages referencing removed names fail at cutover —
+accepted per §0.
### 7.5 Messaging module
-All `postMessage` through one module: versioned envelopes, name constants,
-G4b nonces, 6.8 source validation. A **minimal** messaging module (envelope +
-constants + validation helpers used by the bridge) lands early (Phase 1) so
-Phase 3 does not depend on Phase-4 structure; the full migration of every
-legacy call site completes in Phase 4.
-
-### 7.6 Plugin lifecycle and session model
-
-As revision 3 (review-accepted), with G3's sharpened version semantics:
-manifest versions are ranges (major + minMinor); quarantine on
-incompatibility; first-wins only among compatible. Sessions:
-`RuntimeSession` / `NavigationSession` / `RenderAttempt` with enumerable
-disposal inventories. Error policy: no empty `catch`; auction fetch gets
-timeout + `AbortController`. **Console logging retained, not replaced**
-(paired `warn` with the beacon's reason code; `debug`-level
-delivery/security failures promoted to `warn`).
-
-### 7.7 The bootstrap problem
-
-As revision 3: queue-and-flags stub + bundle replay behind its own flag with
-replay-timing specs; the no-bundle fallback generated from the TypeScript
-source.
-
-### 7.8 GPT correctness fixes carried with the restructure
-
-- **Unconditional early GPT event subscription:** the `slotRenderEnded`
- (and `slotRequested`) listeners register on the command queue at install,
- no longer gated behind `!ts.servicesEnabled` (`gpt/index.ts:1017`) — G4a
- cannot work on bootstrap-first pages otherwise. Recording is idempotent so
- double-registration cannot double-count.
-- Restore #922/#997 attribution and orphan recovery.
-- `changeCorrelator: false` on TS-initiated refreshes (configurable).
-- `enableSingleRequest()` only when GPT services are not already enabled.
-- Ambiguous responsive resolution emits `render_fail{slot_unresolved}`.
+All `postMessage` through one module: versioned envelopes, name constants
+(the `'Prebid Request'` literal exists at six sites today; the APS handshake
+in three copies), G4b nonces, §6.8 validation. The minimal module (envelope +
+constants + validators used by the bridge) lands in Phase 1; full call-site
+migration completes in Phase 4.
+
+### 7.6 Plugin lifecycle — transactional — and sessions
+
+`tsjs.definePlugin(id, install, dispose?)` with `install(ctx)`:
+
+- `ctx.signal` (aborted on quarantine/disposal); synchronous
+ `ctx.onDispose(fn)` registration; effects must be registered as they are
+ made.
+- **Unwind on failure:** a throw, rejection, or abort triggers automatic
+ reverse-order invocation of the disposers registered so far — partial
+ installs cannot leak effects. Per-disposer exception isolation (one
+ throwing disposer cannot stop the rest).
+- A disposer registered (or returned) **after** the owning session was
+ disposed is invoked immediately.
+- Pending late registrations (manifest requested, bundle not yet evaluated):
+ capacity 16, bound 10 s, then `bundle_partial`.
+- Release matching per G3: a plugin whose `release_id` differs from the
+ kernel's is quarantined before `install` runs.
+- Sessions: `RuntimeSession` (page lifetime: bridge listener, history hook,
+ pbjs subscriptions, adapters, beacon queue, **slot cycle state**);
+ `NavigationSession` (per navigation: trace + authorization, render
+ attempts, slot aliases, targeting history); `RenderAttempt` (per G4a cycle
+ or G4f attempt). Each owns an enumerable disposal inventory; navigation
+ disposes only NavigationSession children.
+- Error policy: no empty `catch` — handle, log with context, or emit a
+ disposition. The auction fetch gains timeout + `AbortController`.
+- **Console logging retained:** every issue-surfacing condition keeps or
+ gains a `log.warn` carrying the same reason code as its beacon event;
+ `debug`-level delivery/security failures are promoted to `warn`.
+
+### 7.7 Bootstrap
+
+`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/hydration
+logic, with the live `servicesEnabled` divergence) shrinks to a
+queue-and-flags stub; the bundle replays recorded early calls on install.
+Replay changes observable ordering — it ships inside the cutover with
+browser specs covering replay timing. The no-bundle fallback ("ads render if
+the bundle fails", pinned by `gpt.rs:1174-1179`) is **generated from the
+same TypeScript source** at build time.
+
+### 7.8 GPT correctness carried with the restructure
+
+Unconditional early `slotRequested`/`slotRenderEnded` subscription (replacing
+the `!servicesEnabled` gate, `gpt/index.ts:1017`; recording idempotent);
+restore #922/#997 attribution and orphan recovery; `changeCorrelator: false`
+on TS refreshes (configurable); `enableSingleRequest()` only when GPT
+services are not already enabled; ambiguous responsive resolution emits
+`render_fail{slot_unresolved}` alongside its warning.
### 7.9 Decomposition targets
-As revision 3 (gpt/prebid splits, script-guard consolidation, trace model/UI
-split, `global.d.ts` fix).
-
-### 7.10 Performance
-
-Budgets tightened per review: per-bundle raw/gzip/Brotli for the exact
-ordered module vector vs a checked-in baseline, **+5% byte tolerance**;
-browser timing assertion (bids-script-to-first-`display()`) on a **pinned CI
-runner class and pinned browser version**, **5 warm-up runs discarded, 50
-measured samples, gate on p90 with +10% latency tolerance**; server bench
-(precomputed concatenation) gates CPU and heap at **±10% vs baseline** with
-pinned tool versions. Precompute lands in Phase 0 with asset identity (G5).
-
-### 7.11 Toolchain and dependency currency
-
-As revision 3 (review-accepted): raise the TypeScript floor to the resolved
-5.9 line, then evaluate the next major separately; strictness flags on; dev
-toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from casual
-bumps; monthly review policy.
-
----
+| Today | Target |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
+| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
+| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics |
+| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
+| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
+| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal |
+
+### 7.10 Performance (reproducible)
+
+- Bundle budgets: raw/gzip/Brotli per bundle for **three module vectors**
+ (minimal, reference, maximal), compressors pinned (`gzip -9`,
+ `brotli -q 11`), compared to checked-in baseline artifacts
+ (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs.
+ Tolerance +5% bytes.
+- Browser timing (bids-script-to-first-`display()`): pinned CI runner class
+ (the repository's standard Linux runner image) and the
+ Playwright-pinned browser build; 5 warm-up runs discarded, 50 samples,
+ gate p90 ≤ baseline × 1.10.
+- Server (precomputed concatenation): one-sided gates, CPU and heap ≤
+ baseline × 1.10; improvements always pass. Tool versions pinned in
+ `.tool-versions`.
+
+### 7.11 Toolchain
+
+Raise the TypeScript floor to the resolved 5.9 line (lockfile already
+resolves 5.9.3 under the stale `^5.5.4` manifest); adopt
+`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+`verbatimModuleSyntax`; dev-toolchain bumps (eslint, prettier, jsdom,
+`@playwright/test`, `@types/node`) as individual CI-gated PRs with changelog
+review (this library monkeypatches `fetch`/`sendBeacon`/DOM prototypes —
+jsdom/Playwright changes are real risks); `prebid.js` excluded from casual
+bumps (runtime Prebid is the manifest-locked external bundle; the npm pin
+and deployed bundle version documented together); monthly review; no phase
+starts more than one minor behind stable.
## 8. Migration plan
-Each phase ships behind a feature flag with **phase-specific gates** (the
-render-fail canary cannot evaluate phases that predate the metric):
-
-- **Phase 0 — Asset identity, contracts, toolchain.** Two-stage artifact
- publishing (shared immutable storage) + path-based identity + ordered-vector
- precompute + rolling-deploy tests in both directions + legacy-URL test;
- toolchain floors; contracts G1–G5 as code-adjacent docs; delete dead expando
- writes; server drop-reason surfacing.
- _Gate:_ zero unexpected `410`s and zero legacy-URL breakage on canary; asset
- hit/miss counters nominal.
-- **Phase 1 — Kernel ABI, sessions, minimal messaging, minimal cycle
- registry.** Versioned registry with `(major, minor)` semantics;
- `RuntimeSession`/`NavigationSession`; install manifest; the minimal
- messaging module (7.5) and the cycle-aware slot-record core (G4a's queue)
- land here so Phase 3 has its dependencies; unconditional early GPT
- subscriptions (7.8).
- _Gate:_ ABI install-success counters clean; zero `abi_mismatch` on canary;
- no listener regression in browser specs.
-- **Phase 2 — Trace and beacon.** Server-minted initial trace + page-bids
- `X-TSJS-Trace-Id` echo + capability issuance (G1, 5.3); beacon service;
- four-adapter ingest; `ts_client_events`.
- _Gate:_ ingest acceptance/drop/abuse counters nominal; trace join rate on
- sink-backed canary ≥ 95% of sampled traces.
-- **Phase 3 — APS delivery.** Wire-schema crate + corpus; mediation helper +
- opt-in merge; render token; unconditional versioned renderer route +
- two-stage ack; bridge hardening; request-cycle protocol + render state
- machine + awaitable renderer + scoped notifications (G4a–G4d); the opt-in
- fallback (G4e); restore #922/#997; correlator and SRA fixes.
- _Gate:_ `render_fail` rate within +0.5% absolute of pre-flag baseline over
- 24 h on canary; fill/latency/billing volume deltas within agreed bounds
+Phases are internal build milestones of **one coordinated release** (§0):
+each lands behind a flag in the dark deployment; the cutover switches them
+on together. Gates are executable — every gate names query, cohort,
+denominator, minimum sample, threshold, window, owner (release owner unless
+stated), and action (hold cutover / rollback switch):
+
+- **Phase 0 — Identity, schemas, toolchain.** Path-hashed embedded assets +
+ 410 semantics + ordered-vector precompute; `format_version`; §5.6 schemas
+ deployed and validated (writer-off); toolchain floors; dead expando writes
+ deleted; §5.8 server drop surfacing.
+ _Gate:_ dark-instance health 100%; datasource validation green (rejection
+ rate < 0.1% on synthetic writes, freshness ≤ 5 min); asset `410` rate on
+ dark probes = 0 for known hashes.
+- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3
+ registry (exact release ids); RuntimeSession/NavigationSession; install
+ manifest; minimal messaging module; G4a intent/cycle records; unconditional
+ GPT subscriptions.
+ _Gate:_ browser-spec suite green incl. listener-order assertions; zero
+ `abi_mismatch`/`bundle_partial` on dark probes.
+- **Phase 2 — Trace + beacon.** Server-minted initial trace + page-bids
+ header echo + signed authorizations; beacon service; four-adapter ingest;
+ `ts_client_events` writers on.
+ _Gate:_ on dark probes: ingest acceptance ≥ 99%, group auth-rejection
+ < 0.5%, dedup query returns exactly-once per `(trace, seq)`; freshness
+ ≤ 5 min over a 24 h window.
+- **Phase 3 — APS delivery.** Schema crate + corpus (6.7); mediation
+ algorithm + required `winner_selection` (6.1); render token (G2);
+ renderer route + two-stage ack + CSP report route (6.6); bridge order
+ (6.8); G4a–G4f state machines, awaitable renderer, scoped notifications,
+ fallback; #922/#997 restoration; correlator + SRA fixes.
+ _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000
+ sampled attempts each):_ APS `render_accepted` / attributable APS attempts
+ ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM
+ fill, p90 latency, and billing volume deltas within ±2% of control
(billing measured against GAM/server-side reporting, not the beacon);
- release-gating real-GAM overlap test green.
-- **Phase 4 — Structure.** Full layering + boundary lint; plugin lifecycle
- completion; adapters; full slot registry; full messaging migration;
- namespace window (7.4).
- _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests green.
-- **Phase 5 — Decomposition.** File splits; script-guard consolidation;
- bootstrap shrink (own flag, replay-timing specs); compatibility-window
- close (adoption-gated).
- _Gate:_ bundle budgets and timing assertions hold; adoption gate met before
- any removal.
-
----
+ real-GAM overlap test green. Action on breach: hold cutover.
+- **Phase 4 — Structure.** Full layering + boundary lint; transactional
+ plugin lifecycle; adapters; full slot registry; full messaging migration;
+ final namespace (7.4).
+ _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests
+ green; pre-cutover page smoke on the final namespace.
+- **Phase 5 — Decomposition + cutover.** File splits; script-guard
+ consolidation; bootstrap stub + generated fallback; then the §0 runbook.
+ _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed
+ off; post-switch monitor window 24 h on the §5.7 objectives; rollback =
+ traffic switch back.
## 9. Test acceptance matrix
-Blocking CI is hermetic; the staged smoke suite (real GAM line items,
-including the G4a overlap test) is release-gating.
-
-| Area | Must cover |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Mediation | both lifecycles; ties, floors, currency rejection; provenance dedup; transformed ids → `mediator_provenance_unresolved`; `mediator_only` default; rollback blob round-trip |
-| Cache identity | non-APS cache-backed bids byte-identical; PUC `?uuid=` path |
-| Render token | format/CSPRNG/in-auction retry/TTL/one-time/per-`(trace, nav_gen)` scoping |
-| Request cycles | ts-vs-publisher classification; one-outstanding-TS-cycle serialization; publisher refresh overlapping TS cycle → `cycle_unattributable` fail-closed; late events; **real-GAM overlap (release gate)** |
-| Ack protocol | nonce validation (source, token, nav_gen, refresh_gen); SSAT + client-Prebid + nested SafeFrame; stale/replayed acks |
-| Render semantics | notifications only on carrying paths (never APS); `nurl` at bind, `burl` at accepted, attempt-scoped idempotency; `billed_then_failed`; no paint claims (`adm_document_loaded` labeling); accepted-but-blank |
-| Fallback | renders only on attributed `gam_empty` (adopted **and** TS-owned); publisher-initiated cycles never trigger; timeout diagnostics-only; SPA cancellation; destruction; exactly-once terminal |
-| Bridge security | wrong-slot/stolen/replayed/prior-navigation tokens; nested foreign frames; bounded parent-chain walk; SafeFrame positive |
-| Beacon | initial-trace join; page-bids header echo + response trace/capability; per-trace grouping across navigation-spanning batches; `seq`-gap partial traces; ingest abuse incl. absent Origin; capability signature; sendBeacon Blob |
-| Schema | staleness; adversarial corpus ×3 validators; outer-tolerance vs exact AAX projection |
-| Runtime ABI | one kernel under concatenation; deferred late registration; failure isolation; **mixed-version verdicts (compatible-range install vs quarantine)**; first-wins among compatible only |
-| Lifecycle | `timed_out → present`; session disposal inventories; stale async install; pre-init `tsCreativeConfig` + `tsjs.que`; dual-name window |
-| Delivery | two-stage deploy simulation **both rolling directions**; new-HTML-hash on old instance (storage lookup); retained-hash miss; legacy query-hash sunset path; unknown hash 410 `no-store`; immutable exact-match only; ordered vector |
-| Renderer endpoint | route in all adapters; auth-pattern startup failure; `/v1` immutable caching; version-skew via storage; `document_loaded` vs runner split; CSP report route caps/redaction |
-| Adapter parity | ingest, CSP-report, renderer routes and drop-reason surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
-| Policy | script-creative warning; `invalid_dimensions` + bounded width/height; page-bids `debug` gating; diagnostic unsampled completeness; trace-bearing HTML `private, no-store` invariant |
-
----
+Hermetic CI (deterministic PUC/message harness) blocks PRs; the staged
+real-GAM suite is release-gating. Rows added this revision are marked •.
+
+| Area | Must cover |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Request cycles | intent-vs-request separation; • initial-load-disabled cycle formation (display creates no request); SRA batching; `intent_no_request`; publisher overlap → quarantine; • old-navigation completion before and after replacement; drain/re-arm; real-GAM overlap (gate) |
+| Ack protocol | five-field validation; SSAT/client-Prebid/SafeFrame; stale + replayed acks; • acks after navigation disposal |
+| Bridge security | • TS-reserved id rejected with propagation stopped — neither TS nor native Prebid responds; wrong-slot/stolen/prior-navigation tokens; bounded parent-chain walk; listener-order real-browser assertion; SafeFrame positive |
+| Render semantics | notifications only on carrying paths (never APS); bind per flow (PUC claim / direct render-start / fallback pre-render); `burl` at `render_accepted`; attempt-scoped idempotency; `billed_then_failed`; accepted-but-blank |
+| Direct `/auction` | • full G4f lifecycle: attempt keys, refresh_gen increments, cancellation on navigation, exactly-once terminal, same event sequence |
+| Fallback | only attributed `gam_empty` (adopted + TS-owned); publisher-initiated never; timeout never renders; SPA cancellation; • flag change during an active attempt |
+| Mediation | • candidate-id echo; • transformed/unresolvable provenance → slot fails closed for merging; • duplicate candidates dedup; • deterministic ties independent of arrival order; currency validation at the Prebid parse point; both lifecycles; required `winner_selection` |
+| Render token | format/CSPRNG/in-auction retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scoping; • registry capacity → `registry_full` |
+| Trace auth | • HMAC verification: expiry, skew, max-future, missing kid, rotation (previous key), constant-time path; per-group rejection; cache-privacy invariant (`private, no-store`) |
+| Beacon | initial + SPA trace joins; per-trace grouping; seq gaps; • duplicate fetch/pagehide delivery deduped at sink; queue overflow marker; ingest abuse; sendBeacon Blob type |
+| Ingest/limits | • token-bucket rate + burst; • limiter saturation and address churn; • map capacity/TTL/eviction; fail-closed drop with 204 |
+| Internal routes | • wrong-method → 405 + Allow + no-store on every adapter; • unknown version → 404 no-store; • no publisher fall-through; • dispatch before auth/EC/filters; • no body/cookie/authorization forwarding |
+| CSP | • both media types with separate validators; • opaque/null-origin renderer reports accepted; • bucketed aggregation only; • Chromium/Firefox/WebKit capture; • enforced-policy discovery vs canary-cohort relaxation |
+| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection |
+| Runtime ABI | one kernel under concatenation; exact-release verdicts (match runs, mismatch quarantines); late registration; failure isolation |
+| Plugins | • partial synchronous install unwound in reverse order; • async rejection; • abort while pending; • disposer-after-disposal invoked immediately; per-disposer isolation |
+| Lifecycle | `timed_out → present`; session disposal inventories; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`) |
+| Delivery | unknown hash 410 no-store; immutable on exact match; ordered-vector precompute; cutover runbook rehearsal (switch + purge + rollback switch) |
+| Sink | • datasource-side freshness + rejection monitoring (sink is fire-and-forget); • sink auth/schema rejection surfaced by monitor |
+| Failure injection | • Amazon runner redirect, network hang, CSP block, script error → distinct §5.1 outcomes |
+| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
+| Policy | script-creative warning; `invalid_dimensions` + bounded w/h; `dimensions_out_of_range` unclamped; page-bids `debug` gating; diagnostic completeness |
## 10. Alternatives considered
-Unchanged from revision 3 (patching without telemetry; always-direct-render;
-single module graph; big-bang rewrite; dropping the bootstrap;
-timeout-triggered fallback — all rejected for the recorded reasons).
+1. **Patching APS point-failures without telemetry** — rejected: three
+ correct fixes produced no ads; the next fix would be another guess.
+2. **Always direct-render APS (skip GAM/PUC)** — rejected: unilaterally
+ changes GAM reporting/pacing; kept only as the attributed-`gam_empty`
+ fallback.
+3. **Single module graph / shared chunks now** — rejected for this release:
+ changes the delivery pipeline while everything else changes; recorded as
+ the successor behind the same registry surface.
+4. **Full rewrite in one branch without phases** — rejected: the browser-spec
+ safety net is thinnest exactly where behavior changes.
+5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle
+ guarantee; generation from TS keeps it without dual maintenance.
+6. **Timeout-triggered fallback** — rejected: GPT requests cannot be
+ cancelled; a timeout race can double-render and double-bill.
+7. **N/N−1 compatibility machinery** (revisions 3–4) — removed by the §0
+ policy decision: version ranges, retained artifacts, legacy URLs, and
+ dual-name globals deleted in favor of exact release matching and a
+ coordinated switch.
## 11. Risks
-Revision 3's list, plus: **shared-storage dependency for assets** (stage-1
-publish becomes a deploy prerequisite; mitigated by the embedded fast path
-for the current vector and deploy-time verification that storage matches the
-embedded hashes); **notification-trigger semantics** are now a published
-contract for PBS-path demand — changing them later is a breaking change for
-SSP reporting expectations.
+- **Hard cutover blast radius:** in-flight pages fail at switch; accepted by
+ policy (§0); bounded by the purge + 24 h monitored window + traffic-switch
+ rollback.
+- **Mediator wire-contract change** (candidate-id echo) requires
+ coordinating the mediator implementation; until echoed ids exist,
+ `merge_highest_cpm` cannot be enabled (config validation enforces this).
+- **Notification triggers become a published contract** for PBS-path demand;
+ changing them later is a breaking change for SSP reporting.
+- **Beacon abuse:** bounded by pre-parse caps, origin checks, fail-closed
+ numeric rate limits, server-decided sampling, signed authorizations.
+- **Registry/limiter memory:** all client and server maps carry explicit
+ capacities, TTLs, and eviction rules (G2, §5.4).
+- **CSP relaxation:** enforced-cohort canary + new immutable version prevent
+ false-clean canaries; bucketed aggregation prevents cardinality abuse.
+- **Sink blindness:** fire-and-forget dispatch is compensated by mandatory
+ datasource-side freshness/rejection monitoring.
## 12. Success criteria
-1. APS creatives render on a reference page in each configured flow,
- hermetically in CI and via the staged smoke suite (including the real-GAM
- overlap test).
-2. Every failure point in section 2 maps to a distinct observable signal;
- diagnostic mode names the failing reason from one page load; production
- telemetry meets the 5.4 SLO on sink-backed deployments.
-3. Boundary lint zero exceptions; stateful sharing only via the versioned
- ABI; mixed-version delivery resolves to the G3 verdicts.
-4. No file in `src/` exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
+1. APS creatives render in each configured flow — SSAT, client-Prebid,
+ page-bids, and direct `/auction` (G4f) — hermetically in CI and in the
+ release-gating real-GAM suite.
+2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the
+ failing class from one page load; §5.7 objectives hold on sink-backed
+ deployments.
+3. Boundary lint zero exceptions; stateful sharing only via the G3 registry;
+ exact-release mismatches quarantine loudly.
+4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
generated.
5. Trace counts are per-impression; orphan recovery has a non-vacuous test;
- cycle attribution follows G4a including the publisher-overlap fail-closed
- rule.
-6. The only TSJS-owned global is `window.tsjs` (public globals only inside
- their window, closed by the 7.4 adoption gate); no expandos on GPT slots,
- GPT functions, or `pbjs`.
-7. Bundle budgets (+5% bytes) and the pinned-environment p90 timing assertion
- (50 samples, +10% tolerance) hold; server concatenation is precomputed
- within ±10% CPU/heap of baseline.
-8. No existing warning is lost; every issue-surfacing condition logs at
- `warn` or above with the beacon's reason code.
+ cycle attribution follows G4a (physical requests, publisher-overlap
+ quarantine, drain/re-arm).
+6. The only TSJS-owned global is `window.tsjs` with the §7.4 final shape; no
+ expandos on GPT slots, GPT functions, or `pbjs`; legacy names are gone at
+ cutover.
+7. §7.10 budgets hold (three vectors, pinned tools, one-sided server gates).
+8. No existing warning is lost; every issue-surfacing condition logs `warn`+
+ with the beacon's reason code.
9. TypeScript floor matches the resolved 5.9 line with strictness flags on;
- `prebid.js` pin matches the documented deployed bundle; monthly review
- policy in CI docs.
-10. Rolling-deploy tests pass in both directions; legacy URLs serve through
- their sunset; unknown hashes 410 `no-store`; immutable only on exact
- match.
-11. `nurl`/`burl` fire only on carrying paths, on their G4d transitions,
+ `prebid.js` pin matches the documented deployed bundle.
+10. `nurl`/`burl` fire only on carrying paths at their G4d binds,
attempt-scoped and idempotent; APS fires neither.
-12. Trace-bearing responses are `private, no-store` by test; capabilities are
- per-trace and never authorize a trace they were not bound to.
+11. Trace-bearing responses are `private, no-store` by test; authorizations
+ are per-trace, signed, mode-carrying, and never accepted unsigned.
+12. The cutover runbook has been rehearsed (switch, purge, rollback switch)
+ before the production switch.
## 13. Open questions
@@ -701,8 +927,7 @@ SSP reporting expectations.
should the original be re-merged?
5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep
accept-count-drop?
-6. Does Amazon expose any creative-completion acknowledgement that could
- reintroduce a confirmed state (G4c) under a new name?
-7. Which shared storage backs stage-1 artifact publishing per platform (KV
- store vs config store vs CDN), and who owns the publish step in the deploy
- pipeline?
+6. Does Amazon expose any creative-completion acknowledgement that could add
+ a confirmed state beyond `render_accepted` under a new name?
+7. Who implements and owns the mediator-side candidate-id echo (6.1), and on
+ what timeline relative to this release?
From 5cca7525a8accaa36688feedd0e2ef0b798176f3 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 14:21:06 -0700
Subject: [PATCH 006/194] Close residual review deltas on revision 5
The fifth review round matched revision 4's text and line positions;
revision 5 already contained the contested contracts (slotRequested-
opened cycles, RuntimeSession drain state, propagation-stopping bridge
order, per-flow nurl bind, G4f direct lifecycle, inlined mediation
algorithm, signed server-decided sampling, observation events in the
wire enum, ts_client_events schema, dedup keys, numeric limiter
bounds, isolated internal routes, opaque-origin CSP handling,
transactional plugins, final namespace table, refresh_gen token scope,
generated inline validator, control-cohort Phase 3 gates, and the
failure-to-signal mapping). This commit patches the genuine remainders:
minimum key strength and missing-key startup behavior for the trace
authorization, client-events sink batch cap with startup validation,
sink-unavailable behavior, canonical join and alert ownership, the
three-instrument CSP rollout (enforced discovery, report-only
tightening, enforced-cohort relaxation with named gates and kill
switch) with unused-field discard, p95 for the Phase 3 latency gate,
a four-flow behavioral-parity gate on Phase 4, named runner image and
browser pinning with baseline-validity rules, the operator-query note
on the failure mapping, and the wire-event clarification for G4c
observations.
---
...s-render-fix-and-tsjs-resilience-design.md | 78 ++++++++++++-------
1 file changed, 52 insertions(+), 26 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 4cf8c4d4d..b02247a72 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -107,7 +107,9 @@ time; a bid that never painted is byte-identical to one that painted.
### 2.5 Failure → signal mapping (normative)
Every section-2 failure maps to a distinct observable **failure class** (not
-a claim to distinguish unknowable root causes):
+a claim to distinguish unknowable root causes). The operator query for each
+row is the §5.6 canonical join filtered by that row's event/reason or
+counter; the console column is what diagnostic mode mirrors on-page:
| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console |
| ------- | -------------------------------------------- | -------------------------------------- | ------------ |
@@ -368,6 +370,11 @@ configured flow" in the success criteria includes this one.
] }
```
+Every G4c observation is a **wire event** in this enum (internal state
+transitions map to them one-to-one; nothing observable is state-only).
+`gam_empty` additionally appears as a `render_fail` reason when it is the
+terminal outcome of an attributed attempt.
+
The `t` enum now **contains every G4c observation**, so Phase-3 stage rates
(renderer-document load rate, runner load/failure, GAM fill) are queryable.
Reason enum (closed): `renderer_document_no_load`, `runner_no_load`,
@@ -392,7 +399,10 @@ fetch/pagehide races are expected and handled at the sink (§5.5).
Format `v1....`:
- `kid`: key id; **active and previous keys** live in the platform secret
- store; rotation = introduce new key as active, demote, retire.
+ store; keys are ≥ 256-bit CSPRNG values; rotation = introduce new key as
+ active, demote, retire. **Missing-key startup behavior:** if the beacon is
+ enabled and no signing key resolves at startup, startup fails loudly
+ (config error) — traces are never issued unsigned.
- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future
15 minutes from issuance.
- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1);
@@ -443,7 +453,13 @@ id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`.
Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest
token, configured via new `TinybirdSettings` fields
(`client_events_dataset`, `client_events_token_secret`) — today's settings
- configure only the auction dataset (`settings.rs:1752`).
+ configure only the auction dataset (`settings.rs:1752`). Sink batch cap:
+ 512 rows per dispatch (matching the auction sink). Startup validation:
+ when client events are enabled, the dataset name and token secret must
+ resolve or startup fails. Sink-unavailable behavior at runtime:
+ accept-count-drop (ingest still answers `204`). Canonical join:
+ `ts_client_events` ⋈ auction rows on `(publisher, trace_id)`; dashboards
+ and alerts are owned by the release owner and defined with the datasource.
- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and
`auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)`
and `mode`; add a bounded **`bid_drop` row type**
@@ -564,23 +580,27 @@ render.
- Server route counters (requests, unknown-version, auth-blocked) are
**aggregate only** — the document request carries no trace (the nonce
rides the URL fragment and never reaches the server).
-- **CSP rollout that cannot false-pass:** discovery uses the **currently
- enforced** policy with reporting attached (report-only alone cannot reveal
- what the enforced policy already blocks). Candidate relaxations are tested
- in a small enforced cohort under a short-lived canary document version;
- once frozen, a new immutable `/v2` ships with the final policy. Reports:
- dedicated `POST /_ts/csp-reports` accepting **both**
- `application/csp-report` (legacy) and `application/reports+json`
- (Reporting API), each with its own payload validator; §5.4 caps and
- rate-limit rules; **origin rules account for the renderer's opaque
- sandbox** (reports may carry `null` origin — validated by document URL /
- policy version instead of the beacon's same-origin rule); stored as
- aggregate counters with blocked sources bucketed into
- `https-host | data | blob | inline | eval | other` (no arbitrary host
- labels — cardinality abuse is otherwise trivial). Browser coverage for
- opaque-renderer reports runs on **Chromium, Firefox, and WebKit** (CI is
- Chromium-only today, `playwright.config.ts:16`; the matrix extends for
- this suite).
+- **CSP rollout that cannot false-pass.** Three distinct uses, each with its
+ own instrument: **discovery** uses the **currently enforced** policy with
+ reporting attached (report-only alone cannot reveal what the enforced
+ policy already blocks); **tightening** candidates run report-only;
+ **relaxation** candidates are tested in a small enforced cohort under a
+ short-lived canary document version, gated on runner acceptance rate, CSP
+ violation rate, render-failure rate, and a kill switch that reverts the
+ cohort to the frozen policy. Once frozen, a new immutable `/v2` ships with
+ the final enforced headers. Reports: dedicated `POST /_ts/csp-reports`
+ accepting **both** `application/csp-report` (legacy) and
+ `application/reports+json` (Reporting API), each with its own payload
+ validator; §5.4 caps and rate-limit rules; **origin rules account for the
+ renderer's opaque sandbox** (reports may carry `null` origin — validated
+ by document URL / policy version instead of the beacon's same-origin
+ rule); unused report fields are **discarded before logging or
+ aggregation**; stored as aggregate counters with blocked sources bucketed
+ into `https-host (allowlisted) | data | blob | inline | eval | other` (no
+ arbitrary host labels — cardinality abuse is otherwise trivial). Browser
+ coverage for opaque-renderer reports runs on **Chromium, Firefox, and
+ WebKit** (CI is Chromium-only today, `playwright.config.ts:16`; the matrix
+ extends for this suite).
### 6.7 One descriptor schema — generation covers all three implementations
@@ -747,10 +767,13 @@ services are not already enabled; ambiguous responsive resolution emits
`brotli -q 11`), compared to checked-in baseline artifacts
(`perf/baselines/*.json`); baseline updates are explicit reviewed diffs.
Tolerance +5% bytes.
-- Browser timing (bids-script-to-first-`display()`): pinned CI runner class
- (the repository's standard Linux runner image) and the
- Playwright-pinned browser build; 5 warm-up runs discarded, 50 samples,
- gate p90 ≤ baseline × 1.10.
+- Browser timing (bids-script-to-first-`display()`): named runner image
+ `ubuntu-24.04` (the CI image already used by this repository's workflows)
+ and the Chromium build bundled with the pinned `@playwright/test` version
+ from `package.json`; 5 warm-up runs discarded, 50 samples, gate p90 ≤
+ baseline × 1.10. The baseline artifact records image, browser, and tool
+ versions alongside the numbers; a baseline update is invalid if any of
+ those differ from the pinned set.
- Server (precomputed concatenation): one-sided gates, CPU and heap ≤
baseline × 1.10; improvements always pass. Tool versions pinned in
`.tool-versions`.
@@ -803,14 +826,17 @@ stated), and action (hold cutover / rollback switch):
_Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000
sampled attempts each):_ APS `render_accepted` / attributable APS attempts
≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM
- fill, p90 latency, and billing volume deltas within ±2% of control
+ fill, p95 latency, and billing volume deltas within ±2% of control
(billing measured against GAM/server-side reporting, not the beacon);
real-GAM overlap test green. Action on breach: hold cutover.
- **Phase 4 — Structure.** Full layering + boundary lint; transactional
plugin lifecycle; adapters; full slot registry; full messaging migration;
final namespace (7.4).
_Gate:_ boundary lint zero exceptions; disposal-inventory leak tests
- green; pre-cutover page smoke on the final namespace.
+ green; pre-cutover page smoke on the final namespace; **behavioral-parity
+ suite green across all four flows** (SSAT, client-Prebid, page-bids,
+ direct `/auction`) comparing pre- and post-restructure event sequences on
+ the reference page.
- **Phase 5 — Decomposition + cutover.** File splits; script-guard
consolidation; bootstrap stub + generated fallback; then the §0 runbook.
_Gate:_ bundle budgets + timing assertions hold; cutover checklist signed
From 41667537e730f5edea59a620142304b4e243cb0f Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:02:53 -0700
Subject: [PATCH 007/194] Revise design spec: adopt the 248fe9558 baseline and
answer round five
Advances the baseline to the APS PUC/collapsed-shell fix (citations
refreshed; C5 marked fixed; G4b rebuilt on the MessageChannel
transport with a three-message authenticated sequence; gam_collapsed
added as an observation with the guarded resize adopted as a
sanctioned exception) and resolves the fifth review round: a 256-byte
auth bound with full token encoding rules, a signed unsampled mode
that transmits nothing, an operator-credential diagnostic gate
replacing the non-security tester cookie, trace issuance on the
direct /auction path via header and response extension, causal
intent classification that retires known zero-request display
intents and quarantines ambiguous overlap, removal of timeout
re-arm in favor of drain/destroy/page-end, RuntimeSession tombstones
preserving bridge suppression across navigations, an arrival-
independent mediation order on intrinsic candidate keys with required
auction currency and strategy-specific timeouts, a per-event field
matrix with nullable storage columns and dedicated overflow and
billing-outcome events, an adapter rate-limiter abstraction with
per-platform semantics and reject-at-capacity, direct-auction
serialization and notification plumbing, idempotent field-wise boot
initialization with fallback activation and arbitration, auth renewal
for long-lived pages, selection_summary rows, canonical dedup views
with heartbeat monitoring, a complete settings schema, boot.debug
envelopes making every failure class one-page-load visible, an
object-form plugin API carrying release ids, construction-time
concatenation caching, forgery-resistant CSP report routing with
frozen per-version header manifests, a single router-weight rollout
state machine with phase decision records and a checked-in gates
table, survivorship-safe Phase 3 metrics, and a fully pinned
performance workflow.
---
...s-render-fix-and-tsjs-resilience-design.md | 1533 +++++++++--------
1 file changed, 784 insertions(+), 749 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index b02247a72..0251485b9 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,57 +1,49 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 5 — rewritten for the coordinated hard-cutover policy
- adopted in the fourth review round, and made fully self-contained (no
- contract is defined by reference to an earlier revision).
+- **Status:** revision 6 — baseline advanced to the APS PUC/collapsed-shell
+ fix, and reworked after the fifth review round.
- **Date:** 2026-08-04
-- **Baseline:** `rc/july` @ `541298695` — the full merged state.
-- **Inputs:** three code audits against this baseline; design reviews of
- revisions 1–4; open issues #926, #941, #944, #962, #964, #977, #983, #989,
- #993; open PR #997.
+- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed
+ GAM shells") — the full merged state. All file:line citations refer to this
+ commit.
+- **Inputs:** three code audits; design reviews of revisions 1–5; open issues
+ #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997.
## 0. Release policy: coordinated hard cutover
-This design targets a **single coordinated release**. Explicitly:
-
-- Server, TSJS bundles, config format, and page HTML ship together as one
- release with one **release id** (`release_id`: the git tag / build hash).
-- **No N/N−1 support.** Old pages, old bundles, old config blobs, old
- globals, and old URLs may stop working at cutover. In-flight clients (pages
- loaded before the switch) may fail; this is accepted and stated, not
- mitigated.
-- **Exact release matching only.** The kernel, every service, every plugin,
- and the install manifest carry the same `release_id`; any mismatch is a
- refusal, never a negotiation. There are no version ranges.
-- **Config format version:** the config blob gains a top-level
- `format_version`; the binary rejects any other value. No
- omit-default-for-rollback serialization rules; rollback means redeploying
- the previous release with its own config.
-- **Assets:** binaries embed only their own release's artifacts. Hashed
- pathnames are kept **for cache identity only** (correct caching and
- dedup), not for retention: an unknown hash answers `410 Gone`,
- `Cache-Control: no-store`. No shared artifact store — no separate
- requirement for one was established.
-- **Cutover runbook (normative):** (1) deploy the release to all instances
- dark (flagged off); (2) verify instance health and config
- `format_version`; (3) switch traffic atomically at the routing/CDN layer;
- (4) purge the CDN of prior HTML and assets; (5) monitor the Phase gates
- (§8); rollback = switch traffic back to the previous release and re-purge.
-
-Everything that revisions 3–4 built for mixed-version tolerance is removed:
-no ABI version ranges, no retained-artifact storage, no legacy query-hash
-sunset, no dual-name global window, no adoption gates.
+This design targets a **single coordinated release**:
+
+- Server, TSJS bundles, config format, and page HTML ship together under one
+ **`release_id`** (git tag / build hash). **No N/N−1 support**: old pages,
+ bundles, config blobs, globals, and URLs may stop working at cutover;
+ in-flight clients may fail. Accepted and stated, not mitigated.
+- **Exact release matching only** — kernel, services, plugins, and the
+ install manifest carry the same `release_id`; mismatch is a refusal.
+- **Config:** top-level `format_version`, exact match required. Rollback =
+ redeploy the previous release with its own config.
+- **Assets:** binaries embed only their release's artifacts; hashed pathnames
+ exist for cache identity only; unknown hash → `410 Gone`, `no-store`.
+- **One executable rollout state machine** (resolving the §0/§8 tension the
+ review found): a release ships with a **deployment manifest** enumerating
+ the complete flag set; the new pool comes up **fully enabled but
+ unreachable except by probes**; phase gates (§8) run against probe traffic
+ and a **router-weight canary of coherent routed requests** (a request is
+ served end-to-end by one pool — HTML, assets, and APIs never mix pools);
+ **router weight is the sole activation primitive**; cutover = weight to
+ 100% + CDN purge; rollback = weight back + re-purge. Flags exist for
+ emergency kill switches inside a pool, not as the activation mechanism.
## 1. Problem statement
APS demand is fully integrated server-side — the edge runs the APS OpenRTB
auction, wins bids, and ships a typed renderer descriptor to the page — yet
-APS creatives do not appear for real users. Serial single-cause fixes (the
-`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback) each
-survived review and still did not produce ads. That pattern is the finding:
-the APS pipeline has **multiple independent failure points, most of which
-fail silently**, and the client cannot tell the server which one fired.
+APS creatives do not appear reliably for real users. Four serial fixes (the
+`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback, and
+now the baseline's PUC/collapsed-shell fix) each survived review; the pattern
+is the finding: the pipeline has **multiple independent failure points, most
+of which fail silently**, and the client cannot tell the server which fired.
-The TSJS library (56 files, ~11,900 lines, two ~1,700-line monoliths,
+The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths,
duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch`
blocks) is the same problem structurally. This design fixes APS delivery and
rebuilds TSJS so the next integration cannot reproduce this failure class.
@@ -61,8 +53,7 @@ rebuilds TSJS so the next integration cannot reproduce this failure class.
- No change to the APS OpenRTB endpoint contract or Amazon-side configuration
(including its deliberate absence of `nurl`/`burl`, §G4d).
- No rewrite of the decoupled Prebid.js strategy.
-- **No backward compatibility** (§0). Publisher-visible surfaces change at
- cutover; the replacement shapes are in §7.4.
+- **No backward compatibility** (§0); replacement surfaces are in §7.4.
## 2. Why APS does not render — evidence
@@ -76,28 +67,28 @@ descriptor without GAM.
| # | Failure | Where |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` |
-| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:141-143`, `:773-778` |
-| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:657-668`, `:745-778`, `:838-846` |
+| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` |
+| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` |
| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
### 2.2 Identity
| # | Failure | Where |
| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
-| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1613` |
+| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` |
| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` |
### 2.3 Render
-| # | Failure | Where |
-| --- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
-| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:854-1107`, `core/request.ts:59` |
-| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1188-1245`, `aps/render.ts:384-404` |
-| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:157-183`, `:1599-1600` |
-| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:60-93`, `aps.rs:65-73` |
-| C5 | A dead duplicate renderer branch holds the debug log and dedup logic; it can never execute. | `gpt/index.ts:1643-1674` |
-| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` |
-| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1558-1632` |
+| # | Failure | Where |
+| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` |
+| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` |
+| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` |
+| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` |
+| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated; the served-through-APS-renderer log is reachable. | `gpt/index.ts:1729` |
+| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` |
+| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` |
### 2.4 Observability
@@ -106,458 +97,538 @@ time; a bid that never painted is byte-identical to one that painted.
### 2.5 Failure → signal mapping (normative)
-Every section-2 failure maps to a distinct observable **failure class** (not
-a claim to distinguish unknowable root causes). The operator query for each
-row is the §5.6 canonical join filtered by that row's event/reason or
-counter; the console column is what diagnostic mode mirrors on-page:
-
-| Failure | Client event/reason (§5.1) | Server counter/row (§5.6) | Console |
-| ------- | -------------------------------------------- | -------------------------------------- | ------------ |
-| A1 | — | selection report `mediator_superseded` | startup warn |
-| A2 | — | `bid_drop{script_rendering_disabled}` | startup warn |
-| A3 | — | `bid_drop{invalid_dimensions, w, h}` | warn |
-| A4 | — (fixed by §5.6 itself) | `bid_drop` rows exist on all paths | `ts-debug` |
-| B1/B2 | `bridge_request{matched: false}` | join via `trace_id` | warn |
-| C1 | `gam_empty` then no `bridge_request` | join via `trace_id` | warn |
-| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | warn |
-| C3 | `render_fail{bridge_id_mismatch}` | join via `trace_id` | warn |
-| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | warn |
-| C5 | — (branch deleted) | — | — |
-| C6 | `runner_failed` + CSP report buckets | CSP aggregate counters | warn |
-| C7 | renderer branch emits the full §5.1 sequence | join via `trace_id` | debug/warn |
-
-## 3. The GPT reality this design must respect
+Each failure maps to a distinct observable **failure class** (not a claim to
+distinguish unknowable root causes). The operator query for each row is the
+§5.6 canonical view filtered by that row's event/reason or counter. So that
+**diagnostic mode really can name every class from one page load** — the
+review's objection that A1–A4 are server-side-only — the tester gate also
+delivers a **`tsjs.boot.debug` envelope** in the initial HTML (selection
+summary + drop summary for the initial auction) and the equivalent gated
+`ext.trusted_server.debug` field on page-bids and `/auction` responses;
+diagnostic mode mirrors these to the console:
+
+| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
+| ------- | -------------------------------------------- | ------------------------------------- | ------------------------------- |
+| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary |
+| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
+| A3 | — | `bid_drop{invalid_dimensions, w, h}` | `boot.debug` drop summary |
+| A4 | — (fixed by §5.6 itself) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
+| B1/B2 | `bridge_request{matched: false}` | join via trace | console warn |
+| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
+| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | console warn |
+| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn |
+| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn |
+| C5 | — (fixed at baseline) | — | — |
+| C6 | `runner_failed` + CSP buckets | CSP aggregate counters | console warn |
+| C7 | renderer branch emits the full §5.1 sequence | join via trace | debug/warn |
+
+## 3. The GPT and baseline reality this design must respect
1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the
sentinel race; the bundle's handoff/initial-load code is dead in
production.
2. The #922 merge loss: orphan recovery and `updateRender` are gone
- (`0dc9b19a9`); `__tsRenderGeneration`/`__tsRenderBid` are dead writes;
- bridge impressions double-count. PR #997 is the apparent replacement.
+ (`0dc9b19a9`); bridge impressions double-count. PR #997 is the apparent
+ replacement.
3. TS refreshes never pass `changeCorrelator: false`.
-4. `enableSingleRequest()` is called blind after publisher `enableServices()`.
-5. Responsive resolution ambiguity silently skips slots.
-6. Three independent `pubads().refresh` wrappers coordinate via window-global
- booleans.
+4. `enableSingleRequest()` is called blind after publisher
+ `enableServices()`.
+5. Responsive-resolution ambiguity silently skips slots.
+6. Three independent `pubads().refresh` wrappers coordinate via
+ window-global booleans.
7. **GPT has no request cancellation, no documented per-refresh identity, no
overlapping-completion order.** `slotRenderEnded` means creative code was
- injected, not that resources loaded. The April 2025 `responseIdentifier`
- identifies the ad **response** — usable for response dedup/drain, not for
- attributing which caller initiated a request.
+ injected, not that resources loaded. `responseIdentifier` identifies the
+ ad **response** — usable for response dedup/drain, never initiation
+ attribution.
8. **With initial load disabled, `display()` creates no request** — the
- subsequent `refresh()` does (`gpt/index.ts:1059`, `ad_init.test.ts:1201`).
- Any cycle protocol must model physical requests, not API calls.
+ subsequent `refresh()` does (`gpt/index.ts:1175`,
+ `ad_init.test.ts:1201-1263`). Cycle protocols must model physical
+ requests.
9. The bundle's `slotRenderEnded` registration is gated behind
- `!ts.servicesEnabled` (`gpt/index.ts:1017`); G4a needs unconditional early
- subscription.
+ `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional
+ early subscription.
+10. **The baseline includes the fourth serial APS fix** (`248fe9558`): (a)
+ the renderer handshake moved to a **MessageChannel** on the PUC path —
+ port-transferred nonce message, descriptor over the port (no wildcard
+ broadcast on that path), exact-key replies, `ports.length` checks, a
+ one-shot `accepted` latch, port closing (`aps.rs:65-125`,
+ `aps/render.ts:415-437`) — but the reply still terminates inside the PUC
+ creative frame, so G4b's kernel-observability gap remains; (b) a
+ nonempty GAM render can be a **collapsed 1×1 shell**, remediated by
+ `resizeCollapsedCreativeFrame` (`gpt/index.ts:217`) — a guarded style
+ mutation of GAM-owned elements; (c) the dead renderer branch was
+ consolidated (C5); (d) a real-PUC-topology browser test now exists.
+11. The bridge already keeps **consumed-id tombstones** for security
+ (`gpt/index.ts:1527`) — G2's registry rules must preserve that property
+ across navigations.
+12. The current tester cookie is **explicitly not a security control**
+ (`tester_cookie.rs:3`) — it cannot gate unsampled telemetry (§5.3).
## 4. Design gates
### G1 — Trace identity and correlation
-The client-visible auction id is EC-derived (`publisher.rs:3237`) and is
-never ingested. Initial-HTML auction telemetry is emitted before page JS
-exists (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by
+The client-visible auction id is EC-derived (`publisher.rs:3237`) and never
+ingested. Initial-HTML auction telemetry is emitted before page JS exists
+(`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by
whoever acts first:
- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit
- CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows
- (§5.6 schema), and injects it into `tsjs.boot` with a **signed trace
- authorization** (§5.3). It is never `AuctionRequest.id`.
-- **Cache privacy invariant:** traces/authorizations are injected only into
+ CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows,
+ and injects it into `tsjs.boot` with the signed authorization (§5.3).
+- **Cache-privacy invariant:** traces/authorizations are injected only into
responses that ran a per-request auction; such HTML is
- `Cache-Control: private, no-store` with no validators. Enforced by
- construction and by test.
+ `Cache-Control: private, no-store`, no validators. By construction + test.
- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints
- `trace_id` and sends it in a validated `X-TSJS-Trace-Id` header (the
- request already carries a non-simple TSJS header). The server records it in
- that auction's rows; the JSON response echoes the accepted trace and
- returns its signed authorization.
+ `trace_id`, sends it in `X-TSJS-Trace-Id`; the server records it and the
+ response echoes the trace + its authorization.
+- **Direct `/auction` (closing the G4f gap):** the client sends the same
+ `X-TSJS-Trace-Id` header on the POST (`core/auction.ts:190` gains it); the
+ server validates, stamps that auction's rows, and echoes
+ `ext.trusted_server.trace = {trace_id, auth}` in the OpenRTB response —
+ covering pages whose initial HTML ran no auction.
- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a
- per-trace group `{trace_id, auth, events[]}` (§5.1). `seq` is per-trace
- monotonic. Transport is best-effort: gaps (loss) and duplicates
- (fetch/pagehide races) are both expected; §5.5 defines dedup.
-- **Sampling is server-decided for every trace** — initial via boot, SPA via
- the page-bids response — and carried **inside the signed authorization**
- (§5.3 `mode`). The client never asserts its own sampling; an unsigned or
- missing authorization means the trace group is rejected at ingest.
+ per-trace group `{trace_id, auth, events[]}`. `seq` is per-trace
+ monotonic. Gaps (loss) and duplicates (fetch/pagehide races) are expected;
+ §5.5 dedups.
+- **Sampling is server-decided for every trace** and carried inside the
+ signed authorization (§5.3). Traces are navigation-scoped; **impression /
+ attempt counts are keyed by `(trace_id, nav_gen, refresh_gen, slot)`**
+ (success criterion 5 uses this key, not "per-impression traces").
### G2 — Render identity
-- Cache-backed bids: `hb_adid` = the PBS Cache UUID, byte-for-byte as today
- (`publisher.rs:3355`; the PUC fetches `?uuid=`,
- `publisher.rs:3450`, `gpt/index.ts:1700`). Markup bids without cache ids:
- today's fallback chain unchanged.
-- **Renderer-only bids: `hb_adid` = a server-minted render token**,
- `^[a-z0-9]{12}$` exactly, CSPRNG, collision-retried within the minting
- auction. Cross-auction uniqueness is probabilistic (36¹² ≈ 4.7×10¹⁸;
- birthday-bound negligible at realistic volumes) and made harmless by
- registry scoping.
-- **Registry scope and bounds:** the client bridge registry keys tokens by
- `(trace_id, nav_gen, refresh_gen)` — refresh auctions within one
- navigation cannot collide, closing the revision-4 gap. Capacity: 64 live
- entries per navigation; at capacity a new registration is refused with
- disposition `registry_full`; unexpired entries are evicted only by
- navigation disposal. Token TTL 15 minutes; one-time consumption.
-- The client-Prebid path keeps Prebid's generated `adId`; both paths register
- into the one registry keyed by whichever id that path observes.
-- Regression tests: non-APS cache-backed bids byte-identical.
-
-### G3 — Runtime ABI under the IIFE build (exact-release model)
+- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte
+ (`publisher.rs:3355`; PUC fetches `?uuid=`, `gpt/index.ts:1772`). Markup
+ bids: today's fallback chain.
+- **Renderer-only bids:** `hb_adid` = server-minted token `^[a-z0-9]{12}$`,
+ CSPRNG, collision-retried within the minting auction; cross-auction
+ uniqueness probabilistic (36¹²; negligible) and harmless via scoping.
+- **Registry:** keyed `(trace_id, nav_gen, refresh_gen)`; capacity 64 live
+ entries per navigation (`registry_full` on refusal); TTL 15 min; one-time
+ consumption. **Navigation disposal does not erase security state**
+ (review's tombstone finding): consumed, stale, and disposed ids move into
+ a bounded **RuntimeSession tombstone set** (cap 256, FIFO, entries retained
+ until their original TTL) — the bridge's TS-reserved check consults live
+ registry **and** tombstones, so a late prior-navigation request is still
+ suppressed and refused, never released to native Prebid. This preserves
+ the baseline's existing consumed-id tombstone behavior
+ (`gpt/index.ts:1527`).
+- The client-Prebid path keeps Prebid's `adId`; both paths share the one
+ registry. Non-APS cache-path regression tests.
+
+### G3 — Runtime ABI under the IIFE build (exact-release)
IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`)
means imports never share state across bundles (live defect:
`core/context.ts:11` vs `permutive/index.ts:102`).
-- The kernel ships only in `tsjs-core`, publishes
- `tsjs._internal = { release_id, registry }` once (window sentinel), and
- **freezes** `_internal` after boot. The kernel constructs and registers
- core services (event bus, beacon queue, sessions, slot registry, render
- state machine) during boot; integrations register integration-scoped
- services during `install()`.
-- **Exact release matching:** every service and plugin registration carries
- `release_id`; `registry.get(name)` succeeds only when the registrant's
- `release_id` equals the kernel's. A mismatch quarantines the registration
- and emits `abi_mismatch` (service) / `bundle_partial` (plugin) with a
- console error. No ranges, no minors, no first-wins tiers — under §0 a
- mismatch is a deployment error to surface, not tolerate.
-- Stateful access only through the registry at call time; stateless helpers
- may be imported and inlined. Single-module-graph builds remain the
- recorded successor option behind the same surface.
+- Kernel ships only in `tsjs-core`; publishes
+ `tsjs._internal = { release_id, registry }` once; freezes after boot;
+ constructs and registers core services during boot.
+- **Exact release matching:** every registration carries `release_id`
+ (plugins via the object-form API, §7.6, whose `release` field is a
+ build-generated constant); `registry.get(name)` succeeds only on equality;
+ mismatch quarantines with `abi_mismatch` / `bundle_partial` and a console
+ error.
+- Stateful access only via the registry at call time; stateless helpers may
+ inline. Boundary enforcement is **two lint rules**: `import/no-restricted-
+paths` for layering **and** `no-restricted-globals` forbidding
+ `window.googletag` / `window.pbjs` outside `adapters/` (import paths alone
+ cannot enforce §7.1).
### G4 — Render lifecycle
-**G4a — Physical request-cycle protocol.** Two separated notions:
-
-- **Intent:** a TS `display()`/`refresh()` call (or an observed publisher
- entry) targeting a slot. Intents are classified `ts | publisher` at the
- wrapped entry points. An intent may produce zero physical requests
- (initial-load-disabled `display()`; `refresh()` on a never-displayed
- adopted slot); an intent that produces no `slotRequested` within its bound
- (2 s) expires with disposition `intent_no_request` — diagnostics only.
-- **Cycle (outstanding physical request):** opened **only by
- `slotRequested`**, matched to the oldest unexpired TS intent for that slot,
- else classified publisher-initiated. SRA batching yields one `slotRequested`
- per slot per batch — one cycle each, all matched to the intents of the
- batch call. A cycle closes on its `slotRenderEnded` (matched by slot; GPT's
- `responseIdentifier`, where present, deduplicates responses during drain —
- it never attributes initiation).
-- **Serialization:** TS keeps at most one outstanding TS cycle per slot. A TS
- intent arriving while a TS cycle is outstanding **queues** (bounded: 1
- queued replacement; further intents coalesce into it).
-- **Attribution:** a `slotRenderEnded` is attributable iff exactly one
- TS cycle is outstanding for the slot and no publisher-initiated or
- untracked request overlaps it. Any overlap → the slot enters
- **quarantine**: `cycle_unattributable`, fail closed (no fallback, no state
- transition), and the drain rule applies.
-- **Drain/re-arm (supersession and SPA):** physical cycle state lives in the
- **RuntimeSession-owned slot record**, not the NavigationSession — adopted
- slots outlive navigations. On navigation or supersession, outstanding
- cycles are marked stale; their late events are **matched and discarded**
- with disposition `stale_navigation` (never misattributed); a quarantined or
- stale slot re-arms only after every outstanding request/render pair has
- drained (or its 60 s drain bound elapses, which keeps the slot
- fallback-ineligible for that navigation). Queued TS intents dispatch only
- after re-arm. A timeout never makes an old event disappear — drain-by-match
- does.
-- CI exercises the protocol on the deterministic harness; a **release-gating
- real-GAM overlap test** (publisher refresh racing a TS cycle;
- initial-load-disabled cycle formation) validates it against actual GPT.
-
-**G4b — Acknowledgement protocol.** The renderer document currently posts
-"ready" only to its immediate parent (`aps.rs:105`); in the PUC path the
-top-level kernel cannot observe it, and callbacks fire on send
-(`gpt/index.ts:1572`, `:1620`). Contract: the bridge response embeds a
-**per-attempt 128-bit CSPRNG acknowledgement nonce**; the renderer document
-posts versioned `{t: "render_accepted" | "render_failed", nonce, reason?}`
-to the top window; the kernel validates, in order: source ownership (§6.8
-walk), nonce equality, token binding, `nav_gen`, `refresh_gen` — all five —
-before any state transition or notification. Pinned by tests for SSAT,
-client-Prebid, and nested SafeFrame flows, including stale and replayed
-acks.
+**G4a — Physical request-cycle protocol.**
+
+- **Intents, both classes, one causal queue.** Every observable initiation —
+ TS `display()`/`refresh()` and wrapped publisher entries — records an
+ intent in causal order, classified `ts | publisher`. A TS `display()`
+ issued while initial load is disabled is **known at call time to produce
+ no request** and is retired immediately as bookkeeping (it never enters
+ the matcher) — closing the review's misattribution case where a publisher
+ `refresh()` inside the 2 s window would have been consumed by a stale TS
+ intent. TS intents that _may_ produce no request only in hindsight
+ (`refresh()` on a never-displayed adopted slot) expire at 2 s with
+ `intent_no_request`; **if any publisher intent is recorded for the slot
+ while such a TS intent is pending, the next `slotRequested` is ambiguous
+ and the slot quarantines** — a zero-request TS intent can never silently
+ win FIFO matching. (Exact test in §9.)
+- **Cycles:** opened only by `slotRequested`, matched to the head of the
+ causal intent queue; SRA batching yields one `slotRequested` per slot per
+ batch. A cycle closes on its `slotRenderEnded`; `responseIdentifier`
+ deduplicates responses during drain.
+- **Serialization:** at most one outstanding TS cycle per slot; one queued
+ TS replacement (later intents coalesce).
+- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS
+ cycle is outstanding and no publisher/untracked request overlaps.
+ Overlap → quarantine (`cycle_unattributable`, fail closed).
+- **Drain/re-arm (no timeout re-arm).** Physical cycle and drain state live
+ in the RuntimeSession slot record; **unissued intents are
+ NavigationSession children** and are cancelled by navigation disposal. A
+ quarantined or stale slot re-arms only on: count-based drain (every
+ outstanding request/render pair matched), safe TS-owned slot destruction
+ and redefinition, or page end. **A timeout emits a diagnostic and never
+ restores attribution** — the 60 s bound from revision 5 is removed
+ because an old `slotRenderEnded` arriving after re-arm would be
+ indistinguishable from a new cycle. Late stale events are matched and
+ discarded (`stale_navigation`).
+- CI exercises the protocol on the deterministic harness; a release-gating
+ **real-GAM overlap test** (publisher refresh racing a TS cycle;
+ initial-load-disabled formation) validates it against actual GPT.
+
+**G4b — Acknowledgement protocol (on the baseline port transport).** Since
+`248fe9558` the frame pair speaks MessageChannel (parent-postMessage with
+`ports.length === 0`, or transferred port with `ports.length === 1`;
+exact-key replies; one-shot `accepted` latch; port closed after reply —
+`aps.rs:65-125`, `aps/render.ts:415-437`). Adopted as the contract of record
+within the frame pair. The kernel-observability gap remains (the PUC-flow
+reply resolves inside the creative frame; callbacks fire on send,
+`gpt/index.ts:1632-1760`). Contract — **three authenticated messages per
+attempt**, each carrying the per-attempt 128-bit CSPRNG nonce from the
+bridge response:
+
+1. `renderer_document_loaded` — posted to the top window after the document
+ validates the descriptor and nonce (this is §6.6's first stage, which
+ revision 5's two-message protocol omitted);
+2. the port reply to its frame-pair peer (baseline behavior, unchanged);
+3. `render_accepted` / `render_failed{reason}` — posted to the top window.
+
+The kernel validates, in order: source ownership (§6.8 walk), nonce, token,
+`nav_gen`, `refresh_gen` — before any state transition or notification. The
+one-shot latch + port close mean a re-render is a fresh document instance
+with a fresh nonce. Pinned for SSAT, client-Prebid, and nested SafeFrame,
+including stale/replayed acks and acks after navigation disposal.
**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc`
-without `allow-same-origin` (`gpt/index.ts:358`) — opaque origins; geometry
-proves nothing. Observations: `gam_nonempty`, `gam_empty`,
-`renderer_document_loaded`, `runner_loaded`, `runner_failed`,
-`adm_document_loaded`. Every path terminates at `render_accepted`
-(authenticated per G4b where the renderer protocol exists;
-`adm_document_loaded` stands in for adm frames). **No observation claims
-paint**; there is no `render_confirmed`. A future trusted completion ack
-(OQ6) may add a new state under a new name.
-
-**G4d — Win/billing notifications.** APS intentionally carries neither
-`nurl` nor `burl` (`aps.rs:812`; the minimized AAX envelope excludes them;
-the integration guide documents no generic APS beacons). APS billing lives in
-the Amazon runner lifecycle; unchanged.
-
-For carrying paths (PBS and other OpenRTB providers), **bind is defined per
-flow and is never selection or targeting** (targeting-only firing is
-explicitly prevented today, `ad_init.test.ts:1824`):
-
-- PUC/GAM flow: bind = an owned, slot-and-ad-id-matched bridge claim.
-- Direct `/auction` flow: bind = validated render start (slot resolved,
- descriptor/markup validated, attempt created).
-- Fallback flow: bind = attributed `gam_empty`, immediately before the
- fallback render starts.
-
-`nurl` fires at bind; `burl` at `render_accepted`; both attempt-scoped
-(idempotency key `(trace_id, nav_gen, slot, refresh_gen, hb_adid)`), fired at
-most once, via `sendBeacon`/`no-cors fetch`, no retries. Terminal failure
-after acceptance → `billed_then_failed` label; no un-firing.
+without `allow-same-origin` (`gpt/index.ts:510`) — opaque; geometry proves
+nothing (the shell dimensions are assigned by our own code). Observations:
+`gam_nonempty`, `gam_empty`, **`gam_collapsed`** (nonempty render whose
+shell computes ≤ 1px — the baseline's discovery), `renderer_document_loaded`,
+`runner_loaded`, `runner_failed`, `adm_document_loaded`. Every path
+terminates at `render_accepted`; **no observation claims paint**; there is
+no `render_confirmed`. The baseline's `resizeCollapsedCreativeFrame`
+(`gpt/index.ts:217`) is adopted as a **sanctioned, guarded exception** to
+the no-foreign-DOM-mutation rule (authenticated source frame only; wrapper
+only when both dimensions ≤ 1px; anchor-ad `ins[data-anchor-status]` and
+fixed/sticky guards) and emits `gam_collapsed` when it acts.
+
+**G4d — Win/billing notifications.** APS carries neither `nurl` nor `burl`
+by design (`aps.rs:839`; the AAX envelope excludes them; the integration
+guide documents no generic APS beacons) — APS billing lives in the Amazon
+runner lifecycle, unchanged, and **APS is excluded from everything below**.
+
+For carrying paths (PBS and other OpenRTB providers): bind is per flow and
+never selection or targeting (`ad_init.test.ts:1824` pins that):
+
+- GAM/PUC: an owned, slot-and-ad-id-matched bridge claim.
+- Direct `/auction`: validated render start. **This requires server and
+ client work the current code lacks**: `/auction` response conversion must
+ preserve `nurl`/`burl` with server-side macro expansion
+ (`formats.rs:423` omits them today) and the client parser must carry and
+ https-validate them (`core/auction.ts:43` drops them today).
+- Fallback: attributed `gam_empty`, immediately before fallback render.
+
+`nurl` at bind; `burl` at `render_accepted`; attempt-scoped idempotency key
+`(trace_id, nav_gen, slot, refresh_gen, hb_adid)`; `sendBeacon`/no-cors
+fetch; no retries. Post-acceptance terminal failure emits the dedicated
+**`billing_outcome{billed_then_failed}`** event (§5.1) — it is not a
+`render_fail` reason.
**G4e — Fallback trigger.** Opt-in
-(`[auction].client_render_fallback = "renderer"`). Renders only after a
-terminal `gam_empty` **unambiguously attributed to a TS cycle** (G4a) —
-ownership does not gate it (adopted slots are the common path and are
-eligible); publisher-initiated or unattributable cycles never trigger it;
-timeouts never render. The direct renderer is converted to an awaitable API
-with cancellation and terminal reasons before the fallback lands.
+(`[auction].client_render_fallback = "renderer"`); renders only after a
+terminal `gam_empty` unambiguously attributed to a TS cycle; ownership does
+not gate it; publisher-initiated or unattributable cycles never trigger it;
+timeouts never render.
**G4f — Direct `/auction` lifecycle.** The non-GPT path
-(`core/request.ts:52`) gets the same discipline: a `RenderAttempt` keyed
-`(trace_id, nav_gen, refresh_gen, slot)` where `refresh_gen` increments per
-`requestAds` invocation for the same slot within a navigation; exactly-once
-terminal state; G4b acknowledgement validation; G4d direct-flow bind;
-cancellation and disposal on navigation; the same §5.1 event sequence. "Every
-configured flow" in the success criteria includes this one.
-
-### G5 — Deployment contracts (hard cutover)
-
-- **Config:** top-level `format_version`; exact match required; mismatch is a
- startup error. No default-omission rollback rules.
-- **Assets:** hash-in-pathname (`/static/tsjs//.js`) for cache
- identity; binaries serve only embedded current-release artifacts; unknown
- hash → `410 Gone`, `no-store`; `Cache-Control: immutable` on exact matches.
- Concatenations precomputed per **ordered module-ID vector** at build time.
- The cutover runbook (§0) owns HTML/asset consistency; the CDN purge step is
- what retires old references.
-- **Internal route isolation (all adapters):** the renderer, client-events,
- and CSP-report route families (a) dispatch **before** auth, EC setup, and
- publisher/integration filters (today the renderer can traverse EC setup and
- pre-route filters, `app.rs:709` in the Fastly adapter); (b) reserve **all
- methods and all version prefixes** locally — unsupported method →
- deterministic `405` with `Allow` and `no-store`; unknown version → `404`
- `no-store`; never the publisher fall-through some adapters use today
- (`adapter-spin app.rs:804`); (c) never forward bodies, cookies, or
- authorization headers to publisher origins; (d) compare origins as
- normalized scheme + host + port, not host-only.
-- **Ingest routing:** client-events in all four adapters; Fastly has the real
- sink; others accept-count-drop by contract (OQ5).
-- **Storage:** §5.6 schemas deploy and validate **before** any writer
- enables.
+(`core/request.ts:52`) gets: a `RenderAttempt` keyed
+`(trace_id, nav_gen, refresh_gen, slot)` with `refresh_gen` incremented per
+`requestAds` invocation touching the slot; **per-slot serialization with
+latest-wins cancellation** — concurrent calls for the same slot cancel the
+older attempt, and every DOM or beacon side effect re-checks its attempt
+generation first, so a reversed-arrival response can never replace a newer
+creative or start a second economic lifecycle (`request.ts:31` currently
+races); G4b acknowledgement validation; G4d direct-flow binds; disposal on
+navigation; exactly-once terminal state; the full §5.1 event sequence.
+`tsjs.requestAds(options)` returns
+`Promise` where
+`RequestAdsResult = { traceId, slots: Array<{ slot, outcome: "rendered" |
+"no_bid" | "failed" | "cancelled", reason? }> }`, settling when every slot
+attempt reaches a terminal state. Reversed-response tests required.
+
+### G5 — Deployment contracts
+
+- Config `format_version` exact-match; rollback by redeploy.
+- Assets: hash-in-pathname; embedded only; unknown hash 410 `no-store`;
+ `Cache-Control: public, max-age=31536000, immutable` on exact matches
+ (`immutable` alone carries no lifetime). **Concatenation is materialized
+ and cached at application-state construction from the validated
+ configured module vector** — not per request (`bundle.rs:23` today), and
+ not a build-time-only set, since enabled vectors are runtime
+ configuration; an unlisted vector is a startup error.
+- Internal route families (renderer, client-events, CSP reports): dispatch
+ before auth/EC/publisher/integration filters (Fastly today runs EC setup
+ and pre-route filters first, `app.rs:709`); all methods and version
+ prefixes reserved locally (405 + `Allow` + `no-store`; unknown version
+ 404 `no-store`; never the publisher fall-through in `adapter-spin
+app.rs:804`); no body/cookie/authorization forwarding; origins compared
+ as normalized scheme+host+port.
+- Ingest routes exist in all four adapters; Fastly has the real sink; the
+ others accept-count-drop (OQ5 drives their gates).
+- §5.6 schemas deploy and validate before writers enable.
## 5. Observability
-### 5.1 Wire payload
+### 5.1 Wire payload and per-event field matrix
```
{ v: 1, traces: [
- { trace_id, auth, // auth: signed authorization (§5.3)
- events: [
- { nav_gen, refresh_gen, seq,
- t: "bid_received" | "targeting_set" | "bridge_request" |
- "bridge_response_sent" | "render_attempt" | "render_accepted" |
- "render_fail" | "gam_nonempty" | "gam_empty" |
- "renderer_document_loaded" | "runner_loaded" | "runner_failed" |
- "adm_document_loaded" | "fallback_start",
- slot, // configured slot id if in the injected set, else "s"
- id_kind, // "cache_uuid" | "render_token" | "prebid_adid" | "bid_id" | "none"
- matched, // bridge_request only
- source, // "renderer" | "adm" | "pbs-cache" | "gam"
- reason } // render_fail only
- ] }
+ { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, t, ...fields } ] }
] }
```
-Every G4c observation is a **wire event** in this enum (internal state
-transitions map to them one-to-one; nothing observable is state-only).
-`gam_empty` additionally appears as a `render_fail` reason when it is the
-terminal outcome of an attributed attempt.
-
-The `t` enum now **contains every G4c observation**, so Phase-3 stage rates
-(renderer-document load rate, runner load/failure, GAM fill) are queryable.
-Reason enum (closed): `renderer_document_no_load`, `runner_no_load`,
-`runner_failed`, `descriptor_invalid`, `invalid_dimensions`,
-`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`,
-`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`, `gam_empty`,
+Event types and their fields (closed enums; a field absent from a row is
+absent from the wire and NULL in storage):
+
+| `t` | fields |
+| -------------------------- | ------------------------------------ |
+| `bid_received` | slot, id_kind, source |
+| `targeting_set` | slot, id_kind |
+| `bridge_request` | slot, id_kind, matched |
+| `bridge_response_sent` | slot, source |
+| `render_attempt` | slot, source |
+| `render_accepted` | slot, source |
+| `render_fail` | slot, source, reason |
+| `gam_nonempty` | slot |
+| `gam_empty` | slot |
+| `gam_collapsed` | slot |
+| `renderer_document_loaded` | slot |
+| `runner_loaded` | slot |
+| `runner_failed` | slot, reason |
+| `adm_document_loaded` | slot |
+| `fallback_start` | slot |
+| `billing_outcome` | slot, outcome (`billed_then_failed`) |
+| `client_queue_overflow` | dropped (count) |
+
+`slot` is a configured slot id or `s`; `id_kind` ∈
+`cache_uuid | render_token | prebid_adid | bid_id | none`; `source` ∈
+`renderer | adm | pbs-cache | gam`. Reason enum:
+`renderer_document_no_load`, `runner_no_load`, `runner_failed`,
+`descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`,
+`bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`,
+`stale_navigation`, `bridge_claim_timeout`, `gam_empty`,
`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`,
`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`.
+Queue overflow is its own event (`client_queue_overflow`), never a
+`render_fail` — failure denominators stay clean. The payload carries **no
+client timestamp**; the server stamps `received_at`, and ordering within a
+trace is `seq`.
### 5.2 Transport
`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide`
fallback `navigator.sendBeacon(url, new Blob([json], {type:
-"application/json"}))`. Flush every 5 s and on `visibilitychange`/`pagehide`.
-**Client queue bound:** 256 events; overflow drops oldest, increments a
-counter, and the final flushed batch carries one `render_fail{...}`-class
-overflow marker event so truncation is visible. Duplicates from
-fetch/pagehide races are expected and handled at the sink (§5.5).
+"application/json"}))`. Flush every 5 s and on `visibilitychange`/
+`pagehide`. Client queue bound 256 events; overflow drops oldest and emits
+`client_queue_overflow{dropped}`.
### 5.3 Signed trace authorization
-Format `v1....`:
-
-- `kid`: key id; **active and previous keys** live in the platform secret
- store; keys are ≥ 256-bit CSPRNG values; rotation = introduce new key as
- active, demote, retire. **Missing-key startup behavior:** if the beacon is
- enabled and no signing key resolves at startup, startup fails loudly
- (config error) — traces are never issued unsigned.
-- `exp`: unix epoch seconds; verifier allows ±60 s skew; maximum future
- 15 minutes from issuance.
-- `mode`: `sampled` | `diagnostic`. Sampling is server-decided (G1);
- diagnostic is a distinct authenticated mode gated by the tester cookie at
- issuance — not an overloaded "sampling off" bit.
-- `sig`: HMAC-SHA-256 over the **domain-separated, length-prefixed** input
- `"ts-trace-auth-v1" || len(origin) || origin || len(trace_id) || trace_id
-|| len(mode) || mode || u64(exp)`, where `origin` is the externally visible
- scheme+host+port. Constant-time comparison.
-- Ingest verifies per trace group; a missing key id, expired, future-dated,
- or invalid signature → that **group** is dropped-and-counted (other groups
- in the batch survive). An unsigned `sampled` claim does not exist in the
- wire format, so it cannot be asserted.
+Format `v1....` — **`auth` has its own ingest bound of
+256 bytes** (it cannot fit the general 64-char string cap; every other
+string keeps 64):
+
+- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform secret
+ store; keys ≥ 256-bit CSPRNG; **previous keys are retained at least
+ 24 hours** (≫ max token lifetime + skew); missing key at startup with the
+ beacon enabled = startup failure.
+- `exp`: canonical decimal unix seconds (no sign, no leading zeros); ±60 s
+ skew; max future 15 min.
+- `mode`: `sampled | unsampled | diagnostic`. **`unsampled` is the signed
+ discard decision** (the review's missing state): the client must not
+ enqueue or transmit events for an `unsampled` trace, and ingest rejects
+ any group whose token mode is `unsampled`; the decision is sticky for the
+ trace (renewals preserve mode). `diagnostic` is a distinct authenticated
+ mode — **not** gated by the tester cookie, which is explicitly
+ non-security (`tester_cookie.rs:3`); it requires a separate short-lived
+ **diagnostic credential** issued behind the existing operator/admin
+ authentication (`/_ts/admin` surface): HMAC-signed, bound to publisher
+ origin, expiry ≤ 60 min, revoked by key rotation, issuance
+ CSRF-protected; forgery/replay tests required. The tester cookie may
+ still gate cosmetic overlays; never telemetry volume.
+- `sig`: base64url, unpadded, of HMAC-SHA-256 (43 chars) over the
+ domain-separated input
+ `"ts-trace-auth-v1" || u32be(len(origin)) || origin ||
+u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode ||
+u64be(exp)`, all strings UTF-8; constant-time comparison.
+- **Renewal for long-lived pages:** before expiry the client calls
+ same-origin `GET /_ts/trace-auth` with `X-TSJS-Trace-Id`; the server
+ re-signs the **same trace id and mode** with a fresh `exp` (correlation is
+ the unchanged trace id). On renewal failure the client stops transmitting
+ and counts locally — silent rejection at ingest is thereby a bug, not a
+ policy.
+- Ingest verifies per trace group; invalid/expired/unknown-kid → group
+ dropped-and-counted; other groups survive.
### 5.4 Ingest contract
-- `POST /_ts/client-events`; `Content-Type: application/json` only; no
- `Content-Encoding`; responds `204`, `no-store`; never echoes input.
-- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars;
- `trace_id ^[0-9a-f]{32}$`; integers in `[0, 2³¹)`.
+- `POST /_ts/client-events`; `application/json` only; no
+ `Content-Encoding`; `204`, `no-store`; never echoes input.
+- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars except
+ `auth` ≤ 256 bytes; `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`.
- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized
`Origin` equality; absent both → drop-and-count.
-- **Rate limiting (numeric, fail-closed for telemetry):** token bucket per
- client address, **10 requests/min, burst 20**; limiter map ≤ 65,536
- entries, entry TTL 10 min, LRU eviction; limiter unavailable/errored →
- drop early with `204` (count only). Trusted client address per adapter:
- Fastly — platform client IP; Axum — rightmost `X-Forwarded-For` entry
- beyond required `trusted_proxy_hops` (absent config → socket peer only);
- Cloudflare — `CF-Connecting-IP`; Spin — platform client address.
-
-### 5.5 Sink and deduplication
-
-- Stable event key `(publisher, trace_id, seq)`; deduplication at the sink
- or query layer (Tinybird: latest-write or `GROUP BY` on the key), covering
- fetch/pagehide double-delivery.
+- **Rate limiting via an adapter abstraction** (the review is right that a
+ cross-request in-memory token bucket cannot exist on Fastly, `app.rs:146`):
+ trait `ClientEventLimiter` with a declared per-adapter backing and
+ semantics — Fastly: the platform edge counter (`rate_limiter.rs:40`),
+ fixed 60 s window, limit 20/window (documented approximation of
+ 10 rpm + burst 20); Axum: real in-process token bucket (10 rpm, burst
+ 20), map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/per-instance
+ best-effort with the same parameters. **At capacity, unseen identities
+ are rejected (drop-and-count); active buckets are never evicted by
+ churn.** Limiter unavailable/errored → drop early with `204`. Trusted
+ client address per adapter: Fastly platform client IP; Axum rightmost
+ `X-Forwarded-For` beyond required `trusted_proxy_hops` (absent → socket
+ peer only); Cloudflare `CF-Connecting-IP`; Spin platform address.
+
+### 5.5 Sink, canonical views, and monitoring
+
+- Stable event key `(publisher_domain, trace_id, seq)`.
+- **One named canonical dedup pipe/view per table** (`ts_client_events_v`:
+ latest `received_at` per key; `ts_render_attempts_v`: attempt-grain
+ aggregation keyed `(trace_id, nav_gen, refresh_gen, slot)`). **Joins run
+ at attempt/slot grain against the views, never raw-to-raw** (a raw join on
+ `(publisher_domain, trace_id)` multiplies rows). Dashboards and alerts
+ may query only canonical views.
+- Field naming matches the existing auction rows: **`publisher_domain`**.
- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and
- cannot observe downstream schema/auth rejection — therefore
- **datasource-side freshness monitoring is mandatory** (§8 gates alarm on
- ingestion lag and row-rejection metrics from the datasource side).
+ cannot see downstream rejection — **datasource-side monitoring is
+ mandatory**, driven by **sequence-tagged synthetic heartbeats** from a
+ probe client (accepted rows cannot reveal rejected rows): heartbeat gaps
+ measure rejection; heartbeat lag measures freshness. Alert owner: the
+ release owner's on-call.
### 5.6 Physical schemas (deployed before writers)
-- **New datasource `ts_client_events`** (flattened rows):
- `ts (DateTime64), publisher (LowCardinality String), release_id (String),
-trace_id (FixedString 32), mode (Enum sampled|diagnostic), nav_gen UInt32,
-refresh_gen UInt32, seq UInt32, event (Enum §5.1), slot (String ≤64),
-id_kind (Enum), matched (UInt8), source (Enum), reason (Enum §5.1)`.
- Sorting key `(publisher, ts, trace_id, seq)`; 30-day TTL; its own ingest
- token, configured via new `TinybirdSettings` fields
- (`client_events_dataset`, `client_events_token_secret`) — today's settings
- configure only the auction dataset (`settings.rs:1752`). Sink batch cap:
- 512 rows per dispatch (matching the auction sink). Startup validation:
- when client events are enabled, the dataset name and token secret must
- resolve or startup fails. Sink-unavailable behavior at runtime:
- accept-count-drop (ingest still answers `204`). Canonical join:
- `ts_client_events` ⋈ auction rows on `(publisher, trace_id)`; dashboards
- and alerts are owned by the release owner and defined with the datasource.
-- **Auction rows** (`AuctionEventRow`, `telemetry.rs:262`, and
- `auction_events_raw.datasource`): add nullable `trace_id (FixedString 32)`
- and `mode`; add a bounded **`bid_drop` row type**
- `{provider, slot, reason (Enum), width UInt16?, height UInt16?, count
-UInt32}` with per-auction row cap 32 and an `overflow` bucket row.
-- APS parsing returns a **structured drop observation**
- `{reason, slot, width?, height?}` instead of a bare reason string
- (`aps.rs:722`); dimensions above 8192 use `dimensions_out_of_range` with
- dimensions omitted, never clamped.
-
-### 5.7 Modes and SLOs
-
-- **Production (sink-backed only):** server-decided 10% sampling.
- Two separated objectives: **pipeline availability** — ingestion freshness
- ≤ 5 min and datasource rejection rate < 0.1%, alarmed on breach (fails
- during sink outages, by design); **failure detection** — a failure mode
- affecting ≥ 1% of sampled render attempts is visible within one hour,
- evaluated only at ≥ 10,000 sampled render attempts/hour.
-- **Diagnostic:** authenticated mode (§5.3), unsampled, full stream +
- console mirroring; one page load names the failing class.
+- **`ts_client_events`**: `received_at DateTime64, publisher_domain
+LowCardinality(String), release_id String, trace_id FixedString(32),
+mode Enum(sampled|diagnostic), nav_gen UInt32, refresh_gen UInt32,
+seq UInt32, event Enum(§5.1), slot Nullable(String), id_kind
+Nullable(Enum), matched Nullable(UInt8), source Nullable(Enum), reason
+Nullable(Enum), outcome Nullable(Enum), dropped Nullable(UInt32)`.
+ Sorting key `(publisher_domain, received_at, trace_id, seq)`; TTL
+ 30 days; own ingest token; sink batch cap 512 rows; startup validation of
+ dataset + token when enabled ("startup" on request-bound platforms such
+ as Cloudflare means first-request lazy initialization with a cached
+ result); sink-unavailable at runtime → accept-count-drop.
+- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`):
+ add nullable `trace_id`, `mode`; add two bounded row types — **`bid_drop`**
+ `{provider, slot Nullable, reason Enum, width Nullable(UInt16), height
+Nullable(UInt16), count UInt32}` (nullable slot/dimensions for
+ response-level failures; cap 32 rows/auction + overflow row) and
+ **`selection_summary`** per slot
+ `{slot, winner_source Enum(mediator|direct|none), winner_provider,
+candidates_direct UInt16, candidates_mediator UInt16, dedup_hits,
+currency_rejected, provenance_invalid, mediator_superseded}` (cap 8
+ rows/auction + overflow) — §6.1's selection report now has a physical
+ home.
+- **Settings schema (complete):** `[telemetry.client_events]` `enabled`,
+ `sample_rate` (0–1), `dataset`, `token_secret`; `[telemetry.trace_auth]`
+ `secret_store`, `active_kid`, `previous_kids = []`; the diagnostic
+ credential secret alongside. `RuntimeServices` (`platform/types.rs:158`)
+ gains the client-events sink handle next to the auction sink.
+- APS parsing returns structured drop observations
+ `{reason, slot, width?, height?}` (`aps.rs:722` today loses slot and
+ values); >8192 → `dimensions_out_of_range`, dimensions omitted.
+
+### 5.7 Modes and SLIs
+
+- **Production (sink-backed only):** server-decided sampling
+ (`sample_rate`, default 0.10; `sampled` vs `unsampled` signed per trace).
+ Separated SLIs: **pipeline availability** (heartbeat freshness ≤ 5 min,
+ heartbeat loss < 0.1%; fails during sink outages, alarmed); **failure
+ detection** (a failure mode affecting ≥ 1% of sampled render attempts
+ visible within one hour, evaluated at ≥ 10,000 sampled attempts/hour).
+- **Diagnostic:** credential-gated (§5.3), unsampled, full stream, console
+ mirroring, plus the `boot.debug` / response-`debug` envelopes (§2.5) — one
+ page load names the failing class for every §2 row.
### 5.8 Server-side drop surfacing
-Bounded structured summary whenever any bid is dropped; `bid_drop` rows
-(§5.6); drop summary in the initial-HTML `ts-debug` comment; page-bids gains
-a tester-gated structured `debug` field. Startup warnings: APS +
-`allow_script_creatives = false`; mediator + direct providers without an
-explicit `winner_selection` (§6.1 makes that a hard error).
+Bounded structured summary whenever any bid is dropped; `bid_drop` +
+`selection_summary` rows; `ts-debug` comment carries the drop summary;
+page-bids and `/auction` carry the gated structured `debug` field. Startup
+warnings: APS + `allow_script_creatives = false`; mediator + direct
+providers without an explicit `winner_selection` (§6.1 hard error).
## 6. APS delivery fixes
-### 6.1 Mediation: complete inline algorithm
-
-Current mediation cannot support merging: it forwards no stable candidate id;
-restores fields via a lossy last-write-wins `(provider, slot, bidder)` index
-(`adserver_mock.rs:95`); breaks equal-price ties by response arrival order
-(`orchestrator.rs:827`); and assigns parsed Prebid bids USD without
-validating response currency (`prebid.rs:2318`). The algorithm below replaces
-that, identically in the synchronous and split dispatch/collect paths, via
-one shared candidate-selection helper.
-
-1. **Candidate registration.** Every direct-provider bid admitted by parsing
- becomes a candidate with a **server-minted candidate id** (`c` +
- 11-char CSPRNG, unique per auction). The full candidate (renderer, cache
- coordinates, notification URLs, currency, provenance
- `(provider, upstream_bid_id)`) is stored by candidate id. Winners are
- selected **by candidate id** and their fields read from the stored
- candidate — the lossy index is deleted.
-2. **Currency.** The auction has one configured currency. A provider response
- that declares another currency, or a path that cannot prove its currency
- (the Prebid parse point must validate, not assume USD), rejects that bid
- at parse with `bid_drop{currency_mismatch}`. No conversion.
-3. **Mediator exchange.** Forwarded candidates carry their candidate id; the
- mediator is required (wire contract, including `adserver_mock`) to echo it
- on any bid derived from a forwarded candidate. A mediator bid **without**
- an echoed id is mediator-native. A mediator bid with an id that does not
- resolve → **the slot fails closed** for merging
- (`mediation_provenance_invalid`: mediator-native bids for that slot still
- compete; unresolvable forwarded claims are discarded and counted — a
- warning alone is insufficient).
-4. **Floors.** Slot floors filter both populations before selection.
-5. **Dedup.** A mediator bid that echoes candidate id X removes direct
- candidate X from the pool (it is the same demand, provenance `mediator`).
-6. **Selection.** Per slot, the winner is the maximum under the **total
- deterministic order**: decoded CPM desc → provenance rank (mediator
- before direct) → provider name asc → candidate id asc. Response arrival
- order can never matter.
-7. **Strategy config.** `[auction].winner_selection` is **required whenever a
- mediator and direct providers coexist** — startup error if absent (no
- silent default; §0 removes the compatibility rationale for one):
- `mediator_only` (mediator bids only, direct providers are signal) or
- `merge_highest_cpm` (the algorithm above). A mediator timeout degrades to
- direct-only selection and is reported.
-8. **Reporting.** A selection report per auction: `winner_source`,
- `mediator_superseded`, `currency_mismatch`, `dedup_hits`,
- `mediation_provenance_invalid` — separate from delivery `bid_drop` rows.
-
-Deal priority remains out of scope: the `Bid` model carries no deal identity
-(`types.rs:231`); a rule the model cannot express would be fiction. Recorded
-as follow-up requiring a bid-model extension.
+### 6.1 Mediation: complete, arrival-independent algorithm
+
+Current code cannot merge: no forwarded candidate id; lossy
+last-write-wins `(provider, slot, bidder)` restoration
+(`adserver_mock.rs:95`); arrival-order ties (`orchestrator.rs:827`); Prebid
+assumes USD (`prebid.rs:2433`) and APS stamps USD (`aps.rs:475`) with no
+configured currency. Replacement, identical in the synchronous and split
+dispatch/collect paths via one shared helper:
+
+1. **Currency.** New required field `[auction].currency` (ISO 4217). Every
+ provider parse validates its response currency against it (absent
+ declaration where the provider contract implies one — APS's USD — is
+ validated as that implied value); mismatch → `bid_drop{currency_mismatch}`.
+ No conversion.
+2. **Candidates.** Every admitted bid — direct **and mediator-native** —
+ becomes a candidate. Identity is two-part: `source_candidate_id` =
+ the **intrinsic stable key** `(provider_name, upstream_bid_id)` (for
+ mediator-native bids: the mediator's provider name and its bid id), and
+ `candidate_id` = a server-minted opaque wire id (`c` + 11-char CSPRNG)
+ used **only** for the mediator echo — never for ordering, so response
+ arrival order cannot influence selection.
+3. **Mediator exchange.** Forwarded candidates carry `candidate_id` in the
+ named wire extension **`ext.trusted_server.candidate_id`** (contract for
+ every mediator implementation, `adserver_mock` included); the mediator
+ echoes it on derived bids. Echoed id resolves → the bid is the forwarded
+ candidate with provenance `mediator`; **authoritative fields:** price
+ and deal fields come from the mediator (repricing is its job);
+ render-source fields (renderer, adm, cache coordinates, notification
+ URLs) come from the stored candidate — a mediator that returns its own
+ `adm` for an echoed candidate is treated as mediator-native demand
+ instead. Unresolvable echoed id → the slot **fails closed for merging**
+ (`mediation_provenance_invalid`; mediator-native bids for the slot still
+ compete; the claim is discarded and counted).
+4. **Floors** filter both populations. **Dedup:** an echoed candidate
+ removes its direct twin.
+5. **Selection order (total, intrinsic):** decoded CPM desc → provenance
+ rank (mediator first) → `source_candidate_id` asc. Winner fields are
+ read from the stored candidate per rule 3.
+6. **Strategy.** `[auction].winner_selection` is **required** whenever a
+ mediator and direct providers coexist (startup error if absent):
+ `mediator_only` or `merge_highest_cpm`. **Timeout behavior is
+ strategy-specific:** under `merge_highest_cpm`, mediator timeout →
+ direct-only selection, reported; under `mediator_only`, mediator timeout
+ → **no winners** (direct bids stay signal-only) unless
+ `mediator_timeout_fallback = "direct"` is explicitly configured.
+7. **Reporting:** the `selection_summary` row (§5.6) per slot.
+
+Deal priority stays out of scope (the `Bid` model carries no deal identity;
+recorded follow-up).
### 6.2 Dimensions
-Exact size membership stays (`aps.rs:657-668`). The fix is visibility
-(structured `bid_drop{invalid_dimensions, w, h}`, §5.6) plus documentation
-("sizing your slots for APS"): if a size is acceptable, request it in the
-slot's `formats` — accepting unrequested sizes would conceal an upstream
-protocol violation.
+Exact size membership stays (`aps.rs:675`). Fix is visibility
+(`bid_drop{invalid_dimensions, w, h}`) plus documentation: request the
+sizes you accept.
### 6.3 Script creatives
-`allow_script_creatives` stays default-`false` (defensible sandbox posture);
-the consequence becomes loud (§5.8) and the enablement path documented.
+Default stays `false`; consequence loud (§5.8); enablement documented.
### 6.4 Render identity
-As G2, including the `(trace_id, nav_gen, refresh_gen)` registry scope and
-capacity rules.
+As G2 (including tombstones).
### 6.5 Fallback
@@ -566,80 +637,60 @@ render.
### 6.6 Renderer endpoint
-- The static renderer document route registers **unconditionally in every
- adapter** (the APS provider stays config-gated); startup validation fails
- if an auth handler pattern covers it; §G5 route-isolation rules apply
- (early dispatch, all methods reserved, no publisher fall-through).
-- Path `/integrations/aps/renderer/v1`, embedded in the binary, served
- `Cache-Control: immutable` (its bytes change only by shipping `/v2` in a
- new release; §0's purge retires the old). Unknown versions → `404`
+- Route registers unconditionally in every adapter (provider stays
+ config-gated); startup validation fails if an auth handler pattern covers
+ it; §G5 isolation rules apply.
+- Path `/integrations/aps/renderer/v1`, embedded, served
+ `Cache-Control: public, max-age=31536000, immutable`; canary versions
+ are `no-store` (or bounded below the cohort lifetime); a **checked-in
+ header manifest per renderer version** freezes headers (CSP included)
+ with the bytes — a version's headers never change after publication,
+ resolving the immutable-caching/CSP conflict. Unknown versions → 404
`no-store`.
-- **Two-stage acknowledgement:** authenticated `document_loaded` (proves
- route + auth + document CSP), then the runner-load result — splitting
- `renderer_document_no_load` from `runner_no_load`/`runner_failed`.
-- Server route counters (requests, unknown-version, auth-blocked) are
- **aggregate only** — the document request carries no trace (the nonce
- rides the URL fragment and never reaches the server).
-- **CSP rollout that cannot false-pass.** Three distinct uses, each with its
- own instrument: **discovery** uses the **currently enforced** policy with
- reporting attached (report-only alone cannot reveal what the enforced
- policy already blocks); **tightening** candidates run report-only;
- **relaxation** candidates are tested in a small enforced cohort under a
- short-lived canary document version, gated on runner acceptance rate, CSP
- violation rate, render-failure rate, and a kill switch that reverts the
- cohort to the frozen policy. Once frozen, a new immutable `/v2` ships with
- the final enforced headers. Reports: dedicated `POST /_ts/csp-reports`
- accepting **both** `application/csp-report` (legacy) and
- `application/reports+json` (Reporting API), each with its own payload
- validator; §5.4 caps and rate-limit rules; **origin rules account for the
- renderer's opaque sandbox** (reports may carry `null` origin — validated
- by document URL / policy version instead of the beacon's same-origin
- rule); unused report fields are **discarded before logging or
- aggregation**; stored as aggregate counters with blocked sources bucketed
- into `https-host (allowlisted) | data | blob | inline | eval | other` (no
- arbitrary host labels — cardinality abuse is otherwise trivial). Browser
- coverage for opaque-renderer reports runs on **Chromium, Firefox, and
- WebKit** (CI is Chromium-only today, `playwright.config.ts:16`; the matrix
- extends for this suite).
-
-### 6.7 One descriptor schema — generation covers all three implementations
-
-- Wire truth: the tagged `BidRenderer` envelope (discriminator on the enum,
- `types.rs:188-211`).
-- A wire-schema crate/xtask (separate from `trusted-server-js`; core already
- depends on that crate, `Cargo.toml:45`) generates: the JSON-Schema
- artifact, the **TS structural parser**, the **ES5-compatible inline
- validator fragment** embedded in the renderer document, and shared
- fixtures — all checked in with staleness CI. Only environment-specific
- semantic checks (URL/origin policy, canonical base64, length bounds, the
- exact one-bid AAX projection, cross-field equality) stay handwritten.
-- Tolerance only on the outer versioned descriptor; the decoded AAX envelope
- remains an exact projection. A shared positive + adversarial corpus runs
- through the Rust validator, the generated TS parser, and the generated
- inline fragment in CI.
+- Three-message acknowledgement per G4b (`renderer_document_loaded` is the
+ first authenticated envelope).
+- Server route counters are aggregate (the nonce rides the URL fragment).
+- **CSP rollout:** discovery on the currently enforced policy with
+ reporting; tightening via report-only; relaxation via a small enforced
+ cohort on a short-lived canary version, gated on runner acceptance, CSP
+ violation rate, and render-failure rate, with a kill switch — and **CSP
+ reports are advisory**: for opaque-origin reports the body-supplied
+ document URL and policy version are forgeable, so **policy identity is
+ encoded in a server-selected report path**
+ (`POST /_ts/csp-reports/`, ids server-generated per version),
+ reports are bucketed into closed effective-directive buckets and
+ `https-host (allowlisted) | data | blob | inline | eval | other` source
+ buckets with global and per-cohort caps, unused fields discarded before
+ logging, and CSP data is **never a sole automatic rollback signal**.
+ Physical storage: aggregate counters only. Browser capture on Chromium,
+ Firefox, and WebKit (CI is Chromium-only today,
+ `playwright.config.ts:16`), both report media types
+ (`application/csp-report`, `application/reports+json`) with separate
+ validators.
+
+### 6.7 One descriptor schema
+
+Wire truth is the tagged `BidRenderer` envelope (`types.rs:188-211`). A
+wire-schema crate/xtask (no `core → js` cycle; core already depends on the
+js crate, `Cargo.toml:45`) generates the JSON-Schema artifact, the TS
+structural parser, the ES5 inline validator fragment, and shared fixtures —
+checked in, staleness-gated. Semantic checks (URL/origin policy, canonical
+base64, bounds, exact one-bid AAX projection, cross-field equality) stay
+handwritten. Outer-descriptor tolerance only; AAX projection exact. Shared
+positive + adversarial corpus runs through all three validators in CI.
### 6.8 Bridge hardening
-Processing order (normative — preserves the existing stolen-capability
-defense that suppresses propagation before source validation,
-`gpt/index.ts:1547`):
-
-1. parse `e.data` (bare `catch` → return);
-2. identify a TS-reserved ad id (registry lookup);
-3. if TS-reserved: `stopImmediatePropagation()` **before** any validation —
- a rejected foreign frame must not be answerable by Prebid's native
- handler either;
-4. validate source ownership via the bounded walk: known slot-root
- `WindowProxy` map, sender's own parent chain (`event.source.parent`, …)
- to depth 5 — never scanning an attacker-controllable frame tree;
-5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b);
-6. respond, or refuse with `bridge_id_mismatch`.
-
-Non-TS ad ids are untouched (no propagation suppression). The stolen-token
-browser test asserts **neither TS nor the native Prebid listener responds**;
-listener-registration ordering has a real-browser assertion. The dead
-duplicate renderer branch (C5) is deleted; renderer branches emit the full
-§5.1 sequence with G4d notifications only on carrying paths.
+Order (normative; preserves the baseline defense that suppresses
+propagation before source validation, `gpt/index.ts:1584-1637`): parse →
+identify TS-reserved id (live registry **or tombstone**, G2) →
+`stopImmediatePropagation()` → validate source ownership via the bounded
+walk (known slot-root `WindowProxy` map; sender's parent chain to depth 5;
+never scanning the frame tree) → validate nonce/token/`nav_gen`/
+`refresh_gen` → respond or refuse (`bridge_id_mismatch`). Non-TS ids are
+untouched. The stolen-token browser test asserts neither TS nor native
+Prebid responds; listener-order has a real-browser assertion. Renderer
+branches emit the full §5.1 sequence; notifications only on carrying paths.
## 7. TSJS target architecture
@@ -649,311 +700,295 @@ duplicate renderer branch (C5) is deleted; renderer branches emit the full
kernel/ boot, config, queue, event bus, log, beacon, sessions
adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access
services/ slots (registry+handoff), auction client, render engine, consent
-integrations/ gpt, prebid, aps, creative, datadome, … (plugins over services)
+integrations/ gpt, prebid, aps, creative, datadome, …
```
-Boundary lint in CI (`import/no-restricted-paths`): kernel imports nothing
-above it; adapters import kernel only; services import kernel + adapters;
-integrations import kernel + services, never each other. This dissolves the
-audited inversions (`core/auction.ts` and `core/request.ts` importing
-`integrations/aps/render`; `gpt` and `prebid` importing `aps`; `prebid`
-owning the GPT refresh wrapper). Stateful services via the G3 registry only.
+Enforced by the two G3 lint rules. Dissolves the audited inversions
+(`core/auction.ts`/`core/request.ts` → `integrations/aps/render`;
+`gpt`/`prebid` → `aps`; `prebid` owning the GPT refresh wrapper).
### 7.2 Adapters
-Per external global: `present | pending | timed_out`, `timed_out`
-non-terminal (late loaders transition to `present` and drain what is still
-valid); queued operations carry their own timeouts and expire with
+`present | pending | timed_out` per external global; `timed_out`
+non-terminal; queued operations carry their own timeouts and expire with
disposition reasons.
### 7.3 Slot registry service
-Kernel-owned; `WeakMap` + div-id index; holds
-ownership (ts/publisher/adopted), handoff claims, responsive resolution,
-G4a intent queue + cycle state (RuntimeSession-scoped), targeting-key
-history. No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid`
-deleted).
-
-### 7.4 Final global surface (hard cutover — no dual names)
-
-| Legacy surface (removed at cutover) | Final shape |
-| -------------------------------------------- | ----------------------------------------------------------------------------- |
-| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue |
-| `globalThis.tscreative` (API) | `tsjs.creative.*` (same methods, namespaced) |
-| `globalThis.tsCreativeConfig` (pre-load) | `tsjs.boot.creative` inside the boot container (below) |
-| `requestAds` (void) — and rev-4's dual API | **one** async contract: `tsjs.requestAds(options): Promise` |
-| `window.__tsjs_*` flags, integration configs | `tsjs.boot.*` fields written by server-injected scripts before the bundle |
-| server install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) |
-| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` |
-| `tsjs._internal` | kernel-owned registry (G3), **frozen after boot** |
-
-Boot container lifecycle: pre-core scripts write
-`window.tsjs = window.tsjs || {que: [], boot: {}}` fields; the kernel
-**consumes `boot` at boot, deep-freezes the retained copy, and deletes
-consumed one-shot secrets** (the trace authorization moves into the sealed
-NavigationSession). Old pages referencing removed names fail at cutover —
-accepted per §0.
+Kernel-owned; `WeakMap` + div-id index;
+ownership, adoption, handoff claims, responsive resolution, the G4a causal
+intent queue + cycle/drain state (RuntimeSession) and unissued intents
+(NavigationSession), targeting history. No expandos
+(`__tsRenderGeneration`/`__tsRenderBid` deleted).
+
+### 7.4 Final global surface (hard cutover)
+
+| Legacy surface (removed at cutover) | Final shape |
+| --------------------------------------- | --------------------------------------------------------------------- |
+| `window.tsjs.que` | `window.tsjs.que` — unchanged |
+| `globalThis.tscreative` | `tsjs.creative.*` |
+| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` |
+| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) |
+| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` |
+| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) |
+| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` |
+| `tsjs._internal` | kernel registry (G3), frozen after boot |
+
+**Bootstrap correctness (closing the review's two holes):** every
+server-injected initializer creates the container **idempotently and
+field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` —
+never only-when-absent (today the first ad-slot script does
+`window.tsjs = {}`, `publisher.rs:3665`, which would clobber or starve
+later `boot` writes). Kernel boot claims an **atomic owner sentinel**; it
+consumes `boot`, deep-freezes the retained copy, and deletes one-shot
+secrets. The generated no-bundle fallback (§7.7) activates on bundle
+`error` **or** a bounded hang watchdog (10 s without kernel boot); if the
+fallback has activated and the bundle later arrives, the bundle **defers
+for the rest of the page** (logs + `bundle_partial` disposition) — queue
+ownership never changes hands mid-page.
### 7.5 Messaging module
-All `postMessage` through one module: versioned envelopes, name constants
-(the `'Prebid Request'` literal exists at six sites today; the APS handshake
-in three copies), G4b nonces, §6.8 validation. The minimal module (envelope +
-constants + validators used by the bridge) lands in Phase 1; full call-site
-migration completes in Phase 4.
+All `postMessage` through one module: versioned envelopes, name constants,
+G4b nonces, §6.8 validation. Minimal module lands in Phase 1; full call-site
+migration in Phase 4.
### 7.6 Plugin lifecycle — transactional — and sessions
-`tsjs.definePlugin(id, install, dispose?)` with `install(ctx)`:
-
-- `ctx.signal` (aborted on quarantine/disposal); synchronous
- `ctx.onDispose(fn)` registration; effects must be registered as they are
- made.
-- **Unwind on failure:** a throw, rejection, or abort triggers automatic
- reverse-order invocation of the disposers registered so far — partial
- installs cannot leak effects. Per-disposer exception isolation (one
- throwing disposer cannot stop the rest).
-- A disposer registered (or returned) **after** the owning session was
- disposed is invoked immediately.
-- Pending late registrations (manifest requested, bundle not yet evaluated):
- capacity 16, bound 10 s, then `bundle_partial`.
-- Release matching per G3: a plugin whose `release_id` differs from the
- kernel's is quarantined before `install` runs.
-- Sessions: `RuntimeSession` (page lifetime: bridge listener, history hook,
- pbjs subscriptions, adapters, beacon queue, **slot cycle state**);
- `NavigationSession` (per navigation: trace + authorization, render
- attempts, slot aliases, targeting history); `RenderAttempt` (per G4a cycle
- or G4f attempt). Each owns an enumerable disposal inventory; navigation
- disposes only NavigationSession children.
-- Error policy: no empty `catch` — handle, log with context, or emit a
- disposition. The auction fetch gains timeout + `AbortController`.
-- **Console logging retained:** every issue-surfacing condition keeps or
- gains a `log.warn` carrying the same reason code as its beacon event;
- `debug`-level delivery/security failures are promoted to `warn`.
+`tsjs.definePlugin({id, release, install, dispose?})` — object form; the
+`release` field is the build-generated `release_id` constant (G3 needs it;
+revision 5's positional API omitted it). `install(ctx): void |
+Promise`:
+
+- `ctx.signal`; synchronous `ctx.onDispose(fn)`; reverse-order unwind on
+ throw/reject/abort; per-disposer isolation; disposer registered after
+ disposal → invoked immediately; pending late registrations capacity 16,
+ bound 10 s → `bundle_partial`; release mismatch quarantines before
+ `install`.
+- Sessions: `RuntimeSession` (page-lifetime: bridge listener + tombstones,
+ history hook, pbjs subscriptions, adapters, beacon queue, physical slot
+ cycle/drain state); `NavigationSession` (trace + authorization + renewal
+ timer, render attempts, slot aliases, unissued intents, targeting
+ history); `RenderAttempt` (per G4a cycle / G4f attempt). Enumerable
+ disposal inventories; navigation disposes NavigationSession children
+ only.
+- No empty `catch`; auction fetch gains timeout + `AbortController`.
+- **Console logging retained**: every issue-surfacing condition keeps or
+ gains a `log.warn` with the beacon's reason code; `debug`-level
+ delivery/security failures promoted to `warn`.
### 7.7 Bootstrap
-`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/hydration
-logic, with the live `servicesEnabled` divergence) shrinks to a
-queue-and-flags stub; the bundle replays recorded early calls on install.
-Replay changes observable ordering — it ships inside the cutover with
-browser specs covering replay timing. The no-bundle fallback ("ads render if
-the bundle fails", pinned by `gpt.rs:1174-1179`) is **generated from the
-same TypeScript source** at build time.
+`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays
+recorded calls on install (browser specs cover replay timing); the
+no-bundle fallback is **generated from the same TypeScript source**, with
+the §7.4 activation/arbitration rules.
### 7.8 GPT correctness carried with the restructure
-Unconditional early `slotRequested`/`slotRenderEnded` subscription (replacing
-the `!servicesEnabled` gate, `gpt/index.ts:1017`; recording idempotent);
-restore #922/#997 attribution and orphan recovery; `changeCorrelator: false`
-on TS refreshes (configurable); `enableSingleRequest()` only when GPT
-services are not already enabled; ambiguous responsive resolution emits
-`render_fail{slot_unresolved}` alongside its warning.
+Unconditional early `slotRequested`/`slotRenderEnded` subscription
+(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent
+recording); restore #922/#997 attribution and orphan recovery;
+`changeCorrelator: false` on TS refreshes (configurable);
+`enableSingleRequest()` only when services are not already enabled;
+ambiguous responsive resolution emits `render_fail{slot_unresolved}`.
### 7.9 Decomposition targets
| Today | Target |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
-| `gpt/index.ts` (1777 LOC, 20 jobs) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
+| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics |
| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
-| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
+| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` |
| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal |
### 7.10 Performance (reproducible)
-- Bundle budgets: raw/gzip/Brotli per bundle for **three module vectors**
- (minimal, reference, maximal), compressors pinned (`gzip -9`,
- `brotli -q 11`), compared to checked-in baseline artifacts
- (`perf/baselines/*.json`); baseline updates are explicit reviewed diffs.
- Tolerance +5% bytes.
-- Browser timing (bids-script-to-first-`display()`): named runner image
- `ubuntu-24.04` (the CI image already used by this repository's workflows)
- and the Chromium build bundled with the pinned `@playwright/test` version
- from `package.json`; 5 warm-up runs discarded, 50 samples, gate p90 ≤
- baseline × 1.10. The baseline artifact records image, browser, and tool
- versions alongside the numbers; a baseline update is invalid if any of
- those differ from the pinned set.
-- Server (precomputed concatenation): one-sided gates, CPU and heap ≤
- baseline × 1.10; improvements always pass. Tool versions pinned in
- `.tool-versions`.
+- **Dedicated workflow** on a fixed runner (`runs-on: ubuntu-24.04`
+ explicitly — browser CI is `ubuntu-latest` today,
+ `integration-tests.yml:155` — inside a pinned container image digest);
+ browser = the lockfile-resolved `@playwright/test` build with its browser
+ revision recorded in the baseline artifact (the manifest is a caret
+ range today, `browser/package.json:10` — the lockfile + recorded
+ revision are authoritative); compressors pinned by version in the
+ container (`gzip -9 -n` for determinism, `brotli -q 11`).
+- Bundle budgets: raw/gzip/Brotli for three vectors (minimal, reference,
+ maximal) vs checked-in baselines (`perf/baselines/*.json`, updates are
+ reviewed diffs recording image/browser/tool versions); +5% bytes.
+- Browser timing: 5 warm-ups discarded, 50 samples, p90 ≤ baseline × 1.10.
+- **Server benchmark harness (complete):** workload = concatenation +
+ hash of the reference vector; 100 warm-up iterations, 1,000 measured;
+ statistic = median and p90; one-sided gates ≤ baseline × 1.10; variance
+ policy: 3 consecutive runs must agree within 5% or the result is
+ inconclusive (rerun, never pass).
### 7.11 Toolchain
-Raise the TypeScript floor to the resolved 5.9 line (lockfile already
-resolves 5.9.3 under the stale `^5.5.4` manifest); adopt
-`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
-`verbatimModuleSyntax`; dev-toolchain bumps (eslint, prettier, jsdom,
-`@playwright/test`, `@types/node`) as individual CI-gated PRs with changelog
-review (this library monkeypatches `fetch`/`sendBeacon`/DOM prototypes —
-jsdom/Playwright changes are real risks); `prebid.js` excluded from casual
-bumps (runtime Prebid is the manifest-locked external bundle; the npm pin
-and deployed bundle version documented together); monthly review; no phase
-starts more than one minor behind stable.
-
-## 8. Migration plan
-
-Phases are internal build milestones of **one coordinated release** (§0):
-each lands behind a flag in the dark deployment; the cutover switches them
-on together. Gates are executable — every gate names query, cohort,
-denominator, minimum sample, threshold, window, owner (release owner unless
-stated), and action (hold cutover / rollback switch):
-
-- **Phase 0 — Identity, schemas, toolchain.** Path-hashed embedded assets +
- 410 semantics + ordered-vector precompute; `format_version`; §5.6 schemas
- deployed and validated (writer-off); toolchain floors; dead expando writes
- deleted; §5.8 server drop surfacing.
- _Gate:_ dark-instance health 100%; datasource validation green (rejection
- rate < 0.1% on synthetic writes, freshness ≤ 5 min); asset `410` rate on
- dark probes = 0 for known hashes.
+TypeScript floor to the resolved 5.9 line; strictness flags on; dev
+toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from
+casual bumps; monthly review.
+
+## 8. Rollout: phases, decision records, and executable gates
+
+Phases are build milestones inside the §0 single-release model (dark pool →
+probe gates → router-weight canary of coherent requests → full weight).
+
+**Phase 0 decision records** (promoted from open questions; each has an
+owner, evidence, a deadline, and an explicit go/no-go): DR-1 mediator
+presence in the affected deployment (OQ1 — decides whether §6.1 gates
+Phase 3 entry); DR-2 script-creative share (OQ2 — decides the §6.3
+guidance priority); DR-3 #922 vs #997 (OQ4 — decides the Phase 3 work
+item); DR-4 mediator candidate-id echo owner and timeline (OQ7 —
+`merge_highest_cpm` is config-blocked until delivered); DR-5 non-Fastly
+sink decision (OQ5 — splits Phase 2's gates below).
+
+**Gates are a checked-in table** (`docs/superpowers/specs/rollout-gates.md`,
+created in Phase 0) with columns: query/test command, assignment key,
+expected positive count, denominator, sample floor, threshold, window,
+owner, hold/rollback action. The real-GAM suite's row includes its workflow
+name, fixture account and credentials owner, invocation command, artifact
+location, retry policy, and required approval evidence. Prose below is the
+summary; the table is normative.
+
+- **Phase 0 — Identity, schemas, toolchain, decisions.** Path-hashed
+ assets + 410 semantics + construction-time concatenation cache;
+ `format_version`; §5.6 schemas deployed writer-off; toolchain floors;
+ dead expando writes deleted; §5.8 drop surfacing; the five decision
+ records; the gates table itself.
- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3
- registry (exact release ids); RuntimeSession/NavigationSession; install
- manifest; minimal messaging module; G4a intent/cycle records; unconditional
- GPT subscriptions.
- _Gate:_ browser-spec suite green incl. listener-order assertions; zero
- `abi_mismatch`/`bundle_partial` on dark probes.
-- **Phase 2 — Trace + beacon.** Server-minted initial trace + page-bids
- header echo + signed authorizations; beacon service; four-adapter ingest;
- `ts_client_events` writers on.
- _Gate:_ on dark probes: ingest acceptance ≥ 99%, group auth-rejection
- < 0.5%, dedup query returns exactly-once per `(trace, seq)`; freshness
- ≤ 5 min over a 24 h window.
-- **Phase 3 — APS delivery.** Schema crate + corpus (6.7); mediation
- algorithm + required `winner_selection` (6.1); render token (G2);
- renderer route + two-stage ack + CSP report route (6.6); bridge order
- (6.8); G4a–G4f state machines, awaitable renderer, scoped notifications,
- fallback; #922/#997 restoration; correlator + SRA fixes.
- _Gate (canary cohort vs simultaneous control cohort, 24 h, minimum 10,000
- sampled attempts each):_ APS `render_accepted` / attributable APS attempts
- ≥ 95%; renderer-document load rate ≥ 99%; runner failure+timeout ≤ 1%; GAM
- fill, p95 latency, and billing volume deltas within ±2% of control
- (billing measured against GAM/server-side reporting, not the beacon);
- real-GAM overlap test green. Action on breach: hold cutover.
-- **Phase 4 — Structure.** Full layering + boundary lint; transactional
- plugin lifecycle; adapters; full slot registry; full messaging migration;
- final namespace (7.4).
- _Gate:_ boundary lint zero exceptions; disposal-inventory leak tests
- green; pre-cutover page smoke on the final namespace; **behavioral-parity
- suite green across all four flows** (SSAT, client-Prebid, page-bids,
- direct `/auction`) comparing pre- and post-restructure event sequences on
- the reference page.
+ registry; sessions; install manifest; minimal messaging; G4a intent/cycle
+ records; unconditional GPT subscriptions.
+- **Phase 2 — Trace + beacon.** G1 issuance on all three paths (boot,
+ page-bids, `/auction` extension); §5.3 authorization incl. renewal and
+ the diagnostic credential; four-adapter ingest; `ts_client_events`
+ writers on. Gates split per DR-5: **HTTP parity** (all adapters:
+ routing, limits, 204s, method reservations) vs **persistence**
+ (sink-backed only: acceptance, dedup-exactly-once, heartbeat freshness).
+- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required
+ `winner_selection` and `[auction].currency`; render token + tombstones;
+ renderer route + three-message ack + CSP report route; §6.8; G4a–G4f
+ incl. direct-flow `nurl`/`burl` plumbing; fallback; DR-3's attribution
+ restoration; correlator + SRA fixes.
+ _Gate (sticky randomized canary/control cohorts, 24 h, ≥ 10,000 sampled
+ attempts each; missing telemetry counts as failure):_ **denominator =
+ all server-observed eligible APS wins**; per-stage rates gated
+ separately — targeting_set/eligible, bridge_request/targeting_set,
+ render_accepted/bridge_response_sent, and `cycle_unattributable` rate
+ < 0.5% (survivorship is thereby visible, not excluded); GAM fill and p95
+ latency as **one-sided non-inferiority** (canary not worse than control
+ by > 2%; improvements pass); billing normalized per attempt and per
+ thousand attempts vs control; a separate **duplicate-billing invariant**
+ (zero double `burl` per idempotency key); real-GAM overlap suite green
+ per its table row.
+- **Phase 4 — Structure.** Full layering + both lint rules; plugin
+ lifecycle; adapters; full slot registry; full messaging migration; final
+ namespace.
+ _Gate:_ lints zero exceptions; disposal-inventory leak tests; four-flow
+ behavioral parity (SSAT, client-Prebid, page-bids, direct).
- **Phase 5 — Decomposition + cutover.** File splits; script-guard
- consolidation; bootstrap stub + generated fallback; then the §0 runbook.
- _Gate:_ bundle budgets + timing assertions hold; cutover checklist signed
- off; post-switch monitor window 24 h on the §5.7 objectives; rollback =
- traffic switch back.
+ consolidation; bootstrap stub + generated fallback (with error/hang/
+ fallback-arbitration tests); **four-flow parity reruns here** (it changed
+ bootstrap behavior after Phase 4's parity — the review's ordering
+ point); then the §0 runbook: weight-up, purge, 24 h monitored window,
+ weight-back rollback.
## 9. Test acceptance matrix
-Hermetic CI (deterministic PUC/message harness) blocks PRs; the staged
-real-GAM suite is release-gating. Rows added this revision are marked •.
-
-| Area | Must cover |
-| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| Request cycles | intent-vs-request separation; • initial-load-disabled cycle formation (display creates no request); SRA batching; `intent_no_request`; publisher overlap → quarantine; • old-navigation completion before and after replacement; drain/re-arm; real-GAM overlap (gate) |
-| Ack protocol | five-field validation; SSAT/client-Prebid/SafeFrame; stale + replayed acks; • acks after navigation disposal |
-| Bridge security | • TS-reserved id rejected with propagation stopped — neither TS nor native Prebid responds; wrong-slot/stolen/prior-navigation tokens; bounded parent-chain walk; listener-order real-browser assertion; SafeFrame positive |
-| Render semantics | notifications only on carrying paths (never APS); bind per flow (PUC claim / direct render-start / fallback pre-render); `burl` at `render_accepted`; attempt-scoped idempotency; `billed_then_failed`; accepted-but-blank |
-| Direct `/auction` | • full G4f lifecycle: attempt keys, refresh_gen increments, cancellation on navigation, exactly-once terminal, same event sequence |
-| Fallback | only attributed `gam_empty` (adopted + TS-owned); publisher-initiated never; timeout never renders; SPA cancellation; • flag change during an active attempt |
-| Mediation | • candidate-id echo; • transformed/unresolvable provenance → slot fails closed for merging; • duplicate candidates dedup; • deterministic ties independent of arrival order; currency validation at the Prebid parse point; both lifecycles; required `winner_selection` |
-| Render token | format/CSPRNG/in-auction retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scoping; • registry capacity → `registry_full` |
-| Trace auth | • HMAC verification: expiry, skew, max-future, missing kid, rotation (previous key), constant-time path; per-group rejection; cache-privacy invariant (`private, no-store`) |
-| Beacon | initial + SPA trace joins; per-trace grouping; seq gaps; • duplicate fetch/pagehide delivery deduped at sink; queue overflow marker; ingest abuse; sendBeacon Blob type |
-| Ingest/limits | • token-bucket rate + burst; • limiter saturation and address churn; • map capacity/TTL/eviction; fail-closed drop with 204 |
-| Internal routes | • wrong-method → 405 + Allow + no-store on every adapter; • unknown version → 404 no-store; • no publisher fall-through; • dispatch before auth/EC/filters; • no body/cookie/authorization forwarding |
-| CSP | • both media types with separate validators; • opaque/null-origin renderer reports accepted; • bucketed aggregation only; • Chromium/Firefox/WebKit capture; • enforced-policy discovery vs canary-cohort relaxation |
-| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection |
-| Runtime ABI | one kernel under concatenation; exact-release verdicts (match runs, mismatch quarantines); late registration; failure isolation |
-| Plugins | • partial synchronous install unwound in reverse order; • async rejection; • abort while pending; • disposer-after-disposal invoked immediately; per-disposer isolation |
-| Lifecycle | `timed_out → present`; session disposal inventories; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`) |
-| Delivery | unknown hash 410 no-store; immutable on exact match; ordered-vector precompute; cutover runbook rehearsal (switch + purge + rollback switch) |
-| Sink | • datasource-side freshness + rejection monitoring (sink is fire-and-forget); • sink auth/schema rejection surfaced by monitor |
-| Failure injection | • Amazon runner redirect, network hang, CSP block, script error → distinct §5.1 outcomes |
-| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
-| Policy | script-creative warning; `invalid_dimensions` + bounded w/h; `dimensions_out_of_range` unclamped; page-bids `debug` gating; diagnostic completeness |
+Hermetic CI blocks PRs; the real-GAM suite is release-gating per its gates
+row. New/changed rows this revision are marked •.
+
+| Area | Must cover |
+| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Request cycles | intent-vs-request; • disabled-initial-load `display()` retired at issuance, publisher `refresh()` inside 2 s window attributed to publisher (the exact review case); SRA; `intent_no_request`; overlap quarantine; • no timeout re-arm (drain/destroy/page-end only); stale discard; real-GAM overlap |
+| Ack protocol | three-message sequence (• `renderer_document_loaded` envelope); five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed; after-disposal acks |
+| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; • prior-navigation ids suppressed via RuntimeSession tombstones after NavigationSession disposal; bounded walk; listener order |
+| Render semantics | binds per flow; `burl` at accepted; attempt idempotency; • `billing_outcome{billed_then_failed}` as its own event; accepted-but-blank; • `gam_collapsed` emission + guarded resize (authenticated source only; 1×1 wrapper; anchor/fixed guards) |
+| Direct `/auction` | • trace header + response `ext.trusted_server.trace`; • per-slot latest-wins with reversed responses; • generation check before each DOM/beacon effect; • `RequestAdsResult` settlement; • server preserves + expands `nurl`/`burl`, client validates |
+| Fallback | attributed `gam_empty` only; publisher-initiated never; timeout never renders; SPA cancellation; flag change mid-attempt |
+| Mediation | • `[auction].currency` required + per-provider validation (Prebid parse, APS implied USD); • `ext.trusted_server.candidate_id` echo; • mediator-native candidates ordered by intrinsic key (arrival-order shuffle test); • authoritative-field rules (repricing kept, adm-swap → native); provenance fail-closed; • strategy-specific timeouts; both lifecycles |
+| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; capacity → `registry_full`; • tombstone retention to original TTL, cap 256 |
+| Trace auth | • auth ≤ 256 B bound accepted, 64-char cap for others; • encoding vectors (kid charset, canonical exp, u32be/u64be length prefixes, unpadded base64url); expiry/skew/max-future; • renewal preserves trace + mode; • previous-key retention ≥ 24 h; rotation; per-group rejection |
+| Sampling modes | • signed `unsampled`: client transmits nothing, ingest rejects carried events, stickiness across renewal; • diagnostic requires the operator credential — tester cookie alone must fail; forgery/replay of the credential |
+| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; • `client_queue_overflow` not in failure denominators; ingest abuse; sendBeacon Blob |
+| Ingest/limits | • per-adapter limiter semantics as declared (Fastly fixed-window approximation, Axum token bucket); • at-capacity rejects unseen identities, never evicts active; fail-closed 204 |
+| Internal routes | wrong-method 405 + Allow + no-store on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding |
+| CSP | both media types; opaque/null-origin admission; • policy identity from server-selected report path (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; • header manifest per version (immutable headers frozen with bytes) |
+| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX |
+| Runtime ABI | one kernel; exact-release verdicts; late registration; failure isolation; • object-form `definePlugin` release check |
+| Plugins | partial-install unwind; async rejection; abort pending; disposer-after-disposal; isolation |
+| Lifecycle | `timed_out → present`; session inventories; • unissued intents cancelled by navigation disposal; boot container idempotent field-wise init (• ad-slot script no longer clobbers); • fallback error/hang activation + late-bundle deferral; final-namespace smoke |
+| Delivery | unknown hash 410 no-store; exact-match immutable with full directive; • construction-time vector cache (unlisted vector = startup error); cutover rehearsal |
+| Sink/monitoring | • sequence-tagged synthetic heartbeats measure rejection + freshness; • canonical views only (raw-join multiplication test); `publisher_domain` naming |
+| Failure injection | Amazon runner redirect/hang/CSP/script error → distinct outcomes; EC/filter failure before renderer dispatch |
+| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
+| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; • `boot.debug` + response `debug` gating; diagnostic completeness per §2.5 |
## 10. Alternatives considered
-1. **Patching APS point-failures without telemetry** — rejected: three
- correct fixes produced no ads; the next fix would be another guess.
-2. **Always direct-render APS (skip GAM/PUC)** — rejected: unilaterally
- changes GAM reporting/pacing; kept only as the attributed-`gam_empty`
+1. Patching APS point-failures without telemetry — rejected (four correct
+ fixes, still no reliable ads).
+2. Always direct-render APS — rejected; kept as the attributed-`gam_empty`
fallback.
-3. **Single module graph / shared chunks now** — rejected for this release:
- changes the delivery pipeline while everything else changes; recorded as
- the successor behind the same registry surface.
-4. **Full rewrite in one branch without phases** — rejected: the browser-spec
- safety net is thinnest exactly where behavior changes.
-5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle
- guarantee; generation from TS keeps it without dual maintenance.
-6. **Timeout-triggered fallback** — rejected: GPT requests cannot be
- cancelled; a timeout race can double-render and double-bill.
-7. **N/N−1 compatibility machinery** (revisions 3–4) — removed by the §0
- policy decision: version ranges, retained artifacts, legacy URLs, and
- dual-name globals deleted in favor of exact release matching and a
- coordinated switch.
+3. Single module graph now — rejected for this release; successor option.
+4. Big-bang rewrite without phases — rejected (thin safety net).
+5. Dropping the ES5 bootstrap — rejected (loses the no-bundle guarantee);
+ generated fallback keeps it.
+6. Timeout-triggered fallback rendering — rejected (uncancelable GPT
+ requests race late fills).
+7. Timeout-based quarantine re-arm (revision 5) — removed: it recreated
+ the stale-event bug it claimed to fix.
+8. N/N−1 compatibility machinery (revisions 3–4) — removed by the §0
+ policy.
## 11. Risks
-- **Hard cutover blast radius:** in-flight pages fail at switch; accepted by
- policy (§0); bounded by the purge + 24 h monitored window + traffic-switch
- rollback.
-- **Mediator wire-contract change** (candidate-id echo) requires
- coordinating the mediator implementation; until echoed ids exist,
- `merge_highest_cpm` cannot be enabled (config validation enforces this).
-- **Notification triggers become a published contract** for PBS-path demand;
- changing them later is a breaking change for SSP reporting.
-- **Beacon abuse:** bounded by pre-parse caps, origin checks, fail-closed
- numeric rate limits, server-decided sampling, signed authorizations.
-- **Registry/limiter memory:** all client and server maps carry explicit
- capacities, TTLs, and eviction rules (G2, §5.4).
-- **CSP relaxation:** enforced-cohort canary + new immutable version prevent
- false-clean canaries; bucketed aggregation prevents cardinality abuse.
-- **Sink blindness:** fire-and-forget dispatch is compensated by mandatory
- datasource-side freshness/rejection monitoring.
+- Hard-cutover blast radius (accepted; bounded by the §0 runbook).
+- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates
+ `merge_highest_cpm`.
+- Notification triggers become a published contract for PBS-path demand.
+- Beacon abuse — capped, origin-checked, fail-closed limited, signed
+ modes, credentialed diagnostics.
+- Registry/limiter memory — explicit capacities, TTLs, reject-at-capacity.
+- CSP data is advisory — never a sole rollback signal.
+- Sink blindness — heartbeat-based datasource monitoring.
+- `[auction].currency` and `winner_selection` are new required config in
+ mediated deployments — a deliberate startup-error class under §0.
## 12. Success criteria
-1. APS creatives render in each configured flow — SSAT, client-Prebid,
- page-bids, and direct `/auction` (G4f) — hermetically in CI and in the
- release-gating real-GAM suite.
-2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the
- failing class from one page load; §5.7 objectives hold on sink-backed
+1. APS creatives render in each configured flow (SSAT, client-Prebid,
+ page-bids, direct), hermetically and in the release-gating real-GAM
+ suite.
+2. Every §2 failure maps to its §2.5 signal; **diagnostic mode names the
+ failing class from one page load including A1–A4 via the `boot.debug` /
+ response-`debug` envelopes**; §5.7 SLIs hold on sink-backed
deployments.
-3. Boundary lint zero exceptions; stateful sharing only via the G3 registry;
- exact-release mismatches quarantine loudly.
+3. Lints (both rules) zero exceptions; stateful sharing only via the
+ registry; exact-release mismatches quarantine loudly.
4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
generated.
-5. Trace counts are per-impression; orphan recovery has a non-vacuous test;
- cycle attribution follows G4a (physical requests, publisher-overlap
- quarantine, drain/re-arm).
-6. The only TSJS-owned global is `window.tsjs` with the §7.4 final shape; no
- expandos on GPT slots, GPT functions, or `pbjs`; legacy names are gone at
- cutover.
-7. §7.10 budgets hold (three vectors, pinned tools, one-sided server gates).
-8. No existing warning is lost; every issue-surfacing condition logs `warn`+
- with the beacon's reason code.
-9. TypeScript floor matches the resolved 5.9 line with strictness flags on;
- `prebid.js` pin matches the documented deployed bundle.
-10. `nurl`/`burl` fire only on carrying paths at their G4d binds,
- attempt-scoped and idempotent; APS fires neither.
-11. Trace-bearing responses are `private, no-store` by test; authorizations
- are per-trace, signed, mode-carrying, and never accepted unsigned.
-12. The cutover runbook has been rehearsed (switch, purge, rollback switch)
- before the production switch.
+5. Attempt counts are keyed `(trace_id, nav_gen, refresh_gen, slot)`
+ (traces stay navigation-scoped); no double counting; orphan recovery
+ has a non-vacuous test; G4a holds including the no-timeout-re-arm rule.
+6. The only TSJS-owned global is `window.tsjs` (§7.4 final shape); no
+ expandos; legacy names gone at cutover.
+7. §7.10 budgets hold on the dedicated pinned workflow.
+8. No existing warning lost; issue-surfacing conditions log `warn`+ with
+ the beacon reason code.
+9. TypeScript floor matches resolved 5.9; `prebid.js` pin documented with
+ the deployed bundle.
+10. `nurl`/`burl` only on carrying paths at their G4d binds, idempotent
+ per attempt; APS fires neither; duplicate-billing invariant holds.
+11. Trace-bearing responses are `private, no-store`; authorizations are
+ per-trace, signed, mode-carrying (`sampled|unsampled|diagnostic`),
+ renewal-capable; unsampled traces transmit nothing.
+12. The cutover runbook rehearsed (weight switch, purge, rollback).
## 13. Open questions
-1. Is a mediator configured in the affected production deployment?
-2. What share of live APS demand is `tagtype: "script"`?
-3. Should `client_render_fallback` ever become default-on for publishers
- without GAM line items for `hb_bidder=aps`?
-4. Is PR #997 the intended restoration of the lost #922 attribution core, or
- should the original be re-merged?
-5. Do Axum/Cloudflare/Spin get real client-event sinks, or keep
- accept-count-drop?
-6. Does Amazon expose any creative-completion acknowledgement that could add
- a confirmed state beyond `render_accepted` under a new name?
-7. Who implements and owns the mediator-side candidate-id echo (6.1), and on
- what timeline relative to this release?
+Promoted to Phase 0 decision records: mediator presence (DR-1),
+script-creative share (DR-2), #922 vs #997 (DR-3), candidate-id echo owner
+(DR-4), non-Fastly sinks (DR-5). Remaining open: does Amazon expose any
+creative-completion acknowledgement that could add a post-`render_accepted`
+state under a new name (future enhancement)?
From 517471c9d38cda0da1ea97606e8faa41801a00c7 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:27:56 -0700
Subject: [PATCH 008/194] Revise design spec for the sixth review round
Revision 7: renewal presents and verifies the current signed
authorization so mode survives without server state, sampling becomes
a deterministic keyed function, the diagnostic credential gets a
complete issuance/transport/expiry protocol with the tester cookie
scoped to debug content only, /_ts/trace-auth joins the isolated
route families, sticky-cohort release affinity makes canary requests
coherent end to end, the server auction_id is echoed into client
events for attempt-grain joins with release_id as the cohort
discriminator, a closed flow field drives per-flow funnel gates, the
reservation store caps the live-plus-tombstone union and never evicts
unexpired ids, zero-request display intents are retired for any
caller with a symmetric ambiguity rule, the acknowledgement protocol
is specified for all four render paths, bootstrap ownership becomes
transactional with inert-install and commit, CSP ingest gets concrete
bounds and physical schemas alongside ts_ops_counters, heartbeats
become a first-class probe-mode event, telemetry settings split
collection from sink with lazy secret initialization everywhere,
mediation gains required-unique upstream ids with deterministic
fingerprints and closes its authoritative-field contradictions,
assets pre-materialize at release publication, notification_sent
makes the duplicate-burl invariant observable, one terminal state per
attempt, per-reason source nullability, gam_collapsed carries its
action, the final surface adds definePlugin, lint moves to
member-expression rules, performance gains marks/vectors/heap
budgets, TypeScript flags are enumerated, mid-attempt kill-switch
semantics are defined, DR-2 becomes a deployment decision, the
Phase-3 gate reruns on the immutable Phase-5 candidate, and the
normative gates table ships as Appendix A of this one file. The
baseline APS fixes are adopted as contracts and re-implemented in the
target architecture with their browser tests as the conformance pin.
---
...s-render-fix-and-tsjs-resilience-design.md | 1595 ++++++++---------
1 file changed, 706 insertions(+), 889 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 0251485b9..001011d91 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,994 +1,811 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 6 — baseline advanced to the APS PUC/collapsed-shell
- fix, and reworked after the fifth review round.
+- **Status:** revision 7 — reworked after the sixth review round.
- **Date:** 2026-08-04
- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed
- GAM shells") — the full merged state. All file:line citations refer to this
- commit.
-- **Inputs:** three code audits; design reviews of revisions 1–5; open issues
+ GAM shells"). All file:line citations refer to this commit.
+- **Inputs:** three code audits; design reviews of revisions 1–6; open issues
#926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997.
+- **Normative gates:** the initial rollout-gates table ships as
+ **Appendix A of this document** (one file, one review surface); changes to
+ it require reviewed decision records so thresholds cannot be chosen after
+ observing results.
+- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design
+ adopts their **contracts** — the MessageChannel handshake semantics, the
+ collapsed-shell remediation behavior, the consolidated bridge branch — and
+ **re-implements them inside the target architecture** (the messaging
+ module owns the channel protocol, the render engine owns the resize, the
+ rebuilt `render_bridge` module owns the branch). The patch code itself is
+ not carried forward through the refactor; the baseline's browser tests are
+ retained as the conformance suite that pins the adopted behavior while the
+ implementation is replaced.
## 0. Release policy: coordinated hard cutover
-This design targets a **single coordinated release**:
+One coordinated release:
-- Server, TSJS bundles, config format, and page HTML ship together under one
- **`release_id`** (git tag / build hash). **No N/N−1 support**: old pages,
- bundles, config blobs, globals, and URLs may stop working at cutover;
- in-flight clients may fail. Accepted and stated, not mitigated.
-- **Exact release matching only** — kernel, services, plugins, and the
- install manifest carry the same `release_id`; mismatch is a refusal.
-- **Config:** top-level `format_version`, exact match required. Rollback =
+- Server, TSJS bundles, config, and HTML ship under one **`release_id`**.
+ **No N/N−1**; in-flight clients may fail at cutover — accepted and stated.
+- **Exact release matching**; config `format_version` exact-match; rollback =
redeploy the previous release with its own config.
-- **Assets:** binaries embed only their release's artifacts; hashed pathnames
- exist for cache identity only; unknown hash → `410 Gone`, `no-store`.
-- **One executable rollout state machine** (resolving the §0/§8 tension the
- review found): a release ships with a **deployment manifest** enumerating
- the complete flag set; the new pool comes up **fully enabled but
- unreachable except by probes**; phase gates (§8) run against probe traffic
- and a **router-weight canary of coherent routed requests** (a request is
- served end-to-end by one pool — HTML, assets, and APIs never mix pools);
- **router weight is the sole activation primitive**; cutover = weight to
- 100% + CDN purge; rollback = weight back + re-purge. Flags exist for
- emergency kill switches inside a pool, not as the activation mechanism.
+- **Config is a release-time input** under this policy: the config blob and
+ binary publish together, so the enabled module vectors are known at
+ release publication (this powers §G5 asset materialization).
+- Assets: embedded only; hashed pathnames for cache identity; unknown hash
+ → `410`, `no-store`.
+- **Rollout state machine with release affinity.** A deployment manifest
+ binds each pool to immutable `{release_id, config_store, config_key,
+config_hash}` (rollback binding prevalidated). The new pool comes up fully
+ enabled, reachable only by probes. Canarying uses a **sticky cohort
+ token**: the router assigns `ts-rel=` on the HTML response,
+ routes every subsequent request (assets, APIs, beacons) by it, and cache
+ keys include it — router weights alone apply per request and would mix
+ pools, so affinity is what makes a canary request **coherent** end to end.
+ Router weight over sticky cohorts is the sole activation primitive; flags
+ are in-pool emergency kill switches only. Cutover = weight 100% + CDN
+ purge; rollback = weight back + re-purge.
## 1. Problem statement
-APS demand is fully integrated server-side — the edge runs the APS OpenRTB
-auction, wins bids, and ships a typed renderer descriptor to the page — yet
-APS creatives do not appear reliably for real users. Four serial fixes (the
-`bid.meta` carrier, the decoupled prebid shim, the `hb_adid` fallback, and
-now the baseline's PUC/collapsed-shell fix) each survived review; the pattern
-is the finding: the pipeline has **multiple independent failure points, most
-of which fail silently**, and the client cannot tell the server which fired.
-
+APS demand is fully integrated server-side, yet APS creatives do not appear
+reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, the
+`hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived
+review; the pattern is the finding: **multiple independent failure points,
+most failing silently**, with no client→server signal about which fired.
The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths,
-duplicated ES5/TS logic, inverted layering, ~100 error-swallowing `catch`
-blocks) is the same problem structurally. This design fixes APS delivery and
-rebuilds TSJS so the next integration cannot reproduce this failure class.
+duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches)
+is the same problem structurally.
### Non-goals
-- No change to the APS OpenRTB endpoint contract or Amazon-side configuration
- (including its deliberate absence of `nurl`/`burl`, §G4d).
+- No change to the APS OpenRTB endpoint contract (including its deliberate
+ absence of `nurl`/`burl`, §G4d).
- No rewrite of the decoupled Prebid.js strategy.
-- **No backward compatibility** (§0); replacement surfaces are in §7.4.
+- No backward compatibility (§0); replacement surfaces in §7.4.
## 2. Why APS does not render — evidence
Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer`
-Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`
-`tsjs.requestAds`. Only (d) — unused in production — renders an APS
-descriptor without GAM.
+adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) renders
+an APS descriptor without GAM.
### 2.1 Admission
-| # | Failure | Where |
-| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
-| A1 | A configured `[auction].mediator` discards every direct-provider bid; winners come only from the mediator response. APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` |
-| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; the drop is counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` |
-| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` |
-| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and the `ts-debug` allowlist exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
+| # | Failure | Where |
+| --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
+| A1 | A configured `[auction].mediator` discards every direct-provider bid; APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` |
+| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` |
+| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` |
+| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and `ts-debug` exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
### 2.2 Identity
-| # | Failure | Where |
-| --- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
-| B1 | GAM caps key-value values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge equality check with no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` |
-| B2 | Two id universes: SSAT keys on the APS bid id, the client adapter on Prebid's generated `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` |
+| # | Failure | Where |
+| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- |
+| B1 | GAM caps key-values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge match, no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` |
+| B2 | Two id universes: SSAT keys on the APS bid id; the client adapter on Prebid's `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` |
### 2.3 Render
-| # | Failure | Where |
-| --- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
-| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` is reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` |
-| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` |
-| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` |
-| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` |
-| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated; the served-through-APS-renderer log is reachable. | `gpt/index.ts:1729` |
-| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` |
-| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` |
+| # | Failure | Where |
+| --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` |
+| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` |
+| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` |
+| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` |
+| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` |
+| C6 | The renderer CSP can kill creatives after "ready". | `aps.rs:49` |
+| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` |
### 2.4 Observability
-Zero client→server reporting. Server telemetry marks `is_win=1` at auction
-time; a bid that never painted is byte-identical to one that painted.
+Zero client→server reporting; a bid that never painted is byte-identical
+server-side to one that painted.
### 2.5 Failure → signal mapping (normative)
-Each failure maps to a distinct observable **failure class** (not a claim to
-distinguish unknowable root causes). The operator query for each row is the
-§5.6 canonical view filtered by that row's event/reason or counter. So that
-**diagnostic mode really can name every class from one page load** — the
-review's objection that A1–A4 are server-side-only — the tester gate also
-delivers a **`tsjs.boot.debug` envelope** in the initial HTML (selection
-summary + drop summary for the initial auction) and the equivalent gated
-`ext.trusted_server.debug` field on page-bids and `/auction` responses;
-diagnostic mode mirrors these to the console:
-
-| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
-| ------- | -------------------------------------------- | ------------------------------------- | ------------------------------- |
-| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary |
-| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
-| A3 | — | `bid_drop{invalid_dimensions, w, h}` | `boot.debug` drop summary |
-| A4 | — (fixed by §5.6 itself) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
-| B1/B2 | `bridge_request{matched: false}` | join via trace | console warn |
-| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
-| C2 | `render_fail{renderer_document_no_load}` | renderer route counters | console warn |
-| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn |
-| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn |
-| C5 | — (fixed at baseline) | — | — |
-| C6 | `runner_failed` + CSP buckets | CSP aggregate counters | console warn |
-| C7 | renderer branch emits the full §5.1 sequence | join via trace | debug/warn |
-
-## 3. The GPT and baseline reality this design must respect
-
-1. Bootstrap-first hybrid: server-injected ES5 `gpt_bootstrap.js` wins the
- sentinel race; the bundle's handoff/initial-load code is dead in
+Two distinct gates, precisely separated: the **tester cookie** (explicitly
+non-security, `tester_cookie.rs:3`) gates **debug content** — the
+`tsjs.boot.debug` envelope and the page-bids/`/auction`
+`ext.trusted_server.debug` fields, the same sensitivity class as the
+existing tester-gated `ts-debug` HTML comment. The **diagnostic credential**
+(§5.3) gates **telemetry volume** (unsampled mode). The tester cookie never
+affects sampling; the credential never gates mere content.
+
+| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
+| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- |
+| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary |
+| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
+| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary |
+| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
+| B1/B2 | `bridge_request{matched:false}` | join via trace | console warn |
+| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
+| C2 | `render_fail{renderer_document_no_load}` | route counters (`ts_ops_counters`) | console warn |
+| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn |
+| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn |
+| C5 | — (fixed at baseline) | — | — |
+| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn |
+| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn |
+
+## 3. The GPT and baseline reality
+
+1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead in
production.
-2. The #922 merge loss: orphan recovery and `updateRender` are gone
- (`0dc9b19a9`); bridge impressions double-count. PR #997 is the apparent
- replacement.
+2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement.
3. TS refreshes never pass `changeCorrelator: false`.
-4. `enableSingleRequest()` is called blind after publisher
- `enableServices()`.
+4. `enableSingleRequest()` called blind after publisher `enableServices()`.
5. Responsive-resolution ambiguity silently skips slots.
-6. Three independent `pubads().refresh` wrappers coordinate via
- window-global booleans.
-7. **GPT has no request cancellation, no documented per-refresh identity, no
- overlapping-completion order.** `slotRenderEnded` means creative code was
- injected, not that resources loaded. `responseIdentifier` identifies the
- ad **response** — usable for response dedup/drain, never initiation
- attribution.
-8. **With initial load disabled, `display()` creates no request** — the
- subsequent `refresh()` does (`gpt/index.ts:1175`,
- `ad_init.test.ts:1201-1263`). Cycle protocols must model physical
- requests.
-9. The bundle's `slotRenderEnded` registration is gated behind
- `!ts.servicesEnabled` (`gpt/index.ts:1091`); G4a needs unconditional
- early subscription.
-10. **The baseline includes the fourth serial APS fix** (`248fe9558`): (a)
- the renderer handshake moved to a **MessageChannel** on the PUC path —
- port-transferred nonce message, descriptor over the port (no wildcard
- broadcast on that path), exact-key replies, `ports.length` checks, a
- one-shot `accepted` latch, port closing (`aps.rs:65-125`,
- `aps/render.ts:415-437`) — but the reply still terminates inside the PUC
- creative frame, so G4b's kernel-observability gap remains; (b) a
- nonempty GAM render can be a **collapsed 1×1 shell**, remediated by
- `resizeCollapsedCreativeFrame` (`gpt/index.ts:217`) — a guarded style
- mutation of GAM-owned elements; (c) the dead renderer branch was
- consolidated (C5); (d) a real-PUC-topology browser test now exists.
-11. The bridge already keeps **consumed-id tombstones** for security
- (`gpt/index.ts:1527`) — G2's registry rules must preserve that property
- across navigations.
-12. The current tester cookie is **explicitly not a security control**
- (`tester_cookie.rs:3`) — it cannot gate unsampled telemetry (§5.3).
+6. Three independent `pubads().refresh` wrappers.
+7. **GPT has no cancellation, no per-refresh identity, no completion-order
+ guarantee**; `slotRenderEnded` = code injected, not resources loaded;
+ `responseIdentifier` identifies responses only.
+8. **`display()` under disabled initial load creates no request — for any
+ caller.** GPT's behavior is caller-independent (`gpt/index.ts:1175`,
+ `ad_init.test.ts:1201-1263`); the G4a rules therefore apply to publisher
+ `display()` exactly as to TS `display()`.
+9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled`
+ (`gpt/index.ts:1091`); G4a needs unconditional early subscription.
+10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake
+ (`aps.rs:65-125`, `aps/render.ts:415-437`; reply still terminates inside
+ the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5
+ consolidated, real-PUC browser test added.
+11. The bridge keeps consumed-id tombstones for security
+ (`gpt/index.ts:1527`); G2 preserves that across navigations.
+12. The tester cookie is not a security control (`tester_cookie.rs:3`).
+13. **Fastly constructs application state per request** (`app.rs:146`) — no
+ cross-request in-memory state may be assumed on that adapter.
## 4. Design gates
-### G1 — Trace identity and correlation
-
-The client-visible auction id is EC-derived (`publisher.rs:3237`) and never
-ingested. Initial-HTML auction telemetry is emitted before page JS exists
-(`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by
-whoever acts first:
-
-- **Initial navigation (`nav_gen 0`):** the server mints `trace_id` (128-bit
- CSPRNG, `^[0-9a-f]{32}$`), writes it into that response's auction rows,
- and injects it into `tsjs.boot` with the signed authorization (§5.3).
-- **Cache-privacy invariant:** traces/authorizations are injected only into
- responses that ran a per-request auction; such HTML is
- `Cache-Control: private, no-store`, no validators. By construction + test.
-- **SPA navigations:** `/_ts/page-bids` stays GET; the client mints
- `trace_id`, sends it in `X-TSJS-Trace-Id`; the server records it and the
- response echoes the trace + its authorization.
-- **Direct `/auction` (closing the G4f gap):** the client sends the same
- `X-TSJS-Trace-Id` header on the POST (`core/auction.ts:190` gains it); the
- server validates, stamps that auction's rows, and echoes
- `ext.trusted_server.trace = {trace_id, auth}` in the OpenRTB response —
- covering pages whose initial HTML ran no auction.
-- **Envelope:** every event carries `{nav_gen, refresh_gen, seq}` inside a
- per-trace group `{trace_id, auth, events[]}`. `seq` is per-trace
- monotonic. Gaps (loss) and duplicates (fetch/pagehide races) are expected;
- §5.5 dedups.
-- **Sampling is server-decided for every trace** and carried inside the
- signed authorization (§5.3). Traces are navigation-scoped; **impression /
- attempt counts are keyed by `(trace_id, nav_gen, refresh_gen, slot)`**
- (success criterion 5 uses this key, not "per-impression traces").
+### G1 — Trace identity, sampling, and correlation
+
+- Client-visible auction id (EC-derived, `publisher.rs:3237`) is never
+ ingested. Initial-HTML telemetry precedes page JS, so correlation is
+ minted by whoever acts first: server for `nav_gen 0` (trace + signed
+ authorization in `tsjs.boot`); client afterwards, via `X-TSJS-Trace-Id`
+ on page-bids (GET) and on the `/auction` POST, echoed back with the
+ signed authorization (`ext.trusted_server.trace = {trace_id, auth,
+auction_id}` on JSON/OpenRTB responses).
+- **Sampling is a deterministic keyed function**, not a coin flip:
+ `mode = sampled iff HMAC(sampling_key, trace_id) < sample_rate` — so
+ concurrent requests presenting the same trace always derive the same
+ mode, and first issuance needs no shared state (Fastly is per-request,
+ §3.13).
+- **Cross-tier join key (closing the row-multiplication gap):** the server
+ echoes its telemetry `auction_id` to the client (boot / response
+ extensions); the client stamps `auction_id` on every event of attempts
+ born from that auction. Server rows additionally carry `release_id`.
+ Canonical joins run on `(publisher_domain, trace_id, auction_id, slot,
+refresh_gen)` at attempt grain; canary/control comparison uses the
+ server-side `release_id`.
+- Cache-privacy invariant: traces/authorizations only in per-request
+ auction-bearing responses; such HTML is `private, no-store`.
+- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry
+ `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow` is a closed
+ field** `ssat | prebid | page_bids | direct | fallback` set by the
+ attempt owner — the per-flow funnels in the gates table depend on it.
+- Traces are navigation-scoped; attempt counts key on
+ `(trace_id, nav_gen, refresh_gen, slot)`.
### G2 — Render identity
-- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte
- (`publisher.rs:3355`; PUC fetches `?uuid=`, `gpt/index.ts:1772`). Markup
- bids: today's fallback chain.
-- **Renderer-only bids:** `hb_adid` = server-minted token `^[a-z0-9]{12}$`,
- CSPRNG, collision-retried within the minting auction; cross-auction
- uniqueness probabilistic (36¹²; negligible) and harmless via scoping.
-- **Registry:** keyed `(trace_id, nav_gen, refresh_gen)`; capacity 64 live
- entries per navigation (`registry_full` on refusal); TTL 15 min; one-time
- consumption. **Navigation disposal does not erase security state**
- (review's tombstone finding): consumed, stale, and disposed ids move into
- a bounded **RuntimeSession tombstone set** (cap 256, FIFO, entries retained
- until their original TTL) — the bridge's TS-reserved check consults live
- registry **and** tombstones, so a late prior-navigation request is still
- suppressed and refused, never released to native Prebid. This preserves
- the baseline's existing consumed-id tombstone behavior
- (`gpt/index.ts:1527`).
-- The client-Prebid path keeps Prebid's `adId`; both paths share the one
- registry. Non-APS cache-path regression tests.
-
-### G3 — Runtime ABI under the IIFE build (exact-release)
-
-IIFE-per-bundle with inlined imports (`build-all.mjs:46`, `bundle.rs:23`)
-means imports never share state across bundles (live defect:
-`core/context.ts:11` vs `permutive/index.ts:102`).
-
-- Kernel ships only in `tsjs-core`; publishes
- `tsjs._internal = { release_id, registry }` once; freezes after boot;
- constructs and registers core services during boot.
-- **Exact release matching:** every registration carries `release_id`
- (plugins via the object-form API, §7.6, whose `release` field is a
- build-generated constant); `registry.get(name)` succeeds only on equality;
- mismatch quarantines with `abi_mismatch` / `bundle_partial` and a console
- error.
-- Stateful access only via the registry at call time; stateless helpers may
- inline. Boundary enforcement is **two lint rules**: `import/no-restricted-
-paths` for layering **and** `no-restricted-globals` forbidding
- `window.googletag` / `window.pbjs` outside `adapters/` (import paths alone
- cannot enforce §7.1).
+- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte. Markup bids:
+ existing fallback chain. Renderer-only bids: server-minted token
+ `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min,
+ one-time consumption.
+- **Registry + tombstones, one capacity, no unexpired eviction:** the
+ bridge-reservation store holds live registrations and tombstones
+ (consumed / stale / navigation-disposed ids) in one bounded structure —
+ **capacity 320 for the union**; expired entries are pruned; **unexpired
+ entries are never evicted**; when the union is at capacity, **new
+ registration is refused** with `registry_full`. A late prior-navigation
+ bridge request therefore always meets suppression until its id's original
+ TTL passes (preserving `gpt/index.ts:1527`). Test: >320 registrations,
+ then a late request for the oldest unexpired id.
+- Client-Prebid keeps Prebid's `adId`; one store serves both paths.
+
+### G3 — Runtime ABI (exact-release)
+
+- Kernel only in `tsjs-core`; publishes
+ `tsjs._internal = {release_id, registry}`; frozen after boot; core
+ services constructed at boot; plugins register via the object-form API
+ (§7.6) whose `release` is a build-generated constant; `registry.get`
+ succeeds only on `release_id` equality; mismatch quarantines
+ (`abi_mismatch`/`bundle_partial`) with a console error.
+- Boundary enforcement: `import/no-restricted-paths` for layering plus
+ **`no-restricted-properties`/`no-restricted-syntax`** rules covering
+ `window`, `globalThis`, `self`, and local aliases for `googletag`/`pbjs`
+ access outside `adapters/` (`no-restricted-globals` cannot catch member
+ expressions). Adapters are the only access to **external ad-tech
+ globals**; the kernel and messaging module necessarily touch
+ `window.tsjs`, listeners, and `postMessage`.
### G4 — Render lifecycle
-**G4a — Physical request-cycle protocol.**
-
-- **Intents, both classes, one causal queue.** Every observable initiation —
- TS `display()`/`refresh()` and wrapped publisher entries — records an
- intent in causal order, classified `ts | publisher`. A TS `display()`
- issued while initial load is disabled is **known at call time to produce
- no request** and is retired immediately as bookkeeping (it never enters
- the matcher) — closing the review's misattribution case where a publisher
- `refresh()` inside the 2 s window would have been consumed by a stale TS
- intent. TS intents that _may_ produce no request only in hindsight
- (`refresh()` on a never-displayed adopted slot) expire at 2 s with
- `intent_no_request`; **if any publisher intent is recorded for the slot
- while such a TS intent is pending, the next `slotRequested` is ambiguous
- and the slot quarantines** — a zero-request TS intent can never silently
- win FIFO matching. (Exact test in §9.)
-- **Cycles:** opened only by `slotRequested`, matched to the head of the
- causal intent queue; SRA batching yields one `slotRequested` per slot per
- batch. A cycle closes on its `slotRenderEnded`; `responseIdentifier`
- deduplicates responses during drain.
-- **Serialization:** at most one outstanding TS cycle per slot; one queued
- TS replacement (later intents coalesce).
-- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS
- cycle is outstanding and no publisher/untracked request overlaps.
- Overlap → quarantine (`cycle_unattributable`, fail closed).
-- **Drain/re-arm (no timeout re-arm).** Physical cycle and drain state live
- in the RuntimeSession slot record; **unissued intents are
- NavigationSession children** and are cancelled by navigation disposal. A
- quarantined or stale slot re-arms only on: count-based drain (every
- outstanding request/render pair matched), safe TS-owned slot destruction
- and redefinition, or page end. **A timeout emits a diagnostic and never
- restores attribution** — the 60 s bound from revision 5 is removed
- because an old `slotRenderEnded` arriving after re-arm would be
- indistinguishable from a new cycle. Late stale events are matched and
- discarded (`stale_navigation`).
-- CI exercises the protocol on the deterministic harness; a release-gating
- **real-GAM overlap test** (publisher refresh racing a TS cycle;
- initial-load-disabled formation) validates it against actual GPT.
-
-**G4b — Acknowledgement protocol (on the baseline port transport).** Since
-`248fe9558` the frame pair speaks MessageChannel (parent-postMessage with
-`ports.length === 0`, or transferred port with `ports.length === 1`;
-exact-key replies; one-shot `accepted` latch; port closed after reply —
-`aps.rs:65-125`, `aps/render.ts:415-437`). Adopted as the contract of record
-within the frame pair. The kernel-observability gap remains (the PUC-flow
-reply resolves inside the creative frame; callbacks fire on send,
-`gpt/index.ts:1632-1760`). Contract — **three authenticated messages per
-attempt**, each carrying the per-attempt 128-bit CSPRNG nonce from the
-bridge response:
-
-1. `renderer_document_loaded` — posted to the top window after the document
- validates the descriptor and nonce (this is §6.6's first stage, which
- revision 5's two-message protocol omitted);
-2. the port reply to its frame-pair peer (baseline behavior, unchanged);
-3. `render_accepted` / `render_failed{reason}` — posted to the top window.
-
-The kernel validates, in order: source ownership (§6.8 walk), nonce, token,
-`nav_gen`, `refresh_gen` — before any state transition or notification. The
-one-shot latch + port close mean a re-render is a fresh document instance
-with a fresh nonce. Pinned for SSAT, client-Prebid, and nested SafeFrame,
-including stale/replayed acks and acks after navigation disposal.
-
-**G4c — Honest observations.** Inline-adm frames are sandboxed `srcdoc`
-without `allow-same-origin` (`gpt/index.ts:510`) — opaque; geometry proves
-nothing (the shell dimensions are assigned by our own code). Observations:
-`gam_nonempty`, `gam_empty`, **`gam_collapsed`** (nonempty render whose
-shell computes ≤ 1px — the baseline's discovery), `renderer_document_loaded`,
-`runner_loaded`, `runner_failed`, `adm_document_loaded`. Every path
-terminates at `render_accepted`; **no observation claims paint**; there is
-no `render_confirmed`. The baseline's `resizeCollapsedCreativeFrame`
-(`gpt/index.ts:217`) is adopted as a **sanctioned, guarded exception** to
-the no-foreign-DOM-mutation rule (authenticated source frame only; wrapper
-only when both dimensions ≤ 1px; anchor-ad `ins[data-anchor-status]` and
-fixed/sticky guards) and emits `gam_collapsed` when it acts.
-
-**G4d — Win/billing notifications.** APS carries neither `nurl` nor `burl`
-by design (`aps.rs:839`; the AAX envelope excludes them; the integration
-guide documents no generic APS beacons) — APS billing lives in the Amazon
-runner lifecycle, unchanged, and **APS is excluded from everything below**.
-
-For carrying paths (PBS and other OpenRTB providers): bind is per flow and
-never selection or targeting (`ad_init.test.ts:1824` pins that):
-
-- GAM/PUC: an owned, slot-and-ad-id-matched bridge claim.
-- Direct `/auction`: validated render start. **This requires server and
- client work the current code lacks**: `/auction` response conversion must
- preserve `nurl`/`burl` with server-side macro expansion
- (`formats.rs:423` omits them today) and the client parser must carry and
- https-validate them (`core/auction.ts:43` drops them today).
-- Fallback: attributed `gam_empty`, immediately before fallback render.
-
-`nurl` at bind; `burl` at `render_accepted`; attempt-scoped idempotency key
-`(trace_id, nav_gen, slot, refresh_gen, hb_adid)`; `sendBeacon`/no-cors
-fetch; no retries. Post-acceptance terminal failure emits the dedicated
-**`billing_outcome{billed_then_failed}`** event (§5.1) — it is not a
-`render_fail` reason.
-
-**G4e — Fallback trigger.** Opt-in
-(`[auction].client_render_fallback = "renderer"`); renders only after a
-terminal `gam_empty` unambiguously attributed to a TS cycle; ownership does
-not gate it; publisher-initiated or unattributable cycles never trigger it;
-timeouts never render.
-
-**G4f — Direct `/auction` lifecycle.** The non-GPT path
-(`core/request.ts:52`) gets: a `RenderAttempt` keyed
-`(trace_id, nav_gen, refresh_gen, slot)` with `refresh_gen` incremented per
-`requestAds` invocation touching the slot; **per-slot serialization with
-latest-wins cancellation** — concurrent calls for the same slot cancel the
-older attempt, and every DOM or beacon side effect re-checks its attempt
-generation first, so a reversed-arrival response can never replace a newer
-creative or start a second economic lifecycle (`request.ts:31` currently
-races); G4b acknowledgement validation; G4d direct-flow binds; disposal on
-navigation; exactly-once terminal state; the full §5.1 event sequence.
-`tsjs.requestAds(options)` returns
-`Promise` where
-`RequestAdsResult = { traceId, slots: Array<{ slot, outcome: "rendered" |
-"no_bid" | "failed" | "cancelled", reason? }> }`, settling when every slot
-attempt reaches a terminal state. Reversed-response tests required.
+**G4a — Physical request cycles.**
+
+- Intents recorded for both classes (`ts | publisher`) in one causal queue.
+ **Any `display()` issued while initial load is disabled is retired at
+ issuance regardless of caller** (GPT's no-request behavior is
+ caller-independent, §3.8) — a stale publisher `display()` intent can no
+ more poison a later TS `refresh()` match than the reverse. Hindsight
+ zero-request intents (`refresh()` on a never-displayed slot) expire at
+ 2 s with `intent_no_request`; while one is pending, any opposite-class
+ intent makes the next `slotRequested` ambiguous → quarantine. The
+ ambiguity rule is symmetric.
+- Cycles open only on `slotRequested`, matched to the causal queue head;
+ SRA yields one per slot per batch; cycles close on `slotRenderEnded`;
+ `responseIdentifier` dedups during drain.
+- One outstanding TS cycle per slot; one queued replacement (coalescing).
+- Attribution requires exactly one outstanding TS cycle and no overlap;
+ otherwise quarantine (`cycle_unattributable`), fail closed.
+- **No timeout re-arm.** Re-arm only on count-based drain, safe TS-owned
+ destroy/redefine, or page end. Unissued intents are NavigationSession
+ children; physical cycle/drain state is RuntimeSession.
+- Deterministic-harness CI plus the release-gating real-GAM suite (scope
+ enumerated in the gates table: per-flow topologies, expected sequences,
+ browsers, fixtures, commands, artifacts, approvals — success criterion 1
+ refers to that enumeration).
+
+**G4b — Acknowledgement, specified per render path.** Four normative
+sequences; each names its nonce/token producer, transport into the owned
+frame, authenticated acceptance observation, cancellation, and separate
+document/runner deadlines (document 3 s, runner 10 s, adm 5 s):
+
+1. **APS-PUC** (baseline transport): bridge mints the per-attempt 128-bit
+ nonce; MessageChannel into the renderer document (`ports.length`
+ checks, exact-key replies, one-shot latch, port close); the document
+ posts authenticated `renderer_document_loaded` then
+ `render_accepted | render_failed{reason}` **to the top window**; the
+ kernel validates source ownership, nonce, token, `nav_gen`,
+ `refresh_gen` before transitions or notifications.
+2. **Generic ADM/cache-PUC**: the bridge's display renderer creates the
+ sandboxed adm frame with an injected reporter snippet; the reporter
+ posts authenticated `adm_document_loaded{nonce}` to the top window on
+ document load; acceptance = that message (baseline merely appends an
+ iframe with no observation).
+3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent —
+ the baseline parent-postMessage handshake (`ports.length === 0` branch)
+ is already kernel-observed; same three messages, same validation.
+4. **Direct ADM/cache**: as (2), with the kernel as parent.
+
+Cancellation for all four: navigation/supersession invalidates the nonce;
+late acks are discarded with `stale_navigation`.
+
+**G4c — Honest observations; one terminal state.** Observations:
+`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded,
+reason}` (observation and remediation are separate — a guarded anchor/fixed
+case is still observed), `renderer_document_loaded`, `runner_loaded`,
+`runner_failed`, `adm_document_loaded`. **An attempt has exactly one
+terminal state: `accepted | failed{reason} | no_bid | cancelled`.**
+Post-acceptance runner failure is an observation plus the
+`billing_outcome{billed_then_failed}` event — never a second terminal
+transition. No observation claims paint. The baseline resize is a
+sanctioned, guarded exception to the no-foreign-DOM-mutation rule.
+
+**G4d — Notifications.** APS carries neither `nurl` nor `burl`
+(`aps.rs:839`); excluded entirely. For carrying paths: bind per flow — PUC:
+owned, slot-and-ad-id-matched bridge claim; direct: validated render start
+(server must preserve + macro-expand `nurl`/`burl` in `/auction`
+responses — `formats.rs:423` omits them — and the client must parse and
+https-validate them — `core/auction.ts:43` drops them); fallback:
+attributed `gam_empty` immediately before render. `nurl` at bind, `burl`
+at `accepted`. **Economic identity is the normalized pair
+`(id_kind, id_value)`** (direct attempts without `hb_adid` use
+`bid_id`), idempotency key
+`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Every
+dispatch emits `notification_sent{kind: nurl|burl, id_key_hash, result:
+queued | failed}`** where `id_key_hash` is a 16-hex truncated HMAC of the
+idempotency key — making the duplicate-`burl` invariant observable in
+production (the gates table queries zero duplicates per key hash); external
+billing reconciliation remains the authoritative backstop. No retries.
+
+**G4e — Fallback.** Opt-in; renders only on a terminal `gam_empty`
+unambiguously attributed to a TS cycle; ownership does not gate;
+publisher-initiated or unattributable never triggers; timeouts never
+render.
+
+**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed
+`(trace_id, nav_gen, refresh_gen, slot)`; per-slot latest-wins with
+cancellation; generation checks before every DOM/beacon effect
+(`request.ts:31` races today); G4b sequence 3/4; G4d direct binds;
+disposal on navigation; single terminal state;
+`tsjs.requestAds(options): Promise` with
+`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" |
+"no_bid" | "failed" | "cancelled", reason?}]}`.
+
+**G4g — Mid-attempt configuration.** An attempt **snapshots its
+configuration at creation**. The in-pool emergency kill switch cancels
+attempts that have not yet passed their commit point — defined as
+`bridge_response_sent` (PUC flows) or first DOM insertion (direct/fallback
+flows); attempts past commit run to their terminal state; dispatched
+notifications are never recalled.
### G5 — Deployment contracts
-- Config `format_version` exact-match; rollback by redeploy.
-- Assets: hash-in-pathname; embedded only; unknown hash 410 `no-store`;
- `Cache-Control: public, max-age=31536000, immutable` on exact matches
- (`immutable` alone carries no lifetime). **Concatenation is materialized
- and cached at application-state construction from the validated
- configured module vector** — not per request (`bundle.rs:23` today), and
- not a build-time-only set, since enabled vectors are runtime
- configuration; an unlisted vector is a startup error.
-- Internal route families (renderer, client-events, CSP reports): dispatch
- before auth/EC/publisher/integration filters (Fastly today runs EC setup
- and pre-route filters first, `app.rs:709`); all methods and version
- prefixes reserved locally (405 + `Allow` + `no-store`; unknown version
- 404 `no-store`; never the publisher fall-through in `adapter-spin
-app.rs:804`); no body/cookie/authorization forwarding; origins compared
- as normalized scheme+host+port.
-- Ingest routes exist in all four adapters; Fastly has the real sink; the
- others accept-count-drop (OQ5 drives their gates).
+- Config `format_version`; release-time config (§0).
+- **Assets pre-materialized at release publication:** because config ships
+ with the release, the validated module vectors are known when the release
+ is built — concatenated bytes + hashes are produced then and embedded;
+ serving is lookup-only on every adapter (Fastly's per-request state,
+ §3.13, makes construction-time caching meaningless there — the previous
+ revision's claim is corrected). The §7.10 server benchmark measures the
+ lookup path and guards against regression to per-request concatenation.
+ Unknown vector = release-build error; unknown hash = `410`, `no-store`;
+ exact match = `public, max-age=31536000, immutable`.
+- **Internal route families — now four:** renderer, client-events,
+ CSP-report, **and `/_ts/trace-auth`** — dispatch before auth/EC/
+ publisher/integration filters; all methods reserved locally (405 +
+ `Allow` + `no-store`; unknown versions 404 `no-store`; never publisher
+ fall-through); no body/cookie/authorization forwarding; normalized
+ scheme+host+port origin comparison; each family rate-limited (§5.4) and
+ covered by four-adapter parity tests.
- §5.6 schemas deploy and validate before writers enable.
## 5. Observability
-### 5.1 Wire payload and per-event field matrix
+### 5.1 Wire payload and field matrix
```
-{ v: 1, traces: [
- { trace_id, auth, events: [ { nav_gen, refresh_gen, seq, t, ...fields } ] }
-] }
+{ v: 1, traces: [ { trace_id, auth, events: [
+ { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] }
```
-Event types and their fields (closed enums; a field absent from a row is
-absent from the wire and NULL in storage):
-
-| `t` | fields |
-| -------------------------- | ------------------------------------ |
-| `bid_received` | slot, id_kind, source |
-| `targeting_set` | slot, id_kind |
-| `bridge_request` | slot, id_kind, matched |
-| `bridge_response_sent` | slot, source |
-| `render_attempt` | slot, source |
-| `render_accepted` | slot, source |
-| `render_fail` | slot, source, reason |
-| `gam_nonempty` | slot |
-| `gam_empty` | slot |
-| `gam_collapsed` | slot |
-| `renderer_document_loaded` | slot |
-| `runner_loaded` | slot |
-| `runner_failed` | slot, reason |
-| `adm_document_loaded` | slot |
-| `fallback_start` | slot |
-| `billing_outcome` | slot, outcome (`billed_then_failed`) |
-| `client_queue_overflow` | dropped (count) |
-
-`slot` is a configured slot id or `s`; `id_kind` ∈
-`cache_uuid | render_token | prebid_adid | bid_id | none`; `source` ∈
-`renderer | adm | pbs-cache | gam`. Reason enum:
-`renderer_document_no_load`, `runner_no_load`, `runner_failed`,
-`descriptor_invalid`, `invalid_dimensions`, `dimensions_out_of_range`,
-`bridge_id_mismatch`, `cycle_unattributable`, `intent_no_request`,
-`stale_navigation`, `bridge_claim_timeout`, `gam_empty`,
-`no_render_source`, `slot_unresolved`, `gpt_absent`, `pbjs_absent`,
-`bundle_partial`, `fallback_cancelled`, `abi_mismatch`, `registry_full`.
-Queue overflow is its own event (`client_queue_overflow`), never a
-`render_fail` — failure denominators stay clean. The payload carries **no
-client timestamp**; the server stamps `received_at`, and ordering within a
-trace is `seq`.
-
-### 5.2 Transport
-
-`fetch(..., {keepalive: true, credentials: "omit"})` primary; `pagehide`
-fallback `navigator.sendBeacon(url, new Blob([json], {type:
-"application/json"}))`. Flush every 5 s and on `visibilitychange`/
-`pagehide`. Client queue bound 256 events; overflow drops oldest and emits
-`client_queue_overflow{dropped}`.
+| `t` | fields (absent = absent on wire, NULL in storage) |
+| -------------------------- | ------------------------------------------------- |
+| `bid_received` | slot, id_kind, source |
+| `targeting_set` | slot, id_kind |
+| `bridge_request` | slot, id_kind, matched |
+| `bridge_response_sent` | slot, source |
+| `render_attempt` | slot, source |
+| `render_accepted` | slot, source |
+| `render_fail` | slot, reason, source? |
+| `gam_nonempty` | slot |
+| `gam_empty` | slot |
+| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? |
+| `renderer_document_loaded` | slot |
+| `runner_loaded` | slot |
+| `runner_failed` | slot, reason |
+| `adm_document_loaded` | slot |
+| `fallback_start` | slot |
+| `billing_outcome` | slot, outcome (`billed_then_failed`) |
+| `notification_sent` | slot, kind (`nurl`\|`burl`), id_key_hash, result |
+| `client_queue_overflow` | dropped (count) |
+| `heartbeat` | probe_id, expected_seq |
+
+`source` is **nullable on `render_fail`**: absent for pre-source reasons
+(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`,
+`abi_mismatch`, `registry_full`, `bundle_partial`); required for
+source-specific reasons — the per-reason validity matrix is part of the
+generated schema. Reason enum as revision 6 plus `currency_mismatch`.
+**Heartbeats** are sent by identified probes under mode `probe` (§5.3):
+excluded from every product metric by mode, never sampled out, and the
+canonical freshness/loss query counts `expected_seq` gaps.
+
+### 5.2 Transport and overflow
+
+`fetch keepalive credentials:"omit"` primary; `sendBeacon(url,
+new Blob([json], {type: "application/json"}))` on `pagehide`. Queue bound 256. **Overflow never enqueues into the full queue**: an out-of-band
+saturating counter accumulates drops, and one coalesced
+`client_queue_overflow{dropped}` is materialized into the **next flush**.
### 5.3 Signed trace authorization
-Format `v1....` — **`auth` has its own ingest bound of
-256 bytes** (it cannot fit the general 64-char string cap; every other
-string keeps 64):
-
-- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform secret
- store; keys ≥ 256-bit CSPRNG; **previous keys are retained at least
- 24 hours** (≫ max token lifetime + skew); missing key at startup with the
- beacon enabled = startup failure.
-- `exp`: canonical decimal unix seconds (no sign, no leading zeros); ±60 s
- skew; max future 15 min.
-- `mode`: `sampled | unsampled | diagnostic`. **`unsampled` is the signed
- discard decision** (the review's missing state): the client must not
- enqueue or transmit events for an `unsampled` trace, and ingest rejects
- any group whose token mode is `unsampled`; the decision is sticky for the
- trace (renewals preserve mode). `diagnostic` is a distinct authenticated
- mode — **not** gated by the tester cookie, which is explicitly
- non-security (`tester_cookie.rs:3`); it requires a separate short-lived
- **diagnostic credential** issued behind the existing operator/admin
- authentication (`/_ts/admin` surface): HMAC-signed, bound to publisher
- origin, expiry ≤ 60 min, revoked by key rotation, issuance
- CSRF-protected; forgery/replay tests required. The tester cookie may
- still gate cosmetic overlays; never telemetry volume.
-- `sig`: base64url, unpadded, of HMAC-SHA-256 (43 chars) over the
- domain-separated input
- `"ts-trace-auth-v1" || u32be(len(origin)) || origin ||
-u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode ||
-u64be(exp)`, all strings UTF-8; constant-time comparison.
-- **Renewal for long-lived pages:** before expiry the client calls
- same-origin `GET /_ts/trace-auth` with `X-TSJS-Trace-Id`; the server
- re-signs the **same trace id and mode** with a fresh `exp` (correlation is
- the unchanged trace id). On renewal failure the client stops transmitting
- and counts locally — silent rejection at ingest is thereby a bug, not a
- policy.
-- Ingest verifies per trace group; invalid/expired/unknown-kid → group
- dropped-and-counted; other groups survive.
-
-### 5.4 Ingest contract
-
-- `POST /_ts/client-events`; `application/json` only; no
- `Content-Encoding`; `204`, `no-store`; never echoes input.
-- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars except
- `auth` ≤ 256 bytes; `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`.
-- Same-origin: `Sec-Fetch-Site: same-origin` when present, else normalized
- `Origin` equality; absent both → drop-and-count.
-- **Rate limiting via an adapter abstraction** (the review is right that a
- cross-request in-memory token bucket cannot exist on Fastly, `app.rs:146`):
- trait `ClientEventLimiter` with a declared per-adapter backing and
- semantics — Fastly: the platform edge counter (`rate_limiter.rs:40`),
- fixed 60 s window, limit 20/window (documented approximation of
- 10 rpm + burst 20); Axum: real in-process token bucket (10 rpm, burst
- 20), map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/per-instance
- best-effort with the same parameters. **At capacity, unseen identities
- are rejected (drop-and-count); active buckets are never evicted by
- churn.** Limiter unavailable/errored → drop early with `204`. Trusted
- client address per adapter: Fastly platform client IP; Axum rightmost
- `X-Forwarded-For` beyond required `trusted_proxy_hops` (absent → socket
- peer only); Cloudflare `CF-Connecting-IP`; Spin platform address.
-
-### 5.5 Sink, canonical views, and monitoring
-
-- Stable event key `(publisher_domain, trace_id, seq)`.
-- **One named canonical dedup pipe/view per table** (`ts_client_events_v`:
- latest `received_at` per key; `ts_render_attempts_v`: attempt-grain
- aggregation keyed `(trace_id, nav_gen, refresh_gen, slot)`). **Joins run
- at attempt/slot grain against the views, never raw-to-raw** (a raw join on
- `(publisher_domain, trace_id)` multiplies rows). Dashboards and alerts
- may query only canonical views.
-- Field naming matches the existing auction rows: **`publisher_domain`**.
-- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`) and
- cannot see downstream rejection — **datasource-side monitoring is
- mandatory**, driven by **sequence-tagged synthetic heartbeats** from a
- probe client (accepted rows cannot reveal rejected rows): heartbeat gaps
- measure rejection; heartbeat lag measures freshness. Alert owner: the
- release owner's on-call.
+`v1....`; `auth` ingest bound 256 bytes; kid
+`^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store, previous
+keys retained ≥ 24 h; canonical decimal `exp`, ±60 s skew, ≤ 15 min future;
+`sig` = unpadded base64url HMAC-SHA-256 over the domain-separated
+length-prefixed input (revision 6's exact encoding); constant-time compare;
+per-group rejection.
+
+- **Modes:** `sampled | unsampled | diagnostic | probe`. `unsampled`
+ transmits nothing and is rejected at ingest if carried. `probe` is
+ issued only to synthetic monitors (server-side issuance to the probe
+ runner) and marks heartbeat traffic.
+- **Renewal preserves mode by verification, not trust:** `GET
+/_ts/trace-auth` presents the **current signed authorization** in
+ `X-TSJS-Trace-Auth` (plus the trace header); the server verifies the
+ still-valid token and re-signs the **same trace_id and mode** with fresh
+ `exp`. The trace id itself carries no mode, and no adapter may rely on
+ cross-request state (§3.13) — the presented token is the state. Renewal
+ after expiry fails; the client stops transmitting and counts locally.
+- **Diagnostic credential — complete protocol:** issuance `POST
+/_ts/admin/diagnostic-credential` under the existing admin
+ authentication (CSRF: same-origin + custom header required), response
+ `{credential}` where credential = `d1....`,
+ absolute expiry ≤ 60 min, HMAC over the publisher origin + expiry,
+ **replayable short-lived bearer by design** (bounded by expiry and
+ origin binding; not one-time — stated, not implied). Transport to the
+ page: the operator opens the page with a `#tsdiag=` fragment
+ (never sent to any server in a URL); the client stores it in
+ `sessionStorage` and presents it in `X-TSJS-Diag` on trace-auth,
+ page-bids, and `/auction` requests. The server, seeing a valid
+ credential, issues/renews the trace authorization with
+ `mode = diagnostic` and **`exp = min(now + 15 min,
+credential expiry)`** — a trace authorization never outlives the
+ credential. Initial HTML cannot see the fragment, so `nav_gen 0` starts
+ `sampled|unsampled` and the client immediately upgrades via trace-auth.
+ Validation is stateless HMAC — all four adapters support it. Forgery,
+ replay-past-expiry, and wrong-origin tests required. The tester cookie
+ remains content-only (§2.5).
+- **Lazy cached initialization applies to every secret-backed component**
+ (trace-auth keys, diagnostic keys, sampling key, sinks): first-use
+ resolution with a cached result on request-bound platforms; resolution
+ failure with the feature enabled → the feature's startup/first-use error
+ path, never silent.
+
+### 5.4 Ingest and rate limiting
+
+As revision 6 (limits, same-origin, fail-closed drops), with the limiter
+contract completed: key namespace per route family; portable maps hold
+≤ 65,536 (Axum) / 4,096 (Cloudflare, Spin per-instance) entries with
+10-minute entry TTL, cleanup on access plus periodic sweep — **capacity
+pressure rejects unseen identities but expired entries are always
+reclaimable, so saturation is bounded, not permanent**; missing client
+address → a shared `unknown` bucket at 1 request/min; Fastly uses the
+platform 60 s window counter at limit 20 with documented overshoot ≤ 2×
+under concurrent bursts (`rate_limiter.rs:40` is read-then-increment);
+Axum XFF selection = rightmost entry after skipping exactly
+`trusted_proxy_hops`. `/_ts/trace-auth` and `/_ts/csp-reports/`
+get their own buckets (10/min, burst 20 intent).
+
+### 5.5 Sink, canonical views, monitoring
+
+Stable event key `(publisher_domain, trace_id, seq)`; canonical views
+`ts_client_events_v` (dedup) and `ts_render_attempts_v` (attempt grain,
+joined on the G1 key including `auction_id`); dashboards/alerts query views
+only. Datasource-side monitoring via `heartbeat` events from identified
+probes (mode `probe`); freshness = heartbeat lag ≤ 5 min, loss =
+`expected_seq` gaps < 0.1%; alert owner: release owner's on-call.
### 5.6 Physical schemas (deployed before writers)
-- **`ts_client_events`**: `received_at DateTime64, publisher_domain
-LowCardinality(String), release_id String, trace_id FixedString(32),
-mode Enum(sampled|diagnostic), nav_gen UInt32, refresh_gen UInt32,
-seq UInt32, event Enum(§5.1), slot Nullable(String), id_kind
-Nullable(Enum), matched Nullable(UInt8), source Nullable(Enum), reason
-Nullable(Enum), outcome Nullable(Enum), dropped Nullable(UInt32)`.
- Sorting key `(publisher_domain, received_at, trace_id, seq)`; TTL
- 30 days; own ingest token; sink batch cap 512 rows; startup validation of
- dataset + token when enabled ("startup" on request-bound platforms such
- as Cloudflare means first-request lazy initialization with a cached
- result); sink-unavailable at runtime → accept-count-drop.
-- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`):
- add nullable `trace_id`, `mode`; add two bounded row types — **`bid_drop`**
- `{provider, slot Nullable, reason Enum, width Nullable(UInt16), height
-Nullable(UInt16), count UInt32}` (nullable slot/dimensions for
- response-level failures; cap 32 rows/auction + overflow row) and
- **`selection_summary`** per slot
- `{slot, winner_source Enum(mediator|direct|none), winner_provider,
-candidates_direct UInt16, candidates_mediator UInt16, dedup_hits,
-currency_rejected, provenance_invalid, mediator_superseded}` (cap 8
- rows/auction + overflow) — §6.1's selection report now has a physical
- home.
-- **Settings schema (complete):** `[telemetry.client_events]` `enabled`,
- `sample_rate` (0–1), `dataset`, `token_secret`; `[telemetry.trace_auth]`
- `secret_store`, `active_kid`, `previous_kids = []`; the diagnostic
- credential secret alongside. `RuntimeServices` (`platform/types.rs:158`)
- gains the client-events sink handle next to the auction sink.
-- APS parsing returns structured drop observations
- `{reason, slot, width?, height?}` (`aps.rs:722` today loses slot and
- values); >8192 → `dimensions_out_of_range`, dimensions omitted.
+- **`ts_client_events`**: revision 6 columns plus `flow Enum`,
+ `auction_id Nullable(FixedString(36))`, `action Nullable(Enum)`,
+ `kind Nullable(Enum)`, `id_key_hash Nullable(FixedString(16))`,
+ `result Nullable(Enum)`, `probe_id Nullable(String)`,
+ `expected_seq Nullable(UInt32)`; `mode Enum(sampled|diagnostic|probe)`.
+- **Auction rows**: nullable `trace_id`, `mode`, plus **`release_id`**
+ (the server-side cohort discriminator). Two added row types with full
+ physical definitions:
+ - `bid_drop {provider LowCardinality(String), slot Nullable(String),
+reason Enum(AuctionDropReason), width Nullable(UInt16), height
+Nullable(UInt16), count UInt32}` — cap 32 rows/auction **plus** one
+ overflow row (`reason = overflow`, `count` = dropped-row count; the
+ overflow row is not counted against the cap);
+ - `selection_summary {slot String, winner_source
+Enum(mediator|direct|none), winner_provider Nullable(String) — NULL
+exactly when winner_source = none, candidates_direct UInt16,
+candidates_mediator UInt16, dedup_hits UInt16, currency_rejected
+UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` — cap
+ 8 rows/auction plus one **auction-level totals row** (slot =
+ `_totals`) that always survives truncation, so gate denominators never
+ depend on per-slot rows. Counters are saturating UInt with
+ `0xFFFF`/`0xFFFFFFFF` as the saturation sentinel.
+ - **`AuctionDropReason` (closed, server-side):** `script_rendering_
+disabled, invalid_dimensions, dimensions_out_of_range,
+missing_render_source, invalid_creative_url, unsupported_tagtype,
+render_payload_too_large, unexpected_response_shape,
+currency_mismatch, floor_rejected, provenance_invalid,
+duplicate_demand, overflow`.
+- **`ts_csp_reports`** (aggregate only): `{received_at, release_id,
+policy_id, cohort, directive_bucket Enum, source_bucket Enum, count}`;
+ 30-day TTL. **CSP ingest contract:** pre-buffer body ≤ 8 KiB, ≤ 10
+ reports/request, strings ≤ 256, nesting ≤ 4, no `Content-Encoding`, own
+ limiter bucket, fire-and-forget dispatch covered by probe heartbeats.
+- **`ts_ops_counters`**: `{received_at, release_id, counter Enum, value
+UInt64}` — the physical home for renderer-route counters (requests,
+ unknown-version, auth-blocked) and limiter/abuse counters.
+- **Settings, constructible:** `[telemetry.client_events]`
+ `collection_enabled`, `sink_enabled` (collection without a sink =
+ accept-count-drop by configuration, not accident), `sample_rate`,
+ `api_host`, `dataset`, `token_secret`, `secret_store`,
+ `max_body_bytes`; `[telemetry.trace_auth]` `secret_store`,
+ `active_kid`, `previous_kids`, `sampling_key_secret`;
+ `[telemetry.diagnostic]` `secret_store`, `active_kid`.
+ `RuntimeServices` gains the client-events sink handle.
+- APS parsing returns structured drop observations (slot + dimensions);
+ > 8192 → `dimensions_out_of_range`, dimensions omitted.
### 5.7 Modes and SLIs
-- **Production (sink-backed only):** server-decided sampling
- (`sample_rate`, default 0.10; `sampled` vs `unsampled` signed per trace).
- Separated SLIs: **pipeline availability** (heartbeat freshness ≤ 5 min,
- heartbeat loss < 0.1%; fails during sink outages, alarmed); **failure
- detection** (a failure mode affecting ≥ 1% of sampled render attempts
- visible within one hour, evaluated at ≥ 10,000 sampled attempts/hour).
-- **Diagnostic:** credential-gated (§5.3), unsampled, full stream, console
- mirroring, plus the `boot.debug` / response-`debug` envelopes (§2.5) — one
- page load names the failing class for every §2 row.
+Production (sink-backed): deterministic keyed sampling (default 0.10).
+SLIs: pipeline availability (heartbeat freshness/loss); failure detection
+(≥ 1% of sampled render attempts visible within one hour at ≥ 10,000
+sampled attempts/hour). Diagnostic: credential-gated, unsampled, full
+stream + console mirroring + debug envelopes (§2.5).
### 5.8 Server-side drop surfacing
-Bounded structured summary whenever any bid is dropped; `bid_drop` +
-`selection_summary` rows; `ts-debug` comment carries the drop summary;
-page-bids and `/auction` carry the gated structured `debug` field. Startup
-warnings: APS + `allow_script_creatives = false`; mediator + direct
-providers without an explicit `winner_selection` (§6.1 hard error).
+As revision 6, with `selection_summary`/`bid_drop` physicalized above.
## 6. APS delivery fixes
-### 6.1 Mediation: complete, arrival-independent algorithm
-
-Current code cannot merge: no forwarded candidate id; lossy
-last-write-wins `(provider, slot, bidder)` restoration
-(`adserver_mock.rs:95`); arrival-order ties (`orchestrator.rs:827`); Prebid
-assumes USD (`prebid.rs:2433`) and APS stamps USD (`aps.rs:475`) with no
-configured currency. Replacement, identical in the synchronous and split
-dispatch/collect paths via one shared helper:
-
-1. **Currency.** New required field `[auction].currency` (ISO 4217). Every
- provider parse validates its response currency against it (absent
- declaration where the provider contract implies one — APS's USD — is
- validated as that implied value); mismatch → `bid_drop{currency_mismatch}`.
- No conversion.
-2. **Candidates.** Every admitted bid — direct **and mediator-native** —
- becomes a candidate. Identity is two-part: `source_candidate_id` =
- the **intrinsic stable key** `(provider_name, upstream_bid_id)` (for
- mediator-native bids: the mediator's provider name and its bid id), and
- `candidate_id` = a server-minted opaque wire id (`c` + 11-char CSPRNG)
- used **only** for the mediator echo — never for ordering, so response
- arrival order cannot influence selection.
-3. **Mediator exchange.** Forwarded candidates carry `candidate_id` in the
- named wire extension **`ext.trusted_server.candidate_id`** (contract for
- every mediator implementation, `adserver_mock` included); the mediator
- echoes it on derived bids. Echoed id resolves → the bid is the forwarded
- candidate with provenance `mediator`; **authoritative fields:** price
- and deal fields come from the mediator (repricing is its job);
- render-source fields (renderer, adm, cache coordinates, notification
- URLs) come from the stored candidate — a mediator that returns its own
- `adm` for an echoed candidate is treated as mediator-native demand
- instead. Unresolvable echoed id → the slot **fails closed for merging**
- (`mediation_provenance_invalid`; mediator-native bids for the slot still
- compete; the claim is discarded and counted).
-4. **Floors** filter both populations. **Dedup:** an echoed candidate
- removes its direct twin.
-5. **Selection order (total, intrinsic):** decoded CPM desc → provenance
- rank (mediator first) → `source_candidate_id` asc. Winner fields are
- read from the stored candidate per rule 3.
-6. **Strategy.** `[auction].winner_selection` is **required** whenever a
- mediator and direct providers coexist (startup error if absent):
- `mediator_only` or `merge_highest_cpm`. **Timeout behavior is
- strategy-specific:** under `merge_highest_cpm`, mediator timeout →
- direct-only selection, reported; under `mediator_only`, mediator timeout
- → **no winners** (direct bids stay signal-only) unless
- `mediator_timeout_fallback = "direct"` is explicitly configured.
-7. **Reporting:** the `selection_summary` row (§5.6) per slot.
-
-Deal priority stays out of scope (the `Bid` model carries no deal identity;
-recorded follow-up).
-
-### 6.2 Dimensions
-
-Exact size membership stays (`aps.rs:675`). Fix is visibility
-(`bid_drop{invalid_dimensions, w, h}`) plus documentation: request the
-sizes you accept.
-
-### 6.3 Script creatives
-
-Default stays `false`; consequence loud (§5.8); enablement documented.
-
-### 6.4 Render identity
-
-As G2 (including tombstones).
-
-### 6.5 Fallback
-
-As G4e/G4a; awaitable renderer first; attribution-gated; timeouts never
-render.
+### 6.1 Mediation — total order, closed contradictions
+
+1. **Currency.** Required `[auction].currency` (ISO 4217). Providers
+ validate response currency at parse; providers with a contract-implied
+ currency validate that implication — **APS enabled with a non-USD
+ configured currency is a startup error** (`aps.rs:475` stamps USD), not
+ a silent all-drop. Prebid's parse must validate rather than assume USD
+ (`prebid.rs:2433`). Mismatch → `bid_drop{currency_mismatch}`.
+2. **Candidate identity, total by construction.** `source_candidate_id` =
+ `(provider_name, upstream_bid_id)` where the upstream id is **required
+ bounded (≤ 64 chars) and unique per provider response at admission**;
+ a bid missing an id, or duplicating one, receives a deterministic
+ **intrinsic fingerprint**: `fp = hex(HMAC(auction_id, provider || slot
+|| price_micros || render_source_digest))` — identical fingerprints are
+ the same demand and dedup to one candidate. `candidate_id` (wire echo)
+ is CSPRNG with in-auction collision retry and is **never** an ordering
+ key.
+3. **Mediator exchange.** Forwarded candidates carry
+ `ext.trusted_server.candidate_id`; the mediator echoes it. Resolution
+ rules, stated exhaustively: an echoed id that resolves → the forwarded
+ candidate, provenance `mediator`; **price is authoritative from the
+ mediator; every render-source and notification field comes from the
+ stored candidate; deal fields are out of scope entirely** (no deal
+ identity exists in the model — revision 6's "deal fields from mediator"
+ is deleted). A mediator bid whose **any** render-source field differs
+ from the stored candidate is reclassified mediator-native. An echoed id
+ that does **not** resolve → that bid is discarded and counted
+ (`provenance_invalid`); **mediator-native bids and direct candidates
+ for the slot all remain eligible** — "fails closed" applies to the
+ invalid claim, not the slot.
+4. Floors filter both populations; echoed candidates remove their direct
+ twins.
+5. **Selection order (total):** decoded CPM desc → provenance rank
+ (mediator first) → `source_candidate_id` asc (fingerprints compare as
+ their hex strings). Arrival order can never matter.
+6. **Strategy** required when mediator + direct providers coexist:
+ `mediator_only` (timeout → no winners unless
+ `mediator_timeout_fallback = "direct"`) or `merge_highest_cpm`
+ (timeout → direct-only, reported).
+7. Reporting: `selection_summary` rows + `_totals` (§5.6).
+
+### 6.2–6.5
+
+As revision 6: exact dimensions with structured drops; script creatives
+default-off but loud — **DR-2's output is a deployment decision** (enable
+with explicit sandbox/security approval, or accept a quantified maximum
+excluded-demand share and gate Phase 3 on it), not a documentation
+priority; G2 identity; G4e fallback.
### 6.6 Renderer endpoint
-- Route registers unconditionally in every adapter (provider stays
- config-gated); startup validation fails if an auth handler pattern covers
- it; §G5 isolation rules apply.
-- Path `/integrations/aps/renderer/v1`, embedded, served
- `Cache-Control: public, max-age=31536000, immutable`; canary versions
- are `no-store` (or bounded below the cohort lifetime); a **checked-in
- header manifest per renderer version** freezes headers (CSP included)
- with the bytes — a version's headers never change after publication,
- resolving the immutable-caching/CSP conflict. Unknown versions → 404
- `no-store`.
-- Three-message acknowledgement per G4b (`renderer_document_loaded` is the
- first authenticated envelope).
-- Server route counters are aggregate (the nonce rides the URL fragment).
-- **CSP rollout:** discovery on the currently enforced policy with
- reporting; tightening via report-only; relaxation via a small enforced
- cohort on a short-lived canary version, gated on runner acceptance, CSP
- violation rate, and render-failure rate, with a kill switch — and **CSP
- reports are advisory**: for opaque-origin reports the body-supplied
- document URL and policy version are forgeable, so **policy identity is
- encoded in a server-selected report path**
- (`POST /_ts/csp-reports/`, ids server-generated per version),
- reports are bucketed into closed effective-directive buckets and
- `https-host (allowlisted) | data | blob | inline | eval | other` source
- buckets with global and per-cohort caps, unused fields discarded before
- logging, and CSP data is **never a sole automatic rollback signal**.
- Physical storage: aggregate counters only. Browser capture on Chromium,
- Firefox, and WebKit (CI is Chromium-only today,
- `playwright.config.ts:16`), both report media types
- (`application/csp-report`, `application/reports+json`) with separate
- validators.
+As revision 6 (unconditional route, versioned immutable document with a
+checked-in per-version header manifest, three-message ack, aggregate route
+counters now physically in `ts_ops_counters`, CSP rollout with
+server-selected `policy_id` report paths and closed buckets — ingest
+bounds per §5.6).
### 6.7 One descriptor schema
-Wire truth is the tagged `BidRenderer` envelope (`types.rs:188-211`). A
-wire-schema crate/xtask (no `core → js` cycle; core already depends on the
-js crate, `Cargo.toml:45`) generates the JSON-Schema artifact, the TS
-structural parser, the ES5 inline validator fragment, and shared fixtures —
-checked in, staleness-gated. Semantic checks (URL/origin policy, canonical
-base64, bounds, exact one-bid AAX projection, cross-field equality) stay
-handwritten. Outer-descriptor tolerance only; AAX projection exact. Shared
-positive + adversarial corpus runs through all three validators in CI.
+As revision 6 (generated JSON-Schema + TS parser + ES5 inline fragment +
+fixtures; semantic validators handwritten; outer tolerance only; the
+per-reason `source` validity matrix of §5.1 joins the generated artifacts).
### 6.8 Bridge hardening
-Order (normative; preserves the baseline defense that suppresses
-propagation before source validation, `gpt/index.ts:1584-1637`): parse →
-identify TS-reserved id (live registry **or tombstone**, G2) →
-`stopImmediatePropagation()` → validate source ownership via the bounded
-walk (known slot-root `WindowProxy` map; sender's parent chain to depth 5;
-never scanning the frame tree) → validate nonce/token/`nav_gen`/
-`refresh_gen` → respond or refuse (`bridge_id_mismatch`). Non-TS ids are
-untouched. The stolen-token browser test asserts neither TS nor native
-Prebid responds; listener-order has a real-browser assertion. Renderer
-branches emit the full §5.1 sequence; notifications only on carrying paths.
+As revision 6: parse → identify TS-reserved (live **or tombstoned**, G2) →
+`stopImmediatePropagation` → validate source (bounded walk) →
+nonce/token/`nav_gen`/`refresh_gen` → respond or refuse. Non-TS ids
+untouched. Stolen-token test proves neither TS nor native Prebid responds.
## 7. TSJS target architecture
### 7.1 Layering
-```
-kernel/ boot, config, queue, event bus, log, beacon, sessions
-adapters/ googletag.ts, pbjs.ts, messaging.ts ← the ONLY window.* access
-services/ slots (registry+handoff), auction client, render engine, consent
-integrations/ gpt, prebid, aps, creative, datadome, …
-```
+As revision 6, with G3's corrected lint mechanics and the adapter-scope
+clarification (external ad-tech globals only).
+
+### 7.2 Adapters / 7.3 Slot registry
+
+As revision 6 (registry holds the G4a causal queue; cycle/drain state
+RuntimeSession; unissued intents NavigationSession).
+
+### 7.4 Final global surface
+
+Revision 6's table **plus** the row the review found missing:
+
+| Legacy surface | Final shape |
+| -------------- | ----------------------------------------------------- |
+| (new, public) | `tsjs.definePlugin({id, release, install, dispose?})` |
+
+Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que ||=
+[]; tsjs.boot ||= {}`; the ad-slot script's `window.tsjs = {}` at
+`publisher.rs:3665` is fixed); **transactional ownership**: states
+`unclaimed → installing → kernel | fallback`. The kernel installs wrappers
+**inert** and flips them live at a single commit point; on a throw before
+commit it unwinds its registered disposers (the §7.6 machinery applied to
+kernel boot itself) and marks `failed`; the 10 s watchdog treats a stuck
+`installing` as failed; **fallback claims ownership only from
+`unclaimed | failed`** — never beside a committed kernel. A bundle
+arriving after fallback committed defers for the page (`bundle_partial`).
+Tests: throw injected after each boot checkpoint.
+
+### 7.5 Messaging / 7.6 Plugins and sessions / 7.7 Bootstrap / 7.8 GPT / 7.9 Decomposition
+
+As revision 6 (plugin API object form with `release`; transactional
+install; sessions; generated no-bundle fallback; unconditional GPT
+subscriptions; decomposition table).
+
+### 7.10 Performance (fully reproducible)
-Enforced by the two G3 lint rules. Dissolves the audited inversions
-(`core/auction.ts`/`core/request.ts` → `integrations/aps/render`;
-`gpt`/`prebid` → `aps`; `prebid` owning the GPT refresh wrapper).
-
-### 7.2 Adapters
-
-`present | pending | timed_out` per external global; `timed_out`
-non-terminal; queued operations carry their own timeouts and expire with
-disposition reasons.
-
-### 7.3 Slot registry service
-
-Kernel-owned; `WeakMap` + div-id index;
-ownership, adoption, handoff claims, responsive resolution, the G4a causal
-intent queue + cycle/drain state (RuntimeSession) and unissued intents
-(NavigationSession), targeting history. No expandos
-(`__tsRenderGeneration`/`__tsRenderBid` deleted).
-
-### 7.4 Final global surface (hard cutover)
-
-| Legacy surface (removed at cutover) | Final shape |
-| --------------------------------------- | --------------------------------------------------------------------- |
-| `window.tsjs.que` | `window.tsjs.que` — unchanged |
-| `globalThis.tscreative` | `tsjs.creative.*` |
-| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` |
-| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) |
-| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` |
-| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) |
-| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` |
-| `tsjs._internal` | kernel registry (G3), frozen after boot |
-
-**Bootstrap correctness (closing the review's two holes):** every
-server-injected initializer creates the container **idempotently and
-field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}` —
-never only-when-absent (today the first ad-slot script does
-`window.tsjs = {}`, `publisher.rs:3665`, which would clobber or starve
-later `boot` writes). Kernel boot claims an **atomic owner sentinel**; it
-consumes `boot`, deep-freezes the retained copy, and deletes one-shot
-secrets. The generated no-bundle fallback (§7.7) activates on bundle
-`error` **or** a bounded hang watchdog (10 s without kernel boot); if the
-fallback has activated and the bundle later arrives, the bundle **defers
-for the rest of the page** (logs + `bundle_partial` disposition) — queue
-ownership never changes hands mid-page.
-
-### 7.5 Messaging module
-
-All `postMessage` through one module: versioned envelopes, name constants,
-G4b nonces, §6.8 validation. Minimal module lands in Phase 1; full call-site
-migration in Phase 4.
-
-### 7.6 Plugin lifecycle — transactional — and sessions
-
-`tsjs.definePlugin({id, release, install, dispose?})` — object form; the
-`release` field is the build-generated `release_id` constant (G3 needs it;
-revision 5's positional API omitted it). `install(ctx): void |
-Promise`:
-
-- `ctx.signal`; synchronous `ctx.onDispose(fn)`; reverse-order unwind on
- throw/reject/abort; per-disposer isolation; disposer registered after
- disposal → invoked immediately; pending late registrations capacity 16,
- bound 10 s → `bundle_partial`; release mismatch quarantines before
- `install`.
-- Sessions: `RuntimeSession` (page-lifetime: bridge listener + tombstones,
- history hook, pbjs subscriptions, adapters, beacon queue, physical slot
- cycle/drain state); `NavigationSession` (trace + authorization + renewal
- timer, render attempts, slot aliases, unissued intents, targeting
- history); `RenderAttempt` (per G4a cycle / G4f attempt). Enumerable
- disposal inventories; navigation disposes NavigationSession children
- only.
-- No empty `catch`; auction fetch gains timeout + `AbortController`.
-- **Console logging retained**: every issue-surfacing condition keeps or
- gains a `log.warn` with the beacon's reason code; `debug`-level
- delivery/security failures promoted to `warn`.
-
-### 7.7 Bootstrap
-
-`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays
-recorded calls on install (browser specs cover replay timing); the
-no-bundle fallback is **generated from the same TypeScript source**, with
-the §7.4 activation/arbitration rules.
-
-### 7.8 GPT correctness carried with the restructure
-
-Unconditional early `slotRequested`/`slotRenderEnded` subscription
-(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent
-recording); restore #922/#997 attribution and orphan recovery;
-`changeCorrelator: false` on TS refreshes (configurable);
-`enableSingleRequest()` only when services are not already enabled;
-ambiguous responsive resolution emits `render_fail{slot_unresolved}`.
-
-### 7.9 Decomposition targets
-
-| Today | Target |
-| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
-| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
-| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics |
-| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
-| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` |
-| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal |
-
-### 7.10 Performance (reproducible)
-
-- **Dedicated workflow** on a fixed runner (`runs-on: ubuntu-24.04`
- explicitly — browser CI is `ubuntu-latest` today,
- `integration-tests.yml:155` — inside a pinned container image digest);
- browser = the lockfile-resolved `@playwright/test` build with its browser
- revision recorded in the baseline artifact (the manifest is a caret
- range today, `browser/package.json:10` — the lockfile + recorded
- revision are authoritative); compressors pinned by version in the
- container (`gzip -9 -n` for determinism, `brotli -q 11`).
-- Bundle budgets: raw/gzip/Brotli for three vectors (minimal, reference,
- maximal) vs checked-in baselines (`perf/baselines/*.json`, updates are
- reviewed diffs recording image/browser/tool versions); +5% bytes.
-- Browser timing: 5 warm-ups discarded, 50 samples, p90 ≤ baseline × 1.10.
-- **Server benchmark harness (complete):** workload = concatenation +
- hash of the reference vector; 100 warm-up iterations, 1,000 measured;
- statistic = median and p90; one-sided gates ≤ baseline × 1.10; variance
- policy: 3 consecutive runs must agree within 5% or the result is
- inconclusive (rerun, never pass).
+Revision 6's pinned workflow, plus the missing definitions: **module
+vectors enumerated** — minimal = `[core]`; reference = `[core, creative,
+gpt, prebid, datadome]`; maximal = all 13 discovered modules. **Browser
+timing marks**: `performance.mark("tsjs:bids-script")` emitted by the
+injected bids script and `performance.mark("tsjs:first-display")` emitted
+by the adapter wrapper at the first `display()`/`refresh()` dispatch;
+metric = duration between marks on the reference fixture page (the
+integration-tests reference page), warm HTTP cache, all resources served
+locally (no external network); p90 = nearest-rank over 50 samples;
+inconclusive (3-run agreement worse than 5%) → one rerun, then fail.
+**Maximal-vector peak JS heap ≤ baseline × 1.10.** Server benchmark: the
+G5 lookup path (not concatenation), 100 warm-ups, 1,000 iterations, median
+and p90, one-sided ≤ baseline × 1.10.
### 7.11 Toolchain
-TypeScript floor to the resolved 5.9 line; strictness flags on; dev
+TypeScript floor to the resolved 5.9 line. **Release-gating flags,
+enumerated:** `strict`, `noUncheckedIndexedAccess`,
+`exactOptionalPropertyTypes`, `verbatimModuleSyntax`,
+`noImplicitOverride`, `useUnknownInCatchVariables`; CI command:
+`npx tsc -p crates/trusted-server-js/lib/tsconfig.json --noEmit`. Dev
toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from
casual bumps; monthly review.
-## 8. Rollout: phases, decision records, and executable gates
-
-Phases are build milestones inside the §0 single-release model (dark pool →
-probe gates → router-weight canary of coherent requests → full weight).
-
-**Phase 0 decision records** (promoted from open questions; each has an
-owner, evidence, a deadline, and an explicit go/no-go): DR-1 mediator
-presence in the affected deployment (OQ1 — decides whether §6.1 gates
-Phase 3 entry); DR-2 script-creative share (OQ2 — decides the §6.3
-guidance priority); DR-3 #922 vs #997 (OQ4 — decides the Phase 3 work
-item); DR-4 mediator candidate-id echo owner and timeline (OQ7 —
-`merge_highest_cpm` is config-blocked until delivered); DR-5 non-Fastly
-sink decision (OQ5 — splits Phase 2's gates below).
-
-**Gates are a checked-in table** (`docs/superpowers/specs/rollout-gates.md`,
-created in Phase 0) with columns: query/test command, assignment key,
-expected positive count, denominator, sample floor, threshold, window,
-owner, hold/rollback action. The real-GAM suite's row includes its workflow
-name, fixture account and credentials owner, invocation command, artifact
-location, retry policy, and required approval evidence. Prose below is the
-summary; the table is normative.
-
-- **Phase 0 — Identity, schemas, toolchain, decisions.** Path-hashed
- assets + 410 semantics + construction-time concatenation cache;
- `format_version`; §5.6 schemas deployed writer-off; toolchain floors;
- dead expando writes deleted; §5.8 drop surfacing; the five decision
- records; the gates table itself.
-- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry.** G3
- registry; sessions; install manifest; minimal messaging; G4a intent/cycle
- records; unconditional GPT subscriptions.
-- **Phase 2 — Trace + beacon.** G1 issuance on all three paths (boot,
- page-bids, `/auction` extension); §5.3 authorization incl. renewal and
- the diagnostic credential; four-adapter ingest; `ts_client_events`
- writers on. Gates split per DR-5: **HTTP parity** (all adapters:
- routing, limits, 204s, method reservations) vs **persistence**
- (sink-backed only: acceptance, dedup-exactly-once, heartbeat freshness).
-- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required
- `winner_selection` and `[auction].currency`; render token + tombstones;
- renderer route + three-message ack + CSP report route; §6.8; G4a–G4f
- incl. direct-flow `nurl`/`burl` plumbing; fallback; DR-3's attribution
- restoration; correlator + SRA fixes.
- _Gate (sticky randomized canary/control cohorts, 24 h, ≥ 10,000 sampled
- attempts each; missing telemetry counts as failure):_ **denominator =
- all server-observed eligible APS wins**; per-stage rates gated
- separately — targeting_set/eligible, bridge_request/targeting_set,
- render_accepted/bridge_response_sent, and `cycle_unattributable` rate
- < 0.5% (survivorship is thereby visible, not excluded); GAM fill and p95
- latency as **one-sided non-inferiority** (canary not worse than control
- by > 2%; improvements pass); billing normalized per attempt and per
- thousand attempts vs control; a separate **duplicate-billing invariant**
- (zero double `burl` per idempotency key); real-GAM overlap suite green
- per its table row.
-- **Phase 4 — Structure.** Full layering + both lint rules; plugin
- lifecycle; adapters; full slot registry; full messaging migration; final
- namespace.
- _Gate:_ lints zero exceptions; disposal-inventory leak tests; four-flow
- behavioral parity (SSAT, client-Prebid, page-bids, direct).
-- **Phase 5 — Decomposition + cutover.** File splits; script-guard
- consolidation; bootstrap stub + generated fallback (with error/hang/
- fallback-arbitration tests); **four-flow parity reruns here** (it changed
- bootstrap behavior after Phase 4's parity — the review's ordering
- point); then the §0 runbook: weight-up, purge, 24 h monitored window,
- weight-back rollback.
+## 8. Rollout
+
+Single-release state machine per §0 (sticky-cohort affinity). **The
+normative gates table ships with this design as Appendix A**, including
+initial thresholds, queries, commands, cohort keys, sample floors, owners,
+hold/rollback actions, and the real-GAM suite's operational row. Threshold
+changes require reviewed decision records.
+
+Phases (build milestones inside the one release):
+
+- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time
+ asset materialization; `format_version`; §5.6 schemas writer-off;
+ toolchain floors; dead expando deletion; drop surfacing; decision
+ records DR-1..DR-5 (DR-2 now a deployment decision, §6.2–6.5; DR-4
+ gates `merge_highest_cpm`; DR-5 splits Phase-2 gates).
+- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry,
+ transactional bootstrap ownership.**
+- **Phase 2 — Trace + beacon.** All three issuance paths + renewal +
+ diagnostic credential + probe mode; four-adapter ingest incl.
+ `/_ts/trace-auth` in the isolation family; heartbeats. Gates split:
+ HTTP parity (all adapters) vs persistence (sink-backed).
+- **Phase 3 — APS delivery.** As revision 6, plus `notification_sent`
+ observability and per-flow funnel gating (the `flow` field): expected-
+ stage tables per flow live in the gates file; denominator = server-
+ observed eligible APS wins **within sampled/diagnostic traces**;
+ `cycle_unattributable` gated; one-sided non-inferiority for fill/p95;
+ duplicate-`burl` invariant via `notification_sent` key hashes.
+- **Phase 4 — Structure.** Layering, plugins, adapters, registry,
+ messaging, namespace; four-flow behavioral parity.
+- **Phase 5 — Decomposition + cutover.** File splits; bootstrap stub +
+ generated fallback (error/hang/arbitration tests); four-flow parity
+ rerun; **then the full Phase-3 statistical canary/control gate and the
+ real-GAM suite are repeated on the exact immutable release candidate**
+ before router weight rises beyond the low-weight canary; then weight-up,
+ purge, 24 h monitored window.
## 9. Test acceptance matrix
-Hermetic CI blocks PRs; the real-GAM suite is release-gating per its gates
-row. New/changed rows this revision are marked •.
-
-| Area | Must cover |
-| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Request cycles | intent-vs-request; • disabled-initial-load `display()` retired at issuance, publisher `refresh()` inside 2 s window attributed to publisher (the exact review case); SRA; `intent_no_request`; overlap quarantine; • no timeout re-arm (drain/destroy/page-end only); stale discard; real-GAM overlap |
-| Ack protocol | three-message sequence (• `renderer_document_loaded` envelope); five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed; after-disposal acks |
-| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; • prior-navigation ids suppressed via RuntimeSession tombstones after NavigationSession disposal; bounded walk; listener order |
-| Render semantics | binds per flow; `burl` at accepted; attempt idempotency; • `billing_outcome{billed_then_failed}` as its own event; accepted-but-blank; • `gam_collapsed` emission + guarded resize (authenticated source only; 1×1 wrapper; anchor/fixed guards) |
-| Direct `/auction` | • trace header + response `ext.trusted_server.trace`; • per-slot latest-wins with reversed responses; • generation check before each DOM/beacon effect; • `RequestAdsResult` settlement; • server preserves + expands `nurl`/`burl`, client validates |
-| Fallback | attributed `gam_empty` only; publisher-initiated never; timeout never renders; SPA cancellation; flag change mid-attempt |
-| Mediation | • `[auction].currency` required + per-provider validation (Prebid parse, APS implied USD); • `ext.trusted_server.candidate_id` echo; • mediator-native candidates ordered by intrinsic key (arrival-order shuffle test); • authoritative-field rules (repricing kept, adm-swap → native); provenance fail-closed; • strategy-specific timeouts; both lifecycles |
-| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; capacity → `registry_full`; • tombstone retention to original TTL, cap 256 |
-| Trace auth | • auth ≤ 256 B bound accepted, 64-char cap for others; • encoding vectors (kid charset, canonical exp, u32be/u64be length prefixes, unpadded base64url); expiry/skew/max-future; • renewal preserves trace + mode; • previous-key retention ≥ 24 h; rotation; per-group rejection |
-| Sampling modes | • signed `unsampled`: client transmits nothing, ingest rejects carried events, stickiness across renewal; • diagnostic requires the operator credential — tester cookie alone must fail; forgery/replay of the credential |
-| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; • `client_queue_overflow` not in failure denominators; ingest abuse; sendBeacon Blob |
-| Ingest/limits | • per-adapter limiter semantics as declared (Fastly fixed-window approximation, Axum token bucket); • at-capacity rejects unseen identities, never evicts active; fail-closed 204 |
-| Internal routes | wrong-method 405 + Allow + no-store on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding |
-| CSP | both media types; opaque/null-origin admission; • policy identity from server-selected report path (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; • header manifest per version (immutable headers frozen with bytes) |
-| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX |
-| Runtime ABI | one kernel; exact-release verdicts; late registration; failure isolation; • object-form `definePlugin` release check |
-| Plugins | partial-install unwind; async rejection; abort pending; disposer-after-disposal; isolation |
-| Lifecycle | `timed_out → present`; session inventories; • unissued intents cancelled by navigation disposal; boot container idempotent field-wise init (• ad-slot script no longer clobbers); • fallback error/hang activation + late-bundle deferral; final-namespace smoke |
-| Delivery | unknown hash 410 no-store; exact-match immutable with full directive; • construction-time vector cache (unlisted vector = startup error); cutover rehearsal |
-| Sink/monitoring | • sequence-tagged synthetic heartbeats measure rejection + freshness; • canonical views only (raw-join multiplication test); `publisher_domain` naming |
-| Failure injection | Amazon runner redirect/hang/CSP/script error → distinct outcomes; EC/filter failure before renderer dispatch |
-| Adapter parity | ingest, CSP-report, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
-| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; • `boot.debug` + response `debug` gating; diagnostic completeness per §2.5 |
-
-## 10. Alternatives considered
-
-1. Patching APS point-failures without telemetry — rejected (four correct
- fixes, still no reliable ads).
-2. Always direct-render APS — rejected; kept as the attributed-`gam_empty`
- fallback.
-3. Single module graph now — rejected for this release; successor option.
-4. Big-bang rewrite without phases — rejected (thin safety net).
-5. Dropping the ES5 bootstrap — rejected (loses the no-bundle guarantee);
- generated fallback keeps it.
-6. Timeout-triggered fallback rendering — rejected (uncancelable GPT
- requests race late fills).
-7. Timeout-based quarantine re-arm (revision 5) — removed: it recreated
- the stale-event bug it claimed to fix.
-8. N/N−1 compatibility machinery (revisions 3–4) — removed by the §0
- policy.
-
-## 11. Risks
-
-- Hard-cutover blast radius (accepted; bounded by the §0 runbook).
-- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates
- `merge_highest_cpm`.
-- Notification triggers become a published contract for PBS-path demand.
-- Beacon abuse — capped, origin-checked, fail-closed limited, signed
- modes, credentialed diagnostics.
-- Registry/limiter memory — explicit capacities, TTLs, reject-at-capacity.
-- CSP data is advisory — never a sole rollback signal.
-- Sink blindness — heartbeat-based datasource monitoring.
-- `[auction].currency` and `winner_selection` are new required config in
- mediated deployments — a deliberate startup-error class under §0.
+Revision 6's matrix stands, with these added/changed rows:
+
+| Area | Added coverage |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Request cycles | publisher `display()` under disabled initial load retired at issuance; publisher-display → TS-refresh attribution test; symmetric ambiguity |
+| Trace auth | renewal presents current token and preserves mode; renewal-after-expiry fails closed; deterministic sampling (same trace → same mode across concurrent requests) |
+| Diagnostic | credential issuance under admin auth + CSRF; fragment→sessionStorage transport; upgrade of an initial sampled trace; auth `exp` capped at credential expiry; forgery/wrong-origin |
+| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket |
+| Affinity | sticky-cohort coherence: new-pool HTML never fetches control-pool assets/APIs (cache-key + routing test); rollback binding prevalidation |
+| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; `release_id` cohort attribution |
+| Funnels | `flow` field set per path; per-flow expected-stage conformance (SSAT, prebid, page-bids, direct, fallback) |
+| Tombstones | union capacity 320; unexpired never evicted; >320 registrations then late oldest-id request suppressed; `registry_full` on refusal |
+| Ack per path | all four G4b sequences incl. adm reporter snippet; per-path document/runner deadlines; cancellation on navigation |
+| Terminal states | exactly one terminal per attempt; post-accept runner failure emits observation + `billing_outcome`, no second terminal |
+| Notifications | `notification_sent` emitted per dispatch; duplicate-key-hash query returns zero in canary; direct-flow `(bid_id)` identity; server preserves/expands `nurl`/`burl`; client validates |
+| Bootstrap | inert-install + commit flip; throw after each checkpoint unwinds; watchdog claims only from failed/stuck; fallback-then-late-bundle deferral |
+| Overflow | out-of-band counter; coalesced overflow event in next flush; no recursion at full queue |
+| Heartbeats | probe mode not sampled out; excluded from product denominators; freshness/loss queries from `expected_seq` |
+| CSP ingest | pre-buffer caps (8 KiB / 10 reports / 256 chars / depth 4); both media types; opaque origin; policy-id path identity; aggregate schema rows |
+| Mediation | required upstream id bounds; fingerprint fallback determinism under arrival shuffle; duplicate-id dedup; adm-swap reclassification; APS + non-USD startup error |
+| Limiter | TTL reclaim under saturation; unknown-address bucket; Fastly overshoot bound; XFF hop selection |
+| Perf | marks present; vector contents; heap budget; inconclusive-rerun policy |
+| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters |
+| Kill switch | pre-commit attempts cancelled; post-commit attempts run to terminal; snapshot semantics |
+
+## 10. Alternatives / 11. Risks
+
+As revision 6, plus: **rejected** — per-request Fastly concatenation
+(replaced by release-time materialization); trusting client-asserted
+sampling on renewal (replaced by token-presentation renewal); FIFO
+tombstone eviction (replaced by union capacity with refusal). Risk added:
+sticky-cohort routing is new infrastructure the cutover depends on — it is
+Phase 0 work and its coherence test is release-gating.
## 12. Success criteria
-1. APS creatives render in each configured flow (SSAT, client-Prebid,
- page-bids, direct), hermetically and in the release-gating real-GAM
- suite.
-2. Every §2 failure maps to its §2.5 signal; **diagnostic mode names the
- failing class from one page load including A1–A4 via the `boot.debug` /
- response-`debug` envelopes**; §5.7 SLIs hold on sink-backed
- deployments.
-3. Lints (both rules) zero exceptions; stateful sharing only via the
- registry; exact-release mismatches quarantine loudly.
-4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
- generated.
-5. Attempt counts are keyed `(trace_id, nav_gen, refresh_gen, slot)`
- (traces stay navigation-scoped); no double counting; orphan recovery
- has a non-vacuous test; G4a holds including the no-timeout-re-arm rule.
-6. The only TSJS-owned global is `window.tsjs` (§7.4 final shape); no
- expandos; legacy names gone at cutover.
-7. §7.10 budgets hold on the dedicated pinned workflow.
-8. No existing warning lost; issue-surfacing conditions log `warn`+ with
- the beacon reason code.
-9. TypeScript floor matches resolved 5.9; `prebid.js` pin documented with
- the deployed bundle.
-10. `nurl`/`burl` only on carrying paths at their G4d binds, idempotent
- per attempt; APS fires neither; duplicate-billing invariant holds.
-11. Trace-bearing responses are `private, no-store`; authorizations are
- per-trace, signed, mode-carrying (`sampled|unsampled|diagnostic`),
- renewal-capable; unsampled traces transmit nothing.
-12. The cutover runbook rehearsed (weight switch, purge, rollback).
+Revision 6's criteria with these corrections: (2) diagnostic completeness
+is achieved via the §2.5 gate split (content vs volume); (5) attempt
+counts keyed `(trace_id, nav_gen, refresh_gen, slot)`; (10) the
+duplicate-`burl` invariant is measured via `notification_sent` key hashes
+in production and by hermetic tests, with external billing reconciliation
+as backstop; (add 13) the Phase-3 statistical and real-GAM gates pass on
+the exact immutable Phase-5 release candidate before weight-up; (add 14)
+the Appendix A gates table shipped with this design and every later change
+carries a reviewed decision record; (add 15) the baseline APS fix behaviors
+are re-implemented in the target architecture with the baseline browser
+tests passing unmodified as the conformance pin.
## 13. Open questions
-Promoted to Phase 0 decision records: mediator presence (DR-1),
-script-creative share (DR-2), #922 vs #997 (DR-3), candidate-id echo owner
-(DR-4), non-Fastly sinks (DR-5). Remaining open: does Amazon expose any
-creative-completion acknowledgement that could add a post-`render_accepted`
-state under a new name (future enhancement)?
+Only one remains outside the decision records: does Amazon expose any
+creative-completion acknowledgement that could add a
+post-`render_accepted` state under a new name (future enhancement)?
+
+---
+
+## Appendix A — Normative rollout gates (initial values)
+
+Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** =
+release owner's on-call. Assignment key for canary/control =
+sticky cohort (`ts-rel`), randomized at HTML request, per §0. All
+production queries run against canonical views only (§5.5). "Hold" =
+router weight frozen; "Rollback" = weight to previous release + re-purge.
+Changing any row requires a reviewed decision record.
+
+### A.1 Phase gates
+
+| Phase | Gate | Query / test command | Denominator | Floor | Threshold | Window | Owner | Action |
+| ----- | -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | --------------------------------- | ------ | ----- | -------- |
+| 0 | Dark-pool health | probe suite vs dark pool (all four adapters) | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold |
+| 0 | Schema validation | synthetic writes to `ts_client_events` + auction rows | synthetic rows | 10,000 | rejection < 0.1% | 24 h | RO | Hold |
+| 0 | Asset identity | probe: every manifest hash 200-immutable; unknown hash 410 `no-store` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold |
+| 1 | ABI cleanliness | probe pages: `abi_mismatch` + `bundle_partial` counters | probe page loads | 1,000 | 0 | 24 h | QA | Hold |
+| 1 | Bootstrap ownership | hermetic: throw-after-each-checkpoint suite | checkpoints | all | 100% unwind-to-`failed` | CI | QA | Hold |
+| 2 | Ingest HTTP parity | parity suite vs all four adapters (routes, 405s, limits, 204s) | parity cases | all | 100% | CI | QA | Hold |
+| 2 | Persistence (sink-backed) | acceptance ≥ 99%; dedup exactly-once per `(trace, seq)` | probe batches | 10,000 evts | as stated | 24 h | OPS | Hold |
+| 2 | Heartbeat pipeline | freshness lag; `expected_seq` loss | probe heartbeats | 1,000 | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
+| 3 | APS funnel (per flow) | per-`flow` stage rates from `ts_render_attempts_v` (table A.2) | eligible APS wins in sampled+diagnostic traces | 10,000/cohort | per A.2 | 24 h | RO | Rollback |
+| 3 | Attribution soundness | `cycle_unattributable` rate | attributable-candidate cycles | 10,000 | < 0.5% | 24 h | RO | Rollback |
+| 3 | GAM fill (non-inferiority) | canary fill vs control | cohort ad requests | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback |
+| 3 | Latency (non-inferiority) | canary p95 bids-to-display vs control | cohort attempts | 10,000 | canary ≤ control × 1.02 | 24 h | RO | Rollback |
+| 3 | Billing | GAM/server-side revenue per 1,000 attempts, canary vs control | cohort attempts | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback |
+| 3 | Duplicate `burl` | duplicate `notification_sent{burl}` per `id_key_hash` | burl dispatches | 1,000 | 0 | 24 h | RO | Rollback |
+| 4 | Layering | both lint rules; disposal-inventory leak suite | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
+| 4 | Four-flow parity | hermetic parity: SSAT, prebid, page-bids, direct | parity cases | all | 100% | CI | QA | Hold |
+| 5 | Parity rerun + budgets | four-flow parity; §7.10 budgets on pinned workflow | — | — | 100% / within tolerance | CI | QA | Hold |
+| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback |
+| 5 | Cutover monitor | §5.7 SLIs post-weight-up | production traffic | — | SLIs green | 24 h | OPS | Rollback |
+
+Low-volume handling: a production gate that cannot reach its floor within
+its window is **inconclusive** — extend the window once; a second
+inconclusive result is a Hold, never a pass.
+
+### A.2 Expected stages per flow (Phase 3 funnel)
+
+| Flow | Expected sequence | Stage thresholds |
+| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each stage ≥ 95% of prior; document-load ≥ 99%; runner fail+timeout ≤ 1% |
+| prebid | same as ssat (keyed by Prebid `adId`) | same |
+| page_bids | same as ssat (after SPA navigation) | same |
+| direct | `render_attempt → renderer_document_loaded → render_accepted` (no bridge stages) | document-load ≥ 99%; accepted ≥ 95% of attempts |
+| fallback | `gam_empty → fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of fallback starts |
+
+### A.3 Real-GAM suite (operational row)
+
+| Field | Value |
+| ------------------ | -------------------------------------------------------------------------------------- |
+| Workflow | `real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
+| Topologies | one per flow in A.2, plus publisher-overlap and disabled-initial-load formation (G4a) |
+| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) |
+| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
+| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
+| Command | `npx playwright test --config real-gam.config.ts` from the browser test package |
+| Artifact | Playwright HTML report + trace zips, uploaded as workflow artifacts, retained 90 days |
+| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
+| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO |
From b1611680ddce4bd4e766fea40d571017e9a6423d Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:51:58 -0700
Subject: [PATCH 009/194] Rewrite design spec self-contained for the seventh
review round
Revision 8 inlines every normative contract (no revision references
remain) and resolves the seventh round: beacon and CSP transports move
to credentials same-origin so authenticated sticky-cohort affinity
holds for every request class, the ts-rel cookie becomes an opaque
HMAC token with attribute/TTL/reassignment rules, config blobs are
hash-verified against the deployment manifest at startup, auction_id
becomes the cross-tier equality key with generations client-side only,
canary/control attribution moves to pool-specific datasource tokens,
statistical gates use sampled traces only with one-sided 95 percent
confidence bounds on relative differences and per-flow floors (rare
flows gate hermetically), Appendix A rows name checked-in pipes,
scripts, and workflows with a concrete cutover-monitor threshold and
zero-rejection deterministic schema writes, the duplicate-burl
invariant becomes hermetic proof plus billing reconciliation with
telemetry as a detection alarm, the browser stops computing hashes in
favor of a server-minted notif_id, every datasource gets its own probe
and freshness query, CSP and ops sinks get complete schemas, settings,
and a multi-target sink handle, origin policy becomes per route
family, same-class zero-request intents are superseded by any later
request-capable intent, the auction client returns a discriminated
error result, the bootstrap watchdog aborts and unwinds under an owner
generation before fallback claims, flow gains a system value with a
generated validity matrix, fallback becomes a child attempt with
parent_flow, id-less or duplicate bids are rejected instead of
fingerprinted, the drop enum is exhaustive over baseline producers
with a compile-time mapping test, the Fastly overshoot claim is
withdrawn in favor of documented burst behavior, diagnostic upgrade
becomes the sole authenticated mode transition with fragment clearing
and in-memory-only credential storage plus pre-upgrade buffering,
sampling gets an exact u64 threshold algorithm, totals and overflow
rows gain row_kind with nullable fields, the kill-switch commit point
moves before the first irreversible action including nurl,
billing_outcome is removed for lack of an honest producer, the
plugin-level dispose hook is removed in favor of ctx.onDispose, and
the TypeScript gate uses a checked-in npm script with --no-install.
---
...s-render-fix-and-tsjs-resilience-design.md | 1690 +++++++++++------
1 file changed, 1090 insertions(+), 600 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 001011d91..74e08c867 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,60 +1,83 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 7 — reworked after the sixth review round.
+- **Status:** revision 8 — reworked after the seventh review round and made
+ fully self-contained: no contract in this document is defined by reference
+ to an earlier revision.
- **Date:** 2026-08-04
- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed
GAM shells"). All file:line citations refer to this commit.
-- **Inputs:** three code audits; design reviews of revisions 1–6; open issues
+- **Inputs:** three code audits; design reviews of revisions 1–7; open issues
#926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997.
-- **Normative gates:** the initial rollout-gates table ships as
- **Appendix A of this document** (one file, one review surface); changes to
- it require reviewed decision records so thresholds cannot be chosen after
- observing results.
+- **Normative gates:** the initial rollout-gates table is **Appendix A**;
+ changes require reviewed decision records so thresholds cannot be chosen
+ after observing results.
- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design
adopts their **contracts** — the MessageChannel handshake semantics, the
collapsed-shell remediation behavior, the consolidated bridge branch — and
- **re-implements them inside the target architecture** (the messaging
- module owns the channel protocol, the render engine owns the resize, the
- rebuilt `render_bridge` module owns the branch). The patch code itself is
- not carried forward through the refactor; the baseline's browser tests are
- retained as the conformance suite that pins the adopted behavior while the
- implementation is replaced.
+ **re-implements them inside the target architecture** (the messaging module
+ owns the channel protocol, the render engine owns the resize, the rebuilt
+ `render_bridge` module owns the branch). The patch code is not carried
+ forward; the baseline's browser tests are retained unmodified as the
+ conformance suite pinning the adopted behavior.
## 0. Release policy: coordinated hard cutover
One coordinated release:
-- Server, TSJS bundles, config, and HTML ship under one **`release_id`**.
- **No N/N−1**; in-flight clients may fail at cutover — accepted and stated.
-- **Exact release matching**; config `format_version` exact-match; rollback =
- redeploy the previous release with its own config.
-- **Config is a release-time input** under this policy: the config blob and
- binary publish together, so the enabled module vectors are known at
- release publication (this powers §G5 asset materialization).
-- Assets: embedded only; hashed pathnames for cache identity; unknown hash
- → `410`, `no-store`.
-- **Rollout state machine with release affinity.** A deployment manifest
- binds each pool to immutable `{release_id, config_store, config_key,
-config_hash}` (rollback binding prevalidated). The new pool comes up fully
- enabled, reachable only by probes. Canarying uses a **sticky cohort
- token**: the router assigns `ts-rel=` on the HTML response,
- routes every subsequent request (assets, APIs, beacons) by it, and cache
- keys include it — router weights alone apply per request and would mix
- pools, so affinity is what makes a canary request **coherent** end to end.
- Router weight over sticky cohorts is the sole activation primitive; flags
- are in-pool emergency kill switches only. Cutover = weight 100% + CDN
- purge; rollback = weight back + re-purge.
+- Server, TSJS bundles, config, and HTML ship under one **`release_id`**
+ (git tag / build hash). **No N/N−1**; in-flight clients may fail at
+ cutover — accepted and stated, not mitigated.
+- **Exact release matching**: kernel, services, plugins, and the install
+ manifest carry the same `release_id`; mismatch is a refusal.
+- **Config is a release-time, content-verified input.** The config blob gains
+ a top-level `format_version` (exact match required). Publish order: blob
+ first, deployment manifest second. The manifest binds each pool to
+ immutable `{release_id, config_store, config_key, config_hash}`, and the
+ binary **verifies the loaded blob's hash against the manifest at
+ startup** — a mismatch is a startup failure, so a config overwrite cannot
+ mutate a supposedly immutable release or invalidate pre-materialized asset
+ vectors. Rollback = redeploy the previous release with its own verified
+ config; the rollback binding is prevalidated.
+- **Assets:** binaries embed only their release's artifacts; hashed pathnames
+ exist for cache identity; unknown hash → `410 Gone`, `no-store`.
+- **Rollout state machine with authenticated release affinity.** The new
+ pool comes up fully enabled, reachable only by probes. Canarying uses a
+ **sticky, opaque, authenticated cohort token**: the router sets `ts-rel`
+ on the HTML response — an HMAC-signed opaque value binding
+ `{publisher_host, release_id, cohort, exp}` (attributes: `Secure;
+HttpOnly; SameSite=Lax; Path=/`; TTL 24 h) — and routes every subsequent
+ request by the **validated** token; invalid, expired, forged, or
+ non-allowlisted tokens route to control and are reissued; tokens for
+ retired releases are reassigned on next HTML response after rollback.
+ Cache keys use the post-validation release label (bounded cardinality;
+ raw cookie values never key caches). A plain readable release id would
+ let any visitor opt into the dark pool and would hand cache-key
+ cardinality to attackers — hence opaque and authenticated. Because
+ affinity rides a cookie, **the beacon and CSP-report transports use
+ `credentials: "same-origin"`** (not `omit`): the cookie exists for the
+ routing layer only; application handlers still derive no identity from
+ it. Router weight over sticky cohorts is the sole activation primitive;
+ flags are in-pool emergency kill switches. Cutover = weight 100% + CDN
+ purge; rollback = weight back + re-purge. The affinity acceptance test
+ covers HTML, assets, APIs, **beacons, and CSP reports**.
+- **Canary/control discrimination is infrastructure-attributed:** each pool
+ writes telemetry with **pool-specific datasource tokens**, so cohort
+ attribution comes from the write identity, not from in-row fields the
+ control binary (the baseline) does not emit; in-row `release_id` from the
+ new pool is secondary confirmation.
## 1. Problem statement
-APS demand is fully integrated server-side, yet APS creatives do not appear
-reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim, the
-`hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived
-review; the pattern is the finding: **multiple independent failure points,
-most failing silently**, with no client→server signal about which fired.
-The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths,
-duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches)
-is the same problem structurally.
+APS demand is fully integrated server-side — the edge runs the APS OpenRTB
+auction, wins bids, and ships a typed renderer descriptor — yet APS
+creatives do not appear reliably. Four serial fixes (the `bid.meta`
+carrier, the decoupled shim, the `hb_adid` fallback, the baseline
+PUC/collapsed-shell fix) each survived review; the pattern is the finding:
+**multiple independent failure points, most failing silently**, with no
+client→server signal about which fired. The TSJS library (56 files,
+~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic,
+inverted layering, ~100 error-swallowing catches) is the same problem
+structurally.
### Non-goals
@@ -66,8 +89,8 @@ is the same problem structurally.
## 2. Why APS does not render — evidence
Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer`
-adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d) renders
-an APS descriptor without GAM.
+Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d)
+renders an APS descriptor without GAM.
### 2.1 Admission
@@ -94,7 +117,7 @@ an APS descriptor without GAM.
| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` |
| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` |
| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` |
-| C6 | The renderer CSP can kill creatives after "ready". | `aps.rs:49` |
+| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` |
| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` |
### 2.4 Observability
@@ -104,13 +127,13 @@ server-side to one that painted.
### 2.5 Failure → signal mapping (normative)
-Two distinct gates, precisely separated: the **tester cookie** (explicitly
+Two gates, precisely separated: the **tester cookie** (explicitly
non-security, `tester_cookie.rs:3`) gates **debug content** — the
`tsjs.boot.debug` envelope and the page-bids/`/auction`
`ext.trusted_server.debug` fields, the same sensitivity class as the
-existing tester-gated `ts-debug` HTML comment. The **diagnostic credential**
-(§5.3) gates **telemetry volume** (unsampled mode). The tester cookie never
-affects sampling; the credential never gates mere content.
+existing tester-gated `ts-debug` comment. The **diagnostic credential**
+(§5.3) gates **telemetry volume**. The cookie never affects sampling; the
+credential never gates mere content.
| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- |
@@ -118,9 +141,9 @@ affects sampling; the credential never gates mere content.
| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary |
| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
-| B1/B2 | `bridge_request{matched:false}` | join via trace | console warn |
+| B1/B2 | `bridge_request{matched:false}` | join via trace (§G1) | console warn |
| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
-| C2 | `render_fail{renderer_document_no_load}` | route counters (`ts_ops_counters`) | console warn |
+| C2 | `render_fail{renderer_document_no_load}` | `ts_ops_counters` | console warn |
| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn |
| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn |
| C5 | — (fixed at baseline) | — | — |
@@ -129,8 +152,8 @@ affects sampling; the credential never gates mere content.
## 3. The GPT and baseline reality
-1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead in
- production.
+1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead
+ in production.
2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement.
3. TS refreshes never pass `changeCorrelator: false`.
4. `enableSingleRequest()` called blind after publisher `enableServices()`.
@@ -140,606 +163,1064 @@ affects sampling; the credential never gates mere content.
guarantee**; `slotRenderEnded` = code injected, not resources loaded;
`responseIdentifier` identifies responses only.
8. **`display()` under disabled initial load creates no request — for any
- caller.** GPT's behavior is caller-independent (`gpt/index.ts:1175`,
- `ad_init.test.ts:1201-1263`); the G4a rules therefore apply to publisher
- `display()` exactly as to TS `display()`.
+ caller** (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`); GPT's
+ behavior is caller-independent.
9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled`
(`gpt/index.ts:1091`); G4a needs unconditional early subscription.
10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake
- (`aps.rs:65-125`, `aps/render.ts:415-437`; reply still terminates inside
- the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5
+ (`aps.rs:65-125`, `aps/render.ts:415-437`; the reply still terminates
+ inside the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5
consolidated, real-PUC browser test added.
11. The bridge keeps consumed-id tombstones for security
- (`gpt/index.ts:1527`); G2 preserves that across navigations.
+ (`gpt/index.ts:1527`).
12. The tester cookie is not a security control (`tester_cookie.rs:3`).
-13. **Fastly constructs application state per request** (`app.rs:146`) — no
- cross-request in-memory state may be assumed on that adapter.
+13. **Fastly constructs application state per request** (`app.rs:146`);
+ its platform rate counter is a 60 s fixed window with separate
+ lookup/increment (`rate_limiter.rs:40`).
+14. The baseline auction client collapses every failure into an empty
+ array (`core/auction.ts:185-224`): absent fetch, timeout, network
+ error, non-2xx, wrong content type, malformed body, and a genuine
+ zero-bid auction are indistinguishable to callers.
## 4. Design gates
-### G1 — Trace identity, sampling, and correlation
-
-- Client-visible auction id (EC-derived, `publisher.rs:3237`) is never
- ingested. Initial-HTML telemetry precedes page JS, so correlation is
- minted by whoever acts first: server for `nav_gen 0` (trace + signed
- authorization in `tsjs.boot`); client afterwards, via `X-TSJS-Trace-Id`
- on page-bids (GET) and on the `/auction` POST, echoed back with the
- signed authorization (`ext.trusted_server.trace = {trace_id, auth,
-auction_id}` on JSON/OpenRTB responses).
-- **Sampling is a deterministic keyed function**, not a coin flip:
- `mode = sampled iff HMAC(sampling_key, trace_id) < sample_rate` — so
- concurrent requests presenting the same trace always derive the same
- mode, and first issuance needs no shared state (Fastly is per-request,
- §3.13).
-- **Cross-tier join key (closing the row-multiplication gap):** the server
- echoes its telemetry `auction_id` to the client (boot / response
- extensions); the client stamps `auction_id` on every event of attempts
- born from that auction. Server rows additionally carry `release_id`.
- Canonical joins run on `(publisher_domain, trace_id, auction_id, slot,
-refresh_gen)` at attempt grain; canary/control comparison uses the
- server-side `release_id`.
-- Cache-privacy invariant: traces/authorizations only in per-request
- auction-bearing responses; such HTML is `private, no-store`.
+### G1 — Trace identity, sampling, correlation
+
+- The client-visible auction id is EC-derived (`publisher.rs:3237`) and
+ never ingested. Initial-HTML telemetry precedes page JS
+ (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by
+ whoever acts first: the **server** for `nav_gen 0` (trace + signed
+ authorization in `tsjs.boot`); the **client** afterwards via
+ `X-TSJS-Trace-Id` on page-bids (GET) and the `/auction` POST, echoed
+ back with the authorization and the server's telemetry auction id:
+ `ext.trusted_server.trace = {trace_id, auth, auction_id}`.
+- **Deterministic keyed sampling, numerically exact:** take the first
+ 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as a big-endian u64;
+ `mode = sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate`
+ must be finite and in `[0, 1]` (validated at load; 0 → nothing sampled,
+ 1 → everything). Concurrent requests for one trace always derive the
+ same mode with no shared state (§3.13).
+- **Cross-tier join:** the equality key is the globally unique
+ **`auction_id`** (server-minted telemetry UUID), echoed to the client
+ and stamped on every event of attempts born from that auction.
+ Generations (`nav_gen`, `refresh_gen`) exist **client-side only**, for
+ attempt aggregation — auction rows do not carry them. Canonical join:
+ `(publisher_domain, trace_id, auction_id)`, attempt grain added from
+ client events.
+- **Cache-privacy invariant:** traces/authorizations only in per-request
+ auction-bearing responses; such HTML is `private, no-store`, no
+ validators; by construction and by test.
- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry
- `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow` is a closed
- field** `ssat | prebid | page_bids | direct | fallback` set by the
- attempt owner — the per-flow funnels in the gates table depend on it.
+ `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow`** is closed:
+ `ssat | prebid | page_bids | direct | fallback | system` — `system` for
+ heartbeat and overflow events, which have no render flow; the generated
+ per-event validity matrix (§5.1) says which events may carry which
+ flows.
- Traces are navigation-scoped; attempt counts key on
`(trace_id, nav_gen, refresh_gen, slot)`.
### G2 — Render identity
-- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte. Markup bids:
- existing fallback chain. Renderer-only bids: server-minted token
- `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min,
- one-time consumption.
-- **Registry + tombstones, one capacity, no unexpired eviction:** the
- bridge-reservation store holds live registrations and tombstones
- (consumed / stale / navigation-disposed ids) in one bounded structure —
- **capacity 320 for the union**; expired entries are pruned; **unexpired
- entries are never evicted**; when the union is at capacity, **new
- registration is refused** with `registry_full`. A late prior-navigation
- bridge request therefore always meets suppression until its id's original
- TTL passes (preserving `gpt/index.ts:1527`). Test: >320 registrations,
- then a late request for the oldest unexpired id.
-- Client-Prebid keeps Prebid's `adId`; one store serves both paths.
-
-### G3 — Runtime ABI (exact-release)
-
-- Kernel only in `tsjs-core`; publishes
- `tsjs._internal = {release_id, registry}`; frozen after boot; core
- services constructed at boot; plugins register via the object-form API
- (§7.6) whose `release` is a build-generated constant; `registry.get`
- succeeds only on `release_id` equality; mismatch quarantines
- (`abi_mismatch`/`bundle_partial`) with a console error.
-- Boundary enforcement: `import/no-restricted-paths` for layering plus
- **`no-restricted-properties`/`no-restricted-syntax`** rules covering
- `window`, `globalThis`, `self`, and local aliases for `googletag`/`pbjs`
- access outside `adapters/` (`no-restricted-globals` cannot catch member
- expressions). Adapters are the only access to **external ad-tech
- globals**; the kernel and messaging module necessarily touch
- `window.tsjs`, listeners, and `postMessage`.
+- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte
+ (`publisher.rs:3355`; the PUC fetches `?uuid=`, `gpt/index.ts:1772`).
+ Markup bids: existing fallback chain. Renderer-only bids: server-minted
+ token `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry,
+ cross-auction uniqueness probabilistic (36¹² ≈ 4.7×10¹⁸) and harmless
+ via scoping; TTL 15 min; one-time consumption.
+- **Reservation store — one capacity, no unexpired eviction:** live
+ registrations and tombstones (consumed / stale / navigation-disposed
+ ids) share one bounded structure, **union capacity 320**; expired
+ entries are pruned; **unexpired entries are never evicted**; at
+ capacity, new registration is refused with `registry_full`. A late
+ prior-navigation bridge request always meets suppression until its id's
+ original TTL passes (preserving `gpt/index.ts:1527`). Test: >320
+ registrations, then a late request for the oldest unexpired id.
+- The client-Prebid path keeps Prebid's generated `adId`; one store
+ serves both paths. Non-APS cache-path byte-identity regression tests.
+
+### G3 — Runtime ABI under the IIFE build (exact-release)
+
+Every entry point is a self-contained IIFE with inlined imports
+(`build-all.mjs:46`, `bundle.rs:23`) — imports never share state across
+bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`).
+
+- The kernel ships only in `tsjs-core`, publishes
+ `tsjs._internal = {release_id, registry}` once (window sentinel),
+ freezes `_internal` after boot, and constructs/registers core services
+ (event bus, beacon queue, sessions, slot registry, render engine)
+ during boot; integrations register integration-scoped services during
+ `install()`.
+- **Exact release matching:** every registration carries `release_id`
+ (plugins via the §7.6 object API whose `release` is a build-generated
+ constant); `registry.get(name)` succeeds only on equality; mismatch
+ quarantines (`abi_mismatch` service / `bundle_partial` plugin) with a
+ console error.
+- Stateful access only via the registry at call time; stateless helpers
+ may inline.
+- **Boundary enforcement:** `import/no-restricted-paths` for layering
+ **plus** `no-restricted-properties`/`no-restricted-syntax` rules
+ catching member-expression access to `googletag`/`pbjs` through
+ `window`, `globalThis`, `self`, and local aliases outside `adapters/`
+ (`no-restricted-globals` cannot catch member expressions). Adapters are
+ the only access to **external ad-tech globals**; kernel and messaging
+ necessarily touch `window.tsjs`, listeners, and `postMessage`.
### G4 — Render lifecycle
**G4a — Physical request cycles.**
-- Intents recorded for both classes (`ts | publisher`) in one causal queue.
- **Any `display()` issued while initial load is disabled is retired at
- issuance regardless of caller** (GPT's no-request behavior is
- caller-independent, §3.8) — a stale publisher `display()` intent can no
- more poison a later TS `refresh()` match than the reverse. Hindsight
- zero-request intents (`refresh()` on a never-displayed slot) expire at
- 2 s with `intent_no_request`; while one is pending, any opposite-class
- intent makes the next `slotRequested` ambiguous → quarantine. The
- ambiguity rule is symmetric.
-- Cycles open only on `slotRequested`, matched to the causal queue head;
- SRA yields one per slot per batch; cycles close on `slotRenderEnded`;
- `responseIdentifier` dedups during drain.
-- One outstanding TS cycle per slot; one queued replacement (coalescing).
-- Attribution requires exactly one outstanding TS cycle and no overlap;
+- **Intents, both classes, one causal queue.** Every observable
+ initiation — TS and wrapped publisher `display()`/`refresh()` — records
+ an intent in causal order, classified `ts | publisher`. Any
+ `display()` issued while initial load is disabled is **retired at
+ issuance regardless of caller** (GPT is caller-independent, §3.8) — it
+ never enters the matcher. Hindsight zero-request intents (`refresh()`
+ on a never-displayed slot) expire at 2 s with `intent_no_request`;
+ **any later request-capable intent — same class or opposite —
+ supersedes a pending uncertain intent immediately**, and if the
+ uncertain intent's request could still legitimately be in flight
+ (within its 2 s bound), the next `slotRequested` is ambiguous and the
+ slot quarantines. A stale no-op `refresh()` can therefore never steal
+ a later `display()`'s request in either direction, TS→TS,
+ publisher→publisher, or across classes.
+- **Cycles** open only on `slotRequested`, matched to the causal queue
+ head; SRA batching yields one per slot per batch; cycles close on
+ `slotRenderEnded`; `responseIdentifier` deduplicates responses during
+ drain (it never attributes initiation).
+- **Serialization:** at most one outstanding TS cycle per slot; one
+ queued TS replacement (later intents coalesce).
+- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS
+ cycle is outstanding and no publisher/untracked request overlaps;
otherwise quarantine (`cycle_unattributable`), fail closed.
-- **No timeout re-arm.** Re-arm only on count-based drain, safe TS-owned
- destroy/redefine, or page end. Unissued intents are NavigationSession
- children; physical cycle/drain state is RuntimeSession.
-- Deterministic-harness CI plus the release-gating real-GAM suite (scope
- enumerated in the gates table: per-flow topologies, expected sequences,
- browsers, fixtures, commands, artifacts, approvals — success criterion 1
- refers to that enumeration).
-
-**G4b — Acknowledgement, specified per render path.** Four normative
-sequences; each names its nonce/token producer, transport into the owned
-frame, authenticated acceptance observation, cancellation, and separate
-document/runner deadlines (document 3 s, runner 10 s, adm 5 s):
-
-1. **APS-PUC** (baseline transport): bridge mints the per-attempt 128-bit
- nonce; MessageChannel into the renderer document (`ports.length`
- checks, exact-key replies, one-shot latch, port close); the document
- posts authenticated `renderer_document_loaded` then
- `render_accepted | render_failed{reason}` **to the top window**; the
- kernel validates source ownership, nonce, token, `nav_gen`,
- `refresh_gen` before transitions or notifications.
-2. **Generic ADM/cache-PUC**: the bridge's display renderer creates the
- sandboxed adm frame with an injected reporter snippet; the reporter
- posts authenticated `adm_document_loaded{nonce}` to the top window on
- document load; acceptance = that message (baseline merely appends an
- iframe with no observation).
-3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent —
- the baseline parent-postMessage handshake (`ports.length === 0` branch)
- is already kernel-observed; same three messages, same validation.
-4. **Direct ADM/cache**: as (2), with the kernel as parent.
-
-Cancellation for all four: navigation/supersession invalidates the nonce;
-late acks are discarded with `stale_navigation`.
-
-**G4c — Honest observations; one terminal state.** Observations:
-`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded,
-reason}` (observation and remediation are separate — a guarded anchor/fixed
-case is still observed), `renderer_document_loaded`, `runner_loaded`,
-`runner_failed`, `adm_document_loaded`. **An attempt has exactly one
-terminal state: `accepted | failed{reason} | no_bid | cancelled`.**
-Post-acceptance runner failure is an observation plus the
-`billing_outcome{billed_then_failed}` event — never a second terminal
-transition. No observation claims paint. The baseline resize is a
-sanctioned, guarded exception to the no-foreign-DOM-mutation rule.
+- **No timeout re-arm.** Physical cycle/drain state lives in the
+ RuntimeSession slot record; unissued intents are NavigationSession
+ children (cancelled by navigation disposal). A quarantined or stale
+ slot re-arms only on count-based drain, safe TS-owned
+ destroy/redefine, or page end. Timeouts emit diagnostics and never
+ restore attribution. Late stale events are matched and discarded
+ (`stale_navigation`).
+- Deterministic-harness CI plus the release-gating real-GAM suite
+ (topologies enumerated in Appendix A.3).
+
+**G4b — Acknowledgement, per render path.** Four normative sequences;
+each names its nonce producer, transport, authenticated acceptance
+observation, cancellation, and deadlines (document 3 s, runner 10 s,
+adm 5 s). All nonces are per-attempt 128-bit CSPRNG values minted by the
+attempt owner; the kernel validates, in order: source ownership (§6.8
+walk), nonce, token, `nav_gen`, `refresh_gen` — before any transition or
+notification. Navigation/supersession invalidates the nonce; late acks →
+`stale_navigation`.
+
+1. **APS-PUC** (baseline transport): bridge mints the nonce;
+ MessageChannel into the renderer document (`ports.length` checks,
+ exact-key replies, one-shot `accepted` latch, port close —
+ `aps.rs:65-125`, `aps/render.ts:415-437`); the document posts
+ authenticated `renderer_document_loaded` then
+ `render_accepted | render_failed{reason}` to the top window.
+2. **Generic ADM/cache-PUC:** the display renderer creates the sandboxed
+ adm frame with an injected reporter snippet that posts authenticated
+ `adm_document_loaded{nonce}` on document load; acceptance = that
+ message (the baseline merely appends an iframe with no observation).
+3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent;
+ the baseline parent-postMessage branch (`ports.length === 0`) is
+ already kernel-observed; same three messages, same validation.
+4. **Direct ADM/cache:** as (2) with the kernel as parent.
+
+**G4c — Honest observations; one terminal state.** Inline-adm frames are
+sandboxed `srcdoc` without `allow-same-origin` (`gpt/index.ts:510`) —
+opaque; geometry proves nothing. Observations: `gam_nonempty`,
+`gam_empty`, `gam_collapsed{action: resized | guarded, reason?}`
+(observation and remediation separate), `renderer_document_loaded`,
+`runner_loaded`, `runner_failed`, `adm_document_loaded`. **An attempt has
+exactly one terminal state: `accepted | failed{reason} | no_bid |
+cancelled`.** Post-acceptance runner failure is an observation only —
+there is **no** `billing_outcome` event: no path has an honest producer
+for a post-accept billing-failure claim (APS is excluded from
+notifications and opaque frames offer no authenticated post-accept
+signal), so the design does not pretend otherwise. No observation claims
+paint; there is no `render_confirmed`. The baseline resize
+(`gpt/index.ts:217`) is a sanctioned, guarded exception to the
+no-foreign-DOM-mutation rule (authenticated source frame only; wrapper
+only when both dimensions ≤ 1 px; anchor-ad and fixed/sticky guards).
**G4d — Notifications.** APS carries neither `nurl` nor `burl`
-(`aps.rs:839`); excluded entirely. For carrying paths: bind per flow — PUC:
-owned, slot-and-ad-id-matched bridge claim; direct: validated render start
-(server must preserve + macro-expand `nurl`/`burl` in `/auction`
-responses — `formats.rs:423` omits them — and the client must parse and
-https-validate them — `core/auction.ts:43` drops them); fallback:
-attributed `gam_empty` immediately before render. `nurl` at bind, `burl`
-at `accepted`. **Economic identity is the normalized pair
-`(id_kind, id_value)`** (direct attempts without `hb_adid` use
-`bid_id`), idempotency key
-`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`. **Every
-dispatch emits `notification_sent{kind: nurl|burl, id_key_hash, result:
-queued | failed}`** where `id_key_hash` is a 16-hex truncated HMAC of the
-idempotency key — making the duplicate-`burl` invariant observable in
-production (the gates table queries zero duplicates per key hash); external
-billing reconciliation remains the authoritative backstop. No retries.
-
-**G4e — Fallback.** Opt-in; renders only on a terminal `gam_empty`
-unambiguously attributed to a TS cycle; ownership does not gate;
-publisher-initiated or unattributable never triggers; timeouts never
-render.
+(`aps.rs:839`; the AAX envelope excludes them; the integration guide
+documents no generic APS beacons) — excluded entirely; the Amazon runner
+lifecycle is unchanged. For carrying paths (PBS and other OpenRTB
+providers):
+
+- Bind per flow, never selection or targeting (`ad_init.test.ts:1824`):
+ PUC — an owned, slot-and-ad-id-matched bridge claim; direct —
+ validated render start (the server must preserve and macro-expand
+ `nurl`/`burl` in `/auction` responses, `formats.rs:423` omits them;
+ the client must parse and https-validate them, `core/auction.ts:43`
+ drops them); fallback — attributed `gam_empty` immediately before
+ fallback render.
+- `nurl` at bind; `burl` at `accepted`; **no retries**; idempotency key
+ `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)` with the
+ normalized economic identity `(id_kind, id_value)` (direct attempts
+ without `hb_adid` use `bid_id`).
+- **Observability without client cryptography:** the server mints an
+ opaque **`notif_id`** (12-char token, same generator as G2) per
+ notification-carrying bid and delivers it with the bid; every dispatch
+ emits `notification_sent{kind: nurl | burl, notif_id, result:
+queued | failed}`. The browser computes no hashes (a client-held HMAC
+ key would break the pseudonymization boundary; a rotating token would
+ break stability across a gate window).
+- **The duplicate-`burl` invariant is proven hermetically and
+ reconciled externally, not "proven" by lossy telemetry:** hermetic
+ tests pin exactly-once dispatch logic; production
+ `notification_sent` duplicates are a **detection alarm** (any
+ observed duplicate is a red gate); absence-of-duplicates is
+ established by billing reconciliation (GAM/SSP reports vs server-side
+ win counts) because sampled, best-effort telemetry cannot prove a
+ zero.
+
+**G4e — Fallback.** Opt-in
+(`[auction].client_render_fallback = "renderer"`); renders only after a
+terminal `gam_empty` unambiguously attributed to a TS cycle; ownership
+does not gate it; publisher-initiated or unattributable cycles never
+trigger it; timeouts never render.
**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed
-`(trace_id, nav_gen, refresh_gen, slot)`; per-slot latest-wins with
-cancellation; generation checks before every DOM/beacon effect
-(`request.ts:31` races today); G4b sequence 3/4; G4d direct binds;
-disposal on navigation; single terminal state;
-`tsjs.requestAds(options): Promise` with
+`(trace_id, nav_gen, refresh_gen, slot)`; per-slot **latest-wins with
+cancellation** (concurrent calls cancel the older attempt;
+`request.ts:31` races today); generation checks before every DOM/beacon
+effect; G4b sequences 3/4; G4d direct binds; navigation disposal; one
+terminal state. **The auction client returns a discriminated result** —
+`{ok: bids[]} | {error: "auction_timeout" | "network_error" |
+"http_error" | "invalid_response"}` — replacing the baseline's
+everything-is-an-empty-array collapse (§3.14); only a successfully
+parsed response with no winner maps to `no_bid`. Public API:
+`tsjs.requestAds(options): Promise`,
`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" |
-"no_bid" | "failed" | "cancelled", reason?}]}`.
-
-**G4g — Mid-attempt configuration.** An attempt **snapshots its
-configuration at creation**. The in-pool emergency kill switch cancels
-attempts that have not yet passed their commit point — defined as
-`bridge_response_sent` (PUC flows) or first DOM insertion (direct/fallback
-flows); attempts past commit run to their terminal state; dispatched
-notifications are never recalled.
+"no_bid" | "failed" | "cancelled", reason?}]}`, settling when every slot
+attempt is terminal. Reversed-response tests required.
+**Fallback identity:** fallback is a **child attempt** — new
+`RenderAttempt`, `flow = fallback`, carrying `parent_flow` (the
+originating flow); the terminal `gam_empty` belongs to the parent
+attempt under the parent's flow; the canonical view links parent and
+child on `(trace_id, nav_gen, slot, refresh_gen)`.
+
+**G4g — Mid-attempt configuration and the commit point.** An attempt
+snapshots configuration at creation. **Commit = the earliest
+irreversible action** — the first of: notification dispatch (`nurl` at
+bind), `bridge_response_sent`, or first DOM insertion. The
+generation/kill-switch check runs **immediately before each** of those;
+an attempt past commit runs to its terminal state; dispatched
+notifications are never recalled. (Revision 7 put commit after the
+`nurl` side effect; that ordering error is corrected.)
### G5 — Deployment contracts
-- Config `format_version`; release-time config (§0).
-- **Assets pre-materialized at release publication:** because config ships
- with the release, the validated module vectors are known when the release
- is built — concatenated bytes + hashes are produced then and embedded;
- serving is lookup-only on every adapter (Fastly's per-request state,
- §3.13, makes construction-time caching meaningless there — the previous
- revision's claim is corrected). The §7.10 server benchmark measures the
- lookup path and guards against regression to per-request concatenation.
- Unknown vector = release-build error; unknown hash = `410`, `no-store`;
- exact match = `public, max-age=31536000, immutable`.
-- **Internal route families — now four:** renderer, client-events,
- CSP-report, **and `/_ts/trace-auth`** — dispatch before auth/EC/
- publisher/integration filters; all methods reserved locally (405 +
- `Allow` + `no-store`; unknown versions 404 `no-store`; never publisher
- fall-through); no body/cookie/authorization forwarding; normalized
- scheme+host+port origin comparison; each family rate-limited (§5.4) and
- covered by four-adapter parity tests.
+- Config `format_version` + manifest hash verification (§0).
+- **Assets pre-materialized at release publication:** config is a
+ release-time input, so validated module vectors are known when the
+ release is built; concatenated bytes + hashes are produced then and
+ embedded; serving is lookup-only on every adapter (Fastly is
+ per-request, §3.13, so construction-time caching would be
+ meaningless). Unknown vector = release-build error; unknown hash =
+ `410 no-store`; exact match = `public, max-age=31536000, immutable`.
+- **Internal route families — four:** renderer, client-events,
+ CSP-report, `/_ts/trace-auth`. All dispatch before auth/EC/publisher/
+ integration filters (Fastly today runs EC setup and pre-route filters
+ first, `app.rs:709`); all methods and version prefixes reserved
+ locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`;
+ never the publisher fall-through of `adapter-spin app.rs:804`); no
+ body/cookie/authorization forwarding. **Origin policy is per family**,
+ not universal: client-events and trace-auth require strict normalized
+ same-origin (scheme+host+port); the CSP route admits opaque/`null`
+ origins and authenticates by server-selected path identity plus abuse
+ limits; the renderer document is a public GET validated by
+ version/path only (it is loaded from sandboxed opaque contexts —
+ browser-origin authentication is impossible there by design).
+- Ingest routes exist in all four adapters; Fastly has real sinks;
+ others accept-count-drop by contract (DR-5).
- §5.6 schemas deploy and validate before writers enable.
## 5. Observability
-### 5.1 Wire payload and field matrix
+### 5.1 Wire payload and per-event field matrix
```
{ v: 1, traces: [ { trace_id, auth, events: [
{ nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] }
```
-| `t` | fields (absent = absent on wire, NULL in storage) |
-| -------------------------- | ------------------------------------------------- |
-| `bid_received` | slot, id_kind, source |
-| `targeting_set` | slot, id_kind |
-| `bridge_request` | slot, id_kind, matched |
-| `bridge_response_sent` | slot, source |
-| `render_attempt` | slot, source |
-| `render_accepted` | slot, source |
-| `render_fail` | slot, reason, source? |
-| `gam_nonempty` | slot |
-| `gam_empty` | slot |
-| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? |
-| `renderer_document_loaded` | slot |
-| `runner_loaded` | slot |
-| `runner_failed` | slot, reason |
-| `adm_document_loaded` | slot |
-| `fallback_start` | slot |
-| `billing_outcome` | slot, outcome (`billed_then_failed`) |
-| `notification_sent` | slot, kind (`nurl`\|`burl`), id_key_hash, result |
-| `client_queue_overflow` | dropped (count) |
-| `heartbeat` | probe_id, expected_seq |
-
-`source` is **nullable on `render_fail`**: absent for pre-source reasons
+| `t` | fields | allowed `flow` |
+| -------------------------- | --------------------------------------------- | ----------------------- |
+| `bid_received` | slot, id_kind, source | render flows |
+| `targeting_set` | slot, id_kind | render flows |
+| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids |
+| `bridge_response_sent` | slot, source | ssat, prebid, page_bids |
+| `render_attempt` | slot, source | render flows |
+| `render_accepted` | slot, source | render flows |
+| `render_fail` | slot, reason, source? | render flows |
+| `gam_nonempty` | slot | ssat, prebid, page_bids |
+| `gam_empty` | slot | ssat, prebid, page_bids |
+| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids |
+| `renderer_document_loaded` | slot | render flows |
+| `runner_loaded` | slot | render flows |
+| `runner_failed` | slot, reason | render flows |
+| `adm_document_loaded` | slot | render flows |
+| `fallback_start` | slot, parent_flow | fallback |
+| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows |
+| `client_queue_overflow` | dropped (count) | system |
+| `heartbeat` | probe_id, expected_seq | system |
+
+"Render flows" = `ssat | prebid | page_bids | direct | fallback`.
+`source` on `render_fail` is **nullable**: absent for pre-source reasons
(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`,
-`abi_mismatch`, `registry_full`, `bundle_partial`); required for
-source-specific reasons — the per-reason validity matrix is part of the
-generated schema. Reason enum as revision 6 plus `currency_mismatch`.
-**Heartbeats** are sent by identified probes under mode `probe` (§5.3):
-excluded from every product metric by mode, never sampled out, and the
-canonical freshness/loss query counts `expected_seq` gaps.
+`abi_mismatch`, `registry_full`, `bundle_partial`); required otherwise.
+The per-event/per-reason validity matrix is a generated artifact (§6.7).
+Reason enum (closed): `renderer_document_no_load`, `runner_no_load`,
+`runner_failed`, `descriptor_invalid`, `invalid_dimensions`,
+`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`,
+`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`,
+`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`,
+`pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`,
+`registry_full`, `currency_mismatch`, `auction_timeout`,
+`network_error`, `http_error`, `invalid_response`,
+`adm_document_no_load`. No client timestamp; the server stamps
+`received_at`; ordering within a trace is `seq`.
### 5.2 Transport and overflow
-`fetch keepalive credentials:"omit"` primary; `sendBeacon(url,
-new Blob([json], {type: "application/json"}))` on `pagehide`. Queue bound 256. **Overflow never enqueues into the full queue**: an out-of-band
-saturating counter accumulates drops, and one coalesced
-`client_queue_overflow{dropped}` is materialized into the **next flush**.
+`fetch(..., {keepalive: true, credentials: "same-origin"})` primary (§0
+affinity; the handler still derives no identity from cookies);
+`pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type:
+"application/json"}))`. Flush every 5 s and on
+`visibilitychange`/`pagehide`. Queue bound 256 events. **Overflow never
+enqueues into the full queue:** an out-of-band saturating counter
+accumulates drops and one coalesced `client_queue_overflow{dropped}` is
+materialized into the next flush.
### 5.3 Signed trace authorization
-`v1....`; `auth` ingest bound 256 bytes; kid
-`^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store, previous
-keys retained ≥ 24 h; canonical decimal `exp`, ±60 s skew, ≤ 15 min future;
-`sig` = unpadded base64url HMAC-SHA-256 over the domain-separated
-length-prefixed input (revision 6's exact encoding); constant-time compare;
-per-group rejection.
-
-- **Modes:** `sampled | unsampled | diagnostic | probe`. `unsampled`
- transmits nothing and is rejected at ingest if carried. `probe` is
- issued only to synthetic monitors (server-side issuance to the probe
- runner) and marks heartbeat traffic.
-- **Renewal preserves mode by verification, not trust:** `GET
-/_ts/trace-auth` presents the **current signed authorization** in
- `X-TSJS-Trace-Auth` (plus the trace header); the server verifies the
- still-valid token and re-signs the **same trace_id and mode** with fresh
- `exp`. The trace id itself carries no mode, and no adapter may rely on
- cross-request state (§3.13) — the presented token is the state. Renewal
- after expiry fails; the client stops transmitting and counts locally.
-- **Diagnostic credential — complete protocol:** issuance `POST
+Format `v1....`; the `auth` field has its own
+ingest bound of **256 bytes** (every other string keeps the 64-char
+cap — a 43-char unpadded-base64url signature cannot fit 64 with its
+prefix fields).
+
+- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform
+ secret store; keys ≥ 256-bit CSPRNG; previous keys retained ≥ 24 h
+ (≫ max token lifetime + skew). Missing key with the feature enabled →
+ startup/first-use failure, never silent.
+- `exp`: canonical decimal unix seconds (no sign, no leading zeros);
+ ±60 s skew; ≤ 15 min future.
+- `mode`: `sampled | unsampled | diagnostic | probe`. **`unsampled` is
+ the signed discard decision:** the client neither enqueues nor
+ transmits for it, and ingest rejects any group carrying it. `probe`
+ marks synthetic monitors (server-issued to probe runners); probe
+ traffic is never sampled out and is excluded from product metrics by
+ mode.
+- `sig`: unpadded base64url of HMAC-SHA-256 (43 chars) over the
+ domain-separated, length-prefixed input `"ts-trace-auth-v1" ||
+u32be(len(origin)) || origin || u32be(len(trace_id)) || trace_id ||
+u32be(len(mode)) || mode || u64be(exp)`, strings UTF-8, `origin` =
+ externally visible scheme+host+port. Constant-time comparison.
+- **Renewal preserves mode by verification, not trust:**
+ `GET /_ts/trace-auth` presents the current still-valid token in
+ `X-TSJS-Trace-Auth` (plus the trace header); the server verifies and
+ re-signs the same `trace_id` and `mode` with fresh `exp`. **The only
+ mode transition that exists is the diagnostic upgrade, a distinct
+ operation:** `POST /_ts/trace-auth/upgrade` presenting the current
+ token **and** a valid diagnostic credential; it re-signs with
+ `mode = diagnostic` and `exp = min(now + 15 min, credential expiry)`.
+ Plain renewal never changes mode. Renewal after expiry fails; the
+ client stops transmitting and counts locally.
+- **Diagnostic credential:** issued `POST
/_ts/admin/diagnostic-credential` under the existing admin
- authentication (CSRF: same-origin + custom header required), response
- `{credential}` where credential = `d1....`,
- absolute expiry ≤ 60 min, HMAC over the publisher origin + expiry,
- **replayable short-lived bearer by design** (bounded by expiry and
- origin binding; not one-time — stated, not implied). Transport to the
- page: the operator opens the page with a `#tsdiag=` fragment
- (never sent to any server in a URL); the client stores it in
- `sessionStorage` and presents it in `X-TSJS-Diag` on trace-auth,
- page-bids, and `/auction` requests. The server, seeing a valid
- credential, issues/renews the trace authorization with
- `mode = diagnostic` and **`exp = min(now + 15 min,
-credential expiry)`** — a trace authorization never outlives the
- credential. Initial HTML cannot see the fragment, so `nav_gen 0` starts
- `sampled|unsampled` and the client immediately upgrades via trace-auth.
- Validation is stateless HMAC — all four adapters support it. Forgery,
- replay-past-expiry, and wrong-origin tests required. The tester cookie
- remains content-only (§2.5).
-- **Lazy cached initialization applies to every secret-backed component**
- (trace-auth keys, diagnostic keys, sampling key, sinks): first-use
- resolution with a cached result on request-bound platforms; resolution
- failure with the feature enabled → the feature's startup/first-use error
- path, never silent.
+ authentication (CSRF: same-origin + custom header), format
+ `d1....`, absolute expiry ≤ 60 min,
+ origin-bound, **replayable short-lived bearer by design** (bounded by
+ expiry + origin binding; stated, not implied). **Exposure-minimized
+ transport:** the operator opens the page with `#tsdiag=`;
+ the synchronous bootstrap reads it, **immediately clears the fragment
+ via `history.replaceState`**, holds the credential **in memory only**
+ (RuntimeSession — never `sessionStorage`, which page scripts can
+ read), and exchanges it via the upgrade operation as soon as the trace
+ exists. Because initial HTML cannot see the fragment, `nav_gen 0`
+ starts `sampled | unsampled`; **when a pending `#tsdiag` fragment is
+ detected, the client buffers events locally without transmission
+ (bounded 256) until the upgrade resolves**, then flushes under
+ diagnostic mode — one-page-load diagnostic completeness holds without
+ delaying rendering. Forgery, wrong-origin, and replay-past-expiry
+ tests required. Validation is stateless HMAC — all four adapters.
+- **Lazy cached initialization** applies to every secret-backed
+ component (trace-auth keys, diagnostic keys, sampling key, sinks):
+ first-use resolution with a cached result on request-bound platforms;
+ failure with the feature enabled is that feature's loud error path.
### 5.4 Ingest and rate limiting
-As revision 6 (limits, same-origin, fail-closed drops), with the limiter
-contract completed: key namespace per route family; portable maps hold
-≤ 65,536 (Axum) / 4,096 (Cloudflare, Spin per-instance) entries with
-10-minute entry TTL, cleanup on access plus periodic sweep — **capacity
-pressure rejects unseen identities but expired entries are always
-reclaimable, so saturation is bounded, not permanent**; missing client
-address → a shared `unknown` bucket at 1 request/min; Fastly uses the
-platform 60 s window counter at limit 20 with documented overshoot ≤ 2×
-under concurrent bursts (`rate_limiter.rs:40` is read-then-increment);
-Axum XFF selection = rightmost entry after skipping exactly
-`trusted_proxy_hops`. `/_ts/trace-auth` and `/_ts/csp-reports/`
-get their own buckets (10/min, burst 20 intent).
-
-### 5.5 Sink, canonical views, monitoring
-
-Stable event key `(publisher_domain, trace_id, seq)`; canonical views
-`ts_client_events_v` (dedup) and `ts_render_attempts_v` (attempt grain,
-joined on the G1 key including `auction_id`); dashboards/alerts query views
-only. Datasource-side monitoring via `heartbeat` events from identified
-probes (mode `probe`); freshness = heartbeat lag ≤ 5 min, loss =
-`expected_seq` gaps < 0.1%; alert owner: release owner's on-call.
+- `POST /_ts/client-events`: `application/json` only; no
+ `Content-Encoding`; responds `204`, `no-store`; never echoes input.
+ Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars
+ (`auth` ≤ 256 bytes); `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`;
+ width/height `[0, 8192]`. Violations → drop-and-count with `204`.
+- Same-origin (client-events, trace-auth): `Sec-Fetch-Site:
+same-origin` when present, else normalized `Origin` equality; absent
+ both → drop-and-count.
+- **Rate limiting — adapter abstraction with declared semantics:**
+ trait `ClientEventLimiter`, key namespace per route family; intent
+ 10 req/min, burst 20 per client address. Axum: real in-process token
+ bucket, map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/instance
+ best-effort, ≤ 4,096 entries; entry TTL 10 min with cleanup on access
+ plus periodic sweep — **capacity pressure rejects unseen identities,
+ but expired entries are always reclaimable, so saturation is bounded,
+ not permanent**; missing client address → shared `unknown` bucket at
+ 1 req/min. Fastly: the platform 60 s fixed-window counter at limit 20
+ as a documented approximation; because its lookup and increment are
+ separate operations (§3.13), **overshoot under a synchronized burst
+ is bounded only by in-flight concurrency, and no numeric multiple is
+ claimed** — the synchronized-burst test (> 40 concurrent) documents
+ observed behavior, and a penalty-box follow-up is recorded if
+ observed overshoot is operationally unacceptable. Limiter
+ unavailable/errored → drop early with `204`. Trusted client address:
+ Fastly platform client IP; Axum rightmost `X-Forwarded-For` entry
+ after skipping exactly `trusted_proxy_hops` (absent config → socket
+ peer only); Cloudflare `CF-Connecting-IP`; Spin platform address.
+ `/_ts/trace-auth` and `/_ts/csp-reports/` carry their own
+ buckets with the same intent.
+
+### 5.5 Sinks, canonical views, per-sink monitoring
+
+- Stable event key `(publisher_domain, trace_id, seq)`. Canonical views:
+ `ts_client_events_v` (dedup: latest `received_at` per key) and
+ `ts_render_attempts_v` (attempt grain per G1). Dashboards and alerts
+ query canonical views only; raw-to-raw joins are forbidden (row
+ multiplication).
+- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`)
+ and cannot see downstream rejection — **each datasource gets its own
+ synthetic probe and freshness/loss query, per adapter write path**:
+ client-events via `heartbeat` events (mode `probe`,
+ `expected_seq` gaps = loss, lag = freshness); CSP via probe reports
+ to a reserved `policy_id = probe`; ops via a probe counter. A green
+ client-events heartbeat says nothing about the CSP or ops
+ credentials — hence three probes. Alert owner: release owner's
+ on-call.
### 5.6 Physical schemas (deployed before writers)
-- **`ts_client_events`**: revision 6 columns plus `flow Enum`,
- `auction_id Nullable(FixedString(36))`, `action Nullable(Enum)`,
- `kind Nullable(Enum)`, `id_key_hash Nullable(FixedString(16))`,
- `result Nullable(Enum)`, `probe_id Nullable(String)`,
- `expected_seq Nullable(UInt32)`; `mode Enum(sampled|diagnostic|probe)`.
-- **Auction rows**: nullable `trace_id`, `mode`, plus **`release_id`**
- (the server-side cohort discriminator). Two added row types with full
- physical definitions:
- - `bid_drop {provider LowCardinality(String), slot Nullable(String),
-reason Enum(AuctionDropReason), width Nullable(UInt16), height
-Nullable(UInt16), count UInt32}` — cap 32 rows/auction **plus** one
- overflow row (`reason = overflow`, `count` = dropped-row count; the
- overflow row is not counted against the cap);
- - `selection_summary {slot String, winner_source
-Enum(mediator|direct|none), winner_provider Nullable(String) — NULL
-exactly when winner_source = none, candidates_direct UInt16,
-candidates_mediator UInt16, dedup_hits UInt16, currency_rejected
-UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` — cap
- 8 rows/auction plus one **auction-level totals row** (slot =
- `_totals`) that always survives truncation, so gate denominators never
- depend on per-slot rows. Counters are saturating UInt with
- `0xFFFF`/`0xFFFFFFFF` as the saturation sentinel.
- - **`AuctionDropReason` (closed, server-side):** `script_rendering_
-disabled, invalid_dimensions, dimensions_out_of_range,
-missing_render_source, invalid_creative_url, unsupported_tagtype,
+- **`ts_client_events`**: `received_at DateTime64, publisher_domain
+LowCardinality(String), release_id String, trace_id FixedString(32),
+mode Enum(sampled|diagnostic|probe), nav_gen UInt32, refresh_gen
+UInt32, seq UInt32, flow Enum(ssat|prebid|page_bids|direct|fallback|
+system), auction_id Nullable(FixedString(36)), event Enum(§5.1), slot
+Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8),
+source Nullable(Enum), reason Nullable(Enum), action Nullable(Enum),
+parent_flow Nullable(Enum), kind Nullable(Enum), notif_id
+Nullable(FixedString(12)), result Nullable(Enum), dropped
+Nullable(UInt32), probe_id Nullable(String), expected_seq
+Nullable(UInt32)`. Sorting key `(publisher_domain, received_at,
+trace_id, seq)`; TTL 30 days; own ingest token; sink batch cap 512;
+ startup validation of dataset + token when enabled;
+ sink-unavailable → accept-count-drop.
+- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`):
+ add nullable `trace_id`, `mode`, `release_id`. Two added row types
+ with an explicit **`row_kind Enum(slot | totals | overflow)`** so
+ totals/overflow rows are valid instances (no publisher-controlled
+ sentinel strings; inapplicable fields nullable):
+ - `bid_drop {row_kind, provider Nullable(LowCardinality(String)) —
+NULL on overflow, slot Nullable(String), reason
+Enum(AuctionDropReason), width Nullable(UInt16), height
+Nullable(UInt16), count UInt32}` — cap 32 slot-rows/auction plus one
+ overflow row whose `count` = **actual dropped bids**, not compacted
+ rows;
+ - `selection_summary {row_kind, slot Nullable(String) — NULL on
+totals, winner_source Nullable(Enum(mediator|direct|none)) — NULL on
+totals, winner_provider Nullable(String) — NULL when winner_source
+≠ a winner, candidates_direct UInt16, candidates_mediator UInt16,
+dedup_hits UInt16, currency_rejected UInt16, provenance_invalid
+UInt16, mediator_superseded UInt16}` — cap 8 slot-rows plus one
+ totals row that always survives truncation. Counters saturate at
+ `0xFFFF`/`0xFFFFFFFF`.
+ - **`AuctionDropReason` (closed, exhaustive over baseline
+ producers):** `script_rendering_disabled, invalid_dimensions,
+dimensions_out_of_range, missing_render_source,
+invalid_creative_url, unsupported_tagtype,
render_payload_too_large, unexpected_response_shape,
currency_mismatch, floor_rejected, provenance_invalid,
-duplicate_demand, overflow`.
-- **`ts_csp_reports`** (aggregate only): `{received_at, release_id,
-policy_id, cohort, directive_bucket Enum, source_bucket Enum, count}`;
- 30-day TTL. **CSP ingest contract:** pre-buffer body ≤ 8 KiB, ≤ 10
- reports/request, strings ≤ 256, nesting ≤ 4, no `Content-Encoding`, own
- limiter bucket, fire-and-forget dispatch covered by probe heartbeats.
-- **`ts_ops_counters`**: `{received_at, release_id, counter Enum, value
-UInt64}` — the physical home for renderer-route counters (requests,
- unknown-version, auth-blocked) and limiter/abuse counters.
-- **Settings, constructible:** `[telemetry.client_events]`
- `collection_enabled`, `sink_enabled` (collection without a sink =
- accept-count-drop by configuration, not accident), `sample_rate`,
- `api_host`, `dataset`, `token_secret`, `secret_store`,
- `max_body_bytes`; `[telemetry.trace_auth]` `secret_store`,
- `active_kid`, `previous_kids`, `sampling_key_secret`;
- `[telemetry.diagnostic]` `secret_store`, `active_kid`.
- `RuntimeServices` gains the client-events sink handle.
-- APS parsing returns structured drop observations (slot + dimensions);
- > 8192 → `dimensions_out_of_range`, dimensions omitted.
+duplicate_demand, missing_bid_id, duplicate_bid_id, unknown_impid,
+invalid_price, unsupported_media_type, creative_id_too_large,
+empty_seatbid, renderer_extension_serialization_failed,
+no_render_source, lost_to_higher_bid, overflow` — covering the
+ outcomes emitted at `aps.rs:740-929` and `formats.rs:408-419`; a
+ **compile-time exhaustiveness test maps every producer to the
+ enum**.
+- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain
+LowCardinality(String), release_id String, policy_id
+LowCardinality(String), cohort LowCardinality(String),
+directive_bucket Enum(script|style|frame|img|connect|font|media|
+worker|other), source_bucket Enum(https_host_allowlisted|data|blob|
+inline|eval|other), count UInt32`; sorting key `(publisher_domain,
+received_at, policy_id)`; TTL 30 days; settings
+ `[telemetry.csp_reports] enabled, api_host, dataset, token_secret,
+secret_store`. Ingest: pre-buffer body ≤ 8 KiB, ≤ 10 reports/request,
+ strings ≤ 256, nesting ≤ 4, both media types
+ (`application/csp-report`, `application/reports+json`) with separate
+ validators, unused fields discarded before logging, own limiter
+ bucket.
+- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain
+LowCardinality(String), release_id String, counter
+Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked|
+ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged|
+probe), value UInt64`; sorting key `(publisher_domain, received_at,
+counter)`; TTL 90 days; settings `[telemetry.ops_counters]` (same
+ shape).
+- **Sink plumbing:** one generic multi-target Tinybird sink trait; the
+ `RuntimeServices` (`platform/types.rs:158`) gains handles for
+ client-events, CSP, and ops targets beside the auction sink; each
+ target has its own dataset + token settings as above.
+- **Settings:** `[telemetry.client_events] collection_enabled,
+sink_enabled, sample_rate, api_host, dataset, token_secret,
+secret_store, max_body_bytes`; `[telemetry.trace_auth] secret_store,
+active_kid, previous_kids, sampling_key_secret`;
+ `[telemetry.diagnostic] secret_store, active_kid`.
+- APS parsing returns structured drop observations
+ `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values
+ today); >8192 → `dimensions_out_of_range` with dimensions omitted.
### 5.7 Modes and SLIs
Production (sink-backed): deterministic keyed sampling (default 0.10).
-SLIs: pipeline availability (heartbeat freshness/loss); failure detection
-(≥ 1% of sampled render attempts visible within one hour at ≥ 10,000
-sampled attempts/hour). Diagnostic: credential-gated, unsampled, full
-stream + console mirroring + debug envelopes (§2.5).
+SLIs: **pipeline availability** — per-sink probe freshness ≤ 5 min and
+probe loss < 0.1% (fails during sink outages, alarmed); **failure
+detection** — a failure mode affecting ≥ 1% of sampled render attempts
+visible within one hour, at ≥ 10,000 sampled attempts/hour. Diagnostic:
+credential-gated, unsampled, full stream + console mirroring + debug
+envelopes (§2.5).
### 5.8 Server-side drop surfacing
-As revision 6, with `selection_summary`/`bid_drop` physicalized above.
+Bounded structured summary whenever any bid is dropped; `bid_drop` and
+`selection_summary` rows (§5.6); the initial-HTML `ts-debug` comment
+carries the drop summary; page-bids and `/auction` carry the
+tester-gated structured `debug` field. Startup warnings: APS +
+`allow_script_creatives = false`; mediator + direct providers without
+an explicit `winner_selection` (§6.1 hard error).
## 6. APS delivery fixes
-### 6.1 Mediation — total order, closed contradictions
-
-1. **Currency.** Required `[auction].currency` (ISO 4217). Providers
- validate response currency at parse; providers with a contract-implied
- currency validate that implication — **APS enabled with a non-USD
- configured currency is a startup error** (`aps.rs:475` stamps USD), not
- a silent all-drop. Prebid's parse must validate rather than assume USD
- (`prebid.rs:2433`). Mismatch → `bid_drop{currency_mismatch}`.
-2. **Candidate identity, total by construction.** `source_candidate_id` =
- `(provider_name, upstream_bid_id)` where the upstream id is **required
- bounded (≤ 64 chars) and unique per provider response at admission**;
- a bid missing an id, or duplicating one, receives a deterministic
- **intrinsic fingerprint**: `fp = hex(HMAC(auction_id, provider || slot
-|| price_micros || render_source_digest))` — identical fingerprints are
- the same demand and dedup to one candidate. `candidate_id` (wire echo)
- is CSPRNG with in-auction collision retry and is **never** an ordering
- key.
+### 6.1 Mediation — total order, no fictional identities
+
+Baseline defects: no forwarded candidate id; lossy last-write-wins
+`(provider, slot, bidder)` field restoration (`adserver_mock.rs:95`);
+arrival-order equal-price ties (`orchestrator.rs:827`); Prebid assumes
+USD (`prebid.rs:2433`); APS stamps USD (`aps.rs:475`); no configured
+currency. Replacement, identical in the synchronous and split
+dispatch/collect paths via one shared helper:
+
+1. **Currency.** Required `[auction].currency` (ISO 4217). Every
+ provider parse validates its response currency; contract-implied
+ currencies are validated as implied — **APS enabled with a non-USD
+ configured currency is a startup error**, not a silent all-drop.
+ Mismatch → `bid_drop{currency_mismatch}`.
+2. **Candidate identity.** `source_candidate_id` =
+ `(provider_name, upstream_bid_id)`; the upstream id is **required,
+ ≤ 64 chars, and unique per provider response — bids missing an id or
+ duplicating one are rejected** (`bid_drop{missing_bid_id |
+duplicate_bid_id}`). No fingerprint fallback: a fingerprint over a
+ partial field set can merge economically distinct demand, and
+ OpenRTB requires bid ids — rejection is honest. `candidate_id` (wire
+ echo) is CSPRNG with in-auction collision retry and is never an
+ ordering key. Mediator-native bids get identities the same way from
+ the mediator's response.
3. **Mediator exchange.** Forwarded candidates carry
- `ext.trusted_server.candidate_id`; the mediator echoes it. Resolution
- rules, stated exhaustively: an echoed id that resolves → the forwarded
- candidate, provenance `mediator`; **price is authoritative from the
- mediator; every render-source and notification field comes from the
- stored candidate; deal fields are out of scope entirely** (no deal
- identity exists in the model — revision 6's "deal fields from mediator"
- is deleted). A mediator bid whose **any** render-source field differs
- from the stored candidate is reclassified mediator-native. An echoed id
- that does **not** resolve → that bid is discarded and counted
- (`provenance_invalid`); **mediator-native bids and direct candidates
- for the slot all remain eligible** — "fails closed" applies to the
- invalid claim, not the slot.
-4. Floors filter both populations; echoed candidates remove their direct
- twins.
-5. **Selection order (total):** decoded CPM desc → provenance rank
- (mediator first) → `source_candidate_id` asc (fingerprints compare as
- their hex strings). Arrival order can never matter.
-6. **Strategy** required when mediator + direct providers coexist:
- `mediator_only` (timeout → no winners unless
- `mediator_timeout_fallback = "direct"`) or `merge_highest_cpm`
- (timeout → direct-only, reported).
-7. Reporting: `selection_summary` rows + `_totals` (§5.6).
-
-### 6.2–6.5
-
-As revision 6: exact dimensions with structured drops; script creatives
-default-off but loud — **DR-2's output is a deployment decision** (enable
-with explicit sandbox/security approval, or accept a quantified maximum
-excluded-demand share and gate Phase 3 on it), not a documentation
-priority; G2 identity; G4e fallback.
+ `ext.trusted_server.candidate_id`; the mediator echoes it (contract
+ for every mediator, `adserver_mock` included). Echoed id resolves →
+ the forwarded candidate, provenance `mediator`: **price is
+ authoritative from the mediator; every render-source and
+ notification field comes from the stored candidate; deal fields are
+ out of scope entirely** (no deal identity exists in the model). A
+ mediator bid whose any render-source field differs from the stored
+ candidate is reclassified mediator-native. An unresolvable echoed id
+ → that bid is discarded and counted (`provenance_invalid`);
+ mediator-native bids **and direct candidates remain eligible** —
+ fail-closed applies to the invalid claim, not the slot.
+4. Floors filter both populations; echoed candidates remove their
+ direct twins (`dedup_hits`).
+5. **Selection order (total, intrinsic):** decoded CPM desc →
+ provenance rank (mediator first) → `source_candidate_id` asc.
+ Arrival order can never matter.
+6. **Strategy** required when mediator + direct providers coexist
+ (startup error if absent): `mediator_only` (timeout → no winners
+ unless `mediator_timeout_fallback = "direct"`) or
+ `merge_highest_cpm` (timeout → direct-only, reported).
+7. Reporting: `selection_summary` slot rows + totals row (§5.6).
+
+### 6.2 Dimensions
+
+Exact size membership stays (`aps.rs:675`). The fix is visibility
+(`bid_drop{invalid_dimensions, w, h}`) plus documentation ("request the
+sizes you accept"); accepting unrequested sizes would conceal an
+upstream protocol violation.
+
+### 6.3 Script creatives
+
+`allow_script_creatives` stays default-`false`; the consequence is loud
+(§5.8). **DR-2's output is a deployment decision:** enable with explicit
+sandbox/security approval, or accept a quantified maximum
+excluded-demand share and gate Phase 3 on it.
+
+### 6.4 Render identity
+
+As specified in G2 (token format, registry-union capacity, tombstones,
+cache-path byte identity).
+
+### 6.5 Fallback
+
+As specified in G4e/G4a/G4f (attribution-gated child attempt; awaitable
+renderer conversion precedes it; timeouts never render).
### 6.6 Renderer endpoint
-As revision 6 (unconditional route, versioned immutable document with a
-checked-in per-version header manifest, three-message ack, aggregate route
-counters now physically in `ts_ops_counters`, CSP rollout with
-server-selected `policy_id` report paths and closed buckets — ingest
-bounds per §5.6).
+- The static renderer document route registers **unconditionally in
+ every adapter** (the APS provider stays config-gated); startup
+ validation fails if an auth handler pattern covers it; §G5 isolation
+ rules apply.
+- Path `/integrations/aps/renderer/v1`, embedded, served
+ `Cache-Control: public, max-age=31536000, immutable`; canary versions
+ `no-store` (or bounded below cohort lifetime); a **checked-in header
+ manifest per renderer version** freezes headers (CSP included) with
+ the bytes — a version's headers never change after publication.
+ Unknown versions → 404 `no-store`.
+- Three-message acknowledgement per G4b sequence 1.
+- Server route counters are aggregate (`ts_ops_counters`) — the nonce
+ rides the URL fragment and never reaches the server.
+- **CSP rollout, three instruments:** discovery on the **currently
+ enforced** policy with reporting attached (report-only alone cannot
+ reveal what the enforced policy already blocks); **tightening** via
+ report-only; **relaxation** via a small enforced cohort on a
+ short-lived canary version, gated on runner acceptance rate, CSP
+ violation rate, and render-failure rate, with a kill switch; once
+ frozen, a new immutable `/v2` ships. **CSP reports are advisory** —
+ for opaque-origin reports the body-supplied document URL and policy
+ version are forgeable, so policy identity is encoded in the
+ **server-selected report path** (`/_ts/csp-reports/`);
+ bucketed aggregation only (§5.6 buckets, global and per-cohort caps);
+ never a sole automatic rollback signal. Browser capture on Chromium,
+ Firefox, and WebKit (CI is Chromium-only today,
+ `playwright.config.ts:16`; the matrix extends for this suite).
### 6.7 One descriptor schema
-As revision 6 (generated JSON-Schema + TS parser + ES5 inline fragment +
-fixtures; semantic validators handwritten; outer tolerance only; the
-per-reason `source` validity matrix of §5.1 joins the generated artifacts).
+Wire truth is the tagged `BidRenderer` envelope (discriminator on the
+enum, `types.rs:188-211`). A wire-schema crate/xtask (separate from
+`trusted-server-js` — core already depends on it, `Cargo.toml:45`, so
+the reverse edge would cycle) generates: the JSON-Schema artifact, the
+TS structural parser, the ES5 inline validator fragment, the §5.1
+per-event/per-reason validity matrix, and shared fixtures — checked in,
+staleness-gated. Semantic checks stay handwritten on both sides
+(URL/origin policy, canonical base64, length bounds, the exact one-bid
+AAX projection, cross-field equality). Unknown-field tolerance applies
+only to the outer versioned descriptor; the decoded AAX envelope
+remains an exact projection. A shared positive + adversarial corpus
+(extra fields, wrong versions, oversized payloads, URL smuggling,
+non-canonical base64) runs through the Rust validator, the generated TS
+parser, and the generated inline fragment in CI.
### 6.8 Bridge hardening
-As revision 6: parse → identify TS-reserved (live **or tombstoned**, G2) →
-`stopImmediatePropagation` → validate source (bounded walk) →
-nonce/token/`nav_gen`/`refresh_gen` → respond or refuse. Non-TS ids
-untouched. Stolen-token test proves neither TS nor native Prebid responds.
+Processing order (normative; preserves the baseline defense that stops
+propagation before source validation, `gpt/index.ts:1584-1637`):
+
+1. parse `e.data` (bare catch → return);
+2. identify a TS-reserved ad id — **live registry or tombstone** (G2);
+3. if TS-reserved: `stopImmediatePropagation()` before any validation —
+ a rejected foreign frame must not be answerable by Prebid's native
+ handler either;
+4. validate source ownership via the bounded walk: known slot-root
+ `WindowProxy` map, the sender's own parent chain
+ (`event.source.parent`, …) to depth 5 — never scanning an
+ attacker-controllable frame tree;
+5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b);
+6. respond, or refuse with `bridge_id_mismatch`.
+
+Non-TS ad ids are untouched. The stolen-token browser test asserts
+**neither TS nor the native Prebid listener responds**; listener
+registration order has a real-browser assertion. Renderer branches emit
+the full §5.1 sequence with G4d notifications only on carrying paths.
## 7. TSJS target architecture
### 7.1 Layering
-As revision 6, with G3's corrected lint mechanics and the adapter-scope
-clarification (external ad-tech globals only).
-
-### 7.2 Adapters / 7.3 Slot registry
-
-As revision 6 (registry holds the G4a causal queue; cycle/drain state
-RuntimeSession; unissued intents NavigationSession).
-
-### 7.4 Final global surface
-
-Revision 6's table **plus** the row the review found missing:
-
-| Legacy surface | Final shape |
-| -------------- | ----------------------------------------------------- |
-| (new, public) | `tsjs.definePlugin({id, release, install, dispose?})` |
-
-Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que ||=
-[]; tsjs.boot ||= {}`; the ad-slot script's `window.tsjs = {}` at
-`publisher.rs:3665` is fixed); **transactional ownership**: states
-`unclaimed → installing → kernel | fallback`. The kernel installs wrappers
-**inert** and flips them live at a single commit point; on a throw before
-commit it unwinds its registered disposers (the §7.6 machinery applied to
-kernel boot itself) and marks `failed`; the 10 s watchdog treats a stuck
-`installing` as failed; **fallback claims ownership only from
-`unclaimed | failed`** — never beside a committed kernel. A bundle
-arriving after fallback committed defers for the page (`bundle_partial`).
-Tests: throw injected after each boot checkpoint.
-
-### 7.5 Messaging / 7.6 Plugins and sessions / 7.7 Bootstrap / 7.8 GPT / 7.9 Decomposition
+```
+kernel/ boot, config, queue, event bus, log, beacon, sessions
+adapters/ googletag.ts, pbjs.ts, messaging.ts ← only access to external ad-tech globals
+services/ slots (registry+handoff), auction client, render engine, consent
+integrations/ gpt, prebid, aps, creative, datadome, …
+```
-As revision 6 (plugin API object form with `release`; transactional
-install; sessions; generated no-bundle fallback; unconditional GPT
-subscriptions; decomposition table).
+Enforced by the two G3 lint rule families. Dissolves the audited
+inversions (`core/auction.ts`/`core/request.ts` →
+`integrations/aps/render`; `gpt`/`prebid` → `aps`; `prebid` owning the
+GPT refresh wrapper). Kernel imports nothing above it; adapters import
+kernel only; services import kernel + adapters; integrations import
+kernel + services, never each other; stateful services via the G3
+registry only.
+
+### 7.2 Adapters
+
+Per external global: `present | pending | timed_out`; `timed_out` is
+non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and
+drains what is still valid); queued operations carry their own timeouts
+and expire with disposition reasons.
+
+### 7.3 Slot registry service
+
+Kernel-owned; `WeakMap` + div-id index;
+ownership (ts/publisher/adopted), adoption, handoff claims, responsive
+resolution, the G4a causal intent queue (NavigationSession for unissued
+intents) and cycle/drain state (RuntimeSession), targeting-key history.
+No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid`
+deleted).
+
+### 7.4 Final global surface (hard cutover)
+
+| Legacy surface (removed at cutover) | Final shape |
+| --------------------------------------- | --------------------------------------------------------------------- |
+| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue |
+| `globalThis.tscreative` | `tsjs.creative.*` |
+| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` |
+| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) |
+| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` |
+| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) |
+| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` |
+| `tsjs._internal` | kernel registry (G3), frozen after boot |
+| (new, public) | `tsjs.definePlugin({id, release, install})` |
+
+**Bootstrap correctness and transactional ownership:** every
+server-injected initializer creates the container **idempotently,
+field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`
+(the ad-slot script's `window.tsjs = {}` at `publisher.rs:3665` is
+fixed). Ownership states: `unclaimed → installing → kernel | fallback`,
+with an **owner generation** counter. The kernel installs wrappers
+**inert** and flips them live at a single commit point; a throw before
+commit runs the shared unwind inventory and marks `failed`. **The
+watchdog path is race-free:** on a 10 s stuck `installing`, the
+watchdog aborts the owner-generation-scoped `AbortController`, runs the
+same shared unwind inventory to completion, and only then atomically
+transitions `failed → fallback`; **every late kernel continuation and
+disposer validates the owner generation** and self-discards on
+mismatch — a resumed async installation can neither overwrite fallback
+wrappers nor perform a stale commit. A bundle arriving after fallback
+committed defers for the page (`bundle_partial`). Tests: throws
+injected after each boot checkpoint **and** hung checkpoints that
+resume after fallback claims ownership.
+
+### 7.5 Messaging module
+
+All `postMessage` through one module: versioned envelopes, name
+constants (the `'Prebid Request'` literal appears at six sites today;
+the APS handshake existed in three copies), G4b nonces, §6.8
+validation. The minimal module (envelope + constants + validators used
+by the bridge) lands in Phase 1; full call-site migration in Phase 4.
+
+### 7.6 Plugin lifecycle — transactional — and sessions
+
+`tsjs.definePlugin({id, release, install})` — object form; `release` is
+the build-generated `release_id` constant; **there is no plugin-level
+`dispose` hook** — disposal is exclusively `ctx.onDispose`
+registrations, which have exactly-once reverse-order semantics
+(revision 7's optional `dispose?` had no defined ordering and is
+removed). `install(ctx): void | Promise`:
+
+- `ctx.signal` (aborted on quarantine/disposal); synchronous
+ `ctx.onDispose(fn)`; effects registered as they are made;
+ reverse-order unwind on throw/reject/abort; per-disposer exception
+ isolation; a disposer registered after disposal is invoked
+ immediately; pending late registrations capacity 16, bound 10 s →
+ `bundle_partial`; release mismatch quarantines before `install`.
+- Sessions: `RuntimeSession` (page lifetime: bridge listener +
+ reservation store, history hook, pbjs subscriptions, adapters, beacon
+ queue, physical slot cycle/drain state, in-memory diagnostic
+ credential); `NavigationSession` (per navigation: trace +
+ authorization + renewal timer, render attempts, slot aliases,
+ unissued intents, targeting history); `RenderAttempt` (per G4a cycle
+ / G4f attempt). Each owns an enumerable disposal inventory;
+ navigation disposes NavigationSession children only.
+- Error policy: no empty `catch` — handle, log with context, or emit a
+ disposition. The auction fetch gains timeout + `AbortController` and
+ the G4f discriminated result.
+- **Console logging retained, not replaced:** every issue-surfacing
+ condition keeps or gains a `log.warn` carrying the same reason code
+ as its beacon event; `debug`-level delivery/security failures are
+ promoted to `warn`.
+
+### 7.7 Bootstrap
+
+`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/
+hydration logic, with the live `servicesEnabled` divergence) shrinks to
+a queue-and-flags stub; the bundle replays recorded early calls on
+install (browser specs cover replay timing); the no-bundle fallback
+("ads render if the bundle fails", pinned by `gpt.rs:1174-1179`) is
+**generated from the same TypeScript source** at build time, activated
+per §7.4's transactional rules.
+
+### 7.8 GPT correctness carried with the restructure
+
+Unconditional early `slotRequested`/`slotRenderEnded` subscription
+(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent
+recording); restore the #922 orphan-slot recovery and `updateRender`
+enrichment (DR-3 decides #997 vs re-merge); `changeCorrelator: false`
+on TS-initiated refreshes (configurable); `enableSingleRequest()` only
+when GPT services are not already enabled; ambiguous responsive
+resolution emits `render_fail{slot_unresolved}` alongside its console
+warning.
+
+### 7.9 Decomposition targets
+
+| Today | Target |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
+| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
+| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics |
+| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
+| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
+| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal |
+
+### 7.10 Performance (reproducible)
+
+- **Dedicated workflow** pinned `runs-on: ubuntu-24.04` (browser CI is
+ `ubuntu-latest` today, `integration-tests.yml:155`) inside a pinned
+ container image digest; browser = the lockfile-resolved
+ `@playwright/test` build with its browser revision recorded in the
+ baseline artifact (the manifest is a caret range,
+ `browser/package.json:10` — lockfile + recorded revision are
+ authoritative); compressors pinned by version in the container;
+ deterministic flags (`gzip -9 -n`, `brotli -q 11`).
+- **Module vectors enumerated:** minimal = `[core]`; reference =
+ `[core, creative, gpt, prebid, datadome]`; maximal = all 13
+ discovered modules. Budgets: raw/gzip/Brotli per bundle per vector vs
+ checked-in baselines (`perf/baselines/*.json`; updates are reviewed
+ diffs recording image/browser/tool versions; a baseline update is
+ invalid if any pinned component differs); +5% bytes.
+- **Browser timing:** marks `performance.mark("tsjs:bids-script")`
+ (emitted by the injected bids script) to
+ `performance.mark("tsjs:first-display")` (emitted by the adapter
+ wrapper at the first `display()`/`refresh()` dispatch); reference
+ fixture page; warm HTTP cache; all resources local; 5 warm-ups
+ discarded, 50 samples; p90 = nearest-rank; gate p90 ≤ baseline ×
+ 1.10; inconclusive (3-run agreement worse than 5%) → one rerun, then
+ fail. **Maximal-vector peak JS heap ≤ baseline × 1.10.**
+- **Server benchmark:** the G5 lookup path; 100 warm-ups, 1,000
+ iterations; median and p90; one-sided ≤ baseline × 1.10; 3
+ consecutive runs within 5% or inconclusive (rerun, never pass).
-### 7.10 Performance (fully reproducible)
+### 7.11 Toolchain
-Revision 6's pinned workflow, plus the missing definitions: **module
-vectors enumerated** — minimal = `[core]`; reference = `[core, creative,
-gpt, prebid, datadome]`; maximal = all 13 discovered modules. **Browser
-timing marks**: `performance.mark("tsjs:bids-script")` emitted by the
-injected bids script and `performance.mark("tsjs:first-display")` emitted
-by the adapter wrapper at the first `display()`/`refresh()` dispatch;
-metric = duration between marks on the reference fixture page (the
-integration-tests reference page), warm HTTP cache, all resources served
-locally (no external network); p90 = nearest-rank over 50 samples;
-inconclusive (3-run agreement worse than 5%) → one rerun, then fail.
-**Maximal-vector peak JS heap ≤ baseline × 1.10.** Server benchmark: the
-G5 lookup path (not concatenation), 100 warm-ups, 1,000 iterations, median
-and p90, one-sided ≤ baseline × 1.10.
+TypeScript floor to the resolved 5.9 line (lockfile resolves 5.9.3
+under the stale `^5.5.4` manifest). **Release-gating flags:** `strict`,
+`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+`verbatimModuleSyntax`, `noImplicitOverride`,
+`useUnknownInCatchVariables`. **Gate command (checked-in npm script
+`typecheck`):**
-### 7.11 Toolchain
+```
+cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit
+```
-TypeScript floor to the resolved 5.9 line. **Release-gating flags,
-enumerated:** `strict`, `noUncheckedIndexedAccess`,
-`exactOptionalPropertyTypes`, `verbatimModuleSyntax`,
-`noImplicitOverride`, `useUnknownInCatchVariables`; CI command:
-`npx tsc -p crates/trusted-server-js/lib/tsconfig.json --noEmit`. Dev
-toolchain bumps as individual CI-gated PRs; `prebid.js` excluded from
-casual bumps; monthly review.
+(runs where the pinned compiler is installed; `--no-install` guarantees
+the lockfile-resolved binary). Dev toolchain bumps as individual
+CI-gated PRs with changelog review (this library monkeypatches
+`fetch`/`sendBeacon`/DOM prototypes); `prebid.js` excluded from casual
+bumps (runtime Prebid is the manifest-locked external bundle; npm pin
+and deployed bundle version documented together); monthly review.
## 8. Rollout
-Single-release state machine per §0 (sticky-cohort affinity). **The
-normative gates table ships with this design as Appendix A**, including
-initial thresholds, queries, commands, cohort keys, sample floors, owners,
-hold/rollback actions, and the real-GAM suite's operational row. Threshold
-changes require reviewed decision records.
-
-Phases (build milestones inside the one release):
+Single-release state machine per §0 (authenticated sticky-cohort
+affinity; infrastructure-attributed cohorts). The normative gates table
+is Appendix A; every gate names a **checked-in artifact** (versioned
+Tinybird pipe under `tinybird/pipes/`, script under `scripts/gates/`,
+or workflow under `.github/workflows/`) — prose never substitutes for
+an executable reference. Threshold changes require reviewed decision
+records.
+
+**Phase 0 decision records** (owner, evidence, deadline, explicit
+go/no-go): DR-1 mediator presence (gates §6.1's Phase-3 scope); DR-2
+script creatives — a **deployment decision** (§6.3); DR-3 #997 vs
+re-merge (#922 restoration path); DR-4 mediator candidate-id echo owner
+and timeline (`merge_highest_cpm` is config-blocked until delivered);
+DR-5 non-Fastly sinks (splits Phase-2 gates).
- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time
- asset materialization; `format_version`; §5.6 schemas writer-off;
- toolchain floors; dead expando deletion; drop surfacing; decision
- records DR-1..DR-5 (DR-2 now a deployment decision, §6.2–6.5; DR-4
- gates `merge_highest_cpm`; DR-5 splits Phase-2 gates).
+ asset materialization; `format_version` + config-hash verification;
+ §5.6 schemas deployed writer-off; toolchain floors; dead expando
+ deletion; §5.8 drop surfacing; the five DRs; the gate artifacts
+ themselves (pipes/scripts/workflows).
- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry,
transactional bootstrap ownership.**
-- **Phase 2 — Trace + beacon.** All three issuance paths + renewal +
- diagnostic credential + probe mode; four-adapter ingest incl.
- `/_ts/trace-auth` in the isolation family; heartbeats. Gates split:
- HTTP parity (all adapters) vs persistence (sink-backed).
-- **Phase 3 — APS delivery.** As revision 6, plus `notification_sent`
- observability and per-flow funnel gating (the `flow` field): expected-
- stage tables per flow live in the gates file; denominator = server-
- observed eligible APS wins **within sampled/diagnostic traces**;
- `cycle_unattributable` gated; one-sided non-inferiority for fill/p95;
- duplicate-`burl` invariant via `notification_sent` key hashes.
-- **Phase 4 — Structure.** Layering, plugins, adapters, registry,
- messaging, namespace; four-flow behavioral parity.
-- **Phase 5 — Decomposition + cutover.** File splits; bootstrap stub +
- generated fallback (error/hang/arbitration tests); four-flow parity
- rerun; **then the full Phase-3 statistical canary/control gate and the
- real-GAM suite are repeated on the exact immutable release candidate**
- before router weight rises beyond the low-weight canary; then weight-up,
- purge, 24 h monitored window.
+- **Phase 2 — Trace + beacon.** Issuance on all three paths + renewal +
+ diagnostic upgrade + probe mode; four-adapter ingest incl.
+ `/_ts/trace-auth`; per-sink probes. Gates split per DR-5: HTTP parity
+ (all adapters) vs persistence (sink-backed).
+- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required
+ `winner_selection` + `[auction].currency`; render token + reservation
+ store; renderer route + three-message ack + CSP report route; §6.8;
+ G4a–G4g; `notification_sent` with server-minted `notif_id`; fallback;
+ DR-3 restoration; correlator + SRA fixes.
+- **Phase 4 — Structure.** Full layering + both lint families; plugin
+ lifecycle; adapters; full slot registry; full messaging migration;
+ final namespace; four-flow behavioral parity.
+- **Phase 5 — Decomposition + cutover.** File splits; script-guard
+ consolidation; bootstrap stub + generated fallback (error/hang/
+ arbitration tests); four-flow parity rerun; **the full Phase-3
+ statistical canary/control gates and the real-GAM suite repeat on the
+ exact immutable release candidate** before router weight rises beyond
+ the low-weight canary; then weight-up, purge, 24 h monitored window.
+
+**Statistical method (normative for A.1 production gates):**
+populations are **sampled traces only** — diagnostic traffic is
+operator-selected and failure-enriched, so it is reported separately
+and never enters a statistical gate. Assignment unit = the sticky
+cohort token (browser session); cohorts randomized at HTML request;
+stratification by publisher and slot. Non-inferiority gates use
+**one-sided 95% confidence bounds on the relative difference**
+(canary/control − 1 ≥ −2% for fill and billing-per-1,000-attempts;
+canary/control − 1 ≤ +2% for p95 latency); improvements always pass.
+Floors are **per flow per arm** (Appendix A); flows that cannot reach
+their floor in the window (direct, fallback at low adoption) are gated
+hermetically and by the real-GAM suite instead of statistically — a
+rare flow never permanently blocks rollout, and a statistical gate that
+cannot reach its floor is **inconclusive** (extend once, then Hold).
+`cycle_unattributable` is divided by **all TS request cycles that were
+candidates for attribution** — the failures live in their own
+denominator. Missing telemetry counts as failure. Billing reconciliation
+(§G4d) runs alongside as the authoritative duplicate check.
## 9. Test acceptance matrix
-Revision 6's matrix stands, with these added/changed rows:
-
-| Area | Added coverage |
-| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| Request cycles | publisher `display()` under disabled initial load retired at issuance; publisher-display → TS-refresh attribution test; symmetric ambiguity |
-| Trace auth | renewal presents current token and preserves mode; renewal-after-expiry fails closed; deterministic sampling (same trace → same mode across concurrent requests) |
-| Diagnostic | credential issuance under admin auth + CSRF; fragment→sessionStorage transport; upgrade of an initial sampled trace; auth `exp` capped at credential expiry; forgery/wrong-origin |
-| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket |
-| Affinity | sticky-cohort coherence: new-pool HTML never fetches control-pool assets/APIs (cache-key + routing test); rollback binding prevalidation |
-| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; `release_id` cohort attribution |
-| Funnels | `flow` field set per path; per-flow expected-stage conformance (SSAT, prebid, page-bids, direct, fallback) |
-| Tombstones | union capacity 320; unexpired never evicted; >320 registrations then late oldest-id request suppressed; `registry_full` on refusal |
-| Ack per path | all four G4b sequences incl. adm reporter snippet; per-path document/runner deadlines; cancellation on navigation |
-| Terminal states | exactly one terminal per attempt; post-accept runner failure emits observation + `billing_outcome`, no second terminal |
-| Notifications | `notification_sent` emitted per dispatch; duplicate-key-hash query returns zero in canary; direct-flow `(bid_id)` identity; server preserves/expands `nurl`/`burl`; client validates |
-| Bootstrap | inert-install + commit flip; throw after each checkpoint unwinds; watchdog claims only from failed/stuck; fallback-then-late-bundle deferral |
-| Overflow | out-of-band counter; coalesced overflow event in next flush; no recursion at full queue |
-| Heartbeats | probe mode not sampled out; excluded from product denominators; freshness/loss queries from `expected_seq` |
-| CSP ingest | pre-buffer caps (8 KiB / 10 reports / 256 chars / depth 4); both media types; opaque origin; policy-id path identity; aggregate schema rows |
-| Mediation | required upstream id bounds; fingerprint fallback determinism under arrival shuffle; duplicate-id dedup; adm-swap reclassification; APS + non-USD startup error |
-| Limiter | TTL reclaim under saturation; unknown-address bucket; Fastly overshoot bound; XFF hop selection |
-| Perf | marks present; vector contents; heap budget; inconclusive-rerun policy |
-| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters |
-| Kill switch | pre-commit attempts cancelled; post-commit attempts run to terminal; snapshot semantics |
-
-## 10. Alternatives / 11. Risks
-
-As revision 6, plus: **rejected** — per-request Fastly concatenation
-(replaced by release-time materialization); trusting client-asserted
-sampling on renewal (replaced by token-presentation renewal); FIFO
-tombstone eviction (replaced by union capacity with refusal). Risk added:
-sticky-cohort routing is new infrastructure the cutover depends on — it is
-Phase 0 work and its coherence test is release-gating.
+Hermetic CI blocks PRs; the real-GAM suite is release-gating per
+Appendix A.3.
+
+| Area | Must cover |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Request cycles | intent-vs-request; disabled-initial-load `display()` retired for **any caller**; publisher-display → TS-refresh and TS-noop-refresh → TS-display supersession (same-class stealing); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap |
+| Ack protocol | all four G4b sequences incl. the adm reporter; three-message APS sequence; five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed acks; acks after navigation disposal; per-path deadlines |
+| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; prior-navigation ids suppressed via the reservation store after disposal; bounded parent-chain walk; listener order (real browser) |
+| Render semantics | binds per flow (never targeting); `burl` at accepted; attempt idempotency; `notification_sent{notif_id}` per dispatch; duplicate-detection alarm; accepted-but-blank honesty; `gam_collapsed{action}` emission + guarded resize |
+| Direct `/auction` | trace header + `ext.trusted_server.trace` echo; discriminated auction-client errors (timeout/network/http/invalid vs `no_bid`); per-slot latest-wins with reversed responses; generation checks; `RequestAdsResult` settlement; server preserves + expands `nurl`/`burl`, client validates |
+| Fallback | child-attempt identity with `parent_flow`; renders only on attributed parent `gam_empty`; publisher-initiated never; timeout never renders; SPA cancellation; kill-switch pre-commit cancellation (commit = earliest irreversible action) |
+| Mediation | required-unique upstream ids (`missing_bid_id`/`duplicate_bid_id` rejection — no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules (repricing kept; any render-source difference → native); provenance fail-closed scope; strategy-specific timeouts; both lifecycles; APS + non-USD startup error |
+| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed |
+| Trace auth | auth ≤ 256 B; encoding vectors (kid charset, canonical exp, u32/u64 BE prefixes, unpadded base64url); expiry/skew/max-future; renewal preserves mode via token presentation; renewal-after-expiry fails closed; previous-key retention; deterministic sampling (same trace → same mode concurrently; exact u64 threshold algorithm) |
+| Diagnostic | credential issuance under admin auth + CSRF; fragment cleared via `replaceState`; in-memory-only storage; upgrade as the sole mode transition; auth `exp` capped at credential expiry; pre-upgrade local buffering then diagnostic flush; forgery/wrong-origin/replay-past-expiry |
+| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket |
+| Affinity | opaque token validation (forged/expired/retired → control + reissue); coherence for HTML, assets, APIs, **beacons, CSP reports**; cache-key normalization; rollback reassignment |
+| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; infrastructure cohort attribution (per-pool tokens) |
+| Funnels | `flow` set per path incl. `system`; per-flow expected-stage conformance; heartbeat/overflow excluded from render denominators |
+| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; overflow coalescing without recursion; ingest abuse incl. absent Origin; sendBeacon Blob type; `credentials: same-origin` with identity-free handling |
+| Ingest/limits | per-adapter limiter semantics as declared; TTL reclaim under saturation; unknown-address bucket; Fastly synchronized-burst behavior documented (> 40 concurrent); XFF hop selection; fail-closed 204 |
+| Internal routes | wrong-method 405 + `Allow` + `no-store` on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding; per-family origin policies |
+| CSP | both media types with separate validators; opaque/null-origin admission; policy-id path identity (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; per-version frozen header manifest |
+| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection; generated validity matrix; **compile-time exhaustiveness of `AuctionDropReason` over all producers** |
+| Runtime ABI | one kernel under concatenation; exact-release verdicts; late registration; failure isolation; object-form `definePlugin` release check |
+| Plugins | partial-install unwind; async rejection; abort while pending; disposer-after-disposal; per-disposer isolation |
+| Bootstrap | field-wise idempotent init (ad-slot script no longer clobbers); inert-install + commit flip; throw after each checkpoint unwinds; **hung checkpoint resuming after fallback self-discards via owner generation**; fallback-then-late-bundle deferral |
+| Lifecycle | `timed_out → present`; session disposal inventories; unissued intents cancelled by navigation disposal; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`, `definePlugin`) |
+| Delivery | unknown hash 410 `no-store`; exact-match immutable with full directive; release-time vector materialization (unlisted vector = build error); config-hash verification failure; cutover rehearsal (weight, purge, rollback) |
+| Sinks/monitoring | per-datasource probes (client-events heartbeat, CSP probe policy-id, ops probe counter) per adapter write path; canonical-views-only enforcement (raw-join multiplication test); `publisher_domain` naming |
+| Failure injection | Amazon runner redirect/hang/CSP block/script error → distinct §5.1 outcomes; EC/filter failure before renderer dispatch |
+| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
+| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness per §2.5; kill-switch snapshot semantics |
+| Perf | marks present; three vector contents; heap budget; inconclusive-rerun policy; pinned-environment baseline validity |
+| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters |
+
+## 10. Alternatives considered
+
+1. Patching APS point-failures without telemetry — rejected: four
+ correct fixes have not produced reliable ads.
+2. Always direct-render APS (skip GAM/PUC) — rejected: changes GAM
+ reporting/pacing unilaterally; kept as the attributed-`gam_empty`
+ fallback.
+3. Single module graph / shared chunks now — rejected for this release:
+ changes the delivery pipeline while everything else changes;
+ successor option behind the same registry surface.
+4. Full rewrite in one branch without phases — rejected: the
+ browser-spec safety net is thinnest exactly where behavior changes.
+5. Dropping the ES5 bootstrap — rejected: loses the pinned no-bundle
+ guarantee; the generated fallback keeps it.
+6. Timeout-triggered fallback rendering — rejected: GPT requests cannot
+ be cancelled; timeout racing a late fill can double-render and
+ double-bill.
+7. Timeout-based quarantine re-arm — rejected (recreates the stale-event
+ bug).
+8. N/N−1 compatibility machinery — removed by the §0 policy decision.
+9. Client-computed notification hashes — rejected (no key without
+ breaking the pseudonymization boundary); server-minted `notif_id`.
+10. Fingerprint identities for id-less bids — rejected (can merge
+ distinct demand); rejection with closed reasons instead.
+11. Plain readable cohort cookie — rejected (dark-pool opt-in +
+ cache-cardinality abuse); opaque authenticated token.
+12. `billing_outcome` event — removed (no honest producer exists).
+
+## 11. Risks
+
+- Hard-cutover blast radius — accepted by policy; bounded by the §0
+ runbook (probes, low-weight canary, 24 h window, weight-back
+ rollback).
+- Sticky-cohort routing is new infrastructure the cutover depends on —
+ Phase 0 work; its coherence test is release-gating.
+- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates
+ `merge_highest_cpm`; config validation enforces the block.
+- Notification triggers become a published contract for PBS-path
+ demand — changing them later is a breaking change for SSP reporting.
+- Required `[auction].currency` and `winner_selection` (mediated
+ deployments) are a deliberate startup-error class under §0.
+- Beacon abuse — pre-parse caps, per-family origin policies, fail-closed
+ numeric limits, signed modes, credentialed diagnostics.
+- Registry/limiter memory — explicit capacities, TTL reclamation,
+ reject-at-capacity; Fastly overshoot documented, not claimed.
+- CSP data is advisory — never a sole rollback signal.
+- Sink blindness — per-datasource probes with datasource-side queries.
+- ABI freeze — `tsjs._internal.registry` is load-bearing; exact-release
+ verdicts are the contract.
+- Schema generation — checked-in artifacts + staleness CI.
## 12. Success criteria
-Revision 6's criteria with these corrections: (2) diagnostic completeness
-is achieved via the §2.5 gate split (content vs volume); (5) attempt
-counts keyed `(trace_id, nav_gen, refresh_gen, slot)`; (10) the
-duplicate-`burl` invariant is measured via `notification_sent` key hashes
-in production and by hermetic tests, with external billing reconciliation
-as backstop; (add 13) the Phase-3 statistical and real-GAM gates pass on
-the exact immutable Phase-5 release candidate before weight-up; (add 14)
-the Appendix A gates table shipped with this design and every later change
-carries a reviewed decision record; (add 15) the baseline APS fix behaviors
-are re-implemented in the target architecture with the baseline browser
-tests passing unmodified as the conformance pin.
+1. APS creatives render in each configured flow (SSAT, client-Prebid,
+ page-bids, direct), hermetically and in the release-gating real-GAM
+ suite per Appendix A.3's enumerated topologies.
+2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the
+ failing class from one page load (including A1–A4 via the debug
+ envelopes, and including an initially-unsampled page via pre-upgrade
+ buffering); §5.7 SLIs hold on sink-backed deployments.
+3. Both lint families pass with zero exceptions; stateful sharing only
+ via the registry; exact-release mismatches quarantine loudly.
+4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
+ generated.
+5. Attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`;
+ traces stay navigation-scoped; no double counting; orphan recovery
+ has a non-vacuous test; G4a holds including caller-independent
+ retirement and same-class supersession.
+6. The only TSJS-owned global is `window.tsjs` with the §7.4 final
+ shape; no expandos; legacy names gone at cutover.
+7. §7.10 budgets hold on the dedicated pinned workflow.
+8. No existing warning lost; issue-surfacing conditions log `warn`+
+ with the beacon's reason code.
+9. TypeScript floor matches resolved 5.9 with the §7.11 flags via the
+ checked-in `typecheck` script; `prebid.js` pin documented with the
+ deployed bundle.
+10. `nurl`/`burl` fire only on carrying paths at their G4d binds,
+ attempt-scoped and idempotent; APS fires neither; hermetic
+ exactly-once tests pass; production duplicates alarm via
+ `notification_sent` and reconcile to zero via billing reports.
+11. Trace-bearing responses are `private, no-store`; authorizations
+ are per-trace, signed, mode-carrying, renewal-preserving, with
+ diagnostic upgrade as the sole authenticated mode transition;
+ unsampled traces transmit nothing.
+12. The cutover runbook rehearsed (weight switch, purge, rollback);
+ config-hash verification enforced.
+13. The Phase-3 statistical and real-GAM gates pass on the exact
+ immutable Phase-5 release candidate before weight-up.
+14. The Appendix A gates table shipped with this design; every change
+ carries a reviewed decision record.
+15. The baseline APS fix behaviors are re-implemented in the target
+ architecture with the baseline browser tests passing unmodified.
## 13. Open questions
@@ -752,60 +1233,69 @@ post-`render_accepted` state under a new name (future enhancement)?
## Appendix A — Normative rollout gates (initial values)
Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** =
-release owner's on-call. Assignment key for canary/control =
-sticky cohort (`ts-rel`), randomized at HTML request, per §0. All
-production queries run against canonical views only (§5.5). "Hold" =
-router weight frozen; "Rollback" = weight to previous release + re-purge.
+release owner's on-call. Assignment unit = the authenticated sticky
+cohort token (§0), randomized at HTML request, stratified by publisher
+and slot. Statistical gates use **sampled traces only** (§8 method);
+diagnostic traffic is reported separately. All production queries run
+against canonical views via **checked-in versioned pipes**
+(`tinybird/pipes/gate_.pipe`); probe/parity suites are checked-in
+scripts (`scripts/gates/.sh`) or workflows. "Hold" = router
+weight frozen; "Rollback" = weight to previous release + re-purge.
Changing any row requires a reviewed decision record.
### A.1 Phase gates
-| Phase | Gate | Query / test command | Denominator | Floor | Threshold | Window | Owner | Action |
-| ----- | -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------- | ------------- | --------------------------------- | ------ | ----- | -------- |
-| 0 | Dark-pool health | probe suite vs dark pool (all four adapters) | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold |
-| 0 | Schema validation | synthetic writes to `ts_client_events` + auction rows | synthetic rows | 10,000 | rejection < 0.1% | 24 h | RO | Hold |
-| 0 | Asset identity | probe: every manifest hash 200-immutable; unknown hash 410 `no-store` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold |
-| 1 | ABI cleanliness | probe pages: `abi_mismatch` + `bundle_partial` counters | probe page loads | 1,000 | 0 | 24 h | QA | Hold |
-| 1 | Bootstrap ownership | hermetic: throw-after-each-checkpoint suite | checkpoints | all | 100% unwind-to-`failed` | CI | QA | Hold |
-| 2 | Ingest HTTP parity | parity suite vs all four adapters (routes, 405s, limits, 204s) | parity cases | all | 100% | CI | QA | Hold |
-| 2 | Persistence (sink-backed) | acceptance ≥ 99%; dedup exactly-once per `(trace, seq)` | probe batches | 10,000 evts | as stated | 24 h | OPS | Hold |
-| 2 | Heartbeat pipeline | freshness lag; `expected_seq` loss | probe heartbeats | 1,000 | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
-| 3 | APS funnel (per flow) | per-`flow` stage rates from `ts_render_attempts_v` (table A.2) | eligible APS wins in sampled+diagnostic traces | 10,000/cohort | per A.2 | 24 h | RO | Rollback |
-| 3 | Attribution soundness | `cycle_unattributable` rate | attributable-candidate cycles | 10,000 | < 0.5% | 24 h | RO | Rollback |
-| 3 | GAM fill (non-inferiority) | canary fill vs control | cohort ad requests | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback |
-| 3 | Latency (non-inferiority) | canary p95 bids-to-display vs control | cohort attempts | 10,000 | canary ≤ control × 1.02 | 24 h | RO | Rollback |
-| 3 | Billing | GAM/server-side revenue per 1,000 attempts, canary vs control | cohort attempts | 10,000 | canary ≥ control − 2% (one-sided) | 24 h | RO | Rollback |
-| 3 | Duplicate `burl` | duplicate `notification_sent{burl}` per `id_key_hash` | burl dispatches | 1,000 | 0 | 24 h | RO | Rollback |
-| 4 | Layering | both lint rules; disposal-inventory leak suite | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
-| 4 | Four-flow parity | hermetic parity: SSAT, prebid, page-bids, direct | parity cases | all | 100% | CI | QA | Hold |
-| 5 | Parity rerun + budgets | four-flow parity; §7.10 budgets on pinned workflow | — | — | 100% / within tolerance | CI | QA | Hold |
-| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback |
-| 5 | Cutover monitor | §5.7 SLIs post-weight-up | production traffic | — | SLIs green | 24 h | OPS | Rollback |
-
-Low-volume handling: a production gate that cannot reach its floor within
-its window is **inconclusive** — extend the window once; a second
-inconclusive result is a Hold, never a pass.
+| Phase | Gate | Artifact (checked in) | Denominator | Floor | Threshold | Window | Owner | Action |
+| ----- | ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | ------ | ----- | -------- |
+| 0 | Dark-pool health | `scripts/gates/probe-pool.sh` | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold |
+| 0 | Schema validation | `scripts/gates/schema-writes.sh` (deterministic writes) | synthetic rows | 10,000 | **0 rejections** (writes are deterministic) | 24 h | RO | Hold |
+| 0 | Asset identity | `scripts/gates/asset-probe.sh` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold |
+| 0 | Config binding | `scripts/gates/config-hash.sh` | pools | all | manifest hash verified on every pool | once | OPS | Hold |
+| 1 | ABI cleanliness | `gate_abi.pipe` (probe pages) | probe page loads | 1,000 | 0 `abi_mismatch`/`bundle_partial` | 24 h | QA | Hold |
+| 1 | Bootstrap ownership | hermetic suite `bootstrap-ownership.spec` | checkpoints incl. hung-resume | all | 100% unwind/self-discard | CI | QA | Hold |
+| 2 | Ingest HTTP parity | `scripts/gates/ingest-parity.sh` (4 adapters, 4 families) | parity cases | all | 100% | CI | QA | Hold |
+| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | probe batches | 10,000 events | acceptance ≥ 99%; dedup exactly-once | 24 h | OPS | Hold |
+| 2 | Per-sink probes | `gate_probes.pipe` (3 datasources × adapters) | probe writes per sink | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
+| 3 | Funnel: ssat/prebid/page_bids | `gate_funnel.pipe` per flow | eligible APS wins (sampled traces), per flow-arm | 10,000 each | per A.2 | 24 h | RO | Rollback |
+| 3 | Funnel: direct/fallback | hermetic + real-GAM rows (A.3) — not statistical | suite cases | all | 100% | CI+RG | QA | Hold |
+| 3 | Attribution soundness | `gate_cycles.pipe` | **all TS request cycles candidate for attribution** | 10,000 | `cycle_unattributable` < 0.5% | 24 h | RO | Rollback |
+| 3 | GAM fill | `gate_fill.pipe` | cohort ad requests per arm | 10,000 | one-sided 95% CB: rel. diff ≥ −2% | 24 h | RO | Rollback |
+| 3 | Latency | `gate_latency.pipe` | cohort attempts per arm | 10,000 | one-sided 95% CB: p95 rel. diff ≤ +2% | 24 h | RO | Rollback |
+| 3 | Billing | `gate_billing.pipe` + GAM report reconciliation | attempts per arm (per-1,000 normalization) | 100,000 or 7 d | one-sided 95% CB: rel. diff ≥ −2% | window | RO | Rollback |
+| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` (detection) + billing reconciliation | burl dispatches | 1,000 | 0 observed duplicates; reconciliation clean | 24 h | RO | Rollback |
+| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
+| 4 | Four-flow parity | `flow-parity.spec` (hermetic) | parity cases | all | 100% | CI | QA | Hold |
+| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / within §7.10 tolerances | CI | QA | Hold |
+| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback |
+| 5 | Cutover monitor | `gate_slis.pipe` | production traffic | — | probe lag ≤ 5 min; probe loss < 0.1%; `render_fail` rate ≤ pre-cutover canary + 0.5 pt | 24 h | OPS | Rollback |
+
+Low-volume handling: a statistical gate that cannot reach its floor in
+its window is inconclusive — extend once; a second inconclusive is a
+Hold, never a pass.
### A.2 Expected stages per flow (Phase 3 funnel)
-| Flow | Expected sequence | Stage thresholds |
-| --------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
-| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each stage ≥ 95% of prior; document-load ≥ 99%; runner fail+timeout ≤ 1% |
-| prebid | same as ssat (keyed by Prebid `adId`) | same |
-| page_bids | same as ssat (after SPA navigation) | same |
-| direct | `render_attempt → renderer_document_loaded → render_accepted` (no bridge stages) | document-load ≥ 99%; accepted ≥ 95% of attempts |
-| fallback | `gam_empty → fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of fallback starts |
+Denominators are named per stage; document/runner rates apply wherever
+the renderer document participates.
+
+| Flow | Expected sequence | Stage thresholds (each vs its named denominator) |
+| --------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each ≥ 95% of prior; `renderer_document_loaded`/`bridge_response_sent` ≥ 99%; `runner_failed`+timeouts ≤ 1% of `renderer_document_loaded` |
+| prebid | same as ssat (keyed by Prebid `adId`) | same |
+| page_bids | same as ssat (after SPA navigation) | same |
+| direct | `render_attempt → renderer_document_loaded → render_accepted` | document ≥ 99% of attempts; accepted ≥ 95% of attempts (hermetic/real-GAM gate, not statistical) |
+| fallback | parent `gam_empty` → child `fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of `fallback_start` (hermetic/real-GAM gate, not statistical) |
### A.3 Real-GAM suite (operational row)
-| Field | Value |
-| ------------------ | -------------------------------------------------------------------------------------- |
-| Workflow | `real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
-| Topologies | one per flow in A.2, plus publisher-overlap and disabled-initial-load formation (G4a) |
-| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) |
-| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
-| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
-| Command | `npx playwright test --config real-gam.config.ts` from the browser test package |
-| Artifact | Playwright HTML report + trace zips, uploaded as workflow artifacts, retained 90 days |
-| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
-| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO |
+| Field | Value |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------- |
+| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
+| Topologies | one per A.2 flow, plus publisher-overlap and disabled-initial-load formation (G4a), plus the same-class supersession case |
+| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) |
+| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
+| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
+| Command | `npx playwright test --config real-gam.config.ts` from the browser test package |
+| Artifact | Playwright HTML report + trace zips as workflow artifacts, retained 90 days |
+| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
+| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO |
From 0dc1a5da62d9f7374db1e3f6922e7798ed161568 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 16:40:13 -0700
Subject: [PATCH 010/194] Revise design spec for the eighth review round
Revision 9 closes the mandatory set: distinct attempt_id with nullable
parent_attempt_id (fallback children are separate rows; attempt views
key on attempt_id, the tuple is grouping only); one discriminated
render_terminal event replacing the unrepresentable accepted/fail
pair; diagnostic renewal bounded forever by a signed dexp ceiling;
telemetry batching capped by both events and bytes with ingest budgets
derived from worst-case honest traffic so the limiter cannot reject
steady state; CSP report affinity routed by server-selected policy_id
path instead of the cookie the opaque renderer cannot send; an
observation-only control build plus arm-specific datasources and a
stamped deployment_pool so the control arm can populate sampled gates;
assignment_id as the clustered randomization unit with a checked-in
bootstrap estimator and named numerator/denominator/source per gate;
t_rel_ms as the monotonic latency basis; the ADM acknowledgement moved
into the trusted owner so the nonce never enters the bidder realm; an
AuctionBatch owning multi-slot fetch cancellation; a read-only bridge
lookup that emits the altered-id matched:false signal without
suppressing native Prebid; per-datasource authenticated probes with an
injected-failure alert drill; complete CSP and ops schemas, settings,
and sink handles; Nullable(UUID) join typing with explicit grains;
AuctionDropReason exhaustive over baseline producers via one shared
typed enum with a compile-time test; the withdrawn Fastly overshoot
multiple; a page-bound kill switch honestly scoped; config_hash SHA-256
verification and a fully specified affinity token with vectors;
state-dependent post-cutover routing defaults; the removed plugin
dispose hook; normative notification dispatch mechanics; a reproducible
CDP heap procedure; and RC attestation binding the real-GAM gate to the
exact immutable build.
---
...s-render-fix-and-tsjs-resilience-design.md | 1854 ++++++++---------
1 file changed, 814 insertions(+), 1040 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 74e08c867..b988e6286 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,83 +1,85 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 8 — reworked after the seventh review round and made
- fully self-contained: no contract in this document is defined by reference
- to an earlier revision.
+- **Status:** revision 9 — closes the eighth review round's mandatory set:
+ attempt schema, diagnostic renewal, telemetry/gate model, CSP routing, and
+ rollout measurement. Fully self-contained.
- **Date:** 2026-08-04
- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed
GAM shells"). All file:line citations refer to this commit.
-- **Inputs:** three code audits; design reviews of revisions 1–7; open issues
+- **Inputs:** three code audits; design reviews of revisions 1–8; open issues
#926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997.
-- **Normative gates:** the initial rollout-gates table is **Appendix A**;
- changes require reviewed decision records so thresholds cannot be chosen
- after observing results.
-- **Adoption stance for the baseline APS fixes (`248fe9558`):** this design
- adopts their **contracts** — the MessageChannel handshake semantics, the
- collapsed-shell remediation behavior, the consolidated bridge branch — and
- **re-implements them inside the target architecture** (the messaging module
- owns the channel protocol, the render engine owns the resize, the rebuilt
- `render_bridge` module owns the branch). The patch code is not carried
- forward; the baseline's browser tests are retained unmodified as the
- conformance suite pinning the adopted behavior.
+- **Normative gates:** Appendix A ships with this design; changes require
+ reviewed decision records.
+- **Adoption stance for the baseline APS fixes (`248fe9558`):** contracts
+ adopted (MessageChannel handshake semantics, collapsed-shell remediation,
+ consolidated bridge branch), implementations rebuilt inside the target
+ architecture; the baseline browser tests pass unmodified as the
+ conformance pin.
## 0. Release policy: coordinated hard cutover
-One coordinated release:
-
-- Server, TSJS bundles, config, and HTML ship under one **`release_id`**
- (git tag / build hash). **No N/N−1**; in-flight clients may fail at
- cutover — accepted and stated, not mitigated.
-- **Exact release matching**: kernel, services, plugins, and the install
- manifest carry the same `release_id`; mismatch is a refusal.
-- **Config is a release-time, content-verified input.** The config blob gains
- a top-level `format_version` (exact match required). Publish order: blob
- first, deployment manifest second. The manifest binds each pool to
- immutable `{release_id, config_store, config_key, config_hash}`, and the
- binary **verifies the loaded blob's hash against the manifest at
- startup** — a mismatch is a startup failure, so a config overwrite cannot
- mutate a supposedly immutable release or invalidate pre-materialized asset
- vectors. Rollback = redeploy the previous release with its own verified
- config; the rollback binding is prevalidated.
-- **Assets:** binaries embed only their release's artifacts; hashed pathnames
- exist for cache identity; unknown hash → `410 Gone`, `no-store`.
-- **Rollout state machine with authenticated release affinity.** The new
- pool comes up fully enabled, reachable only by probes. Canarying uses a
- **sticky, opaque, authenticated cohort token**: the router sets `ts-rel`
- on the HTML response — an HMAC-signed opaque value binding
- `{publisher_host, release_id, cohort, exp}` (attributes: `Secure;
-HttpOnly; SameSite=Lax; Path=/`; TTL 24 h) — and routes every subsequent
- request by the **validated** token; invalid, expired, forged, or
- non-allowlisted tokens route to control and are reissued; tokens for
- retired releases are reassigned on next HTML response after rollback.
- Cache keys use the post-validation release label (bounded cardinality;
- raw cookie values never key caches). A plain readable release id would
- let any visitor opt into the dark pool and would hand cache-key
- cardinality to attackers — hence opaque and authenticated. Because
- affinity rides a cookie, **the beacon and CSP-report transports use
- `credentials: "same-origin"`** (not `omit`): the cookie exists for the
- routing layer only; application handlers still derive no identity from
- it. Router weight over sticky cohorts is the sole activation primitive;
- flags are in-pool emergency kill switches. Cutover = weight 100% + CDN
- purge; rollback = weight back + re-purge. The affinity acceptance test
- covers HTML, assets, APIs, **beacons, and CSP reports**.
-- **Canary/control discrimination is infrastructure-attributed:** each pool
- writes telemetry with **pool-specific datasource tokens**, so cohort
- attribution comes from the write identity, not from in-row fields the
- control binary (the baseline) does not emit; in-row `release_id` from the
- new pool is secondary confirmation.
+- One release: server, TSJS bundles, config, HTML under one **`release_id`**.
+ No N/N−1; in-flight clients may fail at cutover — accepted and stated.
+- **Exact release matching**; mismatch is a refusal.
+- **Config is a release-time, content-verified input.** `format_version`
+ exact-match. **`config_hash` = SHA-256 over the exact fetched envelope
+ bytes**, bound through compiled release metadata (or a signed manifest
+ with an embedded verification key); the binary verifies at startup and
+ fails loudly on mismatch. Publish order: blob, then manifest. Rollback =
+ redeploy the previous release with its own verified config; binding
+ prevalidated.
+- Assets: embedded only; hashed pathnames for cache identity; unknown hash →
+ `410`, `no-store`.
+- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML
+ responses — format `r1......
+`: `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp` (TTL 24 h);
+ `release` from the allowlist; `cohort ∈ {canary, control}`;
+ `assignment_id` = 32-hex CSPRNG (the pseudonymous **randomization unit**,
+ §8); `sig` = unpadded base64url HMAC-SHA-256 over the domain-separated
+ length-prefixed input `"ts-affinity-v1" || u32be-len fields ||
+u64be(exp)`; keys owned and rotated by the routing layer (active +
+ previous, ≥ 24 h retention); constant-time verification; test vectors
+ checked in. Attributes `Secure; HttpOnly; SameSite=Lax; Path=/`. Cache
+ keys use the post-validation release label only.
+- **State-dependent routing defaults:** during canary, valid tokens route by
+ their binding; invalid/expired/forged → control + reissue. **After forward
+ cutover ("weight 100%"), the safe default flips:** stale, invalid, or
+ control-bound tokens on HTML requests are reassigned to the active
+ release; non-HTML requests with unknown or stale tokens route to the
+ active release — 100% means 100%, not "except 24 h of old cookies."
+ Rollback flips the default back.
+- **CSP-report affinity never depends on cookies:** the renderer is
+ sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its
+ browser-generated reports are cross-origin to the publisher endpoint and
+ carry no cookie. Release/cohort identity is encoded in the
+ **server-generated report path's `policy_id`** (minted per
+ `{release, policy version, cohort}`, registered in the header manifest);
+ the router routes `/_ts/csp-reports/` by that registry.
+- Beacon and trace-auth transports use `credentials: "same-origin"` so the
+ affinity cookie routes them; handlers derive no identity from cookies.
+- **Canary/control measurement (closing the empty-control-arm gap):** the
+ control pool for Phase-3 statistics runs an **observation-only control
+ build** — the baseline plus Phase-2 instrumentation only (trace, beacon,
+ probes; zero behavior changes) — so both arms emit comparable sampled
+ telemetry. Arms write to **arm-specific datasources**; the canonical
+ union view stamps a trusted `deployment_pool` dimension from the write
+ identity (token → dataset → arm), making the arm a queryable row
+ dimension rather than an authorization side effect.
+- Router weight over sticky cohorts is the sole activation primitive; flags
+ are in-pool emergency kill switches. Cutover = weight 100% + CDN purge;
+ rollback = weight back + re-purge. The affinity acceptance test covers
+ HTML, assets, APIs, beacons, and CSP reports (via path identity).
## 1. Problem statement
-APS demand is fully integrated server-side — the edge runs the APS OpenRTB
-auction, wins bids, and ships a typed renderer descriptor — yet APS
-creatives do not appear reliably. Four serial fixes (the `bid.meta`
-carrier, the decoupled shim, the `hb_adid` fallback, the baseline
-PUC/collapsed-shell fix) each survived review; the pattern is the finding:
-**multiple independent failure points, most failing silently**, with no
-client→server signal about which fired. The TSJS library (56 files,
-~11,900 lines, two ~1,800-line monoliths, duplicated ES5/TS logic,
-inverted layering, ~100 error-swallowing catches) is the same problem
-structurally.
+APS demand is fully integrated server-side, yet APS creatives do not appear
+reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim,
+the `hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived
+review; the pattern is the finding: **multiple independent failure points,
+most failing silently**, with no client→server signal about which fired.
+The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths,
+duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches)
+is the same problem structurally.
### Non-goals
@@ -127,28 +129,25 @@ server-side to one that painted.
### 2.5 Failure → signal mapping (normative)
-Two gates, precisely separated: the **tester cookie** (explicitly
-non-security, `tester_cookie.rs:3`) gates **debug content** — the
-`tsjs.boot.debug` envelope and the page-bids/`/auction`
-`ext.trusted_server.debug` fields, the same sensitivity class as the
-existing tester-gated `ts-debug` comment. The **diagnostic credential**
-(§5.3) gates **telemetry volume**. The cookie never affects sampling; the
-credential never gates mere content.
-
-| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
-| ------- | ----------------------------------------- | ------------------------------------- | ------------------------------- |
-| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary |
-| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
-| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary |
-| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
-| B1/B2 | `bridge_request{matched:false}` | join via trace (§G1) | console warn |
-| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
-| C2 | `render_fail{renderer_document_no_load}` | `ts_ops_counters` | console warn |
-| C3 | `render_fail{bridge_id_mismatch}` | join via trace | console warn |
-| C4 | `render_fail{descriptor_invalid}` | schema corpus CI | console warn |
-| C5 | — (fixed at baseline) | — | — |
-| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn |
-| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn |
+The **tester cookie** (non-security, `tester_cookie.rs:3`) gates **debug
+content** (`tsjs.boot.debug`, response `ext.trusted_server.debug` — same
+class as the existing `ts-debug` comment). The **diagnostic credential**
+(§5.3) gates **telemetry volume**. Neither crosses into the other's role.
+
+| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
+| ------- | ---------------------------------------------------- | ------------------------------------- | ------------------------------- |
+| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary |
+| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
+| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary |
+| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
+| B1/B2 | `bridge_request{matched:false}` (§6.8) | join via trace | console warn |
+| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
+| C2 | `render_terminal{failed, renderer_document_no_load}` | `ts_ops_counters` | console warn |
+| C3 | `render_terminal{failed, bridge_id_mismatch}` | join via trace | console warn |
+| C4 | `render_terminal{failed, descriptor_invalid}` | schema corpus CI | console warn |
+| C5 | — (fixed at baseline) | — | — |
+| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn |
+| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn |
## 3. The GPT and baseline reality
@@ -159,303 +158,243 @@ credential never gates mere content.
4. `enableSingleRequest()` called blind after publisher `enableServices()`.
5. Responsive-resolution ambiguity silently skips slots.
6. Three independent `pubads().refresh` wrappers.
-7. **GPT has no cancellation, no per-refresh identity, no completion-order
- guarantee**; `slotRenderEnded` = code injected, not resources loaded;
- `responseIdentifier` identifies responses only.
-8. **`display()` under disabled initial load creates no request — for any
- caller** (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`); GPT's
- behavior is caller-independent.
+7. GPT has no cancellation, no per-refresh identity, no completion-order
+ guarantee; `slotRenderEnded` = code injected; `responseIdentifier`
+ identifies responses only.
+8. `display()` under disabled initial load creates no request — for any
+ caller (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`).
9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled`
- (`gpt/index.ts:1091`); G4a needs unconditional early subscription.
-10. Baseline fix `248fe9558`: MessageChannel APS-PUC handshake
- (`aps.rs:65-125`, `aps/render.ts:415-437`; the reply still terminates
- inside the PUC frame), collapsed-shell resize (`gpt/index.ts:217`), C5
- consolidated, real-PUC browser test added.
-11. The bridge keeps consumed-id tombstones for security
- (`gpt/index.ts:1527`).
+ (`gpt/index.ts:1091`).
+10. Baseline `248fe9558`: MessageChannel APS-PUC handshake
+ (`aps.rs:65-125`, `aps/render.ts:415-437`), collapsed-shell resize
+ (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test.
+11. The bridge keeps consumed-id tombstones (`gpt/index.ts:1527`).
12. The tester cookie is not a security control (`tester_cookie.rs:3`).
-13. **Fastly constructs application state per request** (`app.rs:146`);
- its platform rate counter is a 60 s fixed window with separate
- lookup/increment (`rate_limiter.rs:40`).
+13. Fastly constructs application state per request (`app.rs:146`); its
+ platform counter is a 60 s window with separate lookup/increment
+ (`rate_limiter.rs:40`).
14. The baseline auction client collapses every failure into an empty
- array (`core/auction.ts:185-224`): absent fetch, timeout, network
- error, non-2xx, wrong content type, malformed body, and a genuine
- zero-bid auction are indistinguishable to callers.
+ array (`core/auction.ts:185-224`).
+15. A single `/auction` request can carry several slots; concurrent calls
+ race today (`request.ts:31`) and share one fetch.
## 4. Design gates
-### G1 — Trace identity, sampling, correlation
+### G1 — Trace identity, attempts, sampling, correlation
-- The client-visible auction id is EC-derived (`publisher.rs:3237`) and
- never ingested. Initial-HTML telemetry precedes page JS
- (`telemetry.rs:148`, `publisher.rs:2452`), so correlation is minted by
- whoever acts first: the **server** for `nav_gen 0` (trace + signed
- authorization in `tsjs.boot`); the **client** afterwards via
- `X-TSJS-Trace-Id` on page-bids (GET) and the `/auction` POST, echoed
- back with the authorization and the server's telemetry auction id:
+- EC-derived auction ids (`publisher.rs:3237`) are never ingested.
+ Initial-HTML telemetry precedes page JS (`telemetry.rs:148`,
+ `publisher.rs:2452`); correlation is minted by whoever acts first: the
+ server for `nav_gen 0` (trace + signed authorization in `tsjs.boot`);
+ the client afterwards via `X-TSJS-Trace-Id` on page-bids (GET) and the
+ `/auction` POST, echoed back with
`ext.trusted_server.trace = {trace_id, auth, auction_id}`.
-- **Deterministic keyed sampling, numerically exact:** take the first
- 8 bytes of `HMAC-SHA-256(sampling_key, trace_id)` as a big-endian u64;
- `mode = sampled` iff `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate`
- must be finite and in `[0, 1]` (validated at load; 0 → nothing sampled,
- 1 → everything). Concurrent requests for one trace always derive the
- same mode with no shared state (§3.13).
-- **Cross-tier join:** the equality key is the globally unique
- **`auction_id`** (server-minted telemetry UUID), echoed to the client
- and stamped on every event of attempts born from that auction.
- Generations (`nav_gen`, `refresh_gen`) exist **client-side only**, for
- attempt aggregation — auction rows do not carry them. Canonical join:
- `(publisher_domain, trace_id, auction_id)`, attempt grain added from
- client events.
-- **Cache-privacy invariant:** traces/authorizations only in per-request
- auction-bearing responses; such HTML is `private, no-store`, no
- validators; by construction and by test.
+- **Attempt identity (closing the parent/child collision):** every render
+ attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`,
+ CSPRNG, unique per trace) and carries nullable **`parent_attempt_id`**
+ (fallback children reference their parent). The tuple
+ `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key
+ only**; `ts_render_attempts_v` keys by `attempt_id`. Exactly one
+ terminal event per `attempt_id` (G4c) is a tested invariant.
+- **Deterministic keyed sampling:** first 8 bytes of
+ `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff
+ `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`.
+- **Cross-tier join:** equality key = the server telemetry
+ **`auction_id`** (UUID; the client column is `Nullable(UUID)` and
+ ingest validates canonical UUID syntax). Generations are client-side
+ only. **Join grains are explicit:** auction-level joins hit the one
+ summary row per `auction_id`; slot-level joins use
+ `auction_id + slot + row_kind`; bid-level joins are opt-in for
+ bid-grained analyses. Raw attempt × auction-row joins are forbidden
+ (row multiplication).
+- Cache-privacy invariant: traces/authorizations only in per-request
+ auction-bearing responses; such HTML is `private, no-store`.
- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry
- `{nav_gen, refresh_gen, seq, flow, auction_id?}`. **`flow`** is closed:
- `ssat | prebid | page_bids | direct | fallback | system` — `system` for
- heartbeat and overflow events, which have no render flow; the generated
- per-event validity matrix (§5.1) says which events may carry which
- flows.
-- Traces are navigation-scoped; attempt counts key on
- `(trace_id, nav_gen, refresh_gen, slot)`.
+ `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?,
+auction_id?, t_rel_ms?}`. **`t_rel_ms`** is a bounded monotonic
+ duration (`performance.now()` truncated to u32 ms, relative to
+ navigation start) — the latency gates' basis; `received_at` is ingest
+ time and is never used as event time. `flow` is closed:
+ `ssat | prebid | page_bids | direct | fallback | system`.
+- Traces are navigation-scoped; attempt counts key on `attempt_id`.
### G2 — Render identity
- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte
- (`publisher.rs:3355`; the PUC fetches `?uuid=`, `gpt/index.ts:1772`).
- Markup bids: existing fallback chain. Renderer-only bids: server-minted
- token `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry,
- cross-auction uniqueness probabilistic (36¹² ≈ 4.7×10¹⁸) and harmless
- via scoping; TTL 15 min; one-time consumption.
-- **Reservation store — one capacity, no unexpired eviction:** live
- registrations and tombstones (consumed / stale / navigation-disposed
- ids) share one bounded structure, **union capacity 320**; expired
- entries are pruned; **unexpired entries are never evicted**; at
- capacity, new registration is refused with `registry_full`. A late
- prior-navigation bridge request always meets suppression until its id's
- original TTL passes (preserving `gpt/index.ts:1527`). Test: >320
- registrations, then a late request for the oldest unexpired id.
-- The client-Prebid path keeps Prebid's generated `adId`; one store
- serves both paths. Non-APS cache-path byte-identity regression tests.
-
-### G3 — Runtime ABI under the IIFE build (exact-release)
-
-Every entry point is a self-contained IIFE with inlined imports
-(`build-all.mjs:46`, `bundle.rs:23`) — imports never share state across
-bundles (live defect: `core/context.ts:11` vs `permutive/index.ts:102`).
-
-- The kernel ships only in `tsjs-core`, publishes
- `tsjs._internal = {release_id, registry}` once (window sentinel),
- freezes `_internal` after boot, and constructs/registers core services
- (event bus, beacon queue, sessions, slot registry, render engine)
- during boot; integrations register integration-scoped services during
- `install()`.
-- **Exact release matching:** every registration carries `release_id`
- (plugins via the §7.6 object API whose `release` is a build-generated
- constant); `registry.get(name)` succeeds only on equality; mismatch
- quarantines (`abi_mismatch` service / `bundle_partial` plugin) with a
- console error.
-- Stateful access only via the registry at call time; stateless helpers
- may inline.
-- **Boundary enforcement:** `import/no-restricted-paths` for layering
- **plus** `no-restricted-properties`/`no-restricted-syntax` rules
- catching member-expression access to `googletag`/`pbjs` through
- `window`, `globalThis`, `self`, and local aliases outside `adapters/`
- (`no-restricted-globals` cannot catch member expressions). Adapters are
- the only access to **external ad-tech globals**; kernel and messaging
- necessarily touch `window.tsjs`, listeners, and `postMessage`.
+ (`publisher.rs:3355`, `gpt/index.ts:1772`). Markup bids: existing
+ fallback chain. Renderer-only bids: server-minted token
+ `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min,
+ one-time consumption.
+- **Reservation store:** live registrations + tombstones (consumed /
+ stale / disposed) share one structure, **union capacity 320**; expired
+ entries pruned; **unexpired entries never evicted**; at capacity, new
+ registration refused with `registry_full`. Late prior-navigation
+ requests always meet suppression until original TTL (preserving
+ `gpt/index.ts:1527`). Test: >320 registrations, late oldest-id request.
+- Client-Prebid keeps Prebid's `adId`; one store serves both paths.
+ Non-APS cache-path byte-identity regression tests.
+
+### G3 — Runtime ABI (exact-release)
+
+- IIFE-per-bundle with inlined imports (`build-all.mjs:46`,
+ `bundle.rs:23`); imports never share state (defect:
+ `core/context.ts:11` vs `permutive/index.ts:102`). Kernel only in
+ `tsjs-core`; `tsjs._internal = {release_id, registry}` frozen after
+ boot; core services constructed at boot; plugins via
+ `definePlugin({id, release, install})` with build-generated `release`;
+ `registry.get` succeeds only on equality; mismatch quarantines
+ (`abi_mismatch`/`bundle_partial`) loudly.
+- **Boundary enforcement:** `import/no-restricted-paths` for layering,
+ plus a **custom scope-aware ESLint rule** for external-global access —
+ standard `no-restricted-properties`/`no-restricted-syntax` cannot
+ follow arbitrary aliasing, so the custom rule tracks member access to
+ `googletag`/`pbjs` through `window`/`globalThis`/`self` **and
+ same-file const aliases**; anything cleverer (cross-module smuggling)
+ is caught by review, and the claim is scoped to exactly that. Adapters
+ are the only access to external ad-tech globals; kernel/messaging
+ necessarily touch `window.tsjs` and `postMessage`.
### G4 — Render lifecycle
-**G4a — Physical request cycles.**
-
-- **Intents, both classes, one causal queue.** Every observable
- initiation — TS and wrapped publisher `display()`/`refresh()` — records
- an intent in causal order, classified `ts | publisher`. Any
- `display()` issued while initial load is disabled is **retired at
- issuance regardless of caller** (GPT is caller-independent, §3.8) — it
- never enters the matcher. Hindsight zero-request intents (`refresh()`
- on a never-displayed slot) expire at 2 s with `intent_no_request`;
- **any later request-capable intent — same class or opposite —
- supersedes a pending uncertain intent immediately**, and if the
- uncertain intent's request could still legitimately be in flight
- (within its 2 s bound), the next `slotRequested` is ambiguous and the
- slot quarantines. A stale no-op `refresh()` can therefore never steal
- a later `display()`'s request in either direction, TS→TS,
- publisher→publisher, or across classes.
-- **Cycles** open only on `slotRequested`, matched to the causal queue
- head; SRA batching yields one per slot per batch; cycles close on
- `slotRenderEnded`; `responseIdentifier` deduplicates responses during
- drain (it never attributes initiation).
-- **Serialization:** at most one outstanding TS cycle per slot; one
- queued TS replacement (later intents coalesce).
-- **Attribution:** a `slotRenderEnded` is attributable iff exactly one TS
- cycle is outstanding and no publisher/untracked request overlaps;
- otherwise quarantine (`cycle_unattributable`), fail closed.
-- **No timeout re-arm.** Physical cycle/drain state lives in the
- RuntimeSession slot record; unissued intents are NavigationSession
- children (cancelled by navigation disposal). A quarantined or stale
- slot re-arms only on count-based drain, safe TS-owned
- destroy/redefine, or page end. Timeouts emit diagnostics and never
- restore attribution. Late stale events are matched and discarded
- (`stale_navigation`).
-- Deterministic-harness CI plus the release-gating real-GAM suite
- (topologies enumerated in Appendix A.3).
-
-**G4b — Acknowledgement, per render path.** Four normative sequences;
-each names its nonce producer, transport, authenticated acceptance
-observation, cancellation, and deadlines (document 3 s, runner 10 s,
-adm 5 s). All nonces are per-attempt 128-bit CSPRNG values minted by the
-attempt owner; the kernel validates, in order: source ownership (§6.8
-walk), nonce, token, `nav_gen`, `refresh_gen` — before any transition or
-notification. Navigation/supersession invalidates the nonce; late acks →
-`stale_navigation`.
-
-1. **APS-PUC** (baseline transport): bridge mints the nonce;
- MessageChannel into the renderer document (`ports.length` checks,
- exact-key replies, one-shot `accepted` latch, port close —
- `aps.rs:65-125`, `aps/render.ts:415-437`); the document posts
- authenticated `renderer_document_loaded` then
- `render_accepted | render_failed{reason}` to the top window.
-2. **Generic ADM/cache-PUC:** the display renderer creates the sandboxed
- adm frame with an injected reporter snippet that posts authenticated
- `adm_document_loaded{nonce}` on document load; acceptance = that
- message (the baseline merely appends an iframe with no observation).
-3. **Direct APS** (`renderApsCreative`): the kernel is the frame parent;
- the baseline parent-postMessage branch (`ports.length === 0`) is
- already kernel-observed; same three messages, same validation.
-4. **Direct ADM/cache:** as (2) with the kernel as parent.
-
-**G4c — Honest observations; one terminal state.** Inline-adm frames are
-sandboxed `srcdoc` without `allow-same-origin` (`gpt/index.ts:510`) —
-opaque; geometry proves nothing. Observations: `gam_nonempty`,
-`gam_empty`, `gam_collapsed{action: resized | guarded, reason?}`
-(observation and remediation separate), `renderer_document_loaded`,
-`runner_loaded`, `runner_failed`, `adm_document_loaded`. **An attempt has
-exactly one terminal state: `accepted | failed{reason} | no_bid |
-cancelled`.** Post-acceptance runner failure is an observation only —
-there is **no** `billing_outcome` event: no path has an honest producer
-for a post-accept billing-failure claim (APS is excluded from
-notifications and opaque frames offer no authenticated post-accept
-signal), so the design does not pretend otherwise. No observation claims
-paint; there is no `render_confirmed`. The baseline resize
-(`gpt/index.ts:217`) is a sanctioned, guarded exception to the
-no-foreign-DOM-mutation rule (authenticated source frame only; wrapper
-only when both dimensions ≤ 1 px; anchor-ad and fixed/sticky guards).
+**G4a — Physical request cycles.** Intents (both classes, one causal
+queue): any `display()` under disabled initial load is retired at
+issuance regardless of caller; hindsight zero-request intents expire at
+2 s with `intent_no_request`; **any later request-capable intent — same
+class or opposite — supersedes a pending uncertain intent**, and if the
+uncertain one could still be in flight, the next `slotRequested` is
+ambiguous → quarantine. Cycles open only on `slotRequested` (causal
+head; SRA = one per slot per batch) and close on `slotRenderEnded`
+(`responseIdentifier` dedups drain). One outstanding TS cycle per slot;
+one queued replacement. Attribution requires exactly one outstanding TS
+cycle and no overlap; otherwise `cycle_unattributable`, fail closed. **No
+timeout re-arm** — re-arm only on count-based drain, safe TS-owned
+destroy/redefine, or page end; unissued intents are NavigationSession
+children; physical state is RuntimeSession. Deterministic-harness CI +
+the release-gating real-GAM suite (Appendix A.3).
+
+**G4b — Acknowledgement, per render path.** Nonces are per-attempt
+128-bit CSPRNG values; the kernel validates source ownership (§6.8),
+nonce, token, `nav_gen`, `refresh_gen` before transitions or
+notifications; navigation/supersession invalidates; late acks →
+`stale_navigation`. Deadlines: document 3 s, runner 10 s, adm 5 s.
+
+1. **APS-PUC** (baseline transport): MessageChannel into the renderer
+ document (`ports.length` checks, exact-key replies, one-shot latch,
+ port close); the document posts authenticated
+ `renderer_document_loaded` then the accepted/failed result to the top
+ window.
+2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the
+ trusted owner, not the creative document** — the owner observes its
+ own iframe's `load`/`error` events and emits `adm_document_loaded`;
+ the nonce never enters the bidder realm (an injected reporter would
+ hand bidder-controlled code the acceptance credential and let it
+ trigger `burl` early — revision 8's reporter is withdrawn).
+3. **Direct APS:** the kernel is the frame parent; the baseline
+ parent-postMessage branch is already kernel-observed.
+4. **Direct ADM/cache:** as (2), owner-observed `load`/`error`.
+
+**G4c — Honest observations; one terminal event.** Observations:
+`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded}`,
+`renderer_document_loaded`, `runner_loaded`, `runner_failed`,
+`adm_document_loaded`. **The terminal is one discriminated event:
+`render_terminal{outcome: accepted | failed | no_bid | cancelled,
+reason?}` — exactly one per `attempt_id`** (replacing separate
+accepted/fail events the schema could not reconcile). A parent attempt
+whose GAM cycle ends empty emits `render_terminal{failed, gam_empty}`
+**before** its fallback child starts. Post-acceptance runner failure is
+an observation only (no billing-failure event exists — no honest
+producer). No observation claims paint. The baseline resize
+(`gpt/index.ts:217`) stays a sanctioned, guarded exception.
**G4d — Notifications.** APS carries neither `nurl` nor `burl`
-(`aps.rs:839`; the AAX envelope excludes them; the integration guide
-documents no generic APS beacons) — excluded entirely; the Amazon runner
-lifecycle is unchanged. For carrying paths (PBS and other OpenRTB
-providers):
-
-- Bind per flow, never selection or targeting (`ad_init.test.ts:1824`):
- PUC — an owned, slot-and-ad-id-matched bridge claim; direct —
- validated render start (the server must preserve and macro-expand
- `nurl`/`burl` in `/auction` responses, `formats.rs:423` omits them;
- the client must parse and https-validate them, `core/auction.ts:43`
- drops them); fallback — attributed `gam_empty` immediately before
- fallback render.
-- `nurl` at bind; `burl` at `accepted`; **no retries**; idempotency key
- `(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)` with the
- normalized economic identity `(id_kind, id_value)` (direct attempts
- without `hb_adid` use `bid_id`).
-- **Observability without client cryptography:** the server mints an
- opaque **`notif_id`** (12-char token, same generator as G2) per
- notification-carrying bid and delivers it with the bid; every dispatch
- emits `notification_sent{kind: nurl | burl, notif_id, result:
-queued | failed}`. The browser computes no hashes (a client-held HMAC
- key would break the pseudonymization boundary; a rotating token would
- break stability across a gate window).
-- **The duplicate-`burl` invariant is proven hermetically and
- reconciled externally, not "proven" by lossy telemetry:** hermetic
- tests pin exactly-once dispatch logic; production
- `notification_sent` duplicates are a **detection alarm** (any
- observed duplicate is a red gate); absence-of-duplicates is
- established by billing reconciliation (GAM/SSP reports vs server-side
- win counts) because sampled, best-effort telemetry cannot prove a
- zero.
-
-**G4e — Fallback.** Opt-in
-(`[auction].client_render_fallback = "renderer"`); renders only after a
-terminal `gam_empty` unambiguously attributed to a TS cycle; ownership
-does not gate it; publisher-initiated or unattributable cycles never
-trigger it; timeouts never render.
-
-**G4f — Direct `/auction` lifecycle.** `RenderAttempt` keyed
-`(trace_id, nav_gen, refresh_gen, slot)`; per-slot **latest-wins with
-cancellation** (concurrent calls cancel the older attempt;
-`request.ts:31` races today); generation checks before every DOM/beacon
-effect; G4b sequences 3/4; G4d direct binds; navigation disposal; one
-terminal state. **The auction client returns a discriminated result** —
-`{ok: bids[]} | {error: "auction_timeout" | "network_error" |
-"http_error" | "invalid_response"}` — replacing the baseline's
-everything-is-an-empty-array collapse (§3.14); only a successfully
-parsed response with no winner maps to `no_bid`. Public API:
+(`aps.rs:839`) — excluded entirely. For carrying paths: bind per flow
+(PUC: owned matched bridge claim; direct: validated render start —
+server must preserve + macro-expand the URLs (`formats.rs:423` omits),
+client must parse + https-validate (`core/auction.ts:43` drops);
+fallback: attributed parent `gam_empty` immediately before child
+render). `nurl` at bind; `burl` at `accepted`; idempotency key
+`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`.
+**Dispatch mechanics (normative):** macros are expanded server-side
+only; the client fires
+`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit",
+redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`;
+on synchronous failure the fallback is a detached `Image()` request; no
+retries either way. Every dispatch emits
+`notification_sent{kind, notif_id, result: queued | failed}` with the
+**server-minted `notif_id`** (12-char token delivered with the bid).
+Duplicates: hermetic exactly-once proof + production detection alarm +
+billing reconciliation (lossy telemetry cannot prove a zero).
+
+**G4e — Fallback.** Opt-in; child attempt (own `attempt_id`,
+`parent_attempt_id`, `flow = fallback`); renders only after the parent's
+attributed `render_terminal{failed, gam_empty}`; publisher-initiated or
+unattributable never triggers; timeouts never render.
+
+**G4f — Direct `/auction` lifecycle and the AuctionBatch.** A single
+`/auction` fetch may serve several slots, so cancellation is
+batch-aware: an **`AuctionBatch`** owns the fetch (its `AbortController`)
+and the child `RenderAttempt`s. Supersession cancels **children
+individually** (`render_terminal{cancelled}`); the fetch aborts only
+when every child is dead or the batch times out or its navigation
+disposes; every response bid is filtered through the **currently live**
+child identity before any effect. Tests: partial overlap, full overlap,
+timeout, navigation disposal, reversed responses. The auction client
+returns a discriminated result — `{ok: bids[]} | {error:
+"auction_timeout" | "network_error" | "http_error" | "invalid_response"}`
+(§3.14); only a parsed empty response is `no_bid`. Public API:
`tsjs.requestAds(options): Promise`,
-`RequestAdsResult = {traceId, slots: [{slot, outcome: "rendered" |
-"no_bid" | "failed" | "cancelled", reason?}]}`, settling when every slot
-attempt is terminal. Reversed-response tests required.
-**Fallback identity:** fallback is a **child attempt** — new
-`RenderAttempt`, `flow = fallback`, carrying `parent_flow` (the
-originating flow); the terminal `gam_empty` belongs to the parent
-attempt under the parent's flow; the canonical view links parent and
-child on `(trace_id, nav_gen, slot, refresh_gen)`.
-
-**G4g — Mid-attempt configuration and the commit point.** An attempt
-snapshots configuration at creation. **Commit = the earliest
-irreversible action** — the first of: notification dispatch (`nurl` at
-bind), `bridge_response_sent`, or first DOM insertion. The
-generation/kill-switch check runs **immediately before each** of those;
-an attempt past commit runs to its terminal state; dispatched
-notifications are never recalled. (Revision 7 put commit after the
-`nurl` side effect; that ordering error is corrected.)
+`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`,
+settling when every child attempt is terminal.
+
+**G4g — Mid-attempt configuration, honestly scoped.** Attempts snapshot
+configuration at creation. **Commit = the earliest irreversible action**
+(first of: notification dispatch, `bridge_response_sent`, first DOM
+insertion), with the generation/kill check immediately before each.
+**The kill switch's delivery is page-bound:** already-loaded pages have
+no push channel, so live switch state travels only on responses the
+page later fetches — page-bids and `/auction` responses carry
+`ext.trusted_server.switches`, and new HTML carries current state. The
+guarantee is therefore scoped: the switch affects attempts created
+after the page received switch state; SSAT attempts on already-loaded
+pages are unaffected by design, and the spec says so rather than
+implying a live channel that does not exist.
### G5 — Deployment contracts
-- Config `format_version` + manifest hash verification (§0).
-- **Assets pre-materialized at release publication:** config is a
- release-time input, so validated module vectors are known when the
- release is built; concatenated bytes + hashes are produced then and
- embedded; serving is lookup-only on every adapter (Fastly is
- per-request, §3.13, so construction-time caching would be
- meaningless). Unknown vector = release-build error; unknown hash =
- `410 no-store`; exact match = `public, max-age=31536000, immutable`.
-- **Internal route families — four:** renderer, client-events,
- CSP-report, `/_ts/trace-auth`. All dispatch before auth/EC/publisher/
- integration filters (Fastly today runs EC setup and pre-route filters
- first, `app.rs:709`); all methods and version prefixes reserved
- locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`;
- never the publisher fall-through of `adapter-spin app.rs:804`); no
- body/cookie/authorization forwarding. **Origin policy is per family**,
- not universal: client-events and trace-auth require strict normalized
- same-origin (scheme+host+port); the CSP route admits opaque/`null`
- origins and authenticates by server-selected path identity plus abuse
- limits; the renderer document is a public GET validated by
- version/path only (it is loaded from sandboxed opaque contexts —
- browser-origin authentication is impossible there by design).
-- Ingest routes exist in all four adapters; Fastly has real sinks;
- others accept-count-drop by contract (DR-5).
-- §5.6 schemas deploy and validate before writers enable.
+- Config verification per §0. Assets pre-materialized at release
+ publication (config is a release-time input); serving is lookup-only;
+ unknown vector = build error; unknown hash = `410 no-store`; exact
+ match = `public, max-age=31536000, immutable`.
+- **Internal route families — four** (renderer, client-events,
+ CSP-report, `/_ts/trace-auth`): dispatch before auth/EC/publisher/
+ integration filters (`app.rs:709` orders these wrong today); all
+ methods + version prefixes reserved locally (405 + `Allow` +
+ `no-store`; unknown version 404 `no-store`; no publisher fall-through,
+ `adapter-spin app.rs:804`); no body/cookie/authorization forwarding.
+ **Per-family origin policy:** client-events + trace-auth strict
+ normalized same-origin; CSP admits opaque/`null` origins with path
+ identity + limits; renderer is a public GET validated by version/path.
+- Ingest routes in all four adapters; Fastly has real sinks; others
+ accept-count-drop (DR-5). §5.6 schemas deploy before writers.
## 5. Observability
-### 5.1 Wire payload and per-event field matrix
+### 5.1 Wire payload and field matrix
```
{ v: 1, traces: [ { trace_id, auth, events: [
- { nav_gen, refresh_gen, seq, flow, auction_id?, t, ...fields } ] } ] }
+ { nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?,
+ auction_id?, t_rel_ms?, t, ...fields } ] } ] }
```
| `t` | fields | allowed `flow` |
| -------------------------- | --------------------------------------------- | ----------------------- |
| `bid_received` | slot, id_kind, source | render flows |
| `targeting_set` | slot, id_kind | render flows |
+| `attempt_started` | slot, source | render flows |
| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids |
| `bridge_response_sent` | slot, source | ssat, prebid, page_bids |
-| `render_attempt` | slot, source | render flows |
-| `render_accepted` | slot, source | render flows |
-| `render_fail` | slot, reason, source? | render flows |
+| `render_terminal` | slot, outcome, reason?, source? | render flows |
| `gam_nonempty` | slot | ssat, prebid, page_bids |
| `gam_empty` | slot | ssat, prebid, page_bids |
| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids |
@@ -463,385 +402,326 @@ notifications are never recalled. (Revision 7 put commit after the
| `runner_loaded` | slot | render flows |
| `runner_failed` | slot, reason | render flows |
| `adm_document_loaded` | slot | render flows |
-| `fallback_start` | slot, parent_flow | fallback |
+| `fallback_start` | slot | fallback |
| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows |
| `client_queue_overflow` | dropped (count) | system |
-| `heartbeat` | probe_id, expected_seq | system |
-
-"Render flows" = `ssat | prebid | page_bids | direct | fallback`.
-`source` on `render_fail` is **nullable**: absent for pre-source reasons
-(`gpt_absent`, `pbjs_absent`, `slot_unresolved`, `intent_no_request`,
-`abi_mismatch`, `registry_full`, `bundle_partial`); required otherwise.
-The per-event/per-reason validity matrix is a generated artifact (§6.7).
-Reason enum (closed): `renderer_document_no_load`, `runner_no_load`,
-`runner_failed`, `descriptor_invalid`, `invalid_dimensions`,
-`dimensions_out_of_range`, `bridge_id_mismatch`, `cycle_unattributable`,
-`intent_no_request`, `stale_navigation`, `bridge_claim_timeout`,
-`gam_empty`, `no_render_source`, `slot_unresolved`, `gpt_absent`,
-`pbjs_absent`, `bundle_partial`, `fallback_cancelled`, `abi_mismatch`,
-`registry_full`, `currency_mismatch`, `auction_timeout`,
-`network_error`, `http_error`, `invalid_response`,
-`adm_document_no_load`. No client timestamp; the server stamps
-`received_at`; ordering within a trace is `seq`.
-
-### 5.2 Transport and overflow
-
-`fetch(..., {keepalive: true, credentials: "same-origin"})` primary (§0
-affinity; the handler still derives no identity from cookies);
-`pagehide` fallback `navigator.sendBeacon(url, new Blob([json], {type:
-"application/json"}))`. Flush every 5 s and on
-`visibilitychange`/`pagehide`. Queue bound 256 events. **Overflow never
-enqueues into the full queue:** an out-of-band saturating counter
-accumulates drops and one coalesced `client_queue_overflow{dropped}` is
-materialized into the next flush.
-
-### 5.3 Signed trace authorization
-
-Format `v1....`; the `auth` field has its own
-ingest bound of **256 bytes** (every other string keeps the 64-char
-cap — a 43-char unpadded-base64url signature cannot fit 64 with its
-prefix fields).
-
-- `kid`: `^[a-z0-9-]{1,16}$`; active + previous keys in the platform
- secret store; keys ≥ 256-bit CSPRNG; previous keys retained ≥ 24 h
- (≫ max token lifetime + skew). Missing key with the feature enabled →
- startup/first-use failure, never silent.
-- `exp`: canonical decimal unix seconds (no sign, no leading zeros);
- ±60 s skew; ≤ 15 min future.
-- `mode`: `sampled | unsampled | diagnostic | probe`. **`unsampled` is
- the signed discard decision:** the client neither enqueues nor
- transmits for it, and ingest rejects any group carrying it. `probe`
- marks synthetic monitors (server-issued to probe runners); probe
- traffic is never sampled out and is excluded from product metrics by
- mode.
-- `sig`: unpadded base64url of HMAC-SHA-256 (43 chars) over the
- domain-separated, length-prefixed input `"ts-trace-auth-v1" ||
-u32be(len(origin)) || origin || u32be(len(trace_id)) || trace_id ||
-u32be(len(mode)) || mode || u64be(exp)`, strings UTF-8, `origin` =
- externally visible scheme+host+port. Constant-time comparison.
-- **Renewal preserves mode by verification, not trust:**
- `GET /_ts/trace-auth` presents the current still-valid token in
- `X-TSJS-Trace-Auth` (plus the trace header); the server verifies and
- re-signs the same `trace_id` and `mode` with fresh `exp`. **The only
- mode transition that exists is the diagnostic upgrade, a distinct
- operation:** `POST /_ts/trace-auth/upgrade` presenting the current
- token **and** a valid diagnostic credential; it re-signs with
- `mode = diagnostic` and `exp = min(now + 15 min, credential expiry)`.
- Plain renewal never changes mode. Renewal after expiry fails; the
- client stops transmitting and counts locally.
-- **Diagnostic credential:** issued `POST
-/_ts/admin/diagnostic-credential` under the existing admin
- authentication (CSRF: same-origin + custom header), format
- `d1....`, absolute expiry ≤ 60 min,
- origin-bound, **replayable short-lived bearer by design** (bounded by
- expiry + origin binding; stated, not implied). **Exposure-minimized
- transport:** the operator opens the page with `#tsdiag=`;
- the synchronous bootstrap reads it, **immediately clears the fragment
- via `history.replaceState`**, holds the credential **in memory only**
- (RuntimeSession — never `sessionStorage`, which page scripts can
- read), and exchanges it via the upgrade operation as soon as the trace
- exists. Because initial HTML cannot see the fragment, `nav_gen 0`
- starts `sampled | unsampled`; **when a pending `#tsdiag` fragment is
- detected, the client buffers events locally without transmission
- (bounded 256) until the upgrade resolves**, then flushes under
- diagnostic mode — one-page-load diagnostic completeness holds without
- delaying rendering. Forgery, wrong-origin, and replay-past-expiry
- tests required. Validation is stateless HMAC — all four adapters.
-- **Lazy cached initialization** applies to every secret-backed
- component (trace-auth keys, diagnostic keys, sampling key, sinks):
- first-use resolution with a cached result on request-bound platforms;
- failure with the feature enabled is that feature's loud error path.
+| `heartbeat` | probe_run_id, expected_seq, adapter, target | system |
+
+Render flows = `ssat | prebid | page_bids | direct | fallback`.
+`attempt_started` carries the attempt's `t_rel_ms` baseline; the latency
+metric is `render_terminal{accepted}.t_rel_ms −
+attempt_started.t_rel_ms` per attempt. `source` on `render_terminal` is
+nullable for pre-source reasons (`gpt_absent`, `pbjs_absent`,
+`slot_unresolved`, `intent_no_request`, `abi_mismatch`, `registry_full`,
+`bundle_partial`); the per-event/per-reason validity matrix is a
+generated artifact (§6.7). Reason enum: `renderer_document_no_load`,
+`runner_no_load`, `runner_failed`, `descriptor_invalid`,
+`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`,
+`cycle_unattributable`, `intent_no_request`, `stale_navigation`,
+`bridge_claim_timeout`, `gam_empty`, `no_render_source`,
+`slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`,
+`fallback_cancelled`, `abi_mismatch`, `registry_full`,
+`currency_mismatch`, `auction_timeout`, `network_error`, `http_error`,
+`invalid_response`, `adm_document_no_load`.
+
+### 5.2 Transport, batching, and budgets (limiter-consistent)
+
+- Batches are capped by **both** 64 events **and** 12 KiB encoded
+ payload (headroom under the 16 KiB ingest cap); a flush drains the
+ queue as up to **4 sequential batches**.
+- Cadence: flush every **10 s** and on `visibilitychange`/`pagehide`.
+ Worst-case honest traffic per tab: 6 flushes/min × ≤ 4 batches = ≤ 24
+ requests/min transient, typically ≤ 6.
+- **Budgets derived from that worst case:** client-side trace budget
+ ≤ 8 batches/min sustained (excess coalesces into the next flush);
+ ingest per-address budget **60 req/min, burst 120** (≈ 5 active tabs
+ plus pagehide bursts). The limiter can no longer reject honest
+ steady-state traffic by construction; tests cover sustained
+ single-tab, multi-tab (5), diagnostic pre-upgrade buffer flush, and
+ pagehide bursts.
+- Transport: `fetch(..., {keepalive: true, credentials:
+"same-origin"})`; `pagehide` fallback `sendBeacon(url, new
+Blob([json], {type: "application/json"}))`. Queue bound 256; overflow
+ uses the out-of-band saturating counter + one coalesced
+ `client_queue_overflow` in the next flush (never enqueued into a full
+ queue).
+
+### 5.3 Signed authorizations
+
+**Trace authorization** `v1...[.].` (`auth`
+ingest bound 256 bytes; all other strings 64):
+
+- `kid ^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store;
+ previous keys retained ≥ 24 h; missing key with the feature enabled →
+ loud first-use failure.
+- `exp` canonical decimal; ±60 s skew; ≤ 15 min future. `mode`:
+ `sampled | unsampled | diagnostic | probe`. **`dexp` is present iff
+ `mode = diagnostic`** — the immutable diagnostic ceiling, signed into
+ the token, set at upgrade to the credential's absolute expiry.
+ **Every renewal of a diagnostic token re-derives
+ `exp = min(now + 15 min, dexp)` and preserves `dexp`; past `dexp`,
+ renewal fails** — diagnostic access is bounded by the credential
+ forever, not just at upgrade (closing the indefinite-renewal hole).
+ Tests: renewal-before-expiry capped, repeated renewal to the ceiling.
+- `sig` = unpadded base64url HMAC-SHA-256 over
+ `"ts-trace-auth-v1" || u32be(len(origin)) || origin ||
+u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode ||
+u64be(exp) [|| u64be(dexp)]`; constant-time compare; per-group
+ rejection at ingest. `unsampled` transmits nothing and is rejected if
+ carried. **Probe issuance protocol:** the probe runner authenticates
+ to `POST /_ts/admin/probe-authorization` (admin auth + CSRF) and
+ receives a batch of pre-signed probe-mode tokens tagged
+ `probe_run_id`; probe traffic is never sampled out and excluded from
+ product metrics by mode.
+- Renewal: `GET /_ts/trace-auth` presenting the current still-valid
+ token in `X-TSJS-Trace-Auth`; re-signs same trace + mode (+ `dexp`).
+ **Diagnostic upgrade** is the sole mode transition:
+ `POST /_ts/trace-auth/upgrade` presenting token + credential.
+
+**Diagnostic credential** `d1....` — full byte-level
+spec with vectors: `oh` = first 16 hex chars of SHA-256 of the
+externally visible origin (scheme+host+port, UTF-8); `sig` = unpadded
+base64url HMAC-SHA-256 over `"ts-diag-cred-v1" || u32be(len(origin)) ||
+origin || u64be(exp)`; same kid charset, key strength, rotation, and
+≥ 24 h previous-key retention; ±60 s skew; absolute expiry ≤ 60 min;
+constant-time compare; replayable short-lived bearer by design (bounded
+by expiry + origin). Issued `POST /_ts/admin/diagnostic-credential`
+(admin auth, CSRF: same-origin + custom header). Transport: `#tsdiag=`
+fragment → read synchronously, cleared via `history.replaceState`, held
+in memory only (RuntimeSession); pre-upgrade events buffer locally
+(bounded 256) and flush after upgrade. Forgery, wrong-origin,
+replay-past-expiry tests.
+
+Lazy cached initialization applies to every secret-backed component;
+failure with the feature enabled is that feature's loud error path.
### 5.4 Ingest and rate limiting
- `POST /_ts/client-events`: `application/json` only; no
- `Content-Encoding`; responds `204`, `no-store`; never echoes input.
- Pre-parse limits: body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 chars
- (`auth` ≤ 256 bytes); `trace_id ^[0-9a-f]{32}$`; integers `[0, 2³¹)`;
- width/height `[0, 8192]`. Violations → drop-and-count with `204`.
+ `Content-Encoding`; `204`, `no-store`; never echoes input. Pre-parse:
+ body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 (`auth` ≤ 256 B);
+ `trace_id ^[0-9a-f]{32}$`; `attempt_id ^[a-z0-9]{8}$`; `auction_id`
+ canonical UUID; integers `[0, 2³¹)`; `t_rel_ms` u32.
- Same-origin (client-events, trace-auth): `Sec-Fetch-Site:
-same-origin` when present, else normalized `Origin` equality; absent
- both → drop-and-count.
-- **Rate limiting — adapter abstraction with declared semantics:**
- trait `ClientEventLimiter`, key namespace per route family; intent
- 10 req/min, burst 20 per client address. Axum: real in-process token
- bucket, map ≤ 65,536 entries; Cloudflare/Spin: per-isolate/instance
- best-effort, ≤ 4,096 entries; entry TTL 10 min with cleanup on access
- plus periodic sweep — **capacity pressure rejects unseen identities,
- but expired entries are always reclaimable, so saturation is bounded,
- not permanent**; missing client address → shared `unknown` bucket at
- 1 req/min. Fastly: the platform 60 s fixed-window counter at limit 20
- as a documented approximation; because its lookup and increment are
- separate operations (§3.13), **overshoot under a synchronized burst
- is bounded only by in-flight concurrency, and no numeric multiple is
- claimed** — the synchronized-burst test (> 40 concurrent) documents
- observed behavior, and a penalty-box follow-up is recorded if
- observed overshoot is operationally unacceptable. Limiter
- unavailable/errored → drop early with `204`. Trusted client address:
- Fastly platform client IP; Axum rightmost `X-Forwarded-For` entry
- after skipping exactly `trusted_proxy_hops` (absent config → socket
- peer only); Cloudflare `CF-Connecting-IP`; Spin platform address.
- `/_ts/trace-auth` and `/_ts/csp-reports/` carry their own
- buckets with the same intent.
-
-### 5.5 Sinks, canonical views, per-sink monitoring
-
-- Stable event key `(publisher_domain, trace_id, seq)`. Canonical views:
- `ts_client_events_v` (dedup: latest `received_at` per key) and
- `ts_render_attempts_v` (attempt grain per G1). Dashboards and alerts
- query canonical views only; raw-to-raw joins are forbidden (row
- multiplication).
-- The Fastly sink is fire-and-forget after dispatch (`tinybird.rs:153`)
- and cannot see downstream rejection — **each datasource gets its own
- synthetic probe and freshness/loss query, per adapter write path**:
- client-events via `heartbeat` events (mode `probe`,
- `expected_seq` gaps = loss, lag = freshness); CSP via probe reports
- to a reserved `policy_id = probe`; ops via a probe counter. A green
- client-events heartbeat says nothing about the CSP or ops
- credentials — hence three probes. Alert owner: release owner's
- on-call.
+same-origin` else normalized `Origin` equality; absent both →
+ drop-and-count.
+- Limiter (per §5.2 budgets): trait `ClientEventLimiter`; Axum real
+ token bucket (60/min, burst 120), map ≤ 65,536; Cloudflare/Spin
+ best-effort ≤ 4,096/instance; TTL 10 min, cleanup on access + sweep;
+ at capacity reject unseen identities (expired always reclaimable);
+ unknown address → shared bucket 6/min; Fastly platform 60 s window at
+ limit 120 (approximation; overshoot bounded only by in-flight
+ concurrency — documented by the synchronized-burst test, no numeric
+ multiple claimed). Limiter unavailable → drop early with `204`.
+ Trusted address per adapter (Fastly platform IP; Axum rightmost XFF
+ after `trusted_proxy_hops`, absent → socket peer; CF
+ `CF-Connecting-IP`; Spin platform). Trace-auth and CSP routes carry
+ their own buckets (10/min, burst 20).
+
+### 5.5 Sinks, canonical views, per-sink authenticated probes
+
+- Event key `(publisher_domain, trace_id, seq)`; canonical views
+ `ts_client_events_v` (dedup) and `ts_render_attempts_v` (**keyed by
+ `attempt_id`**); the arm-union views stamp `deployment_pool` from the
+ write identity (§0). Dashboards/alerts query canonical views only.
+- The Fastly sink is fire-and-forget (`tinybird.rs:153`) —
+ **per-datasource authenticated probes**: every probe row carries
+ `{probe_run_id, expected_seq, adapter, target}`; client-events via
+ `heartbeat` events under probe-mode tokens; CSP via probe reports to a
+ **secret-derived probe `policy_id`** (registered like any policy id,
+ not guessable — `policy_id = "probe"` would be publicly forgeable);
+ ops via an authenticated probe counter write. **Persistence gates are
+ scoped to sink-backed adapters** (DR-5); accept-count-drop adapters
+ get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss =
+ `expected_seq` gaps < 0.1%; alert owner: release owner's on-call.
+- **Alert-delivery drill:** a synthetic canary page injects a known
+ failure class at a known rate; the failure-detection alert must fire
+ within one hour — dashboards and alert latency are tested, not
+ assumed (Appendix A row).
### 5.6 Physical schemas (deployed before writers)
- **`ts_client_events`**: `received_at DateTime64, publisher_domain
-LowCardinality(String), release_id String, trace_id FixedString(32),
-mode Enum(sampled|diagnostic|probe), nav_gen UInt32, refresh_gen
-UInt32, seq UInt32, flow Enum(ssat|prebid|page_bids|direct|fallback|
-system), auction_id Nullable(FixedString(36)), event Enum(§5.1), slot
-Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8),
-source Nullable(Enum), reason Nullable(Enum), action Nullable(Enum),
-parent_flow Nullable(Enum), kind Nullable(Enum), notif_id
-Nullable(FixedString(12)), result Nullable(Enum), dropped
-Nullable(UInt32), probe_id Nullable(String), expected_seq
-Nullable(UInt32)`. Sorting key `(publisher_domain, received_at,
-trace_id, seq)`; TTL 30 days; own ingest token; sink batch cap 512;
- startup validation of dataset + token when enabled;
- sink-unavailable → accept-count-drop.
+LowCardinality(String), release_id String, deployment_pool
+Enum(canary|control), assignment_id Nullable(FixedString(32)),
+trace_id FixedString(32), mode Enum(sampled|diagnostic|probe), nav_gen
+UInt32, refresh_gen UInt32, seq UInt32, flow
+Enum(ssat|prebid|page_bids|direct|fallback|system), attempt_id
+Nullable(FixedString(8)), parent_attempt_id Nullable(FixedString(8)),
+auction_id Nullable(UUID), t_rel_ms Nullable(UInt32), event Enum(§5.1),
+slot Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8),
+source Nullable(Enum), reason Nullable(Enum), outcome
+Nullable(Enum(accepted|failed|no_bid|cancelled)), action
+Nullable(Enum), kind Nullable(Enum), notif_id Nullable(FixedString(12)),
+result Nullable(Enum), dropped Nullable(UInt32), probe_run_id
+Nullable(String), expected_seq Nullable(UInt32), adapter
+Nullable(Enum), target Nullable(Enum)`. Sorting key `(publisher_domain,
+received_at, trace_id, seq)`; TTL 30 days; sink batch cap 512; startup
+ validation; sink-unavailable → accept-count-drop.
- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`):
- add nullable `trace_id`, `mode`, `release_id`. Two added row types
- with an explicit **`row_kind Enum(slot | totals | overflow)`** so
- totals/overflow rows are valid instances (no publisher-controlled
- sentinel strings; inapplicable fields nullable):
- - `bid_drop {row_kind, provider Nullable(LowCardinality(String)) —
-NULL on overflow, slot Nullable(String), reason
+ add nullable `trace_id`, `mode`, `release_id`;
+ `row_kind Enum(slot|totals|overflow)`; `bid_drop {row_kind, provider
+Nullable(LowCardinality(String)), slot Nullable(String), reason
Enum(AuctionDropReason), width Nullable(UInt16), height
-Nullable(UInt16), count UInt32}` — cap 32 slot-rows/auction plus one
- overflow row whose `count` = **actual dropped bids**, not compacted
- rows;
- - `selection_summary {row_kind, slot Nullable(String) — NULL on
-totals, winner_source Nullable(Enum(mediator|direct|none)) — NULL on
-totals, winner_provider Nullable(String) — NULL when winner_source
-≠ a winner, candidates_direct UInt16, candidates_mediator UInt16,
-dedup_hits UInt16, currency_rejected UInt16, provenance_invalid
-UInt16, mediator_superseded UInt16}` — cap 8 slot-rows plus one
- totals row that always survives truncation. Counters saturate at
- `0xFFFF`/`0xFFFFFFFF`.
- - **`AuctionDropReason` (closed, exhaustive over baseline
- producers):** `script_rendering_disabled, invalid_dimensions,
-dimensions_out_of_range, missing_render_source,
-invalid_creative_url, unsupported_tagtype,
-render_payload_too_large, unexpected_response_shape,
-currency_mismatch, floor_rejected, provenance_invalid,
-duplicate_demand, missing_bid_id, duplicate_bid_id, unknown_impid,
-invalid_price, unsupported_media_type, creative_id_too_large,
-empty_seatbid, renderer_extension_serialization_failed,
-no_render_source, lost_to_higher_bid, overflow` — covering the
- outcomes emitted at `aps.rs:740-929` and `formats.rs:408-419`; a
- **compile-time exhaustiveness test maps every producer to the
- enum**.
+Nullable(UInt16), count UInt32}` (32 slot-rows + one overflow row whose
+ `count` = actual dropped bids); `selection_summary {row_kind, slot
+Nullable(String), winner_source Nullable(Enum(mediator|direct|none)),
+winner_provider Nullable(String), candidates_direct UInt16,
+candidates_mediator UInt16, dedup_hits UInt16, currency_rejected
+UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` (8
+ slot-rows + one totals row that survives truncation; saturating
+ counters `0xFFFF`/`0xFFFFFFFF`).
+ - **`AuctionDropReason` (closed, exhaustive over baseline producers,
+ one shared typed enum — no string literals):**
+ `script_rendering_disabled, invalid_dimensions,
+dimensions_out_of_range, missing_render_source, invalid_creative_url,
+unsupported_tagtype, render_payload_too_large,
+unexpected_response_shape, currency_mismatch, floor_rejected,
+provenance_invalid, duplicate_demand, missing_bid_id,
+duplicate_bid_id, bid_id_too_large, empty_seatbid,
+empty_seatbid_bids, unknown_impid, invalid_price,
+unsupported_media_type, creative_id_too_large,
+renderer_extension_serialization_failed, no_render_source,
+lost_to_higher_bid, overflow` — covering `aps.rs:740-929`
+ (incl. `empty_seatbid_bids` at `:875`) and `formats.rs:408-419`; a
+ **compile-time exhaustiveness test maps every producer to the enum**.
- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain
LowCardinality(String), release_id String, policy_id
-LowCardinality(String), cohort LowCardinality(String),
-directive_bucket Enum(script|style|frame|img|connect|font|media|
-worker|other), source_bucket Enum(https_host_allowlisted|data|blob|
-inline|eval|other), count UInt32`; sorting key `(publisher_domain,
-received_at, policy_id)`; TTL 30 days; settings
- `[telemetry.csp_reports] enabled, api_host, dataset, token_secret,
-secret_store`. Ingest: pre-buffer body ≤ 8 KiB, ≤ 10 reports/request,
- strings ≤ 256, nesting ≤ 4, both media types
- (`application/csp-report`, `application/reports+json`) with separate
- validators, unused fields discarded before logging, own limiter
- bucket.
+LowCardinality(String), cohort LowCardinality(String), directive_bucket
+Enum(script|style|frame|img|connect|font|media|worker|other),
+source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other),
+count UInt32`; sorting key `(publisher_domain, received_at, policy_id)`;
+ TTL 30 days. Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256,
+ nesting ≤ 4, both media types with separate validators, unused fields
+ discarded, own limiter bucket. **Caps with values:** 10,000
+ reports/hour/publisher and 1,000/hour/cohort; overflow increments the
+ `csp_overflow` ops counter (dropped reports counted, never parsed
+ further).
- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain
LowCardinality(String), release_id String, counter
Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked|
ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged|
-probe), value UInt64`; sorting key `(publisher_domain, received_at,
-counter)`; TTL 90 days; settings `[telemetry.ops_counters]` (same
- shape).
-- **Sink plumbing:** one generic multi-target Tinybird sink trait; the
+csp_overflow|probe), value UInt64`; sorting key `(publisher_domain,
+received_at, counter)`; TTL 90 days.
+- **Sink plumbing:** one generic multi-target Tinybird sink trait;
`RuntimeServices` (`platform/types.rs:158`) gains handles for
client-events, CSP, and ops targets beside the auction sink; each
- target has its own dataset + token settings as above.
+ target has its own dataset + token settings.
- **Settings:** `[telemetry.client_events] collection_enabled,
sink_enabled, sample_rate, api_host, dataset, token_secret,
-secret_store, max_body_bytes`; `[telemetry.trace_auth] secret_store,
-active_kid, previous_kids, sampling_key_secret`;
- `[telemetry.diagnostic] secret_store, active_kid`.
+secret_store, max_body_bytes`; `[telemetry.csp_reports]` and
+ `[telemetry.ops_counters]` (same transport shape);
+ `[telemetry.trace_auth] secret_store, active_kid, previous_kids,
+sampling_key_secret`; `[telemetry.diagnostic] secret_store,
+active_kid`; `[telemetry.probe]` admin-issued run configuration.
- APS parsing returns structured drop observations
- `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values
- today); >8192 → `dimensions_out_of_range` with dimensions omitted.
+ `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values);
+ > 8192 → `dimensions_out_of_range` unclamped.
### 5.7 Modes and SLIs
-Production (sink-backed): deterministic keyed sampling (default 0.10).
-SLIs: **pipeline availability** — per-sink probe freshness ≤ 5 min and
-probe loss < 0.1% (fails during sink outages, alarmed); **failure
-detection** — a failure mode affecting ≥ 1% of sampled render attempts
-visible within one hour, at ≥ 10,000 sampled attempts/hour. Diagnostic:
-credential-gated, unsampled, full stream + console mirroring + debug
-envelopes (§2.5).
+Production (sink-backed): deterministic sampling (0.10). SLIs: pipeline
+availability (per-sink authenticated probe freshness ≤ 5 min, loss
+< 0.1%); failure detection (≥ 1% of sampled render attempts visible
+within one hour at ≥ 10,000/hour) — **verified by the injected-failure
+alert drill**, not only by probe persistence. Diagnostic:
+credential-gated, `dexp`-bounded, unsampled, full stream + console
+mirroring + debug envelopes.
### 5.8 Server-side drop surfacing
-Bounded structured summary whenever any bid is dropped; `bid_drop` and
-`selection_summary` rows (§5.6); the initial-HTML `ts-debug` comment
-carries the drop summary; page-bids and `/auction` carry the
-tester-gated structured `debug` field. Startup warnings: APS +
+Bounded structured summary whenever any bid is dropped; `bid_drop` +
+`selection_summary` rows; `ts-debug` comment; tester-gated structured
+`debug` on page-bids and `/auction`. Startup warnings: APS +
`allow_script_creatives = false`; mediator + direct providers without
-an explicit `winner_selection` (§6.1 hard error).
+explicit `winner_selection`.
## 6. APS delivery fixes
-### 6.1 Mediation — total order, no fictional identities
-
-Baseline defects: no forwarded candidate id; lossy last-write-wins
-`(provider, slot, bidder)` field restoration (`adserver_mock.rs:95`);
-arrival-order equal-price ties (`orchestrator.rs:827`); Prebid assumes
-USD (`prebid.rs:2433`); APS stamps USD (`aps.rs:475`); no configured
-currency. Replacement, identical in the synchronous and split
-dispatch/collect paths via one shared helper:
-
-1. **Currency.** Required `[auction].currency` (ISO 4217). Every
- provider parse validates its response currency; contract-implied
- currencies are validated as implied — **APS enabled with a non-USD
- configured currency is a startup error**, not a silent all-drop.
- Mismatch → `bid_drop{currency_mismatch}`.
-2. **Candidate identity.** `source_candidate_id` =
- `(provider_name, upstream_bid_id)`; the upstream id is **required,
- ≤ 64 chars, and unique per provider response — bids missing an id or
- duplicating one are rejected** (`bid_drop{missing_bid_id |
-duplicate_bid_id}`). No fingerprint fallback: a fingerprint over a
- partial field set can merge economically distinct demand, and
- OpenRTB requires bid ids — rejection is honest. `candidate_id` (wire
- echo) is CSPRNG with in-auction collision retry and is never an
- ordering key. Mediator-native bids get identities the same way from
- the mediator's response.
-3. **Mediator exchange.** Forwarded candidates carry
- `ext.trusted_server.candidate_id`; the mediator echoes it (contract
- for every mediator, `adserver_mock` included). Echoed id resolves →
- the forwarded candidate, provenance `mediator`: **price is
- authoritative from the mediator; every render-source and
- notification field comes from the stored candidate; deal fields are
- out of scope entirely** (no deal identity exists in the model). A
- mediator bid whose any render-source field differs from the stored
- candidate is reclassified mediator-native. An unresolvable echoed id
- → that bid is discarded and counted (`provenance_invalid`);
- mediator-native bids **and direct candidates remain eligible** —
- fail-closed applies to the invalid claim, not the slot.
-4. Floors filter both populations; echoed candidates remove their
- direct twins (`dedup_hits`).
-5. **Selection order (total, intrinsic):** decoded CPM desc →
- provenance rank (mediator first) → `source_candidate_id` asc.
- Arrival order can never matter.
-6. **Strategy** required when mediator + direct providers coexist
- (startup error if absent): `mediator_only` (timeout → no winners
- unless `mediator_timeout_fallback = "direct"`) or
- `merge_highest_cpm` (timeout → direct-only, reported).
-7. Reporting: `selection_summary` slot rows + totals row (§5.6).
+### 6.1 Mediation
+
+Eight rules, arrival-independent, one shared helper across both
+lifecycles: (1) required `[auction].currency` (APS + non-USD = startup
+error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate
+identity `source_candidate_id = (provider_name, upstream_bid_id)` with
+the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/
+oversized → `bid_drop{missing_bid_id | duplicate_bid_id |
+bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` (CSPRNG
+wire echo, never an ordering key); (3) mediator echoes
+`ext.trusted_server.candidate_id` — resolves → forwarded candidate,
+provenance `mediator`, **price authoritative from the mediator, every
+render-source and notification field from the stored candidate, deal
+fields out of scope**; any render-source difference → mediator-native;
+unresolvable echo → discarded + counted (`provenance_invalid`),
+mediator-native and direct candidates stay eligible; (4) floors both
+populations, echoes dedup twins; (5) **total order** decoded CPM desc →
+provenance rank (mediator first) → `source_candidate_id` asc; (6)
+required `winner_selection` (`mediator_only`: timeout → no winners
+unless `mediator_timeout_fallback = "direct"`; `merge_highest_cpm`:
+timeout → direct-only); (7) `selection_summary` reporting.
### 6.2 Dimensions
-Exact size membership stays (`aps.rs:675`). The fix is visibility
-(`bid_drop{invalid_dimensions, w, h}`) plus documentation ("request the
-sizes you accept"); accepting unrequested sizes would conceal an
-upstream protocol violation.
+Exact membership (`aps.rs:675`); structured `bid_drop{invalid_dimensions,
+w, h}`; "request the sizes you accept."
### 6.3 Script creatives
-`allow_script_creatives` stays default-`false`; the consequence is loud
-(§5.8). **DR-2's output is a deployment decision:** enable with explicit
-sandbox/security approval, or accept a quantified maximum
-excluded-demand share and gate Phase 3 on it.
+Default `false`, loud (§5.8); **DR-2 is a deployment decision** (enable
+with security approval, or accept a quantified excluded share and gate
+Phase 3 on it).
-### 6.4 Render identity
+### 6.4 / 6.5
-As specified in G2 (token format, registry-union capacity, tombstones,
-cache-path byte identity).
-
-### 6.5 Fallback
-
-As specified in G4e/G4a/G4f (attribution-gated child attempt; awaitable
-renderer conversion precedes it; timeouts never render).
+Render identity per G2; fallback per G4e (child attempt).
### 6.6 Renderer endpoint
-- The static renderer document route registers **unconditionally in
- every adapter** (the APS provider stays config-gated); startup
- validation fails if an auth handler pattern covers it; §G5 isolation
- rules apply.
-- Path `/integrations/aps/renderer/v1`, embedded, served
- `Cache-Control: public, max-age=31536000, immutable`; canary versions
- `no-store` (or bounded below cohort lifetime); a **checked-in header
- manifest per renderer version** freezes headers (CSP included) with
- the bytes — a version's headers never change after publication.
- Unknown versions → 404 `no-store`.
-- Three-message acknowledgement per G4b sequence 1.
-- Server route counters are aggregate (`ts_ops_counters`) — the nonce
- rides the URL fragment and never reaches the server.
-- **CSP rollout, three instruments:** discovery on the **currently
- enforced** policy with reporting attached (report-only alone cannot
- reveal what the enforced policy already blocks); **tightening** via
- report-only; **relaxation** via a small enforced cohort on a
- short-lived canary version, gated on runner acceptance rate, CSP
- violation rate, and render-failure rate, with a kill switch; once
- frozen, a new immutable `/v2` ships. **CSP reports are advisory** —
- for opaque-origin reports the body-supplied document URL and policy
- version are forgeable, so policy identity is encoded in the
- **server-selected report path** (`/_ts/csp-reports/`);
- bucketed aggregation only (§5.6 buckets, global and per-cohort caps);
- never a sole automatic rollback signal. Browser capture on Chromium,
- Firefox, and WebKit (CI is Chromium-only today,
- `playwright.config.ts:16`; the matrix extends for this suite).
+Unconditional route in every adapter (provider stays config-gated);
+startup auth-pattern validation; `/integrations/aps/renderer/v1`
+embedded, served `public, max-age=31536000, immutable` with a
+checked-in per-version header manifest (headers frozen with bytes);
+canary versions `no-store`; unknown version 404 `no-store`;
+three-message ack (G4b-1); aggregate route counters in
+`ts_ops_counters`; CSP rollout three instruments (enforced discovery /
+report-only tightening / enforced-cohort relaxation on a short-lived
+canary version, gated on runner acceptance, violation rate, render
+failure, with a kill switch); **policy identity in the server-selected
+`policy_id` path** (also the release/cohort carrier, §0); bucketed
+aggregation only with the §5.6 caps; never a sole rollback signal;
+three-browser capture (`playwright.config.ts:16`).
### 6.7 One descriptor schema
-Wire truth is the tagged `BidRenderer` envelope (discriminator on the
-enum, `types.rs:188-211`). A wire-schema crate/xtask (separate from
-`trusted-server-js` — core already depends on it, `Cargo.toml:45`, so
-the reverse edge would cycle) generates: the JSON-Schema artifact, the
-TS structural parser, the ES5 inline validator fragment, the §5.1
-per-event/per-reason validity matrix, and shared fixtures — checked in,
-staleness-gated. Semantic checks stay handwritten on both sides
-(URL/origin policy, canonical base64, length bounds, the exact one-bid
-AAX projection, cross-field equality). Unknown-field tolerance applies
-only to the outer versioned descriptor; the decoded AAX envelope
-remains an exact projection. A shared positive + adversarial corpus
-(extra fields, wrong versions, oversized payloads, URL smuggling,
-non-canonical base64) runs through the Rust validator, the generated TS
-parser, and the generated inline fragment in CI.
+Tagged `BidRenderer` envelope (`types.rs:188-211`); wire-schema
+crate/xtask (no core↔js cycle, `Cargo.toml:45`) generates JSON-Schema,
+TS parser, ES5 inline fragment, the §5.1 validity matrix, and fixtures;
+staleness CI; semantic validators handwritten; outer tolerance only;
+exact AAX projection; shared positive + adversarial corpus through all
+three validators.
### 6.8 Bridge hardening
-Processing order (normative; preserves the baseline defense that stops
-propagation before source validation, `gpt/index.ts:1584-1637`):
+Order (normative — preserving the baseline stolen-capability defense,
+`gpt/index.ts:1584-1637`, **plus the read-only lookup the altered-id
+signal needs**):
1. parse `e.data` (bare catch → return);
-2. identify a TS-reserved ad id — **live registry or tombstone** (G2);
-3. if TS-reserved: `stopImmediatePropagation()` before any validation —
- a rejected foreign frame must not be answerable by Prebid's native
- handler either;
-4. validate source ownership via the bounded walk: known slot-root
- `WindowProxy` map, the sender's own parent chain
- (`event.source.parent`, …) to depth 5 — never scanning an
- attacker-controllable frame tree;
-5. validate nonce, token, `nav_gen`, `refresh_gen` (G4b);
-6. respond, or refuse with `bridge_id_mismatch`.
-
-Non-TS ad ids are untouched. The stolen-token browser test asserts
-**neither TS nor the native Prebid listener responds**; listener
-registration order has a real-browser assertion. Renderer branches emit
-the full §5.1 sequence with G4d notifications only on carrying paths.
+2. **read-only source→active-slot lookup for every Prebid Request** — if
+ the resolved slot expects a different ad id, emit
+ `bridge_request{matched: false}` (the B1 signal) **without responding
+ and without suppressing native Prebid** (a truncated `hb_adid` is not
+ TS-reserved, so suppression would be wrong and the old order could
+ never produce the signal);
+3. identify a TS-reserved ad id (live registry or tombstone, G2);
+4. if TS-reserved: `stopImmediatePropagation()` before validation;
+5. validate source ownership (known slot-root `WindowProxy` map;
+ sender's parent chain to depth 5; never scanning the frame tree);
+6. validate nonce, token, `nav_gen`, `refresh_gen` (G4b);
+7. respond, or refuse with `bridge_id_mismatch`.
+
+Non-TS ids are otherwise untouched. Stolen-token test: neither TS nor
+native Prebid responds. Listener order: real-browser assertion.
## 7. TSJS target architecture
@@ -854,35 +734,30 @@ services/ slots (registry+handoff), auction client, render engine, consen
integrations/ gpt, prebid, aps, creative, datadome, …
```
-Enforced by the two G3 lint rule families. Dissolves the audited
-inversions (`core/auction.ts`/`core/request.ts` →
-`integrations/aps/render`; `gpt`/`prebid` → `aps`; `prebid` owning the
-GPT refresh wrapper). Kernel imports nothing above it; adapters import
-kernel only; services import kernel + adapters; integrations import
-kernel + services, never each other; stateful services via the G3
-registry only.
+Kernel imports nothing above it; adapters import kernel only; services
+import kernel + adapters; integrations import kernel + services, never
+each other; stateful services via the G3 registry only; enforced per
+G3's two rule families.
### 7.2 Adapters
-Per external global: `present | pending | timed_out`; `timed_out` is
-non-terminal (late GPT/pbjs/CMP arrival transitions to `present` and
-drains what is still valid); queued operations carry their own timeouts
-and expire with disposition reasons.
+`present | pending | timed_out` per external global; `timed_out`
+non-terminal; queued operations carry their own timeouts and expire with
+disposition reasons.
### 7.3 Slot registry service
Kernel-owned; `WeakMap` + div-id index;
-ownership (ts/publisher/adopted), adoption, handoff claims, responsive
-resolution, the G4a causal intent queue (NavigationSession for unissued
-intents) and cycle/drain state (RuntimeSession), targeting-key history.
-No expandos on GPT objects (`__tsRenderGeneration`/`__tsRenderBid`
-deleted).
+ownership, adoption, handoff claims, responsive resolution, the G4a
+causal intent queue (NavigationSession for unissued intents) and
+cycle/drain state (RuntimeSession), targeting history. No expandos
+(`__tsRenderGeneration`/`__tsRenderBid` deleted).
### 7.4 Final global surface (hard cutover)
| Legacy surface (removed at cutover) | Final shape |
| --------------------------------------- | --------------------------------------------------------------------- |
-| `window.tsjs.que` | `window.tsjs.que` — unchanged, the one public queue |
+| `window.tsjs.que` | `window.tsjs.que` — unchanged |
| `globalThis.tscreative` | `tsjs.creative.*` |
| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` |
| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) |
@@ -892,84 +767,52 @@ deleted).
| `tsjs._internal` | kernel registry (G3), frozen after boot |
| (new, public) | `tsjs.definePlugin({id, release, install})` |
-**Bootstrap correctness and transactional ownership:** every
-server-injected initializer creates the container **idempotently,
-field-wise** — `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`
-(the ad-slot script's `window.tsjs = {}` at `publisher.rs:3665` is
-fixed). Ownership states: `unclaimed → installing → kernel | fallback`,
-with an **owner generation** counter. The kernel installs wrappers
-**inert** and flips them live at a single commit point; a throw before
-commit runs the shared unwind inventory and marks `failed`. **The
-watchdog path is race-free:** on a 10 s stuck `installing`, the
-watchdog aborts the owner-generation-scoped `AbortController`, runs the
-same shared unwind inventory to completion, and only then atomically
-transitions `failed → fallback`; **every late kernel continuation and
-disposer validates the owner generation** and self-discards on
-mismatch — a resumed async installation can neither overwrite fallback
-wrappers nor perform a stale commit. A bundle arriving after fallback
-committed defers for the page (`bundle_partial`). Tests: throws
-injected after each boot checkpoint **and** hung checkpoints that
-resume after fallback claims ownership.
+Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que
+||= []; tsjs.boot ||= {}`; `publisher.rs:3665`'s clobber fixed);
+transactional ownership `unclaimed → installing → kernel | fallback`
+with an owner-generation counter; kernel installs inert and flips at one
+commit point; throws unwind to `failed`; the 10 s watchdog aborts the
+owner-generation-scoped controller, completes the shared unwind, then
+atomically transitions `failed → fallback`; late continuations and
+disposers validate the owner generation and self-discard; a bundle
+arriving after fallback committed defers (`bundle_partial`). Tests:
+throws per checkpoint and hung-resume-after-fallback.
### 7.5 Messaging module
All `postMessage` through one module: versioned envelopes, name
-constants (the `'Prebid Request'` literal appears at six sites today;
-the APS handshake existed in three copies), G4b nonces, §6.8
-validation. The minimal module (envelope + constants + validators used
-by the bridge) lands in Phase 1; full call-site migration in Phase 4.
-
-### 7.6 Plugin lifecycle — transactional — and sessions
-
-`tsjs.definePlugin({id, release, install})` — object form; `release` is
-the build-generated `release_id` constant; **there is no plugin-level
-`dispose` hook** — disposal is exclusively `ctx.onDispose`
-registrations, which have exactly-once reverse-order semantics
-(revision 7's optional `dispose?` had no defined ordering and is
-removed). `install(ctx): void | Promise`:
-
-- `ctx.signal` (aborted on quarantine/disposal); synchronous
- `ctx.onDispose(fn)`; effects registered as they are made;
- reverse-order unwind on throw/reject/abort; per-disposer exception
- isolation; a disposer registered after disposal is invoked
- immediately; pending late registrations capacity 16, bound 10 s →
- `bundle_partial`; release mismatch quarantines before `install`.
-- Sessions: `RuntimeSession` (page lifetime: bridge listener +
- reservation store, history hook, pbjs subscriptions, adapters, beacon
- queue, physical slot cycle/drain state, in-memory diagnostic
- credential); `NavigationSession` (per navigation: trace +
- authorization + renewal timer, render attempts, slot aliases,
- unissued intents, targeting history); `RenderAttempt` (per G4a cycle
- / G4f attempt). Each owns an enumerable disposal inventory;
- navigation disposes NavigationSession children only.
-- Error policy: no empty `catch` — handle, log with context, or emit a
- disposition. The auction fetch gains timeout + `AbortController` and
- the G4f discriminated result.
-- **Console logging retained, not replaced:** every issue-surfacing
- condition keeps or gains a `log.warn` carrying the same reason code
- as its beacon event; `debug`-level delivery/security failures are
- promoted to `warn`.
+constants, G4b nonces, §6.8 validation. Minimal module in Phase 1; full
+migration in Phase 4.
+
+### 7.6 Plugins and sessions
+
+`tsjs.definePlugin({id, release, install})`; **no plugin-level dispose
+hook** — `ctx.onDispose` only, exactly-once reverse order.
+`install(ctx): void | Promise` with `ctx.signal`, unwind on
+throw/reject/abort, per-disposer isolation, disposer-after-disposal
+invoked immediately, pending capacity 16 / 10 s → `bundle_partial`,
+release mismatch quarantined before install. Sessions: `RuntimeSession`
+(bridge listener + reservation store, history hook, pbjs subscriptions,
+adapters, beacon queue, cycle/drain state, in-memory diagnostic
+credential), `NavigationSession` (trace + auth + renewal timer,
+attempts, aliases, unissued intents, targeting history),
+`RenderAttempt`/`AuctionBatch`; enumerable disposal inventories. No
+empty `catch`; console logging retained (paired `warn` with the beacon
+reason; `debug`-level delivery/security failures promoted).
### 7.7 Bootstrap
-`gpt_bootstrap.js` (495 ES5 lines duplicating handoff/initial-load/
-hydration logic, with the live `servicesEnabled` divergence) shrinks to
-a queue-and-flags stub; the bundle replays recorded early calls on
-install (browser specs cover replay timing); the no-bundle fallback
-("ads render if the bundle fails", pinned by `gpt.rs:1174-1179`) is
-**generated from the same TypeScript source** at build time, activated
-per §7.4's transactional rules.
+`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays
+recorded calls; the no-bundle fallback is generated from the same
+TypeScript source (pinned by `gpt.rs:1174-1179`), activated per §7.4.
### 7.8 GPT correctness carried with the restructure
Unconditional early `slotRequested`/`slotRenderEnded` subscription
-(replacing the `!servicesEnabled` gate, `gpt/index.ts:1091`; idempotent
-recording); restore the #922 orphan-slot recovery and `updateRender`
-enrichment (DR-3 decides #997 vs re-merge); `changeCorrelator: false`
-on TS-initiated refreshes (configurable); `enableSingleRequest()` only
-when GPT services are not already enabled; ambiguous responsive
-resolution emits `render_fail{slot_unresolved}` alongside its console
-warning.
+(replacing `gpt/index.ts:1091`'s gate); restore #922/#997 (DR-3);
+`changeCorrelator: false` (configurable); `enableSingleRequest()` only
+when services are not already enabled; ambiguous responsive resolution
+emits `render_terminal{failed, slot_unresolved}` alongside its warning.
### 7.9 Decomposition targets
@@ -978,324 +821,255 @@ warning.
| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics |
| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
-| `core/trace.ts` (model + UI) | `services/trace` (model) + `integrations/trace_overlay` (UI) |
+| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` |
| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal |
### 7.10 Performance (reproducible)
-- **Dedicated workflow** pinned `runs-on: ubuntu-24.04` (browser CI is
- `ubuntu-latest` today, `integration-tests.yml:155`) inside a pinned
- container image digest; browser = the lockfile-resolved
- `@playwright/test` build with its browser revision recorded in the
- baseline artifact (the manifest is a caret range,
- `browser/package.json:10` — lockfile + recorded revision are
- authoritative); compressors pinned by version in the container;
- deterministic flags (`gzip -9 -n`, `brotli -q 11`).
-- **Module vectors enumerated:** minimal = `[core]`; reference =
- `[core, creative, gpt, prebid, datadome]`; maximal = all 13
- discovered modules. Budgets: raw/gzip/Brotli per bundle per vector vs
- checked-in baselines (`perf/baselines/*.json`; updates are reviewed
- diffs recording image/browser/tool versions; a baseline update is
- invalid if any pinned component differs); +5% bytes.
-- **Browser timing:** marks `performance.mark("tsjs:bids-script")`
- (emitted by the injected bids script) to
- `performance.mark("tsjs:first-display")` (emitted by the adapter
- wrapper at the first `display()`/`refresh()` dispatch); reference
- fixture page; warm HTTP cache; all resources local; 5 warm-ups
- discarded, 50 samples; p90 = nearest-rank; gate p90 ≤ baseline ×
- 1.10; inconclusive (3-run agreement worse than 5%) → one rerun, then
- fail. **Maximal-vector peak JS heap ≤ baseline × 1.10.**
-- **Server benchmark:** the G5 lookup path; 100 warm-ups, 1,000
- iterations; median and p90; one-sided ≤ baseline × 1.10; 3
- consecutive runs within 5% or inconclusive (rerun, never pass).
+Pinned workflow (`runs-on: ubuntu-24.04` — browser CI is `ubuntu-latest`
+today, `integration-tests.yml:155` — inside a pinned container digest);
+lockfile-resolved Playwright with recorded browser revision
+(`browser/package.json:10` is a caret range); pinned compressors
+(`gzip -9 -n`, `brotli -q 11`). Vectors: minimal `[core]`; reference
+`[core, creative, gpt, prebid, datadome]`; maximal all 13. Budgets vs
+checked-in baselines (+5% bytes; baseline records image/browser/tool
+versions and is invalid if any differ). Browser timing:
+`performance.mark("tsjs:bids-script")` →
+`performance.mark("tsjs:first-display")`; reference fixture; warm cache;
+local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline ×
+1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. **Peak
+JS heap, reproducibly:** via the Playwright CDP session —
+`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at
+five fixed points (post-boot, post-adInit, post-first-render,
+post-refresh, post-SPA-navigation) on the maximal vector; metric = max
+sample; gate ≤ baseline × 1.10; same rerun rule. Server benchmark: the
+G5 lookup path; 100 warm-ups, 1,000 iterations; median + p90 one-sided
+≤ baseline × 1.10; 3-run 5% agreement or inconclusive.
### 7.11 Toolchain
-TypeScript floor to the resolved 5.9 line (lockfile resolves 5.9.3
-under the stale `^5.5.4` manifest). **Release-gating flags:** `strict`,
-`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+TypeScript floor to the resolved 5.9 line; release-gating flags
+`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
`verbatimModuleSyntax`, `noImplicitOverride`,
-`useUnknownInCatchVariables`. **Gate command (checked-in npm script
-`typecheck`):**
+`useUnknownInCatchVariables`; checked-in npm script `typecheck`:
```
cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit
```
-(runs where the pinned compiler is installed; `--no-install` guarantees
-the lockfile-resolved binary). Dev toolchain bumps as individual
-CI-gated PRs with changelog review (this library monkeypatches
-`fetch`/`sendBeacon`/DOM prototypes); `prebid.js` excluded from casual
-bumps (runtime Prebid is the manifest-locked external bundle; npm pin
-and deployed bundle version documented together); monthly review.
+Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded
+from casual bumps; monthly review.
## 8. Rollout
-Single-release state machine per §0 (authenticated sticky-cohort
-affinity; infrastructure-attributed cohorts). The normative gates table
-is Appendix A; every gate names a **checked-in artifact** (versioned
-Tinybird pipe under `tinybird/pipes/`, script under `scripts/gates/`,
-or workflow under `.github/workflows/`) — prose never substitutes for
-an executable reference. Threshold changes require reviewed decision
-records.
-
-**Phase 0 decision records** (owner, evidence, deadline, explicit
-go/no-go): DR-1 mediator presence (gates §6.1's Phase-3 scope); DR-2
-script creatives — a **deployment decision** (§6.3); DR-3 #997 vs
-re-merge (#922 restoration path); DR-4 mediator candidate-id echo owner
-and timeline (`merge_highest_cpm` is config-blocked until delivered);
-DR-5 non-Fastly sinks (splits Phase-2 gates).
-
-- **Phase 0 — Identity, schemas, toolchain, decisions.** Release-time
- asset materialization; `format_version` + config-hash verification;
- §5.6 schemas deployed writer-off; toolchain floors; dead expando
- deletion; §5.8 drop surfacing; the five DRs; the gate artifacts
- themselves (pipes/scripts/workflows).
-- **Phase 1 — Kernel, sessions, minimal messaging, cycle registry,
- transactional bootstrap ownership.**
-- **Phase 2 — Trace + beacon.** Issuance on all three paths + renewal +
- diagnostic upgrade + probe mode; four-adapter ingest incl.
- `/_ts/trace-auth`; per-sink probes. Gates split per DR-5: HTTP parity
- (all adapters) vs persistence (sink-backed).
-- **Phase 3 — APS delivery.** Schema crate + corpus; §6.1 with required
- `winner_selection` + `[auction].currency`; render token + reservation
- store; renderer route + three-message ack + CSP report route; §6.8;
- G4a–G4g; `notification_sent` with server-minted `notif_id`; fallback;
- DR-3 restoration; correlator + SRA fixes.
-- **Phase 4 — Structure.** Full layering + both lint families; plugin
- lifecycle; adapters; full slot registry; full messaging migration;
- final namespace; four-flow behavioral parity.
-- **Phase 5 — Decomposition + cutover.** File splits; script-guard
- consolidation; bootstrap stub + generated fallback (error/hang/
- arbitration tests); four-flow parity rerun; **the full Phase-3
- statistical canary/control gates and the real-GAM suite repeat on the
- exact immutable release candidate** before router weight rises beyond
- the low-weight canary; then weight-up, purge, 24 h monitored window.
-
-**Statistical method (normative for A.1 production gates):**
-populations are **sampled traces only** — diagnostic traffic is
-operator-selected and failure-enriched, so it is reported separately
-and never enters a statistical gate. Assignment unit = the sticky
-cohort token (browser session); cohorts randomized at HTML request;
-stratification by publisher and slot. Non-inferiority gates use
-**one-sided 95% confidence bounds on the relative difference**
-(canary/control − 1 ≥ −2% for fill and billing-per-1,000-attempts;
-canary/control − 1 ≤ +2% for p95 latency); improvements always pass.
-Floors are **per flow per arm** (Appendix A); flows that cannot reach
-their floor in the window (direct, fallback at low adoption) are gated
-hermetically and by the real-GAM suite instead of statistically — a
-rare flow never permanently blocks rollout, and a statistical gate that
-cannot reach its floor is **inconclusive** (extend once, then Hold).
-`cycle_unattributable` is divided by **all TS request cycles that were
-candidates for attribution** — the failures live in their own
-denominator. Missing telemetry counts as failure. Billing reconciliation
-(§G4d) runs alongside as the authoritative duplicate check.
+Single-release state machine per §0. **Statistical method:** sampled
+traces only (diagnostic reported separately); **randomization unit =
+`assignment_id`** (minted inside the affinity token, §0; persisted on
+client-event rows), so attempts cluster by session; the estimator is a
+**checked-in cluster bootstrap** (`scripts/gates/estimator.py`):
+resample assignment ids, 2,000 resamples, fixed seed recorded in the
+gate artifact, strata (publisher × slot) weighted by control-arm
+traffic share; one-sided 95% confidence bounds on **relative
+differences**; each gate is an independent go/no-go (no cross-gate
+multiplicity correction — stated). Missing telemetry counts as failure.
+Per-flow floors; rare flows (direct, fallback) gate hermetically +
+real-GAM, never statistically. `cycle_unattributable` divides by all
+attribution-candidate TS cycles. **Billing cohort dimension in external
+reports:** TS-driven GAM requests set a `ts_arm=` key-value,
+making the arm a reportable GAM dimension for reconciliation.
+
+**Phase 0 decision records:** DR-1 mediator presence; DR-2 script
+creatives (deployment decision); DR-3 #997 vs re-merge; DR-4
+candidate-id echo owner (`merge_highest_cpm` config-blocked until
+delivered); DR-5 non-Fastly sinks (splits Phase-2 gates, scopes
+persistence gates).
+
+Phases: **0** identity/schemas/toolchain/DRs + gate artifacts
+(pipes/scripts/workflows) + observation-only control build definition;
+**1** kernel/sessions/minimal messaging/cycle registry/transactional
+bootstrap — Phase-1 gates are **hermetic** (in-page counters; beacon
+transport does not exist until Phase 2); **2** trace + beacon + renewal
+
+- diagnostic upgrade + probe issuance + four-adapter ingest + per-sink
+ probes + the alert drill; **3** APS delivery (schema crate + corpus;
+ mediation; render token + reservation store; renderer route +
+ three-message ack + CSP route; §6.8; G4a–G4g incl. AuctionBatch;
+ `notification_sent`; fallback; DR-3 restoration; correlator + SRA);
+ **4** structure (layering, plugins, adapters, registry, messaging,
+ namespace; four-flow parity); **5** decomposition + bootstrap stub +
+ parity rerun + **full Phase-3 statistical and real-GAM gates repeated
+ on the exact immutable RC** (attested per A.3) before weight-up, then
+ cutover per §0.
## 9. Test acceptance matrix
-Hermetic CI blocks PRs; the real-GAM suite is release-gating per
-Appendix A.3.
-
-| Area | Must cover |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Request cycles | intent-vs-request; disabled-initial-load `display()` retired for **any caller**; publisher-display → TS-refresh and TS-noop-refresh → TS-display supersession (same-class stealing); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap |
-| Ack protocol | all four G4b sequences incl. the adm reporter; three-message APS sequence; five-field validation; SSAT/client-Prebid/SafeFrame; stale/replayed acks; acks after navigation disposal; per-path deadlines |
-| Bridge security | propagation stopped before validation; neither TS nor native Prebid responds to stolen ids; prior-navigation ids suppressed via the reservation store after disposal; bounded parent-chain walk; listener order (real browser) |
-| Render semantics | binds per flow (never targeting); `burl` at accepted; attempt idempotency; `notification_sent{notif_id}` per dispatch; duplicate-detection alarm; accepted-but-blank honesty; `gam_collapsed{action}` emission + guarded resize |
-| Direct `/auction` | trace header + `ext.trusted_server.trace` echo; discriminated auction-client errors (timeout/network/http/invalid vs `no_bid`); per-slot latest-wins with reversed responses; generation checks; `RequestAdsResult` settlement; server preserves + expands `nurl`/`burl`, client validates |
-| Fallback | child-attempt identity with `parent_flow`; renders only on attributed parent `gam_empty`; publisher-initiated never; timeout never renders; SPA cancellation; kill-switch pre-commit cancellation (commit = earliest irreversible action) |
-| Mediation | required-unique upstream ids (`missing_bid_id`/`duplicate_bid_id` rejection — no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules (repricing kept; any render-source difference → native); provenance fail-closed scope; strategy-specific timeouts; both lifecycles; APS + non-USD startup error |
-| Render token | format/CSPRNG/retry/TTL/one-time; `(trace, nav_gen, refresh_gen)` scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed |
-| Trace auth | auth ≤ 256 B; encoding vectors (kid charset, canonical exp, u32/u64 BE prefixes, unpadded base64url); expiry/skew/max-future; renewal preserves mode via token presentation; renewal-after-expiry fails closed; previous-key retention; deterministic sampling (same trace → same mode concurrently; exact u64 threshold algorithm) |
-| Diagnostic | credential issuance under admin auth + CSRF; fragment cleared via `replaceState`; in-memory-only storage; upgrade as the sole mode transition; auth `exp` capped at credential expiry; pre-upgrade local buffering then diagnostic flush; forgery/wrong-origin/replay-past-expiry |
-| Trace-auth route | four-adapter parity; wrong-method 405; dispatch before filters; no forwarding; own limiter bucket |
-| Affinity | opaque token validation (forged/expired/retired → control + reissue); coherence for HTML, assets, APIs, **beacons, CSP reports**; cache-key normalization; rollback reassignment |
-| Join keys | `auction_id` echo on all three paths; attempt-grain join uniqueness under repeated same-slot auctions; infrastructure cohort attribution (per-pool tokens) |
-| Funnels | `flow` set per path incl. `system`; per-flow expected-stage conformance; heartbeat/overflow excluded from render denominators |
-| Beacon | joins on all three issuance paths; per-trace grouping; seq gaps; duplicate fetch/pagehide deduped in the canonical view; overflow coalescing without recursion; ingest abuse incl. absent Origin; sendBeacon Blob type; `credentials: same-origin` with identity-free handling |
-| Ingest/limits | per-adapter limiter semantics as declared; TTL reclaim under saturation; unknown-address bucket; Fastly synchronized-burst behavior documented (> 40 concurrent); XFF hop selection; fail-closed 204 |
-| Internal routes | wrong-method 405 + `Allow` + `no-store` on every adapter; unknown version 404; no publisher fall-through; dispatch before auth/EC/filters; no forwarding; per-family origin policies |
-| CSP | both media types with separate validators; opaque/null-origin admission; policy-id path identity (forged body URL/version ignored); bucketed aggregation with caps; three-browser capture; per-version frozen header manifest |
-| Schema | staleness; adversarial corpus through Rust + generated TS + generated inline fragment; outer tolerance vs exact AAX projection; generated validity matrix; **compile-time exhaustiveness of `AuctionDropReason` over all producers** |
-| Runtime ABI | one kernel under concatenation; exact-release verdicts; late registration; failure isolation; object-form `definePlugin` release check |
-| Plugins | partial-install unwind; async rejection; abort while pending; disposer-after-disposal; per-disposer isolation |
-| Bootstrap | field-wise idempotent init (ad-slot script no longer clobbers); inert-install + commit flip; throw after each checkpoint unwinds; **hung checkpoint resuming after fallback self-discards via owner generation**; fallback-then-late-bundle deferral |
-| Lifecycle | `timed_out → present`; session disposal inventories; unissued intents cancelled by navigation disposal; boot container consume/freeze/delete; final-namespace smoke (`tsjs.que`, `tsjs.creative`, async `requestAds`, `definePlugin`) |
-| Delivery | unknown hash 410 `no-store`; exact-match immutable with full directive; release-time vector materialization (unlisted vector = build error); config-hash verification failure; cutover rehearsal (weight, purge, rollback) |
-| Sinks/monitoring | per-datasource probes (client-events heartbeat, CSP probe policy-id, ops probe counter) per adapter write path; canonical-views-only enforcement (raw-join multiplication test); `publisher_domain` naming |
-| Failure injection | Amazon runner redirect/hang/CSP block/script error → distinct §5.1 outcomes; EC/filter failure before renderer dispatch |
-| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
-| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness per §2.5; kill-switch snapshot semantics |
-| Perf | marks present; three vector contents; heap budget; inconclusive-rerun policy; pinned-environment baseline validity |
-| Lint | member-expression access to `googletag`/`pbjs` via `window`/`globalThis`/`self`/aliases caught outside adapters |
+Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3).
+
+| Area | Coverage |
+| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Attempts | `attempt_id` uniqueness; parent/child fallback linkage; exactly one `render_terminal` per attempt; parent `failed{gam_empty}` precedes `fallback_start` |
+| Terminal model | discriminated outcomes incl. `no_bid`/`cancelled`; per-reason `source` nullability; validity-matrix conformance |
+| Request cycles | disabled-initial-load `display()` retired for any caller; TS-noop-refresh → TS-display and publisher variants (same-class supersession); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap |
+| Ack per path | four G4b sequences; APS three-message; owner-observed ADM `load`/`error` with **nonce never in bidder realm**; bidder-synthesized message ignored; early-`burl` attempt fails; per-path deadlines; stale/replayed; after-disposal |
+| Bridge signal | truncated/replaced `hb_adid` → `bridge_request{matched:false}` with no response and native Prebid untouched; stolen TS ids fully suppressed (neither TS nor native responds); listener order |
+| AuctionBatch | multi-slot partial/full supersession; fetch aborts only when all children dead; stale-bid filtering through live child identity; timeout; navigation disposal; reversed responses; discriminated auction-client errors |
+| Diagnostic | `dexp` ceiling — renewal-before-expiry capped, repeated renewal to ceiling then failure; credential byte-level vectors; fragment clearing; in-memory storage; pre-upgrade buffering then flush; forgery/wrong-origin/replay |
+| Transport/limits | batch caps (64 events AND 12 KiB); ≤ 4 batches/flush; sustained single-tab, 5-tab, diagnostic flush, pagehide burst inside the 60/120 budget; overflow coalescing; Fastly synchronized-burst documented; unknown-address bucket |
+| Affinity | token vectors (format, rotation, constant-time); state-dependent defaults (canary vs post-cutover reassignment; no stale-cookie stragglers); coherence for HTML/assets/APIs/beacons; **CSP affinity via `policy_id` path, cookie-less renderer reports** |
+| Arms/measurement | observation-only control build emits comparable sampled telemetry (its zero-behavior-change gated by hermetic parity vs baseline); arm-specific datasources; union view stamps `deployment_pool`; `assignment_id` on rows; cluster-bootstrap fixture; GAM `ts_arm` key |
+| Latency/fill | `t_rel_ms` monotonic bounds; `attempt_started`→`render_terminal` durations; per-gate numerator/denominator queries (A.1); named source tables/joins |
+| Joins | `Nullable(UUID)` type equality; auction-level vs slot-level vs bid-level canonical joins; raw-join multiplication rejected |
+| Probes | authenticated probe rows (`probe_run_id`/`expected_seq`/`adapter`/`target`) per datasource; secret-derived CSP probe `policy_id` unforgeable; probe issuance protocol; persistence gates scoped to sink-backed adapters |
+| Alerting | injected-failure drill: alert fires ≤ 1 h |
+| Kill switch | switch state via HTML and response extensions; attempts created after delivery honor it before each irreversible action (incl. before `nurl`); SSAT-on-stale-page exemption documented and tested |
+| Drop enum | `empty_seatbid_bids` + `bid_id_too_large` mapped; shared typed enum across producers; compile-time exhaustiveness |
+| RC attestation | real-GAM workflow consumes the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}` and emits it in the attested output; gates parameterized by (release, pool, epoch); RC re-canary inherits each row's window |
+| Config/affinity | `config_hash` SHA-256 over exact bytes verified at startup (mismatch = fail); affinity HMAC vectors + rotation + constant-time |
+| Heap | CDP procedure at the five fixed points; GC before sample; rerun rule |
+| Lint | custom scope-aware rule catches `window`/`globalThis`/`self` member access and same-file aliases outside adapters (claim scoped to these shapes) |
+| Notifications | dispatch mechanics (no-cors GET, no-referrer, keepalive; `Image()` fallback; server-side macro expansion only); `notif_id` emission; duplicate alarm + reconciliation; hermetic exactly-once |
+| Mediation | required-unique bounded upstream ids (`missing`/`duplicate`/`bid_id_too_large`, no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules; provenance fail-closed scope; strategy timeouts; both lifecycles; APS + non-USD startup error |
+| Render token | format/CSPRNG/retry/TTL/one-time; scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed |
+| Trace auth | auth ≤ 256 B; encoding vectors; expiry/skew/max-future; renewal preserves mode; renewal-after-expiry fails; previous-key retention; deterministic sampling (exact u64 threshold; same trace → same mode concurrently) |
+| Internal routes | wrong-method 405 + `Allow` + `no-store`; unknown version 404; no fall-through; dispatch before filters; no forwarding; per-family origin policies |
+| CSP | both media types; opaque/null origin; policy-id path identity (forged body ignored); bucket caps + overflow counter; three-browser capture; per-version frozen header manifest |
+| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX; generated validity matrix |
+| ABI/plugins/boot | one kernel; exact-release verdicts; object-form release check; partial-install unwind; abort-pending; disposer-after-disposal; hung-resume self-discard; fallback-then-late-bundle deferral |
+| Lifecycle | `timed_out → present`; disposal inventories; unissued intents cancelled by navigation; boot consume/freeze/delete; final-namespace smoke |
+| Delivery | unknown hash 410 `no-store`; exact-match immutable; release-time vector materialization (unlisted = build error); cutover rehearsal |
+| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
+| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics |
+| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity |
## 10. Alternatives considered
-1. Patching APS point-failures without telemetry — rejected: four
- correct fixes have not produced reliable ads.
-2. Always direct-render APS (skip GAM/PUC) — rejected: changes GAM
- reporting/pacing unilaterally; kept as the attributed-`gam_empty`
- fallback.
-3. Single module graph / shared chunks now — rejected for this release:
- changes the delivery pipeline while everything else changes;
- successor option behind the same registry surface.
-4. Full rewrite in one branch without phases — rejected: the
- browser-spec safety net is thinnest exactly where behavior changes.
-5. Dropping the ES5 bootstrap — rejected: loses the pinned no-bundle
- guarantee; the generated fallback keeps it.
-6. Timeout-triggered fallback rendering — rejected: GPT requests cannot
- be cancelled; timeout racing a late fill can double-render and
- double-bill.
-7. Timeout-based quarantine re-arm — rejected (recreates the stale-event
- bug).
-8. N/N−1 compatibility machinery — removed by the §0 policy decision.
-9. Client-computed notification hashes — rejected (no key without
- breaking the pseudonymization boundary); server-minted `notif_id`.
-10. Fingerprint identities for id-less bids — rejected (can merge
- distinct demand); rejection with closed reasons instead.
-11. Plain readable cohort cookie — rejected (dark-pool opt-in +
- cache-cardinality abuse); opaque authenticated token.
-12. `billing_outcome` event — removed (no honest producer exists).
+Revision 8's twelve rejections stand (patch-without-telemetry;
+always-direct-render; shared chunks now; big-bang rewrite; dropping the
+bootstrap; timeout fallback; timeout re-arm; N/N−1 machinery;
+client-computed hashes; fingerprint identities; plain cohort cookie;
+`billing_outcome`), plus: **13.** injected in-realm ADM reporter —
+rejected (hands bidder code the acceptance credential); owner-observed
+`load`/`error` instead. **14.** cookie-routed CSP affinity — rejected
+(opaque-origin reports carry no cookie); path-identity routing. **15.**
+token-identity alone for arm attribution — rejected (authorization is
+not a row dimension); arm-specific datasources + stamped union view.
+**16.** indefinite diagnostic renewal — rejected; `dexp` ceiling.
+**17.** shared attempt identity for parent/child — rejected; distinct
+`attempt_id`.
## 11. Risks
-- Hard-cutover blast radius — accepted by policy; bounded by the §0
- runbook (probes, low-weight canary, 24 h window, weight-back
- rollback).
-- Sticky-cohort routing is new infrastructure the cutover depends on —
- Phase 0 work; its coherence test is release-gating.
-- Mediator wire-contract change (`candidate_id` echo) — DR-4 gates
- `merge_highest_cpm`; config validation enforces the block.
-- Notification triggers become a published contract for PBS-path
- demand — changing them later is a breaking change for SSP reporting.
-- Required `[auction].currency` and `winner_selection` (mediated
- deployments) are a deliberate startup-error class under §0.
-- Beacon abuse — pre-parse caps, per-family origin policies, fail-closed
- numeric limits, signed modes, credentialed diagnostics.
-- Registry/limiter memory — explicit capacities, TTL reclamation,
- reject-at-capacity; Fastly overshoot documented, not claimed.
-- CSP data is advisory — never a sole rollback signal.
-- Sink blindness — per-datasource probes with datasource-side queries.
-- ABI freeze — `tsjs._internal.registry` is load-bearing; exact-release
- verdicts are the contract.
-- Schema generation — checked-in artifacts + staleness CI.
+Revision 8's register stands (cutover blast radius; sticky-cohort
+infrastructure; mediator wire-contract change; published notification
+triggers; required-config startup errors; beacon abuse; memory bounds;
+advisory CSP; sink blindness; ABI freeze; schema generation), plus: the
+**observation-only control build** is new scoped work whose "zero
+behavior change" property is itself gated (hermetic parity vs baseline);
+and `assignment_id` persistence is pseudonymous but new — bounded by the
+24 h token TTL and excluded from any identity join by schema review.
## 12. Success criteria
-1. APS creatives render in each configured flow (SSAT, client-Prebid,
- page-bids, direct), hermetically and in the release-gating real-GAM
- suite per Appendix A.3's enumerated topologies.
+1. APS creatives render in each configured flow, hermetically and in the
+ attested real-GAM suite.
2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the
- failing class from one page load (including A1–A4 via the debug
- envelopes, and including an initially-unsampled page via pre-upgrade
- buffering); §5.7 SLIs hold on sink-backed deployments.
-3. Both lint families pass with zero exceptions; stateful sharing only
- via the registry; exact-release mismatches quarantine loudly.
+ failing class from one page load; §5.7 SLIs hold, including the alert
+ drill.
+3. Both lint families (incl. the custom scope-aware rule) pass; stateful
+ sharing only via the registry; exact-release mismatches quarantine
+ loudly.
4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
generated.
-5. Attempt counts key on `(trace_id, nav_gen, refresh_gen, slot)`;
- traces stay navigation-scoped; no double counting; orphan recovery
- has a non-vacuous test; G4a holds including caller-independent
- retirement and same-class supersession.
-6. The only TSJS-owned global is `window.tsjs` with the §7.4 final
- shape; no expandos; legacy names gone at cutover.
-7. §7.10 budgets hold on the dedicated pinned workflow.
-8. No existing warning lost; issue-surfacing conditions log `warn`+
- with the beacon's reason code.
-9. TypeScript floor matches resolved 5.9 with the §7.11 flags via the
- checked-in `typecheck` script; `prebid.js` pin documented with the
- deployed bundle.
-10. `nurl`/`burl` fire only on carrying paths at their G4d binds,
- attempt-scoped and idempotent; APS fires neither; hermetic
- exactly-once tests pass; production duplicates alarm via
- `notification_sent` and reconcile to zero via billing reports.
-11. Trace-bearing responses are `private, no-store`; authorizations
- are per-trace, signed, mode-carrying, renewal-preserving, with
- diagnostic upgrade as the sole authenticated mode transition;
- unsampled traces transmit nothing.
-12. The cutover runbook rehearsed (weight switch, purge, rollback);
- config-hash verification enforced.
-13. The Phase-3 statistical and real-GAM gates pass on the exact
- immutable Phase-5 release candidate before weight-up.
-14. The Appendix A gates table shipped with this design; every change
- carries a reviewed decision record.
-15. The baseline APS fix behaviors are re-implemented in the target
- architecture with the baseline browser tests passing unmodified.
+5. Exactly one `render_terminal` per `attempt_id`; parent/child fallback
+ attempts are distinct rows; attempt aggregation keys on `attempt_id`
+ with the tuple as grouping only.
+6. The only TSJS-owned global is `window.tsjs` (§7.4); no expandos.
+7. §7.10 budgets hold, including the CDP heap procedure.
+8. No existing warning lost; issue-surfacing conditions log `warn`+ with
+ the beacon reason.
+9. TypeScript floor and flags via the checked-in `typecheck` script;
+ `prebid.js` pin documented.
+10. `nurl`/`burl` only on carrying paths at their binds with the
+ normative dispatch mechanics; APS fires neither; hermetic
+ exactly-once + production alarm + reconciliation.
+11. Trace-bearing responses `private, no-store`; authorizations signed,
+ mode-carrying, renewal-preserving, diagnostic bounded by `dexp`;
+ unsampled transmits nothing.
+12. Cutover rehearsed; config-hash verification enforced; post-cutover
+ routing defaults flip (no stale-cookie stragglers).
+13. Phase-3 statistical and real-GAM gates pass on the attested
+ immutable RC before weight-up.
+14. Appendix A shipped with this design; changes carry decision records.
+15. Baseline APS fix behaviors re-implemented; baseline browser tests
+ pass unmodified.
## 13. Open questions
-Only one remains outside the decision records: does Amazon expose any
-creative-completion acknowledgement that could add a
-post-`render_accepted` state under a new name (future enhancement)?
+One: does Amazon expose any creative-completion acknowledgement that
+could add a post-`accepted` state under a new name (future
+enhancement)?
---
## Appendix A — Normative rollout gates (initial values)
-Owners are roles: **RO** = release owner, **QA** = QA owner, **OPS** =
-release owner's on-call. Assignment unit = the authenticated sticky
-cohort token (§0), randomized at HTML request, stratified by publisher
-and slot. Statistical gates use **sampled traces only** (§8 method);
-diagnostic traffic is reported separately. All production queries run
-against canonical views via **checked-in versioned pipes**
-(`tinybird/pipes/gate_.pipe`); probe/parity suites are checked-in
-scripts (`scripts/gates/.sh`) or workflows. "Hold" = router
-weight frozen; "Rollback" = weight to previous release + re-purge.
-Changing any row requires a reviewed decision record.
+Roles: **RO** release owner, **QA** QA owner, **OPS** on-call.
+Randomization unit: `assignment_id` (§0). Statistical gates: sampled
+traces, cluster bootstrap (§8), canonical views only, checked-in
+artifacts (`tinybird/pipes/gate_*.pipe`, `scripts/gates/*.sh`,
+`.github/workflows/*`). Every statistical row specifies
+numerator/denominator/source; missing rows count as failure. "Hold" =
+weight frozen; "Rollback" = weight back + re-purge. Changes require
+decision records. Low volume: inconclusive → extend once → Hold.
### A.1 Phase gates
-| Phase | Gate | Artifact (checked in) | Denominator | Floor | Threshold | Window | Owner | Action |
-| ----- | ----------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------- | ------ | ----- | -------- |
-| 0 | Dark-pool health | `scripts/gates/probe-pool.sh` | probe requests | 1,000 | 100% expected responses | 24 h | OPS | Hold |
-| 0 | Schema validation | `scripts/gates/schema-writes.sh` (deterministic writes) | synthetic rows | 10,000 | **0 rejections** (writes are deterministic) | 24 h | RO | Hold |
-| 0 | Asset identity | `scripts/gates/asset-probe.sh` | probed hashes | all | 0 misses / 0 wrong-status | once | QA | Hold |
-| 0 | Config binding | `scripts/gates/config-hash.sh` | pools | all | manifest hash verified on every pool | once | OPS | Hold |
-| 1 | ABI cleanliness | `gate_abi.pipe` (probe pages) | probe page loads | 1,000 | 0 `abi_mismatch`/`bundle_partial` | 24 h | QA | Hold |
-| 1 | Bootstrap ownership | hermetic suite `bootstrap-ownership.spec` | checkpoints incl. hung-resume | all | 100% unwind/self-discard | CI | QA | Hold |
-| 2 | Ingest HTTP parity | `scripts/gates/ingest-parity.sh` (4 adapters, 4 families) | parity cases | all | 100% | CI | QA | Hold |
-| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | probe batches | 10,000 events | acceptance ≥ 99%; dedup exactly-once | 24 h | OPS | Hold |
-| 2 | Per-sink probes | `gate_probes.pipe` (3 datasources × adapters) | probe writes per sink | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
-| 3 | Funnel: ssat/prebid/page_bids | `gate_funnel.pipe` per flow | eligible APS wins (sampled traces), per flow-arm | 10,000 each | per A.2 | 24 h | RO | Rollback |
-| 3 | Funnel: direct/fallback | hermetic + real-GAM rows (A.3) — not statistical | suite cases | all | 100% | CI+RG | QA | Hold |
-| 3 | Attribution soundness | `gate_cycles.pipe` | **all TS request cycles candidate for attribution** | 10,000 | `cycle_unattributable` < 0.5% | 24 h | RO | Rollback |
-| 3 | GAM fill | `gate_fill.pipe` | cohort ad requests per arm | 10,000 | one-sided 95% CB: rel. diff ≥ −2% | 24 h | RO | Rollback |
-| 3 | Latency | `gate_latency.pipe` | cohort attempts per arm | 10,000 | one-sided 95% CB: p95 rel. diff ≤ +2% | 24 h | RO | Rollback |
-| 3 | Billing | `gate_billing.pipe` + GAM report reconciliation | attempts per arm (per-1,000 normalization) | 100,000 or 7 d | one-sided 95% CB: rel. diff ≥ −2% | window | RO | Rollback |
-| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` (detection) + billing reconciliation | burl dispatches | 1,000 | 0 observed duplicates; reconciliation clean | 24 h | RO | Rollback |
-| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
-| 4 | Four-flow parity | `flow-parity.spec` (hermetic) | parity cases | all | 100% | CI | QA | Hold |
-| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / within §7.10 tolerances | CI | QA | Hold |
-| 5 | RC re-canary | repeat all Phase-3 rows on the immutable RC | as Phase 3 | as Phase 3 | as Phase 3 | 24 h | RO | Rollback |
-| 5 | Cutover monitor | `gate_slis.pipe` | production traffic | — | probe lag ≤ 5 min; probe loss < 0.1%; `render_fail` rate ≤ pre-cutover canary + 0.5 pt | 24 h | OPS | Rollback |
-
-Low-volume handling: a statistical gate that cannot reach its floor in
-its window is inconclusive — extend once; a second inconclusive is a
-Hold, never a pass.
-
-### A.2 Expected stages per flow (Phase 3 funnel)
-
-Denominators are named per stage; document/runner rates apply wherever
-the renderer document participates.
-
-| Flow | Expected sequence | Stage thresholds (each vs its named denominator) |
-| --------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
-| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_accepted` | each ≥ 95% of prior; `renderer_document_loaded`/`bridge_response_sent` ≥ 99%; `runner_failed`+timeouts ≤ 1% of `renderer_document_loaded` |
-| prebid | same as ssat (keyed by Prebid `adId`) | same |
-| page_bids | same as ssat (after SPA navigation) | same |
-| direct | `render_attempt → renderer_document_loaded → render_accepted` | document ≥ 99% of attempts; accepted ≥ 95% of attempts (hermetic/real-GAM gate, not statistical) |
-| fallback | parent `gam_empty` → child `fallback_start → renderer_document_loaded → render_accepted` | accepted ≥ 95% of `fallback_start` (hermetic/real-GAM gate, not statistical) |
-
-### A.3 Real-GAM suite (operational row)
-
-| Field | Value |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------- |
-| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
-| Topologies | one per A.2 flow, plus publisher-overlap and disabled-initial-load formation (G4a), plus the same-class supersession case |
-| Browsers | Chromium, Firefox, WebKit (CSP/opaque-origin rows); Chromium (funnel rows) |
-| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
-| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
-| Command | `npx playwright test --config real-gam.config.ts` from the browser test package |
-| Artifact | Playwright HTML report + trace zips as workflow artifacts, retained 90 days |
-| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
-| Approval evidence | green workflow run URL linked in the release checklist, signed off by RO |
+| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action |
+| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | --------------- | -------------------------------------------------- | ------------------------- | ----- | -------- |
+| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold |
+| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold |
+| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold |
+| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold |
+| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold |
+| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold |
+| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold |
+| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
+| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold |
+| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback |
+| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold |
+| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback |
+| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback |
+| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback |
+| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback |
+| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback |
+| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
+| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold |
+| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold |
+| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback |
+| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; `render_terminal{failed}` rate vs pre-cutover canary | — | lag ≤ 5 min; loss < 0.1%; failed ≤ canary + 0.5 pt | 24 h | OPS | Rollback |
+
+### A.2 Expected stages per flow
+
+| Flow | Expected sequence | Stage thresholds (named denominators) |
+| --------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_terminal{accepted}` | `targeting_set`/eligible wins ≥ 98%; each later stage ≥ 95% of prior; document/`bridge_response_sent` ≥ 99%; runner fail+timeout ≤ 1% of document |
+| prebid | same (keyed by Prebid `adId`) | same |
+| page_bids | same (post-SPA-navigation) | same |
+| direct | `attempt_started → renderer_document_loaded → render_terminal{accepted}` | document ≥ 99% of attempts; accepted ≥ 95%; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) |
+| fallback | parent `render_terminal{failed, gam_empty}` → child `fallback_start → renderer_document_loaded → render_terminal{accepted}` | `fallback_start`/eligible parent `gam_empty` ≥ 99%; accepted ≥ 95% of starts; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) |
+
+### A.3 Real-GAM suite (attested)
+
+| Field | Value |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
+| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** |
+| **Output** | **attested report embedding the manifest** — a green run attests the exact build it exercised, parameterized by (release, pool, deployment epoch) |
+| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession |
+| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) |
+| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
+| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
+| Command | `npx playwright test --config real-gam.config.ts` |
+| Artifact | Playwright HTML report + trace zips, retained 90 days |
+| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
+| Approval evidence | green attested run URL in the release checklist, signed off by RO |
From fba6e7e664ff41a2b7c038ea6fe8533b9513e302 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Tue, 4 Aug 2026 17:38:39 -0700
Subject: [PATCH 011/194] Revise design spec for the ninth review round
Revision 10 closes the release-blocking findings and restores true
self-containment. The router now injects a trusted internal cohort
header the ingest stamps server-side (assignment_id never a client
field); the observation-only control build gets a normative event-parity
contract so both arms populate the funnel and latency gates identically;
affinity token lifetime covers the full experiment window with same-arm
renewal and a publisher-host binding in the MAC, and cutover/rollback
explicitly retire the opposite release on every request family; the
canonical attempt key becomes (publisher_domain, trace_id, attempt_id)
with a per-trace collision-retry, and the slot funnel joins a dedicated
one-row-per-(auction_id, canonical_slot) summary view with shared client
and server slot normalization; the bridge acknowledgement splits into a
claim phase that mints the nonce after validating the reserved adId and
kernel-held generations and an ack phase that validates the nonce, since
the baseline PUC request cannot carry it; a materialized
request_cycle_started event gives fill and attribution a real
denominator; AuctionBatch terminates every still-live child no_bid after
processing a partial multi-slot response; attempt_started.source and
render_terminal.source become source-presence-driven; probe run
metadata is bound to the issued authorization and added to the CSP and
ops schemas; the drop enum adds unsupported_currency and
missing_request_context under one shared typed enum; an admin issuance
route family joins the internal-route contract; the Image() notification
fallback is removed; billing gets an impression-level or aggregate
estimator that the cluster bootstrap can actually run; the real-GAM
workflow independently verifies the deployed build against its manifest
and emits OIDC-backed provenance; a server-side sampled exposure row
makes whole-missing traces detectable; the heap budget is renamed to
retained-heap; the typecheck gate uses the package script; ts_arm is
reserved and A/A-gated; and the alternatives and risk registers are
inlined in full.
---
...s-render-fix-and-tsjs-resilience-design.md | 529 ++++++++++++------
1 file changed, 353 insertions(+), 176 deletions(-)
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index b988e6286..78a31b9b7 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,8 +1,11 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 9 — closes the eighth review round's mandatory set:
- attempt schema, diagnostic renewal, telemetry/gate model, CSP routing, and
- rollout measurement. Fully self-contained.
+- **Status:** revision 10 — closes the ninth review round (assignment-id
+ trusted path, control-build event parity, affinity lifetime, two-phase
+ bridge ack, missing-slot termination, materialized cycle denominator,
+ publisher-bound affinity token, admin route family, billing estimator
+ source, independent RC attestation) and re-inlines the alternatives and
+ risk registers so the document is genuinely self-contained.
- **Date:** 2026-08-04
- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed
GAM shells"). All file:line citations refer to this commit.
@@ -31,23 +34,45 @@
- Assets: embedded only; hashed pathnames for cache identity; unknown hash →
`410`, `no-store`.
- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML
- responses — format `r1......
-`: `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp` (TTL 24 h);
- `release` from the allowlist; `cohort ∈ {canary, control}`;
- `assignment_id` = 32-hex CSPRNG (the pseudonymous **randomization unit**,
- §8); `sig` = unpadded base64url HMAC-SHA-256 over the domain-separated
- length-prefixed input `"ts-affinity-v1" || u32be-len fields ||
-u64be(exp)`; keys owned and rotated by the routing layer (active +
- previous, ≥ 24 h retention); constant-time verification; test vectors
- checked in. Attributes `Secure; HttpOnly; SameSite=Lax; Path=/`. Cache
- keys use the post-validation release label only.
+ responses — format
+ `r1.......`:
+ `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp`; `phost` = normalized
+ publisher host; `release ^[a-z0-9._-]{1,40}$` from the allowlist;
+ `cohort ∈ {canary, control}`; `assignment_id` = 32-hex CSPRNG (the
+ pseudonymous **randomization unit**, §8); `sig` = unpadded base64url
+ HMAC-SHA-256 over the domain-separated length-prefixed input
+ `"ts-affinity-v1" || u32be(len)||field …` over **every** field
+ including `phost` (so a valid token replayed against another publisher
+ host fails); keys owned/rotated by the routing layer (active + previous,
+ retained ≥ the affinity lifetime + skew); constant-time verification;
+ test vectors checked in. Attributes `Secure; HttpOnly; SameSite=Lax;
+Path=/`. Cache keys use the post-validation release label only.
+- **Affinity lifetime covers the experiment.** `exp` TTL is set to the
+ **maximum experiment window** in force (default 14 days — the billing
+ gate's 7 d + 7 d extension), not 24 h, so a canary visitor never crosses
+ over to control mid-experiment. Renewal on any HTML response before
+ expiry re-signs the **same `assignment_id` and `cohort`** with a fresh
+ `exp` (a browser session's arm is fixed for the experiment). Expired
+ tokens are distinguished from forged ones (expired = valid sig, past
+ `exp`) and, during an active experiment, expired-but-valid tokens are
+ renewed to their original arm rather than reassigned.
+- **assignment_id reaches telemetry only by a trusted server path.** The
+ cookie is `HttpOnly`; the client never reads it and never sends
+ `assignment_id`. The router **strips any inbound assignment/cohort
+ header**, validates `ts-rel`, and injects trusted internal metadata
+ (`X-TS-Cohort`, `X-TS-Assignment`, `X-TS-Release`) on the proxied
+ request; the client-events handler stamps those onto every row
+ server-side. Ingest rejects any client-supplied assignment/cohort field.
+ Tests: spoofed inbound header stripped; wrong-arm; null-rate; cookie
+ expiry mid-session; cross-pool.
- **State-dependent routing defaults:** during canary, valid tokens route by
- their binding; invalid/expired/forged → control + reissue. **After forward
- cutover ("weight 100%"), the safe default flips:** stale, invalid, or
- control-bound tokens on HTML requests are reassigned to the active
- release; non-HTML requests with unknown or stale tokens route to the
- active release — 100% means 100%, not "except 24 h of old cookies."
- Rollback flips the default back.
+ their binding; invalid/forged → control + reissue; expired-but-valid →
+ renewed to their arm (above). **Forward cutover ("weight 100%")
+ explicitly retires the old release:** for HTML **and** non-HTML request
+ families, stale/invalid/old-release-bound tokens are reassigned/routed to
+ the active release — 100% means 100%. **Rollback symmetrically retires
+ the new release:** still-valid canary bindings are overridden on every
+ request family, not merely defaulted.
- **CSP-report affinity never depends on cookies:** the renderer is
sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its
browser-generated reports are cross-origin to the publisher endpoint and
@@ -58,13 +83,21 @@ u64be(exp)`; keys owned and rotated by the routing layer (active +
- Beacon and trace-auth transports use `credentials: "same-origin"` so the
affinity cookie routes them; handlers derive no identity from cookies.
- **Canary/control measurement (closing the empty-control-arm gap):** the
- control pool for Phase-3 statistics runs an **observation-only control
- build** — the baseline plus Phase-2 instrumentation only (trace, beacon,
- probes; zero behavior changes) — so both arms emit comparable sampled
- telemetry. Arms write to **arm-specific datasources**; the canonical
- union view stamps a trusted `deployment_pool` dimension from the write
- identity (token → dataset → arm), making the arm a queryable row
- dimension rather than an authorization side effect.
+ control pool runs an **observation-only control build** — baseline render
+ _decisions_, but with **every measurement-only lifecycle hook the
+ Phase-3 gates read**. The control build implements a normative
+ **event-parity contract**: it emits `request_cycle_started`,
+ `attempt_started`, `bridge_request`, `bridge_response_sent`,
+ `renderer_document_loaded`/`adm_document_loaded`, and `render_terminal`
+ at the **same timing points** as the canary build, populated from
+ baseline behavior (e.g. its own `slotRenderEnded`, its own bridge
+ responses) — only the render-decision code differs, never the event
+ definitions or their emission points. Both arms therefore populate the
+ funnel and latency gates identically. Arms write to **arm-specific
+ datasources**; the union view stamps a trusted `deployment_pool` from the
+ write identity. The event-parity contract is itself gated by an **A/A
+ test** (canary-build vs canary-build) that must show zero metric
+ movement before any A/B canary begins.
- Router weight over sticky cohorts is the sole activation primitive; flags
are in-pool emergency kill switches. Cutover = weight 100% + CDN purge;
rollback = weight back + re-purge. The affinity acceptance test covers
@@ -191,28 +224,40 @@ class as the existing `ts-debug` comment). The **diagnostic credential**
`ext.trusted_server.trace = {trace_id, auth, auction_id}`.
- **Attempt identity (closing the parent/child collision):** every render
attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`,
- CSPRNG, unique per trace) and carries nullable **`parent_attempt_id`**
- (fallback children reference their parent). The tuple
- `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key
- only**; `ts_render_attempts_v` keys by `attempt_id`. Exactly one
- terminal event per `attempt_id` (G4c) is a tested invariant.
+ CSPRNG) and carries nullable **`parent_attempt_id`** (fallback children
+ reference their parent). **The canonical attempt key is
+ `(publisher_domain, trace_id, attempt_id)`** — `attempt_id` alone (≈ 2.8
+ T values) is not globally unique at production volume, so
+ `ts_render_attempts_v` keys and parent lookups use the full triple.
+ Uniqueness within a trace is guaranteed by a **per-trace live-set
+ collision check with retry** (the `NavigationSession` holds the issued
+ set; a collision re-draws). The tuple
+ `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key only**.
+ Exactly one terminal event per attempt key (G4c) is a tested invariant.
- **Deterministic keyed sampling:** first 8 bytes of
`HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff
`u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`.
- **Cross-tier join:** equality key = the server telemetry
**`auction_id`** (UUID; the client column is `Nullable(UUID)` and
ingest validates canonical UUID syntax). Generations are client-side
- only. **Join grains are explicit:** auction-level joins hit the one
- summary row per `auction_id`; slot-level joins use
- `auction_id + slot + row_kind`; bid-level joins are opt-in for
- bid-grained analyses. Raw attempt × auction-row joins are forbidden
- (row multiplication).
+ only. **Join grains are explicit, and the slot grain goes through a
+ dedicated summary view.** `auction_events_raw` has multiple
+ `row_kind=slot` rows per slot (bid, provider, drop, selection), so a
+ `auction_id + slot` join multiplies rows; funnels therefore join against
+ **`ts_auction_slot_summary_v` — one row per `(auction_id,
+canonical_slot)`** built from the `selection_summary` slot rows.
+ Auction-level joins hit the one totals/summary row per `auction_id`;
+ bid/drop/provider joins carry their real grain and are opt-in. **Client
+ `slot` and server `slot_id` use the same canonical normalization**
+ (lowercased, `-container` suffix stripped — the resolution rule of
+ `gpt/index.ts:112-138`), applied on both sides and tested.
- Cache-privacy invariant: traces/authorizations only in per-request
auction-bearing responses; such HTML is `private, no-store`.
- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry
`{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?,
-auction_id?, t_rel_ms?}`. **`t_rel_ms`** is a bounded monotonic
- duration (`performance.now()` truncated to u32 ms, relative to
+auction_id?, t_rel_ms?}`. **`t_rel_ms`** (time, relative, in
+ milliseconds) is a bounded monotonic duration (`performance.now()`
+ truncated to u32 ms, relative to
navigation start) — the latency gates' basis; `received_at` is ingest
time and is never used as event time. `flow` is closed:
`ssat | prebid | page_bids | direct | fallback | system`.
@@ -264,25 +309,44 @@ class or opposite — supersedes a pending uncertain intent**, and if the
uncertain one could still be in flight, the next `slotRequested` is
ambiguous → quarantine. Cycles open only on `slotRequested` (causal
head; SRA = one per slot per batch) and close on `slotRenderEnded`
-(`responseIdentifier` dedups drain). One outstanding TS cycle per slot;
-one queued replacement. Attribution requires exactly one outstanding TS
-cycle and no overlap; otherwise `cycle_unattributable`, fail closed. **No
-timeout re-arm** — re-arm only on count-based drain, safe TS-owned
-destroy/redefine, or page end; unissued intents are NavigationSession
-children; physical state is RuntimeSession. Deterministic-harness CI +
+(`responseIdentifier` dedups drain). **Each attributable `slotRequested`
+emits exactly one `request_cycle_started` event** — the materialized
+denominator the fill and attribution gates divide by (`attempt_started`
+is not equivalent: an attempt can fail before producing a physical
+request). One outstanding TS cycle per slot; one queued replacement.
+Attribution requires exactly one outstanding TS cycle and no overlap;
+otherwise `cycle_unattributable`, fail closed. **No timeout re-arm** —
+re-arm only on count-based drain, safe TS-owned destroy/redefine, or page
+end; unissued intents are NavigationSession children; physical state is
+RuntimeSession. Deterministic-harness CI +
the release-gating real-GAM suite (Appendix A.3).
-**G4b — Acknowledgement, per render path.** Nonces are per-attempt
-128-bit CSPRNG values; the kernel validates source ownership (§6.8),
-nonce, token, `nav_gen`, `refresh_gen` before transitions or
-notifications; navigation/supersession invalidates; late acks →
-`stale_navigation`. Deadlines: document 3 s, runner 10 s, adm 5 s.
-
-1. **APS-PUC** (baseline transport): MessageChannel into the renderer
- document (`ports.length` checks, exact-key replies, one-shot latch,
- port close); the document posts authenticated
- `renderer_document_loaded` then the accepted/failed result to the top
- window.
+**G4b — Two-phase acknowledgement (the nonce is minted by the claim, not
+carried in the request).** The baseline PUC request is `{message, adId}`
+
+- a `MessagePort` — it cannot carry a nonce or generations, and the nonce
+ can only exist **after** a claim succeeds. So the protocol is two phases:
+
+* **Phase 1 — claim (on the incoming `Prebid Request`):** validate source
+ ownership (§6.8), the reserved `adId` against the reservation store, and
+ the internal current `nav_gen`/`refresh_gen` **held in the kernel**
+ (not in the message); on success, **mint the per-attempt 128-bit CSPRNG
+ nonce**, bind notifications, reply over the `MessagePort` (the renderer
+ depends on this reply to settle its PUC promise), and — for the APS
+ path — hand the nonce to the renderer document in the response.
+* **Phase 2 — acknowledgement (later document/runner messages):** validate
+ source, nonce, token, `nav_gen`, `refresh_gen`. Navigation/supersession
+ invalidates the nonce; late acks → `stale_navigation`. Deadlines:
+ document 3 s, runner 10 s, adm 5 s.
+
+Per render path:
+
+1. **APS-PUC** (baseline transport): the Phase-1 reply travels over the
+ `MessagePort`; the response also transfers the freshly minted nonce
+ into the renderer document (`ports.length` checks, exact-key replies,
+ one-shot latch, port close); the document then posts authenticated
+ `renderer_document_loaded` and the accepted/failed result to the top
+ window (Phase 2).
2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the
trusted owner, not the creative document** — the owner observes its
own iframe's `load`/`error` events and emits `adm_document_loaded`;
@@ -315,11 +379,15 @@ fallback: attributed parent `gam_empty` immediately before child
render). `nurl` at bind; `burl` at `accepted`; idempotency key
`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`.
**Dispatch mechanics (normative):** macros are expanded server-side
-only; the client fires
+only; the client fires exactly one
`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit",
-redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`;
-on synchronous failure the fallback is a detached `Image()` request; no
-retries either way. Every dispatch emits
+redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`
+and awaits its settlement. **There is no `Image()` fallback** — a
+detached image request would construct a separate credentialed,
+referrer-bearing request outside this privacy contract; a request that
+rejects (construction error) or resolves to a network failure emits
+`notification_sent{result: failed}` and stops. No retries. Every
+dispatch emits
`notification_sent{kind, notif_id, result: queued | failed}` with the
**server-minted `notif_id`** (12-char token delivered with the bid).
Duplicates: hermetic exactly-once proof + production detection alarm +
@@ -341,7 +409,11 @@ child identity before any effect. Tests: partial overlap, full overlap,
timeout, navigation disposal, reversed responses. The auction client
returns a discriminated result — `{ok: bids[]} | {error:
"auction_timeout" | "network_error" | "http_error" | "invalid_response"}`
-(§3.14); only a parsed empty response is `no_bid`. Public API:
+(§3.14); only a parsed empty response is `no_bid`. **After the batch
+processes every response bid, each still-live child with no valid
+matching winner is terminated `render_terminal{no_bid}`** — a response
+carrying only slot X never leaves slot Y's child nonterminal, so
+`RequestAdsResult` always settles. Public API:
`tsjs.requestAds(options): Promise`,
`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`,
settling when every child attempt is terminal.
@@ -365,15 +437,21 @@ implying a live channel that does not exist.
publication (config is a release-time input); serving is lookup-only;
unknown vector = build error; unknown hash = `410 no-store`; exact
match = `public, max-age=31536000, immutable`.
-- **Internal route families — four** (renderer, client-events,
- CSP-report, `/_ts/trace-auth`): dispatch before auth/EC/publisher/
- integration filters (`app.rs:709` orders these wrong today); all
- methods + version prefixes reserved locally (405 + `Allow` +
- `no-store`; unknown version 404 `no-store`; no publisher fall-through,
- `adapter-spin app.rs:804`); no body/cookie/authorization forwarding.
- **Per-family origin policy:** client-events + trace-auth strict
- normalized same-origin; CSP admits opaque/`null` origins with path
- identity + limits; renderer is a public GET validated by version/path.
+- **Internal route families — five** (renderer, client-events,
+ CSP-report, `/_ts/trace-auth`, and the **admin issuance family**
+ `/_ts/admin/*` — diagnostic-credential and probe-authorization, §5.3):
+ all dispatch before auth/EC/publisher/integration filters (`app.rs:709`
+ orders these wrong today); all methods + version prefixes reserved
+ locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; no
+ publisher fall-through, `adapter-spin app.rs:804`); no
+ body/cookie/authorization forwarding to the publisher. **Per-family
+ origin/auth policy:** client-events + trace-auth strict normalized
+ same-origin; CSP admits opaque/`null` origins with path identity +
+ limits; renderer is a public GET validated by version/path; **the admin
+ family sits behind operator authentication with its own rate limits and
+ exists on all adapters that expose an admin plane** (elsewhere it is
+ absent, and diagnostic/probe modes are unavailable there — stated, not
+ implied). All five families appear in the four-adapter parity matrix.
- Ingest routes in all four adapters; Fastly has real sinks; others
accept-count-drop (DR-5). §5.6 schemas deploy before writers.
@@ -389,9 +467,10 @@ implying a live channel that does not exist.
| `t` | fields | allowed `flow` |
| -------------------------- | --------------------------------------------- | ----------------------- |
+| `request_cycle_started` | slot | ssat, prebid, page_bids |
| `bid_received` | slot, id_kind, source | render flows |
| `targeting_set` | slot, id_kind | render flows |
-| `attempt_started` | slot, source | render flows |
+| `attempt_started` | slot, source? | render flows |
| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids |
| `bridge_response_sent` | slot, source | ssat, prebid, page_bids |
| `render_terminal` | slot, outcome, reason?, source? | render flows |
@@ -408,13 +487,18 @@ implying a live channel that does not exist.
| `heartbeat` | probe_run_id, expected_seq, adapter, target | system |
Render flows = `ssat | prebid | page_bids | direct | fallback`.
-`attempt_started` carries the attempt's `t_rel_ms` baseline; the latency
-metric is `render_terminal{accepted}.t_rel_ms −
-attempt_started.t_rel_ms` per attempt. `source` on `render_terminal` is
-nullable for pre-source reasons (`gpt_absent`, `pbjs_absent`,
-`slot_unresolved`, `intent_no_request`, `abi_mismatch`, `registry_full`,
-`bundle_partial`); the per-event/per-reason validity matrix is a
-generated artifact (§6.7). Reason enum: `renderer_document_no_load`,
+`attempt_started` carries the attempt's `t_rel_ms` baseline and its
+`source` is **optional** (direct attempts start before a response selects
+a source); the latency metric is `render_terminal{accepted}.t_rel_ms −
+attempt_started.t_rel_ms` per attempt. **`render_terminal.source` is
+present iff a render source was actually bound** — absent for
+`no_bid`/`cancelled` and every pre-winner outcome (`auction_timeout`,
+`network_error`, `http_error`, `invalid_response`) as well as the
+pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`,
+`intent_no_request`, `abi_mismatch`, `registry_full`, `bundle_partial`);
+the generated per-event/per-reason validity matrix (§6.7) encodes
+source-presence by whether a source was bound, not by a hand-list.
+Reason enum: `renderer_document_no_load`,
`runner_no_load`, `runner_failed`, `descriptor_invalid`,
`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`,
`cycle_unattributable`, `intent_no_request`, `stale_navigation`,
@@ -525,13 +609,18 @@ same-origin` else normalized `Origin` equality; absent both →
`attempt_id`**); the arm-union views stamp `deployment_pool` from the
write identity (§0). Dashboards/alerts query canonical views only.
- The Fastly sink is fire-and-forget (`tinybird.rs:153`) —
- **per-datasource authenticated probes**: every probe row carries
- `{probe_run_id, expected_seq, adapter, target}`; client-events via
- `heartbeat` events under probe-mode tokens; CSP via probe reports to a
- **secret-derived probe `policy_id`** (registered like any policy id,
- not guessable — `policy_id = "probe"` would be publicly forgeable);
- ops via an authenticated probe counter write. **Persistence gates are
- scoped to sink-backed adapters** (DR-5); accept-count-drop adapters
+ **per-datasource authenticated probes**. **Every probe-capable table
+ carries `{probe_run_id, expected_seq, adapter, target}`** (added to
+ `ts_csp_reports` and `ts_ops_counters` below, not only
+ `ts_client_events`), so loss queries distinguish runs, retries, resets,
+ and adapters. **These four fields are cryptographically bound to the
+ issued probe authorization** — the admin-issued probe token
+ (`POST /_ts/admin/probe-authorization`, §5.3) signs `probe_run_id`,
+ `adapter`, and `target`, and the ingest handler stamps the row from the
+ verified token rather than trusting submitted values. A secret-derived
+ CSP probe `policy_id` is a bearer capability for _routing_ only; it is
+ never treated as proof of authentic run metadata. **Persistence gates
+ are scoped to sink-backed adapters** (DR-5); accept-count-drop adapters
get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss =
`expected_seq` gaps < 0.1%; alert owner: release owner's on-call.
- **Alert-delivery drill:** a synthetic canary page injects a known
@@ -582,27 +671,37 @@ duplicate_bid_id, bid_id_too_large, empty_seatbid,
empty_seatbid_bids, unknown_impid, invalid_price,
unsupported_media_type, creative_id_too_large,
renderer_extension_serialization_failed, no_render_source,
-lost_to_higher_bid, overflow` — covering `aps.rs:740-929`
- (incl. `empty_seatbid_bids` at `:875`) and `formats.rs:408-419`; a
- **compile-time exhaustiveness test maps every producer to the enum**.
+lost_to_higher_bid, unsupported_currency, missing_request_context,
+overflow` — covering every baseline producer at `aps.rs:740-929`
+ (incl. `empty_seatbid_bids` at `:875`, `unsupported_currency`, and
+ the response-level `missing_request_context`, whose totals/error-row
+ disposition is `row_kind = totals` with a null slot) and
+ `formats.rs:408-419`. `currency_mismatch` and `unsupported_currency`
+ are **distinct** (config-vs-response mismatch vs an unsupported
+ currency code). Producers emit a **shared typed enum, no string
+ literals**, so the **compile-time exhaustiveness test is real**: every
+ variant maps to a producer and every producer to a variant.
- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain
LowCardinality(String), release_id String, policy_id
LowCardinality(String), cohort LowCardinality(String), directive_bucket
Enum(script|style|frame|img|connect|font|media|worker|other),
source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other),
-count UInt32`; sorting key `(publisher_domain, received_at, policy_id)`;
- TTL 30 days. Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256,
- nesting ≤ 4, both media types with separate validators, unused fields
- discarded, own limiter bucket. **Caps with values:** 10,000
- reports/hour/publisher and 1,000/hour/cohort; overflow increments the
- `csp_overflow` ops counter (dropped reports counted, never parsed
- further).
+count UInt32, probe_run_id Nullable(String), expected_seq
+Nullable(UInt32), adapter Nullable(Enum), target Nullable(Enum)`;
+ sorting key `(publisher_domain, received_at, policy_id)`; TTL 30 days.
+ Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, nesting ≤ 4,
+ both media types with separate validators, unused fields discarded, own
+ limiter bucket. **Caps with values:** 10,000 reports/hour/publisher and
+ 1,000/hour/cohort; overflow increments the `csp_overflow` ops counter
+ (dropped reports counted, never parsed further).
- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain
LowCardinality(String), release_id String, counter
Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked|
ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged|
-csp_overflow|probe), value UInt64`; sorting key `(publisher_domain,
-received_at, counter)`; TTL 90 days.
+csp_overflow|probe), value UInt64, probe_run_id Nullable(String),
+expected_seq Nullable(UInt32), adapter Nullable(Enum), target
+Nullable(Enum)`; sorting key `(publisher_domain, received_at, counter)`;
+ TTL 90 days.
- **Sink plumbing:** one generic multi-target Tinybird sink trait;
`RuntimeServices` (`platform/types.rs:158`) gains handles for
client-events, CSP, and ops targets beside the auction sink; each
@@ -640,14 +739,17 @@ explicit `winner_selection`.
### 6.1 Mediation
-Eight rules, arrival-independent, one shared helper across both
+Seven rules, arrival-independent, one shared helper across both
lifecycles: (1) required `[auction].currency` (APS + non-USD = startup
error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate
identity `source_candidate_id = (provider_name, upstream_bid_id)` with
the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/
oversized → `bid_drop{missing_bid_id | duplicate_bid_id |
-bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` (CSPRNG
-wire echo, never an ordering key); (3) mediator echoes
+bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` is a
+server-minted 12-char `^[a-z0-9]{12}$` CSPRNG wire echo with in-auction
+collision retry, **never an ordering key**, and the mediator's echoed
+value is validated against the issued set on return (an echo matching no
+issued id is `provenance_invalid`); (3) mediator echoes
`ext.trusted_server.candidate_id` — resolves → forwarded candidate,
provenance `mediator`, **price authoritative from the mediator, every
render-source and notification field from the stored candidate, deal
@@ -837,28 +939,34 @@ versions and is invalid if any differ). Browser timing:
`performance.mark("tsjs:bids-script")` →
`performance.mark("tsjs:first-display")`; reference fixture; warm cache;
local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline ×
-1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail. **Peak
-JS heap, reproducibly:** via the Playwright CDP session —
+1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail.
+**Retained-heap budget (named accurately — this measures retained, not
+transient allocation peak):** via the Playwright CDP session —
`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at
five fixed points (post-boot, post-adInit, post-first-render,
post-refresh, post-SPA-navigation) on the maximal vector; metric = max
-sample; gate ≤ baseline × 1.10; same rerun rule. Server benchmark: the
-G5 lookup path; 100 warm-ups, 1,000 iterations; median + p90 one-sided
-≤ baseline × 1.10; 3-run 5% agreement or inconclusive.
+retained sample; gate ≤ baseline × 1.10; same rerun rule. (Transient
+allocation peak is out of scope; if a future leak needs it, add
+`HeapProfiler` continuous sampling as a separate budget.) Server
+benchmark: the G5 lookup path; 100 warm-ups, 1,000 iterations; median +
+p90 one-sided ≤ baseline × 1.10; 3-run 5% agreement or inconclusive.
### 7.11 Toolchain
TypeScript floor to the resolved 5.9 line; release-gating flags
`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
`verbatimModuleSyntax`, `noImplicitOverride`,
-`useUnknownInCatchVariables`; checked-in npm script `typecheck`:
+`useUnknownInCatchVariables`. The gate is a **checked-in package.json
+script**, `"typecheck": "tsc -p tsconfig.json --noEmit"`, invoked so the
+lockfile-resolved compiler is used:
```
-cd crates/trusted-server-js/lib && npx --no-install tsc -p tsconfig.json --noEmit
+npm --prefix crates/trusted-server-js/lib run typecheck
```
-Dev toolchain bumps as individual CI-gated PRs; `prebid.js` excluded
-from casual bumps; monthly review.
+(the script runs `tsc` from the package's own `node_modules/.bin`, so no
+`npx` path ambiguity). Dev toolchain bumps as individual CI-gated PRs;
+`prebid.js` excluded from casual bumps; monthly review.
## 8. Rollout
@@ -871,12 +979,31 @@ resample assignment ids, 2,000 resamples, fixed seed recorded in the
gate artifact, strata (publisher × slot) weighted by control-arm
traffic share; one-sided 95% confidence bounds on **relative
differences**; each gate is an independent go/no-go (no cross-gate
-multiplicity correction — stated). Missing telemetry counts as failure.
-Per-flow floors; rare flows (direct, fallback) gate hermetically +
-real-GAM, never statistically. `cycle_unattributable` divides by all
-attribution-candidate TS cycles. **Billing cohort dimension in external
-reports:** TS-driven GAM requests set a `ts_arm=` key-value,
-making the arm a reportable GAM dimension for reconciliation.
+multiplicity correction — stated). **"Missing telemetry counts as
+failure" is made enforceable for whole-missing traces:** the server
+writes a **sampled exposure row** (`ts_expected_attempts`, one per
+server-observed eligible APS win in a sampled trace) at auction time;
+gates **left-join client attempts to expected rows**, so a trace whose
+client events never arrive is a visible missing attempt (not an absent
+row that silently shrinks the denominator). A per-window
+client-transport completeness ratio below 90% makes the statistical gate
+**inconclusive** rather than passing on a biased sample. Per-flow floors;
+rare flows (direct, fallback) gate hermetically + real-GAM, never
+statistically. `cycle_unattributable` divides by all
+attribution-candidate TS cycles.
+
+**Billing is the one gate the cluster bootstrap cannot run**, because GAM
+aggregate reports expose only per-`ts_arm` totals, not session clusters.
+Two options, one chosen per DR: (a) source billing from **GAM Data
+Transfer / impression-level logs**, which carry the `ts_arm` key-value
+**and** a joinable impression/`attempt_id` correlator, and run the same
+cluster bootstrap over impression rows; or (b) if Data Transfer is
+unavailable, use a **separate pre-reviewed aggregate estimator** with the
+**day** as the randomization unit (a two-sample non-inferiority test over
+daily per-arm RPM across the billing window), declared in the gate
+artifact. Whichever is chosen, `ts_arm` is a **reserved, network-audited
+key proven untargeted by any production line item and A/A-validated
+before canary** (§0, §11), so it cannot perturb the metric it measures.
**Phase 0 decision records:** DR-1 mediator presence; DR-2 script
creatives (deployment decision); DR-3 #997 vs re-merge; DR-4
@@ -941,32 +1068,81 @@ Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3).
| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics |
| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity |
-## 10. Alternatives considered
-
-Revision 8's twelve rejections stand (patch-without-telemetry;
-always-direct-render; shared chunks now; big-bang rewrite; dropping the
-bootstrap; timeout fallback; timeout re-arm; N/N−1 machinery;
-client-computed hashes; fingerprint identities; plain cohort cookie;
-`billing_outcome`), plus: **13.** injected in-realm ADM reporter —
-rejected (hands bidder code the acceptance credential); owner-observed
-`load`/`error` instead. **14.** cookie-routed CSP affinity — rejected
-(opaque-origin reports carry no cookie); path-identity routing. **15.**
-token-identity alone for arm attribution — rejected (authorization is
-not a row dimension); arm-specific datasources + stamped union view.
-**16.** indefinite diagnostic renewal — rejected; `dexp` ceiling.
-**17.** shared attempt identity for parent/child — rejected; distinct
-`attempt_id`.
-
-## 11. Risks
-
-Revision 8's register stands (cutover blast radius; sticky-cohort
-infrastructure; mediator wire-contract change; published notification
-triggers; required-config startup errors; beacon abuse; memory bounds;
-advisory CSP; sink blindness; ABI freeze; schema generation), plus: the
-**observation-only control build** is new scoped work whose "zero
-behavior change" property is itself gated (hermetic parity vs baseline);
-and `assignment_id` persistence is pseudonymous but new — bounded by the
-24 h token TTL and excluded from any identity join by schema review.
+## 10. Alternatives considered (complete)
+
+1. **Patch APS point-failures without telemetry** — rejected: four correct
+ fixes produced no reliable ads; the next would be another guess.
+2. **Always direct-render APS** (skip GAM/PUC) — rejected: unilaterally
+ changes GAM reporting/pacing; kept only as the attributed-`gam_empty`
+ fallback.
+3. **Single module graph / shared chunks now** — rejected for this release:
+ changes the delivery pipeline while everything else changes; successor
+ option behind the same registry surface.
+4. **Full rewrite in one branch without phases** — rejected: the
+ browser-spec safety net is thinnest exactly where behavior changes.
+5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle
+ guarantee; the generated fallback keeps it.
+6. **Timeout-triggered fallback rendering** — rejected: uncancelable GPT
+ requests race late fills → double-render/double-bill.
+7. **Timeout-based quarantine re-arm** — rejected: recreates the
+ stale-event bug.
+8. **N/N−1 compatibility machinery** — removed by the §0 policy decision.
+9. **Client-computed notification hashes** — rejected: no key without
+ breaking the pseudonymization boundary; server-minted `notif_id`.
+10. **Fingerprint identities for id-less bids** — rejected: can merge
+ distinct demand; rejection with closed reasons instead.
+11. **Plain readable cohort cookie** — rejected: dark-pool opt-in +
+ cache-cardinality abuse; opaque authenticated token.
+12. **`billing_outcome` event** — removed: no honest post-accept producer.
+13. **Injected in-realm ADM reporter** — rejected: hands bidder code the
+ acceptance credential; owner-observed `load`/`error` instead.
+14. **Cookie-routed CSP affinity** — rejected: opaque-origin reports carry
+ no cookie; server-selected `policy_id` path identity.
+15. **Token-identity alone for arm attribution** — rejected: authorization
+ is not a queryable row dimension; the router injects a trusted internal
+ cohort header ingest stamps (§0).
+16. **Indefinite diagnostic renewal** — rejected: signed `dexp` ceiling.
+17. **Shared attempt identity for parent/child** — rejected: distinct
+ `attempt_id`.
+18. **24 h affinity for a multi-day experiment** — rejected: the affinity
+ lifetime now covers the maximum experiment window (§0).
+19. **`Image()` notification fallback** — rejected: it constructs a
+ separate credentialed/referrer-bearing request outside the primary
+ transport's privacy contract; a failed dispatch reports `failed`
+ instead (§G4d).
+
+## 11. Risks (complete register)
+
+- **Hard-cutover blast radius** — accepted by policy; bounded by the §0
+ runbook (probes, low-weight canary, monitored window, weight-back).
+- **Sticky-cohort routing is new infrastructure** the cutover depends on —
+ Phase 0 work; its coherence and affinity-lifetime tests are
+ release-gating.
+- **Mediator wire-contract change** (`candidate_id` echo) — DR-4 gates
+ `merge_highest_cpm`; config validation enforces the block.
+- **Published notification triggers** become a contract for PBS-path
+ demand — changing them later breaks SSP reporting.
+- **Required `[auction].currency`/`winner_selection`** in mediated
+ deployments are a deliberate startup-error class under §0.
+- **Beacon abuse** — pre-parse caps, per-family origin policies,
+ fail-closed numeric limits, signed modes, credentialed diagnostics.
+- **Registry/limiter memory** — explicit capacities, TTL reclamation,
+ reject-at-capacity; Fastly overshoot documented, not claimed.
+- **CSP data is advisory** — never a sole automatic rollback signal.
+- **Sink blindness** — per-datasource authenticated probes.
+- **ABI freeze** — `tsjs._internal.registry` is load-bearing; exact-release
+ verdicts are the contract.
+- **Schema generation** — checked-in artifacts + staleness CI.
+- **Observation-only control build** is new scoped work whose "zero
+ behavior change" property is itself gated by hermetic parity vs baseline
+ **and an A/A pre-canary** (the `ts_arm` GAM key must move no metric).
+- **`assignment_id` persistence** is pseudonymous but new — bounded by the
+ affinity token lifetime and excluded from any identity join by schema
+ review; it reaches telemetry only via the router-injected trusted header
+ (§0), never a client field.
+- **`ts_arm` GAM targeting key** could itself perturb line-item matching —
+ reserved and audited network-wide, proven untargeted by production line
+ items, and A/A-tested before canary (§8).
## 12. Success criteria
@@ -1024,29 +1200,29 @@ decision records. Low volume: inconclusive → extend once → Hold.
### A.1 Phase gates
-| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action |
-| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | --------------- | -------------------------------------------------- | ------------------------- | ----- | -------- |
-| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold |
-| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold |
-| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold |
-| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold |
-| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold |
-| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold |
-| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold |
-| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
-| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold |
-| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback |
-| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold |
-| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback |
-| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback |
-| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback |
-| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback |
-| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback |
-| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
-| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold |
-| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold |
-| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback |
-| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; `render_terminal{failed}` rate vs pre-cutover canary | — | lag ≤ 5 min; loss < 0.1%; failed ≤ canary + 0.5 pt | 24 h | OPS | Rollback |
+| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action |
+| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------- | ----- | -------- |
+| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold |
+| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold |
+| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold |
+| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold |
+| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold |
+| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold |
+| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold |
+| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
+| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold |
+| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback |
+| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold |
+| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback |
+| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback |
+| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback |
+| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback |
+| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback |
+| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
+| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold |
+| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold |
+| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback |
+| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; per-flow `render_terminal{failed}` rate vs pre-cutover canary | 10,000 attempts/flow | lag ≤ 5 min; loss < 0.1%; per-flow failed ≤ canary + 0.5 pt (flow-weighted; a below-floor flow holds, not passes) | 24 h | OPS | Rollback |
### A.2 Expected stages per flow
@@ -1060,16 +1236,17 @@ decision records. Low volume: inconclusive → extend once → Hold.
### A.3 Real-GAM suite (attested)
-| Field | Value |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
-| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** |
-| **Output** | **attested report embedding the manifest** — a green run attests the exact build it exercised, parameterized by (release, pool, deployment epoch) |
-| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession |
-| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) |
-| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
-| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
-| Command | `npx playwright test --config real-gam.config.ts` |
-| Artifact | Playwright HTML report + trace zips, retained 90 days |
-| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
-| Approval evidence | green attested run URL in the release checklist, signed off by RO |
+| Field | Value |
+| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
+| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** |
+| **Independent verification** | **the workflow does not trust its input**: it fetches the deployed pool's runtime `release_id`/`config_hash` from a trusted control-plane endpoint, hashes the actually-served bundle bytes, verifies the deploy-provider binary provenance and the pool/epoch, and **compares all of them to the manifest — any mismatch fails the run** (so a green run cannot claim manifest X while exercising deployment Y) |
+| **Output** | **OIDC-backed signed provenance** embedding the verified (release, pool, deployment epoch), not a caller-supplied echo |
+| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession |
+| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) |
+| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
+| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
+| Command | `npx playwright test --config real-gam.config.ts` |
+| Artifact | Playwright HTML report + trace zips, retained 90 days |
+| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
+| Approval evidence | green attested run URL in the release checklist, signed off by RO |
From 1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4 Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Wed, 5 Aug 2026 20:48:20 -0700
Subject: [PATCH 012/194] docs: finalize APS and TSJS resilience design
---
...8-04-aps-tsjs-resilience-implementation.md | 2737 +++++++++++
...s-render-fix-and-tsjs-resilience-design.md | 4359 ++++++++++++-----
2 files changed, 5859 insertions(+), 1237 deletions(-)
create mode 100644 docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md
diff --git a/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md
new file mode 100644
index 000000000..05c88d6cf
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-aps-tsjs-resilience-implementation.md
@@ -0,0 +1,2737 @@
+# APS Render Fix and TSJS Resilience Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> `superpowers:subagent-driven-development` (recommended) or
+> `superpowers:executing-plans` to implement this plan task-by-task. Steps use
+> checkbox (`- [ ]`) syntax for tracking. Use
+> `superpowers:test-driven-development` for each behavior change and
+> `superpowers:verification-before-completion` before claiming a phase or the plan
+> complete.
+
+**Goal:** make APS render deterministically across SSAT, Trusted Server Prebid,
+page-bids, direct auction, and fallback paths while replacing TSJS's duplicated
+global state with one bounded, lifecycle-owned runtime.
+
+**Architecture:** one composition root constructs a core runtime from injected
+adapters and services. Integration IIFEs register as release-matched integration
+modules, prepare inertly, and activate together behind one synchronous commit
+barrier. Rust emits one bounded tagged render-source union and one exact per-slot
+auction decision set. Universal Creative 1.17.2 supplies the outer response and
+owner-registration channels; the kernel owns control and APS document channels.
+Direct and PUC paths settle through the same terminal state machine.
+
+**Tech Stack:** Rust (`error-stack`, `http`, `serde`), lockfile TypeScript, Vitest,
+Playwright, GPT/Prebid test adapters, Viceroy, and the existing four runtime
+adapters.
+
+---
+
+**Source of truth:**
+`docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md`
+revision 27, frozen review SHA
+`6ed7fd4bafa31fe3a8112ad03ae5c600954d7568e6fef7ceabea5c9f8f94ab69`. This is the
+only implementation-plan document for the work. APS render
+and the runtime architecture are one coupled cutover: neither subsystem is useful or
+safe to release independently, so they remain in this one plan.
+
+## Scope guardrails
+
+- Do not change analytics, persistence, billing, experimentation, or deployment
+ routing. Those are separate specifications.
+- Do not add compatibility aliases, a runtime selector, or dual old/new protocols.
+ New construction is reachable only from test code until the coordinated switch.
+- Keep unrelated publisher integrations behaviorally unchanged. Their only planned
+ changes are thin integration-module registration and shared-runtime access.
+- Follow `CLAUDE.md`: use adapter-specific Cargo aliases, `error-stack`, `log`, and
+ descriptive `expect("should ...")`; never use bare `cargo test --workspace`.
+- Every task follows red → green → refactor: add the narrow failing test, run it to
+ prove the failure, implement the minimum complete contract, rerun focused tests,
+ then run the task's regression commands.
+- Do not create additional design or plan files. Implementation fixtures, schemas,
+ tests, and workflow edits listed below are implementation artifacts, not extra
+ plans.
+- Never check APS runner bytes, a runner version/digest/license/metadata record, SRI,
+ an updater, or an offline runner fallback into source, tests, evidence, or release
+ artifacts. The only positive runner route is the live fixed-target proxy at
+ `/integrations/aps/runner.js`; `/integrations/aps/runner/v1.js` is negative-only.
+ This plan defines no runner cache behavior.
+
+Every task ends with `git status --short`, focused verification, and one intentional
+commit before the next task. Stage only the exact paths from that task's **Files**
+list that the implementation changed; never use broad staging in a dirty worktree.
+Use the task title as the commit subject, normalized to the repository's conventional
+`test:`, `feat:`, `refactor:`, or `chore:` prefix. Task 19's coordinated production
+switch is one atomic commit; do not split it into deployable half-states.
+
+## Planned source shape
+
+The exact split may be adjusted during implementation only when it preserves these
+owners and dependency directions.
+
+```text
+crates/trusted-server-js/lib/src/
+ kernel/
+ identity.ts navigation-prefix + u64 attempts; 128-bit CSPRNG tickets/nonces
+ disposable.ts owned disposer stack and terminal latch primitives
+ integration_registry.ts release-matched prepare/activate transaction
+ runtime.ts bootstrap ownership and shared Runtime object
+ sessions.ts RuntimeSession and NavigationSession
+ adapters/
+ googletag.ts only GPT-global access
+ prebid.ts only Prebid-global access
+ messaging.ts exact/versioned global and MessagePort envelopes
+ services/
+ context.ts runtime-owned auction-context contributors
+ slots.ts SlotRecord indexes, request intents, physical cycles
+ projections.ts immutable navigation projection admission/commit
+ targeting.ts owner-aware GPT targeting journal and restoration
+ reservations.ts live renderer capabilities, WinnerContext, tombstones
+ render.ts RenderAttempt state machine and path drivers
+ auction_batch.ts shared fetch and child cancellation
+ integrations/
+ aps/render.ts descriptor validation and static-renderer client
+ gpt/index.ts thin GPT composition
+ prebid/index.ts thin Prebid composition
+ core/
+ index.ts final public API installation
+ request.ts input validation and AuctionBatch entry point
+ types.ts public and wire types
+ composition/
+ browser.ts sole construction root for concrete adapters and services
+```
+
+Test files mirror source ownership under `crates/trusted-server-js/lib/test/`.
+Do not make `kernel/` depend on `adapters/`, `services/`, or `integrations/`.
+Only `composition/` may import every layer and construct concrete dependencies.
+
+## Dependency order
+
+```text
+descriptor contract
+ -> server admission/mediation/projection
+ -> renderer endpoint
+
+kernel primitives
+ -> sessions + adapters
+ -> slot/reservation services
+ -> lifecycle + auction batch
+ -> GPT/Prebid/direct/fallback migration
+
+server path + browser path
+ -> hermetic browser matrix
+ -> real-GAM conformance
+ -> legacy deletion and hard cutover
+```
+
+## Coordinated-switch rule
+
+Tasks 1–18 build contracts, pure serializers/parsers, services, integration modules,
+and test-only composition without switching a shipped page onto an incompatible
+half-state. In particular:
+
+- Task 3 creates canonical projection/response serializers and parsers exercised
+ directly in tests; production `/auction`, initial HTML, and page-bids stay on their
+ current shapes until Task 19;
+- Task 5 builds the versioned handler and exercises adapter parity through test-only
+ registries while production dispatch remains unchanged until Task 19;
+- Task 8 computes and exposes release metadata but does not emit a required-integration
+ manifest or claim production bootstrap ownership;
+- Tasks 16–18 produce tested integration-module preparation/activation and composition without changing the
+ shipped entry-point side effects.
+
+Task 19 is the single coordinated production switch: initial HTML/page-bids, core,
+GPT, Prebid, APS, fallback, and every enabled integration begin using the new
+projection, manifest, versioned renderer, and integration-module runtime together. No runtime
+flag, deployment router, or operator-visible old/new selector is introduced. Task
+22 removes the now-unreachable old surfaces before any release candidate is built.
+Every task's regression suite therefore remains green in task order.
+
+## Phase 0 — pin the contract and baseline
+
+### Task 0: Establish the focused baseline and scope checks
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/package.json`
+- Modify: `crates/trusted-server-js/lib/tsconfig.json`
+- Modify: `crates/trusted-server-js/lib/build-all.mjs`
+- Create: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs`
+- Create: `crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json`
+- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts`
+- Modify: `scripts/integration-tests-browser.sh`
+- Create: `scripts/dispatch-workflow-run.mjs`
+- Create: `crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs`
+- Create: `crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs`
+- Modify: `.github/workflows/test.yml`
+- Modify: `.github/workflows/integration-tests.yml`
+- Test: existing Rust, Vitest, and APS Playwright suites
+
+- [ ] **Step 1: Write and run the failing executable adoption-manifest test.** Extract
+ the `rcjuly-tsjs-manifest-v1` JSON block from the revision-27 spec, enumerate
+ every `includeRoot` and exact file at
+ `905984e62a0858c53d9f0ff6dd3a1bf190cf311d` with `git ls-tree`, and fail for an
+ unmapped pinned file, a `lib/src` file mapped only to `RCJ-QUAL-01`, a dead
+ mapping, or a manifest/ledger id mismatch. Pin the expected audit result at 144
+ files, 38 mapping rows, and 23 ledger/manifest ids so a moving baseline cannot
+ enter implementation silently:
+
+ ```bash
+ node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs
+ ```
+
+ Expected before the script exists: FAIL. Expected after the minimal extractor and
+ checker: PASS with zero unmapped/dead/gap arrays. Add the same command to CI and run
+ it before every phase exit.
+
+- [ ] **Step 2: Record baseline results before behavior changes:**
+
+ ```bash
+ cargo test-fastly
+ cargo test-axum
+ cargo test-cloudflare
+ cargo test-spin
+ npm --prefix crates/trusted-server-js/lib ci
+ npm --prefix crates/trusted-server-js/lib test
+ npm --prefix crates/trusted-server-js/lib run build
+ ```
+
+ Any pre-existing failure is recorded in the execution notes; it is not silently
+ attributed to this work.
+
+- [ ] **Step 3: Keep the lockfile-resolved compiler; dependency upgrading is not part of this**
+ work. Add the checked-in `typecheck` script:
+
+ ```json
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ ```
+
+ Enable `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+ `verbatimModuleSyntax`, `noImplicitOverride`, and
+ `useUnknownInCatchVariables`. Add the CI invocation with the lockfile-resolved
+ compiler. Add both `typecheck` and the existing architectural `lint` command to
+ CI.
+
+- [ ] **Step 4: Before behavior changes, make bundle measurement deterministic and record the**
+ minimal/reference/maximal gzip and Brotli bytes, Node/npm/TypeScript versions,
+ Chromium version, CI machine class, fixture, five warmups, 50 samples, p90
+ boot-to-first-display, and forced-GC CDP heap checkpoints. Commit these values to
+ `aps-tsjs-prechange.json`; later tasks may compare against it but must not
+ regenerate it from the completed implementation.
+
+ Extend `scripts/integration-tests-browser.sh` with
+ `TS_BROWSER_FRAMEWORKS=nextjs` and use `npm --prefix ... exec -- playwright` for
+ argument-safe invocation. The script remains the clean-checkout fixture builder:
+ it builds release Fastly WASM with the integration environment, generates
+ Viceroy configuration, builds/loads the framework image, installs browser
+ dependencies, and builds both TSJS fixture variants. Run the new performance
+ test itself—not only the bundle script—and write its 50-sample/heap output to the
+ exact baseline path:
+
+ ```bash
+ TS_BROWSER_FRAMEWORKS=nextjs \
+ TSJS_PERF_MODE=baseline \
+ TSJS_PERF_OUTPUT=crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json \
+ ./scripts/integration-tests-browser.sh \
+ tests/shared/tsjs-performance.spec.ts --project=chromium
+ node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs --baseline-only
+ ```
+
+ The integration workflow runs the same focused command on its pinned CI image and
+ uploads the resulting JSON. Add required manual input `evidence_id` and include it
+ in `run-name`. `dispatch-workflow-run.mjs` validates that the ref is a pushed
+ branch/tag, dispatches with a unique evidence id, polls for exactly that run, and
+ prints its numeric run id. Record the successful id in the baseline JSON:
+
+ ```bash
+ TASK0_REF="$(git branch --show-current)"
+ TASK0_SHA="$(git rev-parse HEAD)"
+ test -n "$TASK0_REF"
+ git fetch origin "$TASK0_REF"
+ test "$TASK0_SHA" = "$(git rev-parse "origin/$TASK0_REF")"
+ TASK0_EVIDENCE_ID="aps-tsjs-baseline-$TASK0_SHA"
+ TASK0_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \
+ integration-tests.yml "$TASK0_REF" \
+ evidence_id="$TASK0_EVIDENCE_ID")"
+ gh run watch "$TASK0_RUN_ID" --exit-status
+ test "$TASK0_SHA" = "$(gh run view "$TASK0_RUN_ID" --json headSha --jq .headSha)"
+ ```
+
+- [ ] **Step 5: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ npm --prefix crates/trusted-server-js/lib run lint
+ node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs
+ test -s crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json
+ ```
+
+### Task 1: Make the APS descriptor a cross-language executable contract
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/auction/types.rs`
+- Modify: `crates/trusted-server-core/src/auction/formats.rs`
+- Modify: `crates/trusted-server-core/src/integrations/aps.rs`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts`
+- Modify: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json`
+- Create: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1-corpus.json`
+- Create: `crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.schema.json`
+- Create: `scripts/generate-aps-renderer-contract.mjs`
+- Create: `crates/trusted-server-core/src/integrations/generated/aps_renderer_validator_v1.js`
+- Create: `crates/trusted-server-js/lib/src/integrations/aps/generated/renderer_validator_v1.ts`
+- Create: `crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs`
+- Modify: `crates/trusted-server-js/lib/package.json`
+- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts`
+
+- [ ] **Step 1: Add failing corpus tests in Rust and Vitest for:**
+ - required versus optional exact keys;
+ - UTF-8 byte limits, not JavaScript character counts;
+ - exact numeric/integral dimensions at 0/1/4096/4097 and distinct
+ `invalid_dimensions` versus `dimensions_out_of_range` results;
+ - HTTPS URL, credentials, publisher-origin rejection, and URL byte limit;
+ - canonical standard base64 and decoded 256 KiB limit;
+ - UTF-8 decode failure and malformed JSON;
+ - exactly one seat/bid and exact nested keys;
+ - duplicated-field disagreement;
+ - nonfinite, negative, and wrong-type price;
+ - unknown descriptor version/type/tag type.
+
+- [ ] **Step 2: Run the focused tests and confirm at least one new adversarial vector fails in**
+ each implementation:
+
+ ```bash
+ cargo test-fastly aps_renderer
+ npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/aps/render.test.ts
+ ```
+
+- [ ] **Step 3: Add `generate:aps-contract` and `check:aps-contract` scripts. The generator reads**
+ the neutral schema/corpus and writes both named generated validator files. The
+ check runs the generator in staleness mode and fails on any diff. The Node test
+ executes the exact ES5 file embedded by `aps.rs` in a `vm` for every corpus
+ vector; it is not allowed to substitute the TypeScript validator.
+
+- [ ] **Step 4: Implement `BidRenderSourceV1`/the equivalent Rust enum with exactly `aps`, `adm`,**
+ and `cache` members. Ensure APS has only `ApsRendererV1`; delete alternate APS
+ `adm`, `meta`, or debug reconstruction. Align upstream and descriptor bid ids to
+ 1–64 UTF-8 bytes with no NUL/control. Define shared
+ `RENDER_DIMENSION_MIN = 1` and `RENDER_DIMENSION_MAX = 4096`; apply the same
+ noninteger/nonpositive versus out-of-range distinction in Rust, TS, generated
+ ES5, ADM/cache sources, programmatic sizes, and later DOM construction. No
+ validator or adapter may clamp.
+
+- [ ] **Step 5: Rerun the focused commands plus:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ npm --prefix crates/trusted-server-js/lib run lint
+ npm --prefix crates/trusted-server-js/lib run check:aps-contract
+ node --test crates/trusted-server-js/lib/test/contract/aps-renderer-es5.test.mjs
+ ```
+
+### Phase 0 exit
+
+- The baseline is known.
+- Strict TypeScript is a checked-in CI command.
+- Rust, TypeScript, and embedded ES5 agree on every APS descriptor vector.
+- No external observability or experiment artifact is planned or created.
+
+## Phase 1 — make the server APS path deterministic
+
+### Task 2: Harden APS admission and typed drop reasons
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/integrations/aps.rs`
+- Modify: `crates/trusted-server-core/src/auction/provider.rs`
+- Modify: `crates/trusted-server-core/src/auction/types.rs`
+- Modify: `crates/trusted-server-core/src/auction/formats.rs`
+- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs`
+
+- [ ] **Step 1: Add failing tests for missing, duplicate, and >64-byte upstream bid ids;**
+ malformed contextual data isolated to a bid when safe; missing/invalid
+ `creativeurl`; invalid `tagtype`; non-HTTPS/self-origin URL; exact 1–4096
+ dimension membership and reason mapping; default-off and enabled script
+ creatives; invalid/nonfinite price;
+ and one invalid sibling beside one valid bid.
+
+- [ ] **Step 2: Replace stringly ad-hoc reasons with one typed APS/auction drop-reason enum used**
+ exhaustively by provider response parsing and publisher debug projection. Do not
+ add a persistence or external-event failure reason.
+
+- [ ] **Step 3: Validate per bid before constructing `ApsRendererV1`. Preserve the exact accepted**
+ AAX bid in the encoded projection and cross-check it against the descriptor.
+
+- [ ] **Step 4: Treat a provider currency that violates the existing auction/provider contract as**
+ `invalid_provider_response`. Do not introduce a currency-specific public failure,
+ add or change auction configuration, add currency requirements, or alter non-APS
+ behavior. A slot with zero eligible providers is exactly
+ `failed{reason:'slot_not_eligible'}`.
+
+- [ ] **Step 5: Run:**
+
+ ```bash
+ cargo test-fastly integrations::aps
+ cargo test-fastly auction
+ cargo test-axum
+ ```
+
+### Task 3: Make per-slot outcomes explicit and mediation provenance-safe
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/auction/orchestrator.rs`
+- Modify: `crates/trusted-server-core/src/auction/types.rs`
+- Modify: `crates/trusted-server-core/src/auction/formats.rs`
+- Modify: `crates/trusted-server-core/src/auction/provider.rs`
+- Modify: `crates/trusted-server-core/src/auction/endpoints.rs`
+- Modify: `crates/trusted-server-core/src/integrations/adserver_mock.rs`
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/auction.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts`
+
+- [ ] **Step 1: Add failing server and TS parser tests proving every requested slot receives**
+ exactly one ordered `winner | no_bid | failed` decision. Cover provider launch,
+ transport, timeout, HTTP, parse, per-bid validation, mediation failure, selected
+ winner projection failure, one provider failure beside another provider winner,
+ all-provider no-bid, partial slots, duplicate/extra/missing decisions, and the
+ exact closed failure priority from the spec, including the direct
+ `identity_generation_failed` wire result.
+
+ Add exact `BrowserAuctionProjectionV1` boundary cases: 0/256/257 results and bids;
+ auction id `^[A-Za-z0-9._:-]{1,128}$`, exact 12-character base64url candidate,
+ provider `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, upstream id 1–64 UTF-8 bytes,
+ slot 1–256 UTF-8 bytes with no NUL/control, exact unique `r1_` reservation, finite
+ nonnegative CPM, literal `USD`; targeting 31/32/33 entries, exact
+ `[A-Za-z0-9_]{1,20}` keys at 19/20/21 characters,
+ 39/40/41-scalar and 159/160/161-byte values, control/unpaired-surrogate rejection,
+ reserved `hb_adid`, duplicate joins, and accessors/prototypes. Measure canonical
+ schema-order/request-order/lexically-sorted-targeting JSON just below/at/above
+ `8 * 1024 * 1024` bytes.
+
+- [ ] **Step 2: Normalize every provider response to exactly one internal `ProviderSlotOutcome`**
+ per dispatched slot. Produce `AuctionDecisionSetV1` once in the orchestrator.
+ Add pure serializers/parsers for `/auction`
+ `ext.trusted_server.slot_results` and `BrowserAuctionProjectionV1`, exercised by
+ direct unit tests while preserving the current internal return API. Do not yet
+ wire them into production `/auction`, initial HTML, or page-bids; their exact
+ coordinated switch occurs in Task 19. A winner must join exactly one projected
+ bid by exact slot plus candidate id; no-bid/failed joins none.
+
+ When a complete canonical projection exceeds 8 MiB, transactionally convert every
+ winner decision to `failed{reason:'winner_not_renderable'}`, emit zero projected TS
+ winner bids, retain existing no-bid/failed decisions in request order, and remove
+ the corresponding `/auction` TS `seatbid` entries. Never choose a first-fit subset
+ or emit a partial projection. Prove the reduced result is bounded and deterministic
+ under response-order permutations.
+
+- [ ] **Step 3: Introduce internal provenance `(provider_name, upstream_bid_id)` and a**
+ response-unique opaque 12-character base64url `candidate_id` from 9 CSPRNG bytes;
+ test eight response-local collision retries and terminal `internal_error`. Store
+ candidates before mediation; put the id only at
+ `ext.trusted_server.candidate_id`; require the mock/configured mediator to echo
+ exactly one known id. Take only the selected price/selection metadata from
+ mediation and restore every render, identity, dimension, currency, and
+ notification field from the stored source candidate. Reject missing/unknown/
+ duplicate echoes, substitutions, and mediator-native render sources.
+
+- [ ] **Step 4: Preserve the repository's configured mediator selection and timeout fallback.**
+ Preserve direct highest-CPM selection, adding only the deterministic
+ `(provider_name, upstream_bid_id)` tie break. Add response-order permutation tests
+ and prove opaque ids/arrival order never break ties.
+
+- [ ] **Step 5: Define and test the exact `/auction` winner wire. Standard `bid.id` is the**
+ `r1_` renderer reservation; `bid.impid` maps exactly to the server slot; and
+ `bid.ext.trusted_server` has only `candidate_id`, `slot_id`, and
+ `render_source`. Require the four-way decision/candidate/impid/slot join. Reject
+ missing, duplicate, extra, or mismatched bids; APS/cache standard `adm`; and ADM
+ disagreement between standard `adm` and `render_source.adm`. TSJS renders only
+ the tagged source and never reconstructs it from standard fields.
+
+ The producer and parser must both deny unknown keys and enforce the same projection
+ field/count/byte grammars before any slot, reservation, targeting, or bid mutation.
+ Boot maps malformed/oversized input to `abi_mismatch`; page-bids/direct admission
+ maps it to `invalid_response` with no partial state.
+
+- [ ] **Step 6: Do not modify `auction_config_types.rs`, `auction/config.rs`, settings, TOML, or**
+ auction documentation unless compilation reveals an existing type reference that
+ must be renamed for the tagged render-source union. This task adds no new
+ `winner_selection`, currency, or mediator-fallback requirement.
+
+- [ ] **Step 7: Run:**
+
+ ```bash
+ cargo test-fastly orchestrator
+ cargo test-fastly formats
+ cargo test-fastly publisher
+ cargo test-axum
+ npm --prefix crates/trusted-server-js/lib test -- --run test/core/auction.test.ts
+ ```
+
+### Task 4: Project one tagged render source and exact renderer identity
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+- Modify: `crates/trusted-server-core/src/auction/formats.rs`
+- Modify: `crates/trusted-server-core/src/auction/types.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt.rs`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/config.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts`
+
+- [ ] **Step 1: Add failing server tests for SSAT boot bids, page-bids, and `/auction`. Each must**
+ preserve the exact `BidRenderSourceV1`, `candidateId`, and server-minted renderer
+ reservation plus the finite nonnegative selected CPM that later becomes the
+ internal `WinnerContext`. Browser projection exposes `rendererReservationId`;
+ `/auction` uses the same value as standard OpenRTB `bid.id`. CPM is never copied
+ into a render descriptor or capability.
+
+- [ ] **Step 2: Add failing browser tests proving every TS-owned APS, ADM, and cache PUC source**
+ uses the `r1_` reservation byte-for-byte as GAM `hb_adid`. For Trusted Server
+ Prebid, replace the generated TS bid `adId` with that same reservation before
+ targeting; keep native Prebid `adId` untouched. Preserve PBS Cache UUID only as
+ `renderSource.cacheId` and the exact fetch query binding, never as bridge
+ authority. Add negative tests for truncation, fallback to upstream/cache ids, and
+ native-bid mutation.
+
+- [ ] **Step 3: Add Rust generation tests for `r1_` plus 22 unpadded base64url characters from**
+ 16 CSPRNG bytes, response-local uniqueness, eight collision retries, and
+ `identity_generation_failed`. Make projection choose identity from a tagged
+ enum/path decision, not an `or_else` chain. Reject invalid targeting before
+ serialization; never truncate.
+
+- [ ] **Step 4: Add the immutable `CacheFetchPolicyV1` boot contract. When cache rendering is**
+ enabled, project the trusted configured HTTPS base URL at
+ `tsjs.boot.cachePolicy`, validate/freeze it before integration-module preparation, and build
+ `fetchUrl` server-side with exactly one canonical `uuid` query. Test credentials,
+ query, fragment, origin/port/path mismatches, duplicate query keys, missing policy,
+ and configuration mutation after the navigation snapshot. This is cache render
+ correctness only; do not add a cache subsystem or runner caching.
+
+- [ ] **Step 5: Limit this task to wire projection and pre-mutation validation. Attempt-owned**
+ compare-and-restore targeting cleanup is implemented only after `RenderAttempt`
+ exists in Tasks 13 and 16.
+
+ This task does not publish the new initial/page-bid projection or mutate a live
+ Prebid/GPT bid. Those production mutations begin atomically in Task 19 after the
+ reservation store and integration-module runtime exist.
+
+- [ ] **Step 6: Run:**
+
+ ```bash
+ cargo test-fastly publisher
+ npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt/ad_init.test.ts test/integrations/prebid/index.test.ts
+ ```
+
+### Task 5: Serve the static renderer and live APS runner proxy with adapter parity
+
+**Files:**
+
+- Modify: `crates/trusted-server-core/src/integrations/aps.rs`
+- Modify: `crates/trusted-server-core/src/integrations/registry.rs`
+- Modify: `crates/trusted-server-core/src/platform/http.rs`
+- Modify: `crates/trusted-server-core/src/platform/test_support.rs`
+- Modify: `crates/trusted-server-core/src/platform/types.rs`
+- Modify: `crates/trusted-server-adapter-fastly/src/app.rs`
+- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs`
+- Modify: `crates/trusted-server-adapter-fastly/Cargo.toml`
+- Modify: `crates/trusted-server-adapter-axum/src/app.rs`
+- Modify: `crates/trusted-server-adapter-axum/src/platform.rs`
+- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs`
+- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs`
+- Modify: `crates/trusted-server-adapter-cloudflare/src/platform.rs`
+- Modify: `crates/trusted-server-adapter-cloudflare/Cargo.toml`
+- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs`
+- Create: `crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml`
+- Modify: `crates/trusted-server-adapter-spin/src/app.rs`
+- Modify: `crates/trusted-server-adapter-spin/src/platform.rs`
+- Modify: `crates/trusted-server-adapter-spin/Cargo.toml`
+- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs`
+- Create: `crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml`
+- Create: `crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml`
+- Modify: `crates/trusted-server-integration-tests/Cargo.toml`
+- Create: `crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs`
+- Create: `crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/common/mod.rs`
+- Create: `crates/trusted-server-integration-tests/tests/environments/spin.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/environments/mod.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/environments/cloudflare.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/environments/fastly.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/parity.rs`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts`
+- Create: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js`
+- Create: `scripts/integration-tests-aps-runner-proxy.sh`
+- Modify: `scripts/integration-tests-browser.sh`
+- Modify: `.github/workflows/integration-tests.yml`
+
+- [ ] **Step 1: Write failing route and exact renderer-policy tests.**
+
+ Cover enabled `GET /integrations/aps/renderer/v1` and
+ `GET /integrations/aps/runner.js`; APS-disabled local `404 no-store`; local
+ negative `404 no-store` for `/integrations/aps/runner/v1.js`, unknown renderer
+ versions, and malformed family paths; `405` plus `Allow: GET`; and proof that no
+ reserved path reaches publisher auth, EC, or fallback. Assert renderer body bytes,
+ the exact ordered sandbox tokens, the exact CSP from spec §3.6, exact content type,
+ immutable cache policy, `nosniff`, and referrer policy. Assert the deliberate
+ absence of `X-Frame-Options` and CSP `frame-ancestors`.
+
+- [ ] **Step 2: Run the new focused tests and prove they fail.**
+
+ ```bash
+ cargo test-fastly integrations::aps
+ cargo test-axum --test routes
+ cargo test-cloudflare --test routes
+ cargo test-spin --test routes
+ ```
+
+ Expected: the live runner route/raw proxy policy and exact renderer headers are not
+ yet implemented on every adapter.
+
+- [ ] **Step 3: Define the bounded raw-proxy platform contract.**
+
+ Add a dedicated request/response policy in `platform/http.rs` and adapter
+ implementations that:
+ - sends only credential-free `GET` to the compile-time fixed URL
+ `https://client.aps.amazon-adsystem.com/prebid-creative.js` with
+ `Accept-Encoding: identity`, no forwarded browser/publisher headers, no referrer,
+ and redirect following disabled;
+ - exposes `ProxyResponseEvidenceV1`: status plus every occurrence of the three
+ security-relevant headers when the runtime preserves pairs, or the exact visibly
+ combined value when it does not. Combined values are never split and every
+ comma/list form fails the singleton grammar; erased/ambiguous evidence is
+ `unavailable`, never reconstructed;
+ - returns a bounded stream or bounded buffer with a five-second monotonic deadline
+ from dispatch through the final body byte, cancellation on timeout/overflow, and
+ generation-inert late continuations; and
+ - uses the common `APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` cap.
+
+ Cloudflare must inspect the initial Workers headers before its generic adapter
+ strips encoding/length; concatenated duplicates remain visibly combined and fail.
+ Spin must bypass `spin_sdk::http::send`, call the WASI HTTP outgoing handler with
+ supported request options, and poll both response and body stream against a
+ monotonic-clock total deadline. Axum and Fastly must enforce the same deadline,
+ evidence, redirect, encoding, and cap contract instead of their generic client
+ defaults. If a runtime cannot supply the required evidence or cancellation
+ behavior, APS cannot be enabled there and the release is blocked.
+
+- [ ] **Step 4: Write and pass the complete actual-adapter proxy corpus.**
+
+ Drive each real transport boundary—including Cloudflare and Spin wasm and full
+ Fastly routes—against a controlled fictional upstream. Cover status other than
+ 200; redirects; stall and slow-drip total deadlines; late data after cancellation;
+ absent/duplicate/malformed/mismatched/over-limit `Content-Length`;
+ absent/identity/listed/other `Content-Encoding`; missing/duplicate/parameterized/
+ rejected `Content-Type`; invalid UTF-8; exactly-at and one-byte-over 8 MiB bodies;
+ buffered and streamed overflow; byte-preserving success; stripped upstream
+ cookies/headers; and empty non-leaking `502 no-store` failures. Do not inject an
+ already-normalized core response in place of an adapter transport.
+
+ Build a hermetic `aps_runner_proxy` runtime test, not a core fake. Its private
+ control socket selects the next fictional upstream response out-of-band; browser
+ requests cannot choose a scenario or target. The request entering core always
+ carries the compile-time APS logical URL. An integration-test-only platform
+ resolver maps only that exact logical origin to the loopback fixture below target
+ validation and immediately above the real adapter transport. Fastly uses a
+ generated Viceroy backend, Cloudflare uses a workerd service binding in the
+ dedicated Wrangler manifest, Spin uses the dedicated Spin manifest, and Axum uses
+ its real bounded client. Production constructors expose no resolver/override, and
+ release-build absence tests fail if the integration feature, fixture address, or
+ service binding is enabled or embedded.
+
+ `scripts/integration-tests-aps-runner-proxy.sh` builds the actual Fastly
+ `wasm32-wasip1`, Cloudflare `wasm32-unknown-unknown`, and Spin `wasm32-wasip1`
+ artifacts with that integration-only transport seam; launches Viceroy,
+ `wrangler dev`/workerd, and `spin up`; waits for readiness; runs the identical
+ `aps_runner_proxy` corpus against each runtime with `--test-threads=1`; and always
+ terminates process groups. The test asserts the logical URL and Host remain the
+ fixed APS target, the fixture is loopback-only, and actual runtime header
+ normalization, cancellation/resource drop, streaming cap, and
+ dispatch-through-final-byte clock produce the expected response.
+
+- [ ] **Step 5: Implement the reserved dispatcher and live proxy response.**
+
+ Register the family ahead of auth/EC/fallback through one explicit test-only
+ registry constructor used by unit tests and the dedicated integration artifacts.
+ `scripts/integration-tests-browser.sh` accepts `TS_TEST_APS_V1=1` only to build that
+ non-release artifact; ordinary production dispatch stays unchanged until Task 19.
+ CI/release absence checks reject the integration feature/sentinel in a production
+ bundle, so this cannot become a hidden dual route.
+ Accept only status 200, the exact closed content-type/encoding/content-length
+ grammars from spec §3.6, a body within 8 MiB, and exact UTF-8 bytes. Relay accepted
+ bytes unchanged while replacing all headers with exactly the specified
+ JavaScript content type, wildcard CORS, cross-origin CORP, `nosniff`, and
+ no-referrer policy. Every upstream or validation failure returns a local empty
+ `502 no-store`, with no vendor body or descriptor/capability data in logs.
+
+- [ ] **Step 6: Implement and test the static renderer contract.**
+
+ The renderer validates/clears the fragment nonce, accepts one exact source-bound
+ parent port, validates the descriptor and kernel-captured publisher origin, and
+ resolves only its own absolute `/integrations/aps/runner.js`. Before loading the
+ script, queue the exact `prebid/creative/render` event with one-shot
+ `resolve`/`reject`. Set only `crossOrigin='anonymous'` and
+ `referrerPolicy='no-referrer'`; do not set integrity/SRI. Script `load` is
+ nonterminal progress. Proxy/CORS/script-load failure reports `runner_no_load`;
+ callback rejection reports `runner_failed`; callback success reports completion.
+ The renderer starts no completion timer—the kernel owns the only ten-second timer
+ from document acceptance. Mutable APS callback correctness is an accepted external
+ trust dependency, not a fact TS can derive from script load or body inspection.
+
+- [ ] **Step 7: Add the hermetic fictional runner fixture.**
+
+ Author a minimal local fixture that implements only the documented event and
+ queue/resolve/reject behavior. Assert it is neither a copy, transformation, nor
+ derivative of APS bytes. Use it for deterministic success, rejection, script-load,
+ callback-silence, nested-iframe, and duplicate-callback tests. The fixture is not
+ served as a production fallback and cannot be included in release bundles.
+
+- [ ] **Step 8: Run the full route, transport, parity, and browser checks.**
+
+ ```bash
+ cargo test-fastly
+ cargo test-axum
+ cargo test-cloudflare
+ cargo test-spin
+ cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin
+ TS_TEST_APS_V1=1 TS_BROWSER_FRAMEWORKS=nextjs TS_BROWSER_PROJECTS=chromium \
+ ./scripts/integration-tests-browser.sh \
+ tests/shared/aps-renderer.spec.ts --project=chromium
+ ```
+
+- [ ] **Step 9: Commit the transport and renderer slice.**
+
+ ```bash
+ git add \
+ crates/trusted-server-core/src/integrations/aps.rs \
+ crates/trusted-server-core/src/integrations/registry.rs \
+ crates/trusted-server-core/src/platform/http.rs \
+ crates/trusted-server-core/src/platform/test_support.rs \
+ crates/trusted-server-core/src/platform/types.rs \
+ crates/trusted-server-adapter-fastly/src/app.rs \
+ crates/trusted-server-adapter-fastly/src/platform.rs \
+ crates/trusted-server-adapter-fastly/Cargo.toml \
+ crates/trusted-server-adapter-axum/src/app.rs \
+ crates/trusted-server-adapter-axum/src/platform.rs \
+ crates/trusted-server-adapter-axum/tests/routes.rs \
+ crates/trusted-server-adapter-cloudflare/src/app.rs \
+ crates/trusted-server-adapter-cloudflare/src/platform.rs \
+ crates/trusted-server-adapter-cloudflare/Cargo.toml \
+ crates/trusted-server-adapter-cloudflare/tests/routes.rs \
+ crates/trusted-server-adapter-cloudflare/wrangler.aps-runner-proxy.toml \
+ crates/trusted-server-adapter-spin/src/app.rs \
+ crates/trusted-server-adapter-spin/src/platform.rs \
+ crates/trusted-server-adapter-spin/Cargo.toml \
+ crates/trusted-server-adapter-spin/tests/routes.rs \
+ crates/trusted-server-integration-tests/Cargo.toml \
+ crates/trusted-server-integration-tests/fixtures/configs/spin-aps-runner-proxy.toml \
+ crates/trusted-server-integration-tests/fixtures/configs/viceroy-aps-runner-proxy-template.toml \
+ crates/trusted-server-integration-tests/tests/aps_runner_proxy.rs \
+ crates/trusted-server-integration-tests/tests/common/aps_runner_upstream.rs \
+ crates/trusted-server-integration-tests/tests/common/mod.rs \
+ crates/trusted-server-integration-tests/tests/environments/spin.rs \
+ crates/trusted-server-integration-tests/tests/environments/mod.rs \
+ crates/trusted-server-integration-tests/tests/environments/cloudflare.rs \
+ crates/trusted-server-integration-tests/tests/environments/fastly.rs \
+ crates/trusted-server-integration-tests/tests/parity.rs \
+ crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts \
+ crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js \
+ scripts/integration-tests-aps-runner-proxy.sh \
+ scripts/integration-tests-browser.sh \
+ .github/workflows/integration-tests.yml
+ git commit -m "feat(aps): proxy the live creative runner safely"
+ ```
+
+### Phase 1 exit
+
+- Valid APS bids survive mediation and projection without identity loss.
+- Invalid bids fail with exact local reasons.
+- Test-only adapter registries prove the same secure static renderer and live runner
+ proxy behavior through every actual transport without activating a dual production
+ route.
+- Existing non-APS auction and publisher tests remain green.
+
+## Phase 2 — build one TSJS runtime
+
+### Task 6: Enforce layering and external-global ownership
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/eslint.config.js`
+- Modify: `crates/trusted-server-js/lib/package.json`
+- Create: `crates/trusted-server-js/lib/eslint-rules/no-adtech-globals.js`
+- Create: `crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs`
+- Create: `crates/trusted-server-js/lib/src/adapters/googletag.ts`
+- Create: `crates/trusted-server-js/lib/src/adapters/prebid.ts`
+- Create: `crates/trusted-server-js/lib/src/adapters/messaging.ts`
+- Create: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Create: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing lint-rule tests for direct and aliased access through `window`,**
+ `globalThis`, and `self`, plus aliases of `googletag`/`pbjs`. Add allowed cases
+ inside adapters and kernel `window.tsjs`/messaging code.
+
+- [ ] **Step 2: Configure `import/no-restricted-paths` for the dependency direction in the**
+ source-shape diagram. Add a narrow, enumerated temporary allowlist for current
+ production files that still violate the target (`core/request.ts`, GPT/Prebid
+ integration files, and diagnostics files found by the initial lint inventory).
+ New files receive no exemption. Check the allowlist into the lint test and make
+ Task 22 fail if any entry remains.
+
+- [ ] **Step 3: Add adapter interfaces and no-op/fake constructors before moving behavior. Add a**
+ composition root that alone imports concrete adapters/services. Kernel files
+ accept interfaces and never construct downstream objects. At this task's end
+ production remains behaviorally unchanged, while new kernel/service files cannot
+ touch GPT or Prebid globals.
+
+ The messaging interface must support synchronous capture-phase listener
+ installation. Reserve that hook as the first reversible core activation so the
+ final core can install the TS capability recognizer before integration-module
+ activation and before TS-owned GPT/Prebid injection, while leaving no listener live
+ during asynchronous preparation.
+
+- [ ] **Step 4: Ensure `.github/workflows/format.yml` continues to run lint and**
+ `.github/workflows/test.yml` runs typecheck. Enforcement begins with the narrow
+ allowlist and becomes repository-clean in Task 22.
+
+- [ ] **Step 5: Run:**
+
+ ```bash
+ node --test crates/trusted-server-js/lib/test/eslint/no-adtech-globals.test.mjs
+ npm --prefix crates/trusted-server-js/lib run lint
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Task 7: Implement disposal, terminal latch, and transactional integration modules
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/kernel/disposable.ts`
+- Create: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts`
+- Create: `crates/trusted-server-js/lib/test/kernel/disposable.test.ts`
+- Create: `crates/trusted-server-js/lib/test/kernel/integration_registry.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+
+- [ ] **Step 1: Write failing fake-timer tests for reverse-order exactly-once disposal, disposer**
+ failure isolation, registration after disposal, first-terminal-wins, duplicate
+ integration id, malformed/unknown manifest id, wrong 64-hex release, pending
+ capacity 16, prepare throw/async rejection/abort, detached continuation,
+ activation throw, duplicate `afterCommit`, shared deadline abort, and late
+ continuation.
+
+- [ ] **Step 2: Implement `DisposableStack` without relying on a browser proposal unavailable at**
+ the configured target. It must be synchronous at ownership boundaries; async side
+ work can observe its signal but cannot delay terminal disposal.
+
+- [ ] **Step 3: Implement the exact `BootManifestV1` contract and release-internal**
+ `_registerIntegration({id,release,prepare})` collection. All manifest entries are
+ required. Registration executes no module code. In manifest order, core awaits
+ only each returned preparation Promise; preparation may validate frozen config,
+ acquire interfaces, allocate inert private data, and register private-memory
+ disposers, but cannot touch globals/DOM, attach wrappers/listeners, inject/load,
+ start timers/network, invoke publisher code/stateful adapters, or detach work.
+ Quarantine wrong-release, unexpected, duplicate, and post-fallback bundles
+ before calling `prepare`.
+
+- [ ] **Step 4: Implement the synchronous activation barrier.** A prepared module
+ returns one synchronous `activate(ctx)`. Activation may install only
+ compare-restorable wrappers/listeners/observers/subscriptions, registering each
+ disposer before mutation. It may stage at most one `afterCommit` callback and
+ cannot yield, inject/load, start timers/network, drain a queue, or invoke
+ publisher callbacks. Activate in manifest order; on throw, unwind every
+ activated/prepared module in reverse order before fallback. Check the shared
+ monotonic deadline before and after every activation and immediately before
+ handoff: 9,999 ms may commit; 10,000/10,001 ms must unwind even if the timer task
+ has not run. Document/test that a permanently nonreturning same-thread function
+ is unpreemptable. A second `afterCommit` registration throws and becomes
+ `bundle_partial`.
+
+- [ ] **Step 5: Test post-commit behavior.** After all activations, publish the full
+ API, invoke staged callbacks in manifest order, then drain preload work. An
+ `afterCommit` throw disposes only that module, records a bounded local runtime
+ failure, and cannot roll back the kernel or create fallback. Race publisher GPT
+ calls and script/creative DOM activity before, during, and after a later module
+ failure; prove preparation is inert, activation is same-task, and no wrapper,
+ guard, script, request, listener, or timer survives a fallback commit.
+
+- [ ] **Step 6: Expose only frozen interfaces; keep mutable maps and owner tokens in closures.**
+
+- [ ] **Step 7: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/disposable.test.ts test/kernel/integration_registry.test.ts
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Task 8: Implement bootstrap ownership and the single runtime registry, dormant
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/kernel/runtime.ts`
+- Create: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/queue.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/log.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts`
+- Create: `crates/trusted-server-js/lib/test/core/queue.test.ts`
+- Create: `crates/trusted-server-js/lib/test/core/log.test.ts`
+- Create: `crates/trusted-server-js/lib/src/integrations/gpt/bootstrap_fallback.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts`
+- Modify: `crates/trusted-server-core/src/integrations/gpt.rs`
+- Modify: `crates/trusted-server-core/src/tsjs.rs`
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+- Modify: `crates/trusted-server-js/lib/build-all.mjs`
+- Modify: `crates/trusted-server-js/lib/package.json`
+- Create: `crates/trusted-server-js/lib/scripts/print-release-id.mjs`
+- Modify: `crates/trusted-server-js/build.rs`
+- Modify: `crates/trusted-server-js/src/bundle.rs`
+- Modify: `crates/trusted-server-js/src/lib.rs`
+
+- [ ] **Step 1: Add failing tests for field-wise initialization, existing publisher queue/config,**
+ two core loads, invalid manifests, wrong/duplicate/unknown integrations,
+ prepare/activate/after-commit failure at every checkpoint, a missing or hung
+ integration, the shared watchdog and monotonic activation checks,
+ owner-generation mismatch, late continuation after fallback, bundle after
+ fallback, and exactly-one committed owner. At each failure checkpoint assert the
+ exact immutable `abi_mismatch | bundle_partial` reason; FIFO draining despite a
+ throwing callback; queued and later `requestAds`; already-aborted signals; late
+ integration refusal without invoking module code; and zero runtime/service/adapter/
+ listener/timer/port/iframe construction after the fallback commits.
+
+ Exercise the queue boundary as a real Array: pushes before/during/at activation and
+ commit, retained ingress references, snapshot-versus-forward exactly once, nested
+ push ordering, `this === tsjs`, throw isolation, and non-callables. The final queue
+ has `length:0`, its own immediate `push`, and is frozen; native/borrowed mutators,
+ index/length assignment, deletion, and property definition in strict/sloppy callers
+ cannot retain work or change length.
+
+- [ ] **Step 2: Implement `unclaimed → installing → kernel` and**
+ `installing → failed → fallback`. Start the only ten-second watchdog immediately
+ before core injection; it covers registration, preparation, and activation for
+ every required integration. Combine the timer with `performance.now()`/the
+ injected monotonic clock checks before/after every activation and before
+ handoff. Abort completes synchronous reverse-order unwind before fallback.
+ Deferred bundles after fallback are rejected and cannot replace that generation.
+ Exercise this through the test-only composition harness; do not yet replace the
+ production bootstrap.
+
+ Normalize `tsjs.que` to an actual ingress Array with a writable-false,
+ configurable-true property during preparation. After successful activation (or
+ after unwind for fallback), perform the spec §5.3 six-step synchronous handoff:
+ construct/freeze the final real Array, snapshot callable own data entries, clear
+ ingress and replace its `push` with a forwarder, redefine the public property
+ non-writable/non-configurable with all committed fields, run kernel `afterCommit`
+ callbacks, then drain the snapshot. No task/microtask may split the steps.
+
+ The fallback is one terminal non-rendering shell, never a reduced runtime. Before
+ draining the queue it installs the final `requestAds` validator, immediate queue,
+ permanently refusing `_registerIntegration`, exact `version:'1.0.0'`/`releaseId`,
+ validating-then-refusing `addAdUnits`, local logger, immutable safe boot value, and
+ frozen non-enumerable `_internal` fallback record. It
+ constructs no runtime, registry, adapter, bridge, timer, listener, port, or
+ iframe. Batch membership comes only from exact server slot ids in the immutable
+ boot auction projection. Retain it only when the full §3.1–3.2 shape, grammar,
+ 256-slot, dimension, and 8 MiB bounds pass; otherwise substitute exactly
+ `{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}` plus
+ creative/diagnostics disabled safe defaults. Known members resolve with the boot failure, unknown ids
+ resolve `slot_unresolved`, aborted known members cancel, and an omitted empty
+ projection resolves `slots:[]`. No valid call remains pending.
+
+- [ ] **Step 3: Make `build-all.mjs` emit all bundles with one fixed sentinel, compute 64**
+ lowercase SHA-256 hex over the canonical ordered ids plus sentinel-normalized
+ bytes, replace exactly one sentinel per bundle, and verify none remains. Embed
+ that release id in every bundle. Write exact generated
+ `crates/trusted-server-js/dist/tsjs-release-v1.json` with
+ `{version:1,releaseId,bundles:[{id,file}]}` in canonical bundle order; add
+ `npm run --silent print:release-id` to validate that file and print only its
+ 64-hex id. Extend `build.rs` generated metadata to include
+ the sentinel-normalized all-bundle `release_id`; expose it through `bundle.rs` and
+ the crate API beside bundle bytes/content hashes. Add Rust tests proving generated
+ metadata and every bundle carry the same id. Implement the pure
+ `BootManifestV1` serializer with exactly the enabled unique integration ids in actual
+ injection order and `required:true`, but do not emit it into production HTML yet.
+ Test changed logical bytes, reordered integrations, sentinel multiplicity, wrong
+ release, missing integration, and server/bundle disagreement.
+
+- [ ] **Step 4: Install one `Runtime`; the composition root keeps the mutable service**
+ registry and owner tokens in its closure, and integrations obtain frozen
+ interfaces only through exact-release preparation/activation contexts.
+ `tsjs._internal` is non-enumerable frozen status data only—never the registry.
+
+ In test-only composition, activate the capture-phase bridge recognizer as the first
+ reversible core effect, followed by correctness GPT listeners and prepared modules.
+ Production bootstrap and manifest emission remain unchanged until Task 19.
+
+- [ ] **Step 5: Generate the proposed queue/boot-flags fallback artifact from**
+ `bootstrap_fallback.ts` and test its bytes and terminal behavior at every
+ checkpoint. Task 19 performs the one production replacement and adds the final
+ embedded staleness assertion; there is never a hand-maintained second fallback
+ implementation.
+
+- [ ] **Step 6: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/runtime.test.ts test/integrations/gpt/gpt_bootstrap.test.ts
+ cargo test-fastly release_id
+ cargo test-fastly
+ npm --prefix crates/trusted-server-js/lib run build
+ ```
+
+### Task 9: Add runtime and navigation sessions
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/kernel/sessions.ts`
+- Create: `crates/trusted-server-js/lib/src/kernel/identity.ts`
+- Create: `crates/trusted-server-js/lib/test/kernel/sessions.test.ts`
+- Create: `crates/trusted-server-js/lib/test/kernel/identity.test.ts`
+- Create: `crates/trusted-server-js/lib/src/services/projections.ts`
+- Create: `crates/trusted-server-js/lib/test/services/projections.test.ts`
+- Create: `crates/trusted-server-js/lib/src/services/context.ts`
+- Create: `crates/trusted-server-js/lib/test/services/context.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing tests for RuntimeSession singleton ownership, navigation replacement,**
+ reverse-order disposal inventory, late old-generation callbacks, same DOM id on a
+ new navigation, timer/listener/port cleanup, and double disposal.
+
+ Add deterministic-crypto issuer tests proving one `NavigationSession` obtains an
+ eight-byte CSPRNG prefix and combines it with one big-endian unsigned 64-bit ordinal
+ stored as two u32 words. Each `a1_` encodes those 16 bytes as 22 unpadded base64url
+ characters; ordinal increments exactly once, never wraps, and needs no issued-id
+ history. Unavailable/throwing crypto or ordinal exhaustion fails
+ `identity_generation_failed` before creating work. Separately prove `t1_` and `n1_`
+ encode 16 fresh CSPRNG bytes; their eight-draw collision/capacity behavior belongs
+ to Tasks 14 and 13. Never pass raw bytes or issued identities to logging/debug
+ callbacks.
+
+- [ ] **Step 2: Implement explicit owner APIs:**
+ - `RuntimeSession`: injected adapter interfaces and owner/disposer scopes; it
+ does not import concrete adapters or services;
+ - `NavigationSession`: aliases, intents, targeting ownership, batches, attempts,
+ and one internal immutable current auction projection;
+ - `AuctionContextRegistry`: runtime-owned, manifest-bounded contributor callbacks
+ registered with owner-scoped disposers; it snapshots contributions for one batch,
+ preserves manifest registration order and later-key precedence, freezes the
+ result, and isolates/logs one contributor throw without retaining its values;
+ - child scopes for `AuctionBatch` and `RenderAttempt`.
+
+ Concrete slot maps, physical cycles, reservations, and the bridge listener are
+ services constructed in `composition/browser.ts` and disposed by the runtime
+ scopes through interfaces.
+
+ Seed only the initial session from recursively frozen
+ `tsjs.boot.auctionProjection`; never mutate boot. A new SPA session begins with no
+ projection. Its page-bids controller deep-copies/freezes one exact current-
+ generation `BrowserAuctionProjectionV1`, reserves all projected slots against the
+ shared 256 cap (including already-admitted programmatic units), and commits slots +
+ projection atomically. Stale/duplicate/malformed/over-cap responses commit nothing
+ and never retain prior-navigation data.
+
+- [ ] **Step 3: Every callback captures an owner generation and verifies it before mutation.**
+ Provide test-only inventory snapshots; do not expose mutable production state.
+
+ Keep the issuer injectable only through test composition. Production composition
+ always uses browser Web Crypto; no `Math.random`, counter, timestamp, publisher
+ input, or compatibility-form parser may mint a capability.
+
+- [ ] **Step 4: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/sessions.test.ts
+ npm --prefix crates/trusted-server-js/lib test -- --run test/kernel/identity.test.ts
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/projections.test.ts
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/context.test.ts
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Task 10: Implement bounded adapters and readiness queues
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts`
+- Create: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts`
+- Create: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts`
+- Create: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing tests for `present | pending | timed_out | incompatible`, late external readiness,**
+ per-operation timeout/removal, abort/disposal, queue capacity, command throw,
+ callback throw, exact message keys/version, zero/one/two ports, and port closure.
+ Race readiness immediately before/at/after the exact deadline.
+
+- [ ] **Step 2: Move global access behind injected adapter methods. `timed_out` describes one**
+ operation, not a permanent library state; `incompatible` describes only the
+ currently bound external object/stamp and later replacement may succeed. Each adapter
+ readiness queue holds at most 64 operations. Overflow fails the new operation
+ synchronously with `external_queue_full`; timed-out/aborted entries are removed
+ immediately and readiness drains live entries FIFO. Each operation expires with
+ `external_ready_timeout` exactly ten seconds from enqueue, independent of
+ `requestAds.timeoutMs` and the auction-fetch deadline. Dispatch versus expiry
+ races through one latch.
+
+- [ ] **Step 3: Centralize all message names, exact-shape guards, safe port extraction, and port**
+ disposal in the messaging adapter. Do not put reservation or lifecycle policy in
+ the adapter.
+
+- [ ] **Step 4: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/adapters
+ npm --prefix crates/trusted-server-js/lib run lint
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Phase 2 exit
+
+- One runtime and registry exist across separately built IIFEs.
+- Bootstrap/fallback ownership is transactional and generation-safe.
+- Sessions and adapters own every external global and disposal boundary.
+- No APS/GPT/Prebid production behavior has yet been switched without tests.
+
+## Phase 3 — move slot, auction, and render state into services
+
+### Task 11: Implement the slot registry and physical GPT cycle model
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/services/slots.ts`
+- Create: `crates/trusted-server-js/lib/test/services/slots.test.ts`
+- Create: `crates/trusted-server-js/lib/src/services/targeting.ts`
+- Create: `crates/trusted-server-js/lib/test/services/targeting.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing tests for exact nonempty server slot ids at the 256-UTF-8-byte**
+ boundary with NUL/control rejection, combined 255/256/257 server plus
+ programmatic records, ad-unit codes, DOM aliases, alias
+ collision, GPT object identity, SRA, one active/one queued replacement, TS versus
+ publisher intent, `display()` under disabled initial load, same/opposite-class
+ supersession, ambiguous overlap, duplicate response identifiers, no timeout
+ re-arm, safe destroy/redefine, and navigation disposal. For disabled initial load,
+ prove TS `display()` only registers and exactly one
+ `refresh([slot],{changeCorrelator:false})` owns the intent; refresh unavailability/
+ throw fails `gpt_request_failed`, while publisher display stays publisher-owned.
+ Include old completion
+ after navigation and before/after the replacement completion on the same DOM id.
+ Race `slotRequested` immediately before/at/after three seconds and
+ `slotRenderEnded` immediately before/at/after ten seconds from request start.
+
+ Cover transactional destroy/redefine at every recovery site: throw/false destroy,
+ failed replacement definition, stale generation after define, and proof that no
+ second physical GPT slot or binding appears. Destroy failure retires/quarantines
+ the old identity and makes later TS work fail `gpt_request_failed` until publisher
+ destruction or reload.
+
+- [ ] **Step 2: Implement `SlotRecord`, navigation-local registration ordinals, and indexes.**
+ Reject missing/empty/over-limit server ids and exact server-id collisions before
+ indexing. Ad-unit and DOM alias resolution must produce exactly one record or
+ `slot_unresolved`; never normalize server identity or choose first registration.
+ Enforce one `MAX_ACTIVE_SLOT_RECORDS = 256` transaction across server projection
+ and later programmatic registration; navigation disposal releases all records.
+
+- [ ] **Step 3: Record intent before the adapter operation. Open a physical cycle only on**
+ `slotRequested`; close on `slotRenderEnded`; attribute only when exactly one live
+ compatible TS intent exists. Fail ambiguous TS ownership instead of guessing.
+ A request-capable GPT call with no `slotRequested` by three seconds fails
+ `gpt_request_timeout`; because no attributable cycle exists, immediately
+ invoke one adapter transaction that marks the old TS object retired, requires
+ successful `destroySlots([old])`, and only then defines/binds a replacement; or
+ place a publisher-owned object in permanent
+ page-lifetime quarantine until explicit publisher destruction/reload. Future GPT
+ events never release that request-timeout quarantine. An opened cycle with no
+ completion by ten seconds fails `gpt_completion_timeout`; only its matching real
+ completion may drain that exact cycle. Neither timeout starts fallback.
+
+- [ ] **Step 4: Expose narrow operations to integrations: register/adopt slot, record intent,**
+ handle GPT event, own/clear targeting, resolve exact slot, dispose navigation.
+
+- [ ] **Step 5: On navigation disposal, destroy/redefine only a TS-owned GPT slot object and keep**
+ the retired object in the `WeakMap` until late completion drains. Define a
+ replacement only when a current navigation needs it and the transactional
+ destroy succeeded. Quarantine an
+ open publisher-owned object until its real `slotRenderEnded`, publisher
+ destruction, or reload; new TS work fails `slot_quarantined`. No timeout or
+ navigation token re-arms a physical cycle.
+
+- [ ] **Step 6: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/slots.test.ts
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/targeting.test.ts
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Task 12: Implement the bounded renderer reservation store
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/services/reservations.ts`
+- Create: `crates/trusted-server-js/lib/test/services/reservations.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing tests for `r1_` reservation validation, exact identity lookup, duplicate**
+ insertion, atomic claim, duplicate simultaneous claim, consumed/stale/disposed
+ tombstones, the fixed 15-minute render boundary, the ten-second Prebid admission
+ lease, atomic selection promotion to a new 15-minute render expiry, unselected/
+ aborted/selection-timeout tombstoning only through the original lease, union
+ capacity 320, suppression/refusal and contract-failure tombstoning of a PUC claim
+ against a pre-selection lease, refusal at capacity, no live eviction, and a late
+ request for the oldest unexpired id. Cover immutable finite/nonnegative
+ `WinnerContext{selectedCpm}`, exact Prebid-bid CPM equality, context preservation
+ across promotion and projection replacement, transfer into the attempt before
+ consumption, and context/source deletion from tombstones. Renderer-id generation/collision retry
+ belongs to Rust Task 4, not this browser store.
+
+- [ ] **Step 2: Move `apsPrebidRenderers` and consumed-id maps out of public globals into the**
+ runtime-owned service. Entries carry exact slot, tagged render source, navigation
+ generation, fixed expiry, state, attempt binding, and immutable winner context
+ while live. Source
+ `WindowProxy` is intentionally absent until the first valid PUC claim acquires
+ it. Consumption transfers the render source/context to the exact attempt before
+ replacing the entry. It never extends expiry; tombstones remain through original
+ expiry with only id/expiry/state/minimum suppression metadata.
+
+- [ ] **Step 3: Use one reservation type for every TS-owned APS, ADM, and cache PUC source.**
+ Validate the supplied server id but never generate it in the browser. Cache UUID
+ remains only `cacheId` transport state; upstream bid id remains provenance;
+ native Prebid ids never enter this store. Reject an id collision against any
+ live/tombstoned entry; lookup recognizes a TS id before detailed validation.
+
+- [ ] **Step 4: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/reservations.test.ts test/integrations/aps/render.test.ts
+ ```
+
+### Task 13: Implement the RenderAttempt state machine and direct paths
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/services/render.ts`
+- Create: `crates/trusted-server-js/lib/test/services/render.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/render.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing state-table tests for every valid transition and every invalid/replay**
+ transition. Race success, failure, timeout, caller abort, supersession, and
+ navigation disposal through one terminal latch. Add accepted-artifact promotion
+ races and prove terminal attempt disposal cannot remove a committed render. At
+ construction assert one exact navigation-unique `a1_` attempt id; fallback child
+ ids are distinct and bind their exact parent id. Test navigation-prefix failure,
+ ordinal exhaustion, disposal, and that neither ids nor issuer bytes reach logs.
+
+ Add renderer-nonce live-registry tests at 255/256/257 entries, eight collision
+ draws, crypto failure, exact source/port/attempt/generation binding, disposal reuse,
+ and no tombstone/history set. Capacity maps `capability_registry_full`; exhausted
+ draws map `identity_generation_failed`.
+
+- [ ] **Step 2: Implement path-independent `RenderAttempt` ownership: state, exact slot,**
+ generation, exact `a1_` id, optional parent attempt id, tagged render source,
+ immutable `WinnerContext{selectedCpm}`, timers, ports, iframe, terminal result,
+ and disposer. Direct winner admission constructs the context from the exact
+ validated joined server winner; a PUC claim receives the same context from the
+ consumed reservation.
+ Obtain the id only from the NavigationSession issuer before registering work; an
+ issuance failure settles `identity_generation_failed` without DOM/global
+ mutation. Add `SlotOperation` above attempts so a primary and optional fallback
+ retain immutable child results while the operation exposes one final result and
+ `path`.
+ Transition methods—not callers—create and clear deadlines. Before acceptance,
+ atomically detach committed iframe/targeting/physical-slot metadata into one
+ slot/navigation-owned `CommittedRenderArtifact`; the attempt disposer removes
+ only uncommitted resources. On replacement, promote the new artifact, dispose
+ the prior artifact before publishing the new one, and rebase targeting ownership
+ without clearing the newer generation. Direct
+ iframe artifacts remove their DOM; PUC artifacts defer DOM ownership to GPT and
+ follow TS-owned destroy/redefine versus publisher-owned metadata-only rules.
+
+- [ ] **Step 3: Implement direct APS:**
+ - validate descriptor before DOM mutation;
+ - mint one exact `n1_` nonce from the 16-byte Web Crypto issuer and create the
+ inner channel immediately before insertion; bind the nonce to the exact attempt,
+ generation, renderer `contentWindow`, and retained port;
+ - put the nonce in the fragment and transfer an envelope containing the
+ kernel-captured publisher origin;
+ - bind to the exact iframe `contentWindow` and transferred port;
+ - atomically consume the nonce on the first valid document acceptance and
+ invalidate it on failure, supersession, navigation, or disposal; duplicate,
+ wrong-source, stale, or late use is inert and nonce values are never logged;
+ - start the document deadline at iframe insertion and fail
+ `renderer_document_no_load` at three seconds;
+ - make the kernel the sole owner of the ten-second APS-completion deadline,
+ starting only at document acceptance; the static renderer owns no competing
+ timer;
+ - map proxy/CORS/script-load failure to `runner_no_load`, callback rejection or
+ ten-second callback silence to `runner_failed`, treat `runner_loaded` only as
+ progress, and accept only `TS APS Render Completed`; remove on failure/cancel.
+
+- [ ] **Step 4: Implement direct ADM through one shared constructor used later by the PUC owner.**
+ Use the exact ordered sandbox, no-referrer policy, integral 1–4096 source dimensions,
+ CSS sizing, zero border/margin, hidden overflow, display, scrolling, title, and
+ aria attributes from spec §4.5. Create the iframe detached, install one-shot
+ handlers/disposal first, assign exactly one complete `srcdoc`, and append once;
+ never append empty or set `src`. Accept only the exact current pending frame's
+ intended `srcdoc` load while its generation/latch remain current. Initial
+ `about:blank`, pre-assignment, removed/replaced frame, stale generation,
+ post-disposal, duplicate load, error, and five-second timeout cannot accept and
+ map to `adm_document_no_load`.
+
+ Implement cache as a preceding bounded fetch. Require a frozen valid
+ `CacheFetchPolicyV1`; require `baseUrl`/`fetchUrl` at the 4,096-byte boundary and
+ the server-built HTTPS URL to have the exact base origin/port/path and exactly
+ one canonical `uuid` query equal to `cacheId`; use `redirect:'error'`, omit
+ credentials/referrer, require CORS and a
+ successful status, and enforce five seconds and 512 KiB. Parse only a JSON object
+ with required own bounded nonempty `adm`; optional `w`/`h` must appear together,
+ stay in 1–4096, and match; optional finite nonnegative `price` is ignored; unknown OpenRTB bid
+ keys are ignored; raw bodies, aliases, arrays/primitives, and wrappers fail.
+ Expand only the exact `${AUCTION_PRICE}` token as
+ `String(attempt.winnerContext.selectedCpm)`; leave `${AUCTION_PRICE:B64}` untouched
+ and never read response `price`, current projection, targeting, or a later winner.
+ Test delayed PUC cache after projection replacement, Prebid lease promotion, and a
+ separate direct-cache context, plus URL/query/redirect/body/shape/macro cases, all
+ three typed cache failures, and proof none becomes `no_bid`.
+
+- [ ] **Step 5: Keep all remote side effects outside terminal correctness. APS has no synthetic**
+ notification.
+
+- [ ] **Step 6: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/render.test.ts test/core/render.test.ts test/integrations/aps/render.test.ts
+ ```
+
+### Task 14: Implement Universal Creative claim and owner-control channels
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/src/services/render.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts`
+- Create: `crates/trusted-server-js/lib/src/services/puc_bridge.ts`
+- Create: `crates/trusted-server-js/lib/test/services/puc_bridge.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js`
+- Create: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.sha256`
+
+- [ ] **Step 1: Vendor the exact supported PUC 1.17.2 artifact and checksum for hermetic tests;**
+ the GAM template pins the same version and never `latest`. Add failing tests for
+ the exact JSON string
+ `{message:"Prebid Request",adId,adServerDomain}`, object/extended shapes,
+ zero/two ports, native id, live/tombstoned TS id, duplicate simultaneous claim,
+ replay, prior navigation, SafeFrame-shaped nesting, outer post failure, and every
+ ordering of early claim, nonempty/empty GAM, navigation, supersession, claim
+ deadline, and GPT-cycle deadline. Include caller abort before/after registration,
+ insertion, and document acceptance; lost/closed control channel before start and
+ after insertion; settlement-post throw; remote cleanup at 19,999/20,000/20,001
+ ms; and proof accepted remote DOM remains while every uncommitted failure removes
+ exactly its owned iframe and settles the PUC Promise once.
+
+- [ ] **Step 2: Install exactly one capture-phase bridge dispatcher synchronously as the first**
+ reversible core activation, before integration-module activation and any
+ TS-owned GPT/Prebid injection; asynchronous preparation leaves no listener. It
+ multiplexes only `Prebid Request` and
+ `TS Render Owner Register`; no attempt installs another global listener. First
+ perform side-effect-free minimal extraction of own data
+ `message`/`adId`/optional `lifecycleTicket` from JSON strings or clone-safe plain
+ objects without invoking accessors. For the request branch, lookup before exact
+ parsing or port checks. Leave non-TS ids to native Prebid; for live/tombstoned TS
+ ids call `stopImmediatePropagation()` immediately, including object/extended
+ shapes and zero/two ports, then exact-parse and generically refuse/close invalid
+ requests. The first valid claim acquires
+ `MessageEvent.source` as the authoritative PUC `WindowProxy`; never precompute or
+ walk the SafeFrame ancestry.
+
+- [ ] **Step 3: Implement the two-condition join. An early claim buffers only source plus outer**
+ response port and discloses no render data. A nonempty GAM result starts the
+ three-second claim timer when claim is absent. Empty/disposal/supersession closes
+ the port and tombstones. When both are present, atomically revalidate/consume,
+ mint an exact `t1_` ticket from 16 Web Crypto CSPRNG bytes through the Task 9
+ issuer, with at most eight total draws and fixed three-second TTL, and
+ reply with the exact ready outer schema. Bind it to the exact attempt id,
+ reservation id, PUC source, and navigation generation; issuance failure settles
+ `identity_generation_failed` before any ready response. Refusals use the exact
+ generic refused schema.
+ The outer response carries only owner kind/ticket plus the checked-in dynamic
+ owner, never ADM, APS descriptor, nonce, or final document port.
+
+ Store live tickets plus tombstones in one runtime-owned capacity-320 registry.
+ Prune expired entries first, never evict an unexpired entry, preserve the original
+ three-second expiry on consumption/disposal, and map capacity to
+ `capability_registry_full` and eight-draw collision exhaustion to
+ `identity_generation_failed` before exposing any usable capability.
+
+ Bound a claim-first path by the GPT request-start/completion deadlines from Task
+ 11 and the attempt deadline. Test boundary races and prove only an attributable
+ completion-timeout event drains its exact physical cycle; request-timeout events
+ never release publisher quarantine. Neither can revive the claim or start
+ fallback.
+
+- [ ] **Step 4: In the hidden dynamic renderer call PUC's supplied**
+ `h.sendMessage('TS Render Owner Register',{version:1,lifecycleTicket},callback)`;
+ never global `postMessage`. Assert the kernel sees the original captured PUC
+ source, exact auto-added `adId/message` keys, and one helper-created response
+ port. Test exact registered/refused responses, ticket TTL/atomic consumption,
+ wrong source, stale generation, replay, zero/two response ports, the owner's
+ three-second watchdog, helper disposer, and late response.
+
+ Receive registration through the same dispatcher. Minimally lookup the ticket
+ map first; ignore unknown tickets, but suppress live/tombstoned TS tickets before
+ exact source/adId/attempt/generation/shape/one-port checks. Refuse and close
+ recognized invalid/replayed registration, keep ticket tombstones through their
+ original TTL, and prove attempt disposal removes ticket state without removing
+ the runtime dispatcher. Atomically consume the first valid use, invalidate on
+ timeout/failure/supersession/navigation/disposal, make duplicate/stale/late uses
+ inert, and prove ticket values and issuer bytes never reach logs.
+
+- [ ] **Step 5: On registration the kernel creates the owner-control channel, keeps one endpoint,**
+ and transfers exactly one endpoint in `TS Render Owner Registered`. For APS it
+ then sends exact `TS APS Start` with the descriptor/envelope plus exactly one
+ renderer-document port. For ADM it sends exact `TS ADM Start` and no port. Add
+ exact-shape tests for every start, insertion, document progress, render
+ completion/failure, ADM load/failure, and final owner settlement message and for
+ every wrong/extra key or port count. `OwnerSettlementV1` cancellation includes
+ exactly `caller_aborted | superseded | navigation_disposed`; every terminal
+ RenderOutcome is therefore encodable after registration.
+
+- [ ] **Step 6: For APS, make the owner create exactly one iframe using the immutable renderer**
+ sandbox constant, meet the one-second insertion deadline, and leave document and
+ completion timing to the kernel anchors from Task 13. For ADM/cache, call the
+ exact shared detached-iframe constructor from Task 13; report `TS Owner Inserted`
+ only after its one append, then report only the intended load/failure. Initial
+ blank, replacement, removal, duplicate, stale, disposed, error, and timeout races
+ cannot resolve the PUC Promise. Resolve/reject that Promise only from the kernel's
+ final settlement. The remote owner—not the kernel—owns that iframe: accepted
+ settlement promotes it, removes temporary handlers, closes the port, and
+ resolves once; failed/cancelled settlement removes it, removes handlers, closes,
+ and rejects once. Arm one fail-closed 20-second settlement/channel watchdog when
+ registration accepts the control port, before start; never rearm it on start.
+ Malformed control, `messageerror`, local disposal, silent loss, or expiry runs
+ remote cleanup but cannot report acceptance to the kernel. Direct-path iframe
+ cleanup remains kernel-owned. The kernel owns all terminal decisions; bidder ADM
+ receives no capability. Prove each channel creator/retained/transferred endpoint
+ is closed by success, refusal, timeout, cancellation, and navigation tests.
+
+- [ ] **Step 7: Run the shared protocol corpus through both global and port parsers. Enforce the**
+ 4,096-byte inbound JSON cap before parse; exact 25-character capabilities;
+ field-specific 256/2,048/4,096-byte limits; safe generations; 64 KiB dynamic
+ owner and 72 KiB successful outer-response limits; exact keys/prototypes; and
+ boundary-minus-one/boundary/boundary-plus-one multibyte, duplicate-key, malformed
+ encoding, accessor, and exact 1/4096 dimension cases.
+
+- [ ] **Step 8: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/puc_bridge.test.ts test/services/render.test.ts test/integrations/gpt/ad_init.test.ts
+ ```
+
+### Task 15: Implement the exact public API, programmatic registration, and `requestAds`
+
+**Files:**
+
+- Create: `crates/trusted-server-js/lib/src/services/auction_batch.ts`
+- Create: `crates/trusted-server-js/lib/test/services/auction_batch.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/context.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/request.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/auction.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/registry.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/log.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/queue.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts`
+- Modify: `crates/trusted-server-js/lib/src/index.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/registry.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add failing input tests for omitted/undefined options, null/array/non-object,**
+ foreign/accessor/unknown properties, invalid/duplicate/too-many slots, timeout
+ bounds, AbortSignal brand, exact server-slot-id selection, an unknown id beside a
+ valid sibling, ad-unit/DOM alias collisions, and input-order output. Prove an
+ omitted-slot call synchronously snapshots current NavigationSession registrations
+ by ordinal and excludes a later registration. Prove an omitted timeout uses
+ exactly 10,000 ms for only the shared auction fetch; readiness and renderer/GPT
+ deadlines neither inherit nor reset it.
+
+- [ ] **Step 2: Add failing public-surface and registration tests.** Assert the exact
+ kernel/fallback `TsjsApi` own properties, `version:'1.0.0'`, release equality,
+ recursively frozen `TsjsBootV1`, non-enumerable frozen status-only `_internal`,
+ permanently refusing `_registerIntegration`, diagnostics present only on kernel,
+ and no compatibility/placeholder/mutable-config aliases. Keep numeric `V1` only
+ on actually serialized boot/wire schemas; public helpers are `TsjsApi`,
+ `TsjsCommandQueue`, `TsjsLog`, and `TsjsDiagnostics`. Update the package barrel
+ to export only the final public names and remove old `AdUnit`/API aliases.
+
+ Cover `addAdUnits` one/array, empty/257 units, unknown/accessor/prototype fields,
+ duplicate/colliding codes, malformed media/bids/params, encoded 256 KiB auction
+ body cap, 63/64/65-byte bidder names, integral dimensions at 0/1/4096/4097 with
+ exact `invalid_dimensions | dimensions_out_of_range`, and combined registry totals
+ 255/256/257. Registration is synchronous, all-or-nothing, navigation-scoped, and
+ assigns ordinals after server slots. Fallback fully validates then throws exact
+ `TsjsUnavailableError` without constructing a registry.
+
+ Assert logger default level/methods, all valid levels, invalid-level throw without
+ mutation, bounded output, and missing/throwing console methods. Reuse Task 8's
+ frozen real-Array queue tests as the final public queue contract.
+
+- [ ] **Step 3: Implement `addAdUnits` before live mutation.** Validate the complete
+ input in deterministic field order, reserve capacity for the whole call, then
+ transactionally register exact programmatic `code` values into the same
+ navigation slot service. A code is a public registered slot id but never a GPT
+ path/DOM alias; successful units participate only in later direct-auction
+ snapshots and do not define/display/target GPT.
+
+- [ ] **Step 4: Replace the void/callback API with the exact Promise contract. Interpret every**
+ explicit entry only as an exact case-sensitive registered slot id—server or
+ programmatic—never a GPT ad-unit path, DOM id, or alias. Preserve explicit input order or omitted snapshot order in
+ the results and always return the exact server id. Input errors reject before
+ creating attempts. Unknown explicit ids resolve `slot_unresolved` without
+ blocking valid siblings. After attempt creation, operational/render failures
+ resolve as typed per-slot results.
+
+- [ ] **Step 5: Implement one `AuctionBatch` per fetch. Test partial overlap across concurrent**
+ calls, one child superseded, all children superseded, already-aborted caller,
+ later abort, response deadline, reversed response order, invalid response,
+ missing/duplicate/extra slot decisions, winner-to-bid join mismatch, server
+ failed decisions, and navigation disposal. Parse the exact standard `bid.id` /
+ `impid` plus exact three-key `bid.ext.trusted_server` contract and require the
+ decision/candidate/impid/slot join. Reject standard `adm` on APS/cache and any ADM
+ mismatch; never infer a render source from standard bid fields.
+
+ Snapshot the runtime-owned auction-context contributors exactly once before request
+ serialization. Contributors are invoked in manifest registration order; later keys
+ retain the baseline precedence, one throw is locally logged/isolated, and module or
+ runtime disposal removes the contributor before the next batch. Navigation changes
+ do not duplicate or discard a document-scoped contributor. No integration imports
+ or mutates a module-global provider map.
+
+- [ ] **Step 6: Preserve error distinctions:**
+ `auction_timeout | network_error | http_error | invalid_response`. After parsing,
+ consume the server's exact per-slot decisions; only an explicit `no_bid` decision
+ is no-bid. Never infer no-bid from an absent/malformed sibling.
+
+- [ ] **Step 7: Exercise the exact `TsjsApi` through test-only composition. Keep the shipped public**
+ entry point unchanged until Task 19; Task 22 deletes the old callback/void overload
+ and declarations rather than keeping an alias.
+
+- [ ] **Step 8: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services/auction_batch.test.ts test/core/request.test.ts test/core/auction.test.ts test/core/index.test.ts
+ npm --prefix crates/trusted-server-js/lib test -- --run test/core/registry.test.ts test/core/log.test.ts test/core/queue.test.ts
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Phase 3 exit
+
+- Slot/cycle attribution, reservations, direct/PUC lifecycles, and auction batches
+ are service-owned and independently tested.
+- Every created attempt has one terminal result under adversarial ordering.
+- The new test-only public contract has no compatibility aliases; the shipped API
+ remains unchanged until the coordinated Task 19 switch.
+
+## Phase 4 — migrate integrations and remove duplicate state
+
+### Task 16: Prepare the GPT integration module over adapters, slots, and render services
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/schedule_initial_ad_init.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts`
+- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/slots.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/targeting.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add or preserve failing tests for early unconditional GPT subscriptions,**
+ publisher services already enabled, SRA, disabled initial load, one refresh path,
+ `changeCorrelator:false`, responsive ambiguity, collapsed-shell guard, targeting
+ ownership, page-bids, SPA replacement, retired TS-owned slot objects,
+ publisher-owned cycle quarantine/drain, old completion races, and direct
+ publisher GPT activity. Include external-readiness, request-start, and completion
+ deadline boundaries and accepted-artifact replacement/navigation ownership.
+ Add the exact handoff corpus: exact and hydration-renamed late `defineSlot`,
+ mismatch/ambiguity, publisher duplicate `display`, explicit/global refresh with
+ disabled initial load, unrelated slot/options preservation, ownership transfer
+ racing navigation, and proof that publisher work never starts TS fallback.
+
+ Add deterministic fake-timer DOM-reconciliation tests at 249/250 ms and
+ 4,999/5,000 ms for first/final pass success, ambiguity, expiry-versus-commit latch,
+ ownership transfer during a pass, one and two successful rebinds, immediate
+ `reconciliation_capacity` on a third disconnect, and navigation disposal. Exercise
+ request-timeout, completion-timeout, navigation, and reconciliation through the
+ same destroy/redefine transaction. A throwing/false `destroySlots([old])` retires
+ and quarantines the old identity, defines no second physical slot, and returns
+ `gpt_request_failed`; a failed replacement leaves the slot unbound. A stale
+ generation destroys any just-created TS replacement and cannot bind it. Prove no
+ path destroys a publisher-owned slot.
+
+- [ ] **Step 2: Extract a GPT integration module used by test-only composition.** Its
+ `prepare(ctx)` reads only validated frozen boot data and creates closures; it
+ performs no GPT/global/DOM/script/timer/listener mutation. Its synchronous
+ `activate(ctx)` installs every reversible adapter interception and registers
+ disposers before mutation. Script injection or other irreversible startup is
+ staged in the module's single `afterCommit` callback. Move every GPT-global
+ call into `GoogletagAdapter` and all slot/cycle state into `slots.ts`, while
+ retaining the shipped entry-point behavior until Task 19.
+
+- [ ] **Step 3: Route APS and ADM winners to `RenderAttempt`/PUC bridge. Remove duplicate renderer**
+ branches, slot expandos, local consumed-id maps, and independent refresh wrappers.
+
+- [ ] **Step 4: Implement the owner-and-value targeting journal in `services/targeting.ts`.**
+ Keep one closure-private stack per physical GPT slot/key. Each TS write pushes a
+ distinct frame containing its owner id, exact installed string, and predecessor
+ value/owner—even when the string is unchanged. The GPT adapter observes the live
+ slot's `setTargeting`, per-key `clearTargeting`, and clear-all calls. A private
+ reentrancy marker distinguishes TS calls; every publisher-originated mutation
+ invalidates the affected restoration chain before forwarding, including a
+ same-value write, without changing arguments, return value, throw behavior, or
+ ordering.
+
+ Before a TS write, compare the actual GPT value to the current frame and discard a
+ stale chain rather than overwriting publisher state. Cleanup writes only when the
+ disposing frame is the current owner and the actual value still equals its
+ installed string. Removing a non-top frame performs no GPT call and rebases the
+ successor to the removed predecessor. Acceptance promotes frames into the
+ `CommittedRenderArtifact`; disposal of an older accepted artifact uses the same
+ non-top rebase. Test two equal-string generations under newer success, failure,
+ supersession, and older-artifact disposal, plus publisher different-value and
+ same-value set, per-key clear, clear-all, wrapper replacement, and throws at every
+ cleanup point. Never blind-clear.
+
+ Implement the ordered publication transaction: validate source/slot/reservation,
+ register the live reservation, expose exact `hb_adid` and other targeting, record
+ GPT intent, then invoke the request. Any intervening failure tombstones the
+ reservation, compare-restores targeting, and settles. Prove a fast creative
+ request always finds the store entry.
+
+- [ ] **Step 5: Fold integration-specific script-guard mechanics onto the shared factory while**
+ keeping GPT configuration in its integration. Implement one runtime-owned
+ `MutationObserver` per `NavigationSession`, 250 ms debounce, 5,000 ms monotonic
+ window, one final boundary pass, the two-success cap, exact physical-object
+ quarantine, and complete timer/candidate/reference disposal. Successful handoff
+ cancels reconciliation and transfers cleanup ownership synchronously.
+
+- [ ] **Step 6: Run the entire GPT suite, not only new files:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/gpt
+ npm --prefix crates/trusted-server-js/lib test -- --run test/adapters/googletag.test.ts test/services/slots.test.ts test/services/targeting.test.ts
+ npm --prefix crates/trusted-server-js/lib run lint
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ ```
+
+### Task 17: Prepare Prebid and APS registration on the shared runtime
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/aliases.d.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/prebid_modules/liveIntentIdSystem.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json`
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts`
+- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs`
+- Modify: `crates/trusted-server-js/lib/test/build-prebid-external.test.mjs`
+- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs`
+
+- [ ] **Step 1: Add failing tests for the server `r1_` reservation replacing a Trusted Server**
+ Prebid bid's generated `adId` before targeting, exact equality with `hb_adid`,
+ exact ad-unit ownership, invalid descriptor, duplicate response, registration
+ capacity, navigation disposal, native non-TS bid/id preservation, and lifecycle
+ success/failure. Pin the exact content-addressed external artifact built from
+ lockfile-resolved Prebid.js 10.26.0 and its admission behavior: `not_admitted`
+ produces no bid/event/targeting state; throw and partial publication fail closed
+ at runtime; and all three map to the exact admission/contract failure reasons.
+ Cover selected versus losing TS bids, native winners, multiple ad units, exact
+ auction-id ownership, navigation/auction abort, missing/late `auctionEnd`, event
+ listener ordering before publisher callbacks, ten-second admission lease, and
+ capacity release. Prove a PUC request before selection is suppressed/refused,
+ tombstones the suppress-only lease, and maps to `prebid_contract_violation`. A
+ dependency-version change requires deliberate fixture review.
+
+- [ ] **Step 2: Extract a release-matched Prebid integration module used by test-only**
+ composition. `prepare(ctx)` is inert; `activate(ctx)` synchronously installs
+ only reversible adapter state and contributes at most one `afterCommit`
+ callback. Use the Prebid adapter for every global call and the RuntimeSession
+ reservation service for APS, ADM, and cache PUC entries. The adapter binds one
+ exact `pbjs` object plus its own recursively frozen artifact-stamp identity and
+ rechecks both on every operation. Missing or invalid bindings report
+ `incompatible` only for that readiness operation; later whole-object replacement
+ can satisfy later work. Keep shipped entry-point behavior unchanged until Task 19.
+
+- [ ] **Step 3: Remove `tsjs.apsPrebidRenderers`, Prebid function sentinels, and direct imports of**
+ GPT integration internals. Preserve eids and unrelated Prebid behavior through
+ existing tests.
+
+- [ ] **Step 4: Keep `aps/render.ts` responsible for descriptor/client renderer mechanics only;**
+ registry and lifecycle state live in services.
+
+ Prepare and validate the complete bid, register the reservation, replace the TS
+ `adId`, then call the version-pinned adapter's single
+ `admitTrustedBid(preparedBid): admitted | not_admitted` boundary as the only
+ irreversible action. Atomic `not_admitted` and throw tombstone and settle
+ `prebid_admission_failed`; detected partial publication tombstones/suppresses,
+ settles `prebid_contract_violation`, and blocks the artifact gate. Never alter
+ native Prebid `adId` values.
+
+ `admitted` enters `awaiting_prebid_selection` on a ten-second lease, not a render
+ attempt. The adapter's early synchronous `auctionEnd` listener queries exact
+ auction/ad-unit winners before publisher targeting callbacks, promotes only the
+ selected TS id and its immutable `WinnerContext` into a new 15-minute render
+ reservation/attempt, and tombstones all losing TS ids only through their original
+ short lease. Missing auction end records
+ `prebid_selection_timeout`; navigation/auction abort clears the admitted set. No
+ losing bid remains live for 15 minutes.
+
+- [ ] **Step 5: Make the external artifact independently correct and pure.** Build exactly
+ lockfile-resolved Prebid.js 10.26.0 with no TS auction, admission, render,
+ targeting, or refresh behavior. The first wrapper statement arms an independent
+ 5,000 ms queue-drain watchdog before stamp inspection or module factories. It
+ calls the then-current real object's idempotent `processQueue()` at most once per
+ wrapper so every publisher callback runs exactly once even when the TS module is
+ absent or incompatible.
+
+ Expose only one own, non-enumerable, non-writable, non-configurable
+ `__trustedServerArtifactV1` data property containing the exact recursively frozen
+ `ExternalPrebidArtifactV1`. Same-release/content duplicates reuse the object without
+ re-executing factories; a different valid release refuses the new wrapper without
+ disturbing the working object. Absent, accessor, inherited, malformed, and hostile
+ non-configurable descriptors never throw or stop publisher Prebid; they only make
+ TS readiness incompatible and emit at most one bounded warning. Verify all manifest
+ sort/uniqueness/UTF-8/count/alias/config/EID bounds and exact version 10.26.0.
+
+ Emit exactly one 64-zero release sentinel, hash the sentinel-normalized JavaScript,
+ replace it with the lowercase SHA-256 artifact release id, and separately compute
+ the final-byte SHA-256/SRI. Assert no sentinel remains and do not require the
+ artifact release id to equal the TSJS release id. Black-box tests bind and recheck
+ the exact `pbjs` plus stamp identities, cover late valid replacement, and prove the
+ external artifact contains no TS behavior or `window.__tsjs_*` handshake.
+
+- [ ] **Step 6: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps
+ npm --prefix crates/trusted-server-js/lib run build:prebid-external
+ npm --prefix crates/trusted-server-js/lib test -- --run test/prebid-artifact-integration.test.mjs
+ ```
+
+### Task 18: Prepare creative, diagnostics, and remaining integration modules
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/click.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/dynamic_src_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/iframe.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/image.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/proxy_sign.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/osano/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/segments.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/trace.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/context.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/async.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/beacon_guard.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/globals.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/origin.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/scheduler.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/script_guard.ts`
+- Modify: `crates/trusted-server-js/lib/test/shared/async.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/shared/beacon_guard.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/shared/dom_insertion_dispatcher.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/shared/scheduler.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/creative/click.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/creative/iframe.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/creative/image.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/creative/proxy_sign.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/datadome/script_guard.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/api.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/badges.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/binding.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/observer.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/overlay.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/store.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/types.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/lockr/script_guard.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/osano/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/permutive/segments.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/script_guard.test.ts`
+- Create: `crates/trusted-server-js/lib/test/integrations/testlight/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/build-all.mjs`
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+- Modify: `crates/trusted-server-core/src/trace_cookie.rs`
+- Modify: `crates/trusted-server-core/src/integrations/datadome.rs`
+- Modify: `crates/trusted-server-core/src/integrations/datadome/protection.rs`
+- Modify: `crates/trusted-server-core/src/integrations/datadome/protection_scope.rs`
+- Modify: `crates/trusted-server-core/src/integrations/didomi.rs`
+- Modify: `crates/trusted-server-core/src/integrations/google_tag_manager.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js`
+- Modify: `crates/trusted-server-core/src/integrations/lockr.rs`
+- Modify: `crates/trusted-server-core/src/integrations/mod.rs`
+- Modify: `crates/trusted-server-core/src/integrations/osano.rs`
+- Modify: `crates/trusted-server-core/src/integrations/permutive.rs`
+- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs`
+- Modify: `crates/trusted-server-core/src/integrations/testlight.rs`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+
+- [ ] **Step 1: Add a maximal-bundle failing smoke test that loads core followed by every**
+ server-declared integration in manifest order and asserts one runtime, no unknown
+ integration id, no duplicate activation, exact reverse-order disposal, and no
+ leaked timer/listener/wrapper/observer after disposal. Run every module alone
+ and in the maximal manifest with missing globals, readiness/timeouts, malformed
+ config/consent/storage, matcher false positives, callback throws, startup
+ failure, and cross-integration isolation.
+
+- [ ] **Step 2: Convert every remaining capability into a thin integration module.** Each
+ `_registerIntegration({id,release,prepare})` call is pure registration;
+ `prepare(ctx)` is inert and Promise-returning; the returned `activate(ctx)` is
+ synchronous, registers a disposer before each reversible mutation, and uses at
+ most one staged `afterCommit` callback for irreversible work. Exercise all
+ modules through the same manifest-ordered test composition. Preserve existing
+ feature behavior and integration-owned matchers/configuration; shared helpers
+ must not broaden matching, reorder startup, stack interception, or retain work
+ after disposal. Do not change shipped entry-point side effects until Task 19.
+
+- [ ] **Step 3: Rebuild creative startup around the exact frozen `CreativeBootV1`.** Validate
+ the complete plain-object shape, defaults, disabled/manifest mismatch, unknown
+ keys, accessors, prototypes, and literals before preparation. Activation installs
+ the click guard when `clickGuard` is true and dynamic image/iframe guards when
+ `renderGuard` is true, with no baseline DOM rewrite. A still-loading document
+ receives one owned `DOMContentLoaded` rescan; an already-ready document stages
+ that scan in the module's single `afterCommit`. Disabled creative and
+ enabled-with-both-guards-false perform zero wrappers, observers, listeners,
+ scans, or DOM mutation. Disposal compare-restores only the exact installed
+ wrapper and clears owned DOM state once.
+
+ Preserve sanitization as opt-in/default-off and rewriting as its independent
+ existing policy across direct, SSAT, cache, and auction paths. Cover dynamic-node
+ guards, sandbox attributes, font/CORS/body/base behavior, opaque-origin click
+ recovery through `/first-party/proxy-rebuild`, validated absolute HTTP(S), and
+ rejection of credentials, malformed values, and non-network schemes. Delete the
+ mutable/install creative globals only in Task 22.
+
+- [ ] **Step 4: Move render tracing to the kernel diagnostics bus and exact public surface.**
+ `tsjs.diagnostics.renderTrace` exposes only frozen `current()`, `history()`, and
+ `subscribe()`. Keep current state keyed by exact slot and capped by the 256-slot
+ navigation registry; prune on disposal. Keep document-runtime history at 200,
+ one row per physical impression, monotonic `count`/global `seq`, immutable `at`,
+ and non-weakening enrichment. Remove stale DOM stamp fields/badges on update and
+ preserve bounded overlay/export failure isolation.
+
+ Commit correctness state before public delivery. Capture subscriber ids and enqueue
+ frozen full records asynchronously in a 200-entry FIFO keyed by `seq`; same-sequence
+ enrichment replaces the pending record and captured ids without reordering. One
+ owned zero-delay task drains FIFO. Enforce callable-before-capacity validation,
+ 32-live-subscriber cap, idempotent unsubscribe, unsubscribe-before-delivery,
+ registration-during-dispatch, callback throw isolation, and 199/200/201 overflow.
+ Emit no `CustomEvent`, mutable trace global, or compatibility alias.
+
+- [ ] **Step 5: Preserve GPT diagnostics through the adapter event stream.** Validate exact
+ `DiagnosticsBootV1` plus manifest activation before any listener/buffer exists.
+ When active, core owns the six documented GPT observations before TS requests,
+ buffers 512 raw facts until module activation, then replays and releases the
+ buffer. When inactive, require zero diagnostics-added listeners, DOM, timers,
+ observers, API, storage, or network work beyond the two correctness listeners.
+ Preserve exact physical-slot binding/replacement, per-slot monotonic request
+ numbers, callback truth/timing, frozen exports, Shadow DOM overlay, badges, SPA,
+ privacy, and non-interference.
+
+ Bound the store to 64 slot objects, ten cycles per slot, and 128 callback issues.
+ Expose only `tsjs.diagnostics.gpt`, with `snapshot()` plus the shared 32-subscriber
+ limit. Public delivery uses a separate one-entry latest-snapshot notifier on one
+ owned zero-delay task; 0/1/2-update coalescing, captured ids, unsubscribe/disposal,
+ slow/throwing listeners, and callback-stack isolation are executable tests. No
+ storage, upload, old flag, runtime expando, or `tsjs.gptDiagnostics` alias remains
+ after Task 22.
+
+- [ ] **Step 6: Preserve each remaining `rc/july` integration corpus exactly.** Cover DataDome
+ script/preload path rewriting; Didomi absolute SDK path without config clobber;
+ GTM script/preload and GA beacon/fetch rewriting; Lockr bounded readiness and API
+ host; Osano USP/GPP/TCF marker ownership and lifecycle; Permutive bounded
+ readiness/API host and at-most-100 normalized segments; Sourcepoint optional SDK
+ plus GPP storage/marker lifecycle; and Testlight preexisting/later callbacks,
+ invalid entries, and throw isolation. Run unchanged pre-cutover fixtures beside
+ module-composed fixtures. Permutive and any other auction-context contributor
+ register only through the injected runtime service during activation and remove
+ that contribution through the pre-registered disposer; no import-time global
+ provider survives failed activation or module/runtime disposal, and SPA
+ navigation does not register a duplicate.
+
+- [ ] **Step 7: Generate and test the prospective manifest member list/order from the exact**
+ enabled bundle list. Embed the same release id in core and every integration
+ IIFE. Add failures for integration before core, unknown/missing/duplicate member,
+ malformed/unsorted/oversized manifest, wrong release, preparation or activation
+ failure, duplicate `afterCommit`, and the 16-member/10-second transaction limits.
+ Production manifest emission starts only in Task 19.
+
+- [ ] **Step 8: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test
+ npm --prefix crates/trusted-server-js/lib run build
+ npm --prefix crates/trusted-server-js/lib run lint
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ cargo test-fastly publisher
+ ```
+
+### Task 19: Complete lifecycle behavior and perform the coordinated production switch
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/src/services/render.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/slots.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/projections.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/targeting.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/reservations.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/auction_batch.ts`
+- Modify: `crates/trusted-server-js/lib/src/services/context.ts`
+- Modify: `crates/trusted-server-js/lib/src/kernel/integration_registry.ts`
+- Modify: `crates/trusted-server-js/lib/src/kernel/runtime.ts`
+- Modify: `crates/trusted-server-js/lib/src/kernel/sessions.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/googletag.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/prebid.ts`
+- Modify: `crates/trusted-server-js/lib/src/adapters/messaging.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/config.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/log.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/queue.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/registry.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/trace.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/request.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/auction.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/composition/browser.ts`
+- Modify: `crates/trusted-server-js/lib/test/composition/browser.test.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/datadome/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/google_tag_manager/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/lockr/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/osano/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/permutive/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/testlight/index.ts`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js`
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+- Modify: `crates/trusted-server-core/src/tsjs.rs`
+- Modify: `crates/trusted-server-core/src/auction/endpoints.rs`
+- Modify: `crates/trusted-server-core/src/auction/formats.rs`
+- Modify: `crates/trusted-server-core/src/integrations/registry.rs`
+- Modify: `crates/trusted-server-adapter-fastly/src/app.rs`
+- Modify: `crates/trusted-server-adapter-axum/src/app.rs`
+- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs`
+- Modify: `crates/trusted-server-adapter-spin/src/app.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/parity.rs`
+- Modify: `crates/trusted-server-core/src/html_processor.rs`
+- Modify: `crates/trusted-server-core/src/integrations/prebid.rs`
+- Modify: `crates/trusted-server-core/src/integrations/didomi.rs`
+- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js`
+- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs`
+- Modify: `crates/trusted-server-js/lib/build-all.mjs`
+- Modify: `crates/trusted-server-js/lib/test/core/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/auction.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/kernel/runtime.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/render.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/slots.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/projections.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/targeting.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/reservations.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/auction_batch.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/services/context.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/queue.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/registry.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/log.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/core/trace.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/adapters/googletag.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/adapters/prebid.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/adapters/messaging.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/gpt_bootstrap.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts`
+
+- [ ] **Step 1: Add failing tests proving fallback begins only after an attributable TS-owned**
+ empty GAM cycle; the primary child settles before fallback starts; publisher,
+ ambiguous, quarantined, timeout, and stale cases do not fall back; both child
+ histories remain immutable; and `SlotOperation` publishes exactly one final
+ result with `path:'fallback'` when the child runs.
+
+- [ ] **Step 2: Snapshot render-relevant configuration at attempt creation. Re-check generation**
+ and existing kill-switch state immediately before the earliest irreversible
+ action (bridge response, DOM insertion, or an existing non-APS notification).
+
+- [ ] **Step 3: Preserve existing non-APS `nurl`/`burl` behavior but route it through the attempt**
+ terminal transition so it initiates once and never blocks. Add an assertion that
+ APS never synthesizes either URL.
+
+- [ ] **Step 4: Test already-loaded-page limits honestly: configuration changes reach a page only**
+ through an existing response path; do not add polling, push, or event ingestion.
+
+- [ ] **Step 5: Atomically activate the new production surface in one task and one commit:**
+ - `/auction` emits/parses only the exact decision-set/tagged-source wire, and
+ initial HTML/page-bids emit only `tsjs.boot.auctionProjection`;
+ - the immutable initial projection seeds the first `NavigationSession`; every SPA
+ page-bids response validates and commits only to the replacement session's
+ internal projection and never mutates recursively frozen `tsjs.boot`;
+ - projection parsing enforces the exact 256-array/member, identifier, targeting,
+ currency/CPM, reservation, dimension, and canonical 8 MiB bounds before mutation;
+ an over-cap projection converts every otherwise winning decision to
+ `winner_not_renderable`, emits no projected bid, and omits the corresponding
+ `/auction` TS seatbid;
+ - the server emits exact frozen `TsjsBootV1`, `CreativeBootV1`,
+ `DiagnosticsBootV1`, and `BootManifestV1` before core from generated release
+ metadata, after validating every integration config and manifest relationship;
+ - core inertly prepares every required integration in manifest order while no
+ bridge/listener/global mutation is live. Only after all Promises resolve does the
+ same-task synchronous activation barrier install the capture bridge as its first
+ reversible core effect, install correctness GPT listeners, and activate modules
+ in order with monotonic pre/post-call and pre-handoff checks. Failure rolls back
+ every reversible effect; success commits the complete `TsjsApi`, runs staged
+ `afterCommit` callbacks in manifest order, and drains the preload queue;
+ - the preload queue handoff uses the exact real-Array algorithm: capture ingress,
+ install the fixed installing descriptor, snapshot, forward retained ingress
+ pushes, install the frozen final actual Array with own immediate `push` and
+ `length:0`, publish the complete API, run `afterCommit`, then drain snapshot plus
+ forwarded work exactly once. Native/borrowed mutators and retained references
+ cannot retain entries or create a second runtime;
+ - the kernel surface is exactly `TsjsApi` with semantic `version`, exact
+ `releaseId`, immutable `boot`, real `que`, `addAdUnits`, Promise `requestAds`,
+ local `log`, diagnostics, `_registerIntegration`, and frozen status-only
+ `_internal`. Fallback exposes its exact smaller own surface, validates then refuses
+ `addAdUnits`, settles known slots with the committed fallback reason, drains the
+ queue once, and creates no runtime/adapters/listeners/timers/DOM work;
+ - `addAdUnits` transactionally validates and registers programmatic direct-auction
+ slots against the same combined 256-slot cap, exact identifier/bidder/dimension
+ grammar, and collision indexes. Omitted-slot `requestAds` snapshots server and
+ programmatic registrations in ordinal order; later registrations cannot enter an
+ in-flight snapshot;
+ - GPT, Prebid, APS, creative, diagnostics, all remaining integrations, Promise
+ `requestAds`, versioned APS renderer client, and generated bootstrap/fallback
+ switch together on the shared sessions/services and terminal latches;
+ - the external publisher artifact switches as independently useful pure Prebid.js
+ 10.26.0 with its own watchdog and frozen artifact stamp; TS admission, render,
+ refresh, targeting, and release matching remain only in the separate Prebid
+ integration module;
+ - all adapters atomically register only the versioned static renderer and
+ unversioned live `/integrations/aps/runner.js` proxy; the abandoned
+ `/integrations/aps/runner/v1.js` and unversioned renderer are local negative
+ routes;
+ - every Rust/JS integration config emitter moves its existing values from
+ scattered `window.__tsjs_*` globals into its exact `tsjs.boot.*` member before
+ the corresponding integration prepares; no integration loses configuration;
+ - accepted artifacts, `WinnerContext`, targeting journals, renderer reservations,
+ GPT physical-object reconciliation, and navigation ownership use the shared
+ services; and
+ - render trace and GPT diagnostics commit only after correctness transitions and
+ expose their exact bounded asynchronous frozen APIs. Creative guards auto-install
+ from frozen boot configuration and both-false guards have zero DOM side effects.
+
+ Run old-surface and new-surface fixture tests immediately before the switch, then
+ require the entire suite green after it. Do not add a production selector, dual
+ manifest, or shape autodetection. The temporarily unused server routes and old
+ declarations are deleted in Task 22 before release.
+
+- [ ] **Step 6: Run:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib test -- --run test/services test/core test/integrations/gpt
+ npm --prefix crates/trusted-server-js/lib test -- --run test/integrations/prebid test/integrations/aps test/kernel
+ npm --prefix crates/trusted-server-js/lib run build
+ cargo test-fastly
+ cargo test-axum
+ cargo test-cloudflare
+ cargo test-spin
+ cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity
+ ```
+
+### Phase 4 exit
+
+- GPT, Prebid, APS, and all integration entry points use one kernel/integration-module
+ surface.
+- The old registries, sentinels, expandos, refresh wrappers, and bridge branches are
+ gone.
+- All Vitest and production-bundle tests pass.
+
+## Phase 5 — browser conformance, deletion, and release readiness
+
+### Task 20: Build the hermetic browser race matrix
+
+**Files:**
+
+- Modify: `crates/trusted-server-integration-tests/browser/playwright.config.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts`
+- Create: `crates/trusted-server-integration-tests/browser/tests/shared/aps-puc-lifecycle.spec.ts`
+- Create: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-runtime.spec.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/helpers/infra.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/helpers/state.ts`
+- Modify: `crates/trusted-server-integration-tests/browser/fixtures/fictional-aps-runner.js`
+- Modify: `crates/trusted-server-integration-tests/browser/fixtures/prebid-universal-creative-1.17.2.js`
+- Modify: `scripts/integration-tests-browser.sh`
+- Modify: `.github/workflows/integration-tests.yml`
+
+- [ ] **Step 1: Extend Playwright projects to Chromium, Firefox, and WebKit for the focused APS**
+ conformance files. Keep the broader existing suite's browser matrix unchanged
+ unless runtime permits expansion.
+
+ Extend the clean-checkout browser script's `TS_BROWSER_PROJECTS` input so it
+ installs the selected engines and forwards Playwright arguments with
+ `npm --prefix ... exec -- playwright`; it must retain the release-WASM, Viceroy
+ config, Docker image, npm install, and TSJS fixture preparation from Task 0.
+
+- [ ] **Step 2: Create deterministic local GPT and locally authored fictional APS-runner**
+ success/failure fixtures; run the vendored exact PUC 1.17.2 artifact for the
+ creative path. The fictional runner must not copy, transform, derive from, or
+ archive APS runner bytes and must never be packaged as a production fallback. Do
+ not replace PUC's `prebidMessenger`, `runDynamicRenderer`, or `h.sendMessage`
+ behavior and do not mock the kernel/services under test.
+
+- [ ] **Step 3: Implement every spec §7.2 browser-observable race as grouped tables with exact**
+ terminal, DOM, targeting, listener, port, timer, and network assertions:
+ - PUC claim/join: simultaneous duplicate requests; live/tombstoned/native ids;
+ wrong source/slot, altered id, SafeFrame-shaped nesting, claim before/after
+ attributable nonempty or empty GAM, navigation/supersession on both sides, and
+ replay at tombstone expiry;
+ - capability/channel ownership: ticket/nonce capacity and eighth-draw collision,
+ zero/one/two transferred ports, registration before/at/after deadline, caller
+ abort before/after registration/insertion/document acceptance, channel loss,
+ settlement-post throw, owner watchdog versus late response, and exactly one
+ `OwnerSettlementV1` plus Promise settlement. The remote owner removes only an
+ uncommitted iframe at 20 seconds; accepted DOM survives;
+ - APS/ADM/cache documents: renderer load/error/removal/replacement, runner
+ acknowledgement/failure/timeout, exact 1/4096 dimensions in Rust/TS/embedded ES5/
+ cache/PUC DOM, ADM initial `about:blank` versus intended `srcdoc`, and proof that
+ only the current intended navigation can accept. Cache expansion uses only
+ `String(attempt.winnerContext.selectedCpm)` and leaves `${AUCTION_PRICE:B64}`
+ untouched;
+ - runtime/bootstrap: prepare reject/abort, activation throw at every checkpoint,
+ 9,999/10,000/10,001 ms boundaries, duplicate `afterCommit`, 15/16 member capacity,
+ late continuation after fallback, publisher work during startup, exact same-task
+ rollback, full/fallback `TsjsApi` own surfaces, malformed boot, actual-Array queue
+ swap/retained references/native mutators/nested pushes/callback throws, and missing
+ main bundle after server projection;
+ - navigation/projection/API: immutable initial boot versus SPA-owned replacement,
+ stale/duplicate/malformed page-bids, exact grammar/count/UTF-8 and 8 MiB all-winner
+ reduction, 255/256/257 combined server/programmatic slots, transactional
+ `addAdUnits`, explicit and omitted `requestAds` snapshots, unknown/colliding ids,
+ concurrent partially overlapping batches, aborts/timeouts, logger/error behavior,
+ and attempt-prefix/ordinal exhaustion without issued-id retention;
+ - GPT/targeting: readiness, request-start, and completion on both deadline sides;
+ SRA ordering, duplicate response ids, old navigation callbacks, handoff and
+ disabled-initial-load suppression, DOM reconciliation at 249/250 and
+ 4,999/5,000 ms, two-success cap, throw/false destroy, no second physical slot,
+ publisher ownership, and identical-string targeting generations plus same-value/
+ different-value set, per-key clear, clear-all, non-top rebase, and artifact
+ promotion/disposal races;
+ - Prebid: missing/stub/late/duplicate/older/partial artifacts; exact 10.26.0 and all
+ stamp/manifest caps; same-release reuse, different-release refusal, hostile own
+ property shapes, watchdog versus late module, `pbjs`/stamp replacement, exact
+ prepared-bid admission and non-publication, bidder aliases/user-ID/EID coverage,
+ selection/loser/timeout/abort behavior, refresh exclusions, native bid/queue
+ survival, and proof that the external artifact has no TS auction/render behavior;
+ - creative/diagnostics/integrations: every creative policy/boot/automatic-guard/
+ opaque-click case; render-trace ordering/enrichment/200-history/200-pending/32-
+ subscriber/async-delivery cases with no event alias; GPT diagnostics 512-fact,
+ 64-slot, 10-cycle, 128-issue, 32-subscriber, latest-snapshot and inactive-zero-
+ effect cases; and every remaining integration alone/maximal with startup failure,
+ disposal, matcher, storage/consent, callback, and cross-isolation cases; and
+ - transport/protocol: every message/body/count/string boundary at minus-one/exact/
+ plus-one with multibyte and malformed encodings, plus the complete runner-proxy
+ redirect, media type, encoding, content-length, streamed-size, slow-drip, header-
+ stripping, byte-preserving, and empty non-leaking failure corpus through all
+ actual adapters, including Cloudflare and Spin wasm evidence.
+
+- [ ] **Step 4: Assert DOM/network/lifecycle outcomes directly. The suite must run with no**
+ external analytics or persistence service.
+
+- [ ] **Step 5: Run:**
+
+ ```bash
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin
+ TS_BROWSER_FRAMEWORKS=nextjs \
+ TS_BROWSER_PROJECTS=chromium,firefox,webkit \
+ ./scripts/integration-tests-browser.sh \
+ tests/shared/aps-renderer.spec.ts \
+ tests/shared/aps-puc-lifecycle.spec.ts \
+ tests/shared/tsjs-runtime.spec.ts \
+ --project=chromium --project=firefox --project=webkit
+ ```
+
+### Task 21: Add and pass the attested real-GAM test-network suite
+
+**Files:**
+
+- Create: `crates/trusted-server-integration-tests/browser/tests/shared/aps-real-gam.spec.ts`
+- Create: `crates/trusted-server-integration-tests/browser/helpers/gam-test-network.ts`
+- Create: `crates/trusted-server-integration-tests/browser/playwright.real-gam.config.ts`
+- Create: `crates/trusted-server-integration-tests/fixtures/configs/aps-real-gam.template.toml`
+- Create: `.github/workflows/aps-real-gam.yml`
+- Do not create a second runbook; this plan contains the release gate and commands
+
+- [ ] **Step 1: Add a manually dispatched `aps-real-gam.yml` job using the protected GitHub**
+ environment `aps-real-gam` and a required `workflow_dispatch` string input
+ `release_id`, plus required `evidence_id` and `previous_artifact_id` inputs used
+ only for attestation/rollback evidence. Its exact environment contract is
+ `TS_REAL_GAM_PAGE_URL`, `TS_REAL_GAM_AUTH_HEADER`, and
+ `TS_REAL_GAM_EXPECTED_RELEASE_ID`; the first two are protected secrets and no
+ value is checked in. The template contains fictional placeholders only. This job
+ is not added to ordinary PR CI, but a successful run for the exact release id is
+ a mandatory cutover artifact.
+
+ The dedicated Playwright config has no local `globalSetup`, `globalTeardown`,
+ Viceroy, Docker, or WASM dependency. It runs only the remote real-GAM spec against
+ `TS_REAL_GAM_PAGE_URL`; importing the shared local config is forbidden by a test.
+
+- [ ] **Step 2: Cover SSAT APS-PUC, Trusted Server Prebid APS-PUC, page-bids APS-PUC, direct APS,**
+ direct ADM/cache, attributable empty fallback, SRA, refresh, SPA navigation, and
+ collapsed-shell resize.
+
+- [ ] **Step 3: Add negative fixtures for wrong id/source, invalid descriptor, no outer claim,**
+ no owner registration, no document acknowledgement, and APS runner failure.
+ Exercise the live fixed-target proxy and assert route, DOM, nested-iframe, exact
+ lifecycle callback, and terminal outcomes. There is no runner digest/version
+ check: mutable APS bytes are not TS source or release identity.
+
+- [ ] **Step 4: Capture browser console, GPT events, sanitized network metadata, DOM snapshots,**
+ screenshots, and sanitized traces under `test-results/`, `playwright-report/`,
+ and `real-gam-evidence/`. Disable response-body/HAR embedding for the APS runner
+ and creative resources and strip any such bodies if the browser tool records them
+ despite configuration. Before upload, inspect every archive/trace/HAR and fail if
+ it contains a runner or creative response body, authorization value, account id,
+ descriptor, or lifecycle capability; metadata such as URL/status/timing is
+ allowed. Upload one artifact named
+ `aps-real-gam-` with the actual GitHub run id and 30-day retention.
+ Pass/fail comes from
+ exact request/DOM/lifecycle assertions.
+
+- [ ] **Step 5: Require a clean pass in Chromium, Firefox, and WebKit before cutover. If the GAM**
+ test network itself cannot support a browser, record that as a release blocker
+ instead of silently weakening the criterion.
+
+- [ ] **Step 6: Provide and verify the exact manual equivalent from the browser package:**
+
+ ```bash
+ TS_REAL_GAM_PAGE_URL=... \
+ TS_REAL_GAM_AUTH_HEADER=... \
+ TS_REAL_GAM_EXPECTED_RELEASE_ID=... \
+ npm --prefix crates/trusted-server-integration-tests/browser exec -- \
+ playwright test --config=playwright.real-gam.config.ts \
+ tests/shared/aps-real-gam.spec.ts \
+ --project=chromium --project=firefox --project=webkit
+ ```
+
+### Task 22: Delete final legacy surfaces and enforce absence
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/package.json`
+- Modify: `crates/trusted-server-js/lib/eslint.config.js`
+- Create: `crates/trusted-server-js/lib/scripts/check-architecture.mjs`
+- Modify: `crates/trusted-server-js/lib/src/core/global.d.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/core/types.ts`
+- Delete: `crates/trusted-server-js/lib/src/core/context.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/prebid/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/aps/render.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/creative/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/shared/globals.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/didomi/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/index.ts`
+- Modify: `crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts`
+- Modify: `crates/trusted-server-js/lib/build-prebid-external.mjs`
+- Modify: `crates/trusted-server-js/lib/test/core/request.test.ts`
+- Delete: `crates/trusted-server-js/lib/test/core/context.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/aps/render.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/creative/helpers.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/didomi/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/bootstrap.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/integrations/sourcepoint/index.test.ts`
+- Modify: `crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_bootstrap.js`
+- Modify: `crates/trusted-server-core/src/html_processor.rs`
+- Modify: `crates/trusted-server-core/src/integrations/prebid.rs`
+- Modify: `crates/trusted-server-core/src/integrations/didomi.rs`
+- Modify: `crates/trusted-server-core/src/integrations/sourcepoint.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics.rs`
+- Modify: `crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js`
+- Modify: `crates/trusted-server-core/src/publisher.rs`
+- Modify: `crates/trusted-server-core/src/integrations/aps.rs`
+- Modify: `crates/trusted-server-core/src/auth.rs`
+- Modify: `crates/trusted-server-adapter-fastly/src/app.rs`
+- Modify: `crates/trusted-server-adapter-axum/src/app.rs`
+- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs`
+- Modify: `crates/trusted-server-adapter-spin/src/app.rs`
+- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs`
+- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs`
+- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs`
+- Modify: `crates/trusted-server-integration-tests/tests/parity.rs`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts`
+- Modify: `docs/guide/integrations/aps.md`
+- Modify: `docs/guide/auction-orchestration.md`
+- Modify: `docs/guide/configuration.md`
+- Modify: `docs/guide/creative-processing.md`
+- Modify: `docs/guide/integration-guide.md`
+- Modify: `docs/guide/integrations/prebid.md`
+- Modify: `docs/guide/integrations/didomi.md`
+
+- [ ] **Step 1: Add an absence test/search for:**
+ - `globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`,
+ `setConfig`, and `getConfig`;
+ - legacy `window.__tsjs_*` runtime flags;
+ - callback/void `requestAds`, `renderAdUnit`, `renderAllAdUnits`, mutable generic
+ config, `TsjsApiV1`, and every old public declaration/alias;
+ - `tsjs.renders`, `renderLog`, `renderSeq`, `tsjs:adRendered`,
+ `tsjs.gptDiagnostics`, and old GPT diagnostics flags/expandos;
+ - `__tsRenderGeneration` and `__tsRenderBid`;
+ - `tsjs.apsPrebidRenderers`;
+ - the module-global core context-provider map and integration imports of
+ `registerContextProvider`/`collectContext`;
+ - integration-owned GPT/Prebid function sentinels;
+ - duplicate `Prebid Request` listeners and refresh wrappers;
+ - empty catches in migrated paths;
+ - `PAGE_BIDS_LEGACY_PATH`, `/__ts/page-bids`, and the JS retry/fallback marker;
+ - the unversioned `/integrations/aps/renderer` route;
+ - APS `pub_id` deserialization alias and its compatibility documentation;
+ - any APS runner asset, copied body, version/digest/metadata/license record,
+ updater/downloader, SRI/integrity attribute, generated runner artifact, offline
+ fallback, positive `/integrations/aps/runner/v1.js` route, or runner-cache
+ requirement;
+ - any enabled `TS_TEST_APS_V1`, integration-only upstream resolver, loopback
+ fixture address, Wrangler service binding, or Spin/Viceroy proxy-test manifest
+ reference in a production build/release artifact;
+ - every temporary architectural lint allowlist entry.
+
+ For `window.__tsjs_*`, assert absence in all shipped JavaScript, including the pure
+ generated Prebid external artifact. For every former integration configuration
+ emitter/consumer, separately assert the exact immutable `tsjs.boot.*` replacement
+ in server output, integration consumers, fixtures, and current guides; deleting an
+ emitter without migrating its value is a test failure.
+
+ Scope the executable search to shipped source, current guides, tests, scripts,
+ and workflows; exclude historical `docs/superpowers` designs/plans because this
+ work does not rewrite separate completed specifications. The enumerated file list
+ above is the current baseline hit inventory and must be updated if Task 0 finds
+ another in-scope hit.
+
+- [ ] **Step 2: Delete unreachable old paths, compatibility declarations,**
+ branches, and test-only production exports. Keep only `tsjs.que`, `tsjs.boot`,
+ the exact `TsjsApi` public surfaces, `_registerIntegration`, and the frozen
+ status-only `_internal` surface described by the spec. Do not expose the
+ service registry or a second integration-registration name.
+
+- [ ] **Step 3: Make `/__ts/page-bids`, the unversioned renderer path, unknown renderer versions,**
+ and `/integrations/aps/runner/v1.js` local unknown-route responses, never aliases.
+ Keep only `/_ts/page-bids`, static `/integrations/aps/renderer/v1`, and live
+ fixed-target proxy `/integrations/aps/runner.js`. Update route/parity tests and
+ APS/configuration guides to those hard-cutover surfaces. The absence test must
+ distinguish the required negative `runner/v1.js` assertion from a forbidden
+ positive handler.
+
+- [ ] **Step 4: Add an executable absence script to `package.json` and CI, build every**
+ integration combination used by server fixtures, and rerun all TS and adapter
+ route tests.
+
+### Task 23: Add deterministic bundle, browser-time, and retained-heap gates
+
+**Files:**
+
+- Modify: `crates/trusted-server-js/lib/build-all.mjs`
+- Modify: `crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs`
+- Read: `crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json`
+- Modify: `crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts`
+- Modify: `.github/workflows/test.yml`
+- Modify: `.github/workflows/integration-tests.yml`
+
+- [ ] **Step 1: Consume, but do not regenerate, the pre-change artifact captured in Task 0. Fail**
+ minimal/reference/maximal deterministic gzip or Brotli growth above 5% unless a
+ separate review explicitly updates the baseline.
+
+- [ ] **Step 2: On the pinned Chromium/CI-machine/fixture, measure boot-to-first-display p90 after**
+ five warmups and 50 samples and require ≤1.10× the Task 0 baseline. Do not rerun
+ selectively to turn a failed sample into a pass.
+
+- [ ] **Step 3: Through Chromium CDP, collect garbage then record retained heap after boot, first**
+ render, refresh, and SPA navigation; gate each checkpoint at ≤1.10×. Firefox and
+ WebKit remain correctness-only and do not emit synthetic heap equivalents.
+
+- [ ] **Step 4: Keep these gates separate from render correctness: performance cannot convert a**
+ failed conformance test to a pass.
+
+- [ ] **Step 5: Run the gate from a clean checkout through the same fixture-preparation script**
+ and write a separate measured artifact; never overwrite the Task 0 baseline:
+
+ ```bash
+ TS_BROWSER_FRAMEWORKS=nextjs \
+ TS_BROWSER_PROJECTS=chromium \
+ TSJS_PERF_MODE=gate \
+ TSJS_PERF_OUTPUT=crates/trusted-server-integration-tests/browser/test-results/tsjs-performance-current.json \
+ ./scripts/integration-tests-browser.sh \
+ tests/shared/tsjs-performance.spec.ts --project=chromium
+ node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs
+ ```
+
+### Task 24: Run final repository verification and assemble the cutover evidence
+
+**Files:**
+
+- Modify: `.github/workflows/test.yml`
+- Modify: `.github/workflows/integration-tests.yml`
+- Modify: `.github/workflows/aps-real-gam.yml`
+- Do not create another plan/design/runbook; workflow artifacts are the evidence
+
+- [ ] **Step 1: Run formatting:**
+
+ ```bash
+ cargo fmt --all -- --check
+ npm --prefix crates/trusted-server-js/lib run format
+ npm --prefix docs run format
+ ```
+
+- [ ] **Step 2: Run Rust correctness and lint for every adapter:**
+
+ ```bash
+ cargo test-fastly
+ cargo test-axum
+ cargo test-cloudflare
+ cargo test-spin
+ cargo clippy-fastly
+ cargo clippy-axum
+ cargo clippy-cloudflare
+ cargo clippy-cloudflare-wasm
+ cargo clippy-spin-native
+ cargo clippy-spin-wasm
+ cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime axum
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime fastly
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime cloudflare
+ ./scripts/integration-tests-aps-runner-proxy.sh --runtime spin
+ cargo clippy --manifest-path crates/trusted-server-integration-tests/Cargo.toml --all-targets -- -D warnings
+ ```
+
+- [ ] **Step 3: Run TypeScript and bundle verification:**
+
+ ```bash
+ npm --prefix crates/trusted-server-js/lib ci
+ npm --prefix crates/trusted-server-js/lib run typecheck
+ npm --prefix crates/trusted-server-js/lib run lint
+ npm --prefix crates/trusted-server-js/lib test
+ npm --prefix crates/trusted-server-js/lib run build
+ npm --prefix crates/trusted-server-js/lib run build:prebid-external
+ npm --prefix crates/trusted-server-js/lib run check:aps-contract
+ npm --prefix crates/trusted-server-js/lib run check:architecture
+ node --test crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs
+ node crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs
+ npm --prefix docs run lint
+ npm --prefix docs run build
+ ```
+
+- [ ] **Step 4: Run three clean-checkout workflows for the exact release commit. Add**
+ `workflow_dispatch` with required `evidence_id` and `release_id` inputs to
+ `test.yml`; that workflow executes the complete format/typecheck/lint/Vitest/
+ bundle/Rust-test/clippy matrix from steps 1–3 and uploads its command logs plus
+ validated release id. The integration workflow owns adapter startup/artifact
+ setup and executes the full integration suite plus focused
+ Chromium/Firefox/WebKit APS files. The protected real-GAM workflow runs for the
+ same ref/release id.
+
+ Add manual `evidence_id`, `release_id`, and `previous_artifact_id` inputs where
+ relevant and include the evidence id in each workflow's `run-name`. After the
+ checked build, derive `RELEASE_ID` only from Task 8's validated generated
+ manifest. For the repository's Fastly production target, the authoritative prior
+ immutable artifact is the one active Fastly service version immediately before
+ dispatch; query it through the pinned Fastly CLI and require exactly one active
+ version. Pass both values explicitly; an empty/ambiguous value is a blocker.
+ Dispatch only a pushed branch verified to resolve to `RELEASE_SHA`, capture each
+ exact run id through the Task 0 dispatch helper, wait with `--exit-status`, and
+ verify every `headSha`, conclusion, and release-id attestation:
+
+ ```bash
+ RELEASE_REF="$(git branch --show-current)"
+ RELEASE_SHA="$(git rev-parse HEAD)"
+ RELEASE_ID="$(npm --prefix crates/trusted-server-js/lib run --silent print:release-id)"
+ test -n "$RELEASE_REF"
+ test -n "$RELEASE_ID"
+ test -n "$FASTLY_SERVICE_ID"
+ PREVIOUS_FASTLY_VERSION="$(fastly service version list \
+ --service-id "$FASTLY_SERVICE_ID" --json | \
+ jq -er '[.[] | select(.active == true)] | if length == 1 then .[0].number else error("expected one active Fastly version") end')"
+ PREVIOUS_ARTIFACT_ID="fastly-service-version:$PREVIOUS_FASTLY_VERSION"
+ git fetch origin "$RELEASE_REF"
+ test "$RELEASE_SHA" = "$(git rev-parse "origin/$RELEASE_REF")"
+ test -n "$PREVIOUS_ARTIFACT_ID"
+ EVIDENCE_ID="aps-tsjs-cutover-$RELEASE_SHA"
+ QUALITY_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \
+ test.yml "$RELEASE_REF" \
+ evidence_id="$EVIDENCE_ID" \
+ release_id="$RELEASE_ID")"
+ INTEGRATION_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \
+ integration-tests.yml "$RELEASE_REF" \
+ evidence_id="$EVIDENCE_ID" \
+ release_id="$RELEASE_ID" \
+ previous_artifact_id="$PREVIOUS_ARTIFACT_ID")"
+ REAL_GAM_RUN_ID="$(node scripts/dispatch-workflow-run.mjs \
+ aps-real-gam.yml "$RELEASE_REF" \
+ evidence_id="$EVIDENCE_ID" \
+ release_id="$RELEASE_ID" \
+ previous_artifact_id="$PREVIOUS_ARTIFACT_ID")"
+ for RUN_ID in "$QUALITY_RUN_ID" "$INTEGRATION_RUN_ID" "$REAL_GAM_RUN_ID"; do
+ gh run watch "$RUN_ID" --exit-status
+ test "$RELEASE_SHA" = "$(gh run view "$RUN_ID" --json headSha --jq .headSha)"
+ test success = "$(gh run view "$RUN_ID" --json conclusion --jq .conclusion)"
+ done
+ ```
+
+ All three exact runs must conclude `success`; every evidence manifest must report
+ the same embedded release id and commit SHA, and integration plus real-GAM
+ artifacts must report the same prior artifact id. The dispatch helper or a
+ post-download manifest check verifies those fields against `RELEASE_ID`,
+ `RELEASE_SHA`, and `PREVIOUS_ARTIFACT_ID`; a run-name alone is not evidence. An
+ unavailable protected environment is a blocker.
+
+- [ ] **Step 5: Audit the final diff:**
+ - only planned source/test/build/runbook surfaces changed;
+ - no old/new compatibility path remains;
+ - no new external observability, persistence, billing, or experiment artifact;
+ - no descriptor/capability/account/creative payload is logged;
+ - no empty catch or unowned timer/listener/port/iframe in migrated paths;
+ - every one of the 144 pinned `rc/july` files maps through all 38 live rows to the
+ same 23 ledger ids, with no unmapped/dead/gap result;
+ - no integration-module preparation performs observable work, no activation yields,
+ and no post-fallback callback can revive the kernel;
+ - the public surface is `TsjsApi` only; numeric suffixes remain only on serialized
+ versioned boot/wire/artifact schemas; and
+ - no unrelated integration behavior was refactored.
+
+- [ ] **Step 6: Extend the workflows to upload `aps-tsjs-quality-`,**
+ `aps-tsjs-cutover-`, and `aps-real-gam-` artifacts,
+ substituting actual GitHub values. Include exact command logs, sanitized
+ Playwright reports/traces, route parity output, corpus/staleness output, bundle/
+ performance reports, release id, commit SHA, run id, conclusion, and where
+ applicable the prior deployable artifact id. Run the Task 21 pre-upload scrub on
+ every browser artifact and fail if APS runner/creative bodies, secrets,
+ descriptors, or capabilities are present. GitHub Actions artifacts for those
+ three successful runs are the sole evidence location; do not create another
+ repository document.
+
+## Cutover procedure
+
+Use the existing deployment mechanism; this plan adds no router or experiment
+infrastructure.
+
+1. Deploy to pre-production and rerun renderer-route, direct, PUC, refresh, SRA, and
+ SPA smoke tests.
+2. Confirm the final artifact contains only the new runtime/API and that server and
+ TSJS bundles belong to the same ordinary release.
+3. Hold an exclusive production deployment window from Task 24 evidence capture
+ through cutover. Immediately before deployment, re-query the active Fastly
+ version with the same exact command and require it to equal the attested
+ `PREVIOUS_FASTLY_VERSION`:
+
+ ```bash
+ CURRENT_FASTLY_VERSION="$(fastly service version list \
+ --service-id "$FASTLY_SERVICE_ID" --json | \
+ jq -er '[.[] | select(.active == true)] | if length == 1 then .[0].number else error("expected one active Fastly version") end')"
+ test "$CURRENT_FASTLY_VERSION" = "$PREVIOUS_FASTLY_VERSION"
+ ```
+
+ Any mismatch blocks deployment and requires fresh evidence for the new prior
+ artifact; never roll back to a stale attestation.
+
+4. Perform one binary production cutover. Immediately verify:
+ - existing service availability/error/latency health is normal;
+ - APS renderer endpoint smoke passes;
+ - direct APS and one GAM/PUC APS smoke pass;
+ - no CSP/security console regression;
+ - no non-APS cache/ADM, native Prebid, refresh, SRA, or SPA regression.
+
+5. Hold before cutover if evidence is missing. After cutover, roll back the complete
+ artifact immediately on a TS code, request, CSP/security, or non-APS regression;
+ do not add percentage routing or dual-pool infrastructure. For Fastly, rollback
+ reactivates the exact attested prior immutable version:
+
+ ```bash
+ fastly service version activate \
+ --service-id "$FASTLY_SERVICE_ID" \
+ --version "$PREVIOUS_FASTLY_VERSION"
+ ```
+
+6. Treat a live-runner incident separately because binary rollback cannot restore
+ older APS-owned bytes. If the proxied runner is unavailable, incompatible, or
+ produces suspect completion behavior, disable `[integrations.aps]` using the
+ existing configuration mechanism. Verify that new APS bids are not admitted and
+ both reserved APS routes return local `404 no-store` without publisher fallback.
+ Keep APS disabled until the controlled Chromium/Firefox/WebKit real-browser
+ conformance gate passes again; do not vendor or pin a runner as containment.
+7. Monitor existing operational signals for 24 hours and rerun the focused
+ real-browser smoke suite.
+8. Confirm again that the deployed artifact contains no development selector or
+ compatibility path; Task 22 made this a pre-release absence gate.
+
+## Completion criteria
+
+The plan is complete only when:
+
+1. All five render flows pass exact hermetic and real-GAM lifecycle assertions, and
+ every created attempt settles exactly once under the mandatory race matrix.
+2. Rust, TypeScript, embedded ES5, programmatic registration, cache expansion, and
+ DOM validation agree on the exact descriptor grammar and 1–4096 dimensions while
+ preserving invalid-versus-out-of-range reasons.
+3. Initial and SPA projections enforce all grammar/count/UTF-8/8 MiB bounds
+ transactionally; SPA state lives only in `NavigationSession`, boot stays frozen,
+ and over-cap projections reduce all winners to `winner_not_renderable` without TS
+ projected bids or `/auction` seatbids.
+4. Server reservations are the sole PUC authority, attempt/ticket/nonce issuers are
+ bounded as specified, and every live reservation/attempt retains the immutable
+ `WinnerContext` used for cache price expansion instead of response, targeting, or
+ current-projection data.
+5. GPT physical-cycle ownership, exact handoff, two-success DOM reconciliation,
+ transactional destroy/redefine, and the owner-and-value targeting journal pass all
+ publisher-mutation and same-string generation races without a second physical
+ slot or blind clear.
+6. PUC capture, owner-control, direct/remote iframe cleanup, accepted-artifact
+ promotion, and the 20-second owner watchdog obey their exact ownership boundaries;
+ native Prebid requests remain untouched.
+7. One runtime owns all integration modules, sessions, slots, projections, auction-
+ context contributors, reservations, batches, targeting frames, timers, listeners,
+ observers, ports, and renderer iframes. Module preparation is inert, activation is
+ synchronous and reversible, commit is atomic, and post-commit work cannot expose a
+ partial kernel.
+8. The kernel and fallback expose their exact `TsjsApi` own surfaces, semantic
+ version/release identity, immutable boot, actual-Array queue semantics, logger,
+ programmatic registration, Promise `requestAds`, and diagnostics presence. No
+ `TsjsApiV1` or placeholder/callback alias exists.
+9. The pure external Prebid.js 10.26.0 artifact independently drains publisher work,
+ implements the exact frozen artifact stamp and duplicate/conflict rules, binds
+ `pbjs` plus stamp identity, and contains no TS auction, admission, render,
+ targeting, refresh, global flag, or TSJS release coupling.
+10. Render trace and GPT diagnostics expose only the exact bounded frozen asynchronous
+ APIs; no correctness callback runs publisher diagnostics code, no legacy event or
+ alias remains, inactive GPT diagnostics has zero incremental side effects, and
+ creative guards auto-install from exact frozen boot data with both-false zero DOM
+ effects.
+11. Every one of the 144 pinned `rc/july` files maps through 38 live mappings to all
+ 23 ledger ids, and the complete GPT, Prebid, APS, creative, diagnostics, shared-
+ helper, remaining-integration, and browser parity corpora pass alone and in the
+ maximal manifest.
+12. Static renderer and live fixed-target runner-proxy status, exact headers,
+ bounded/deadline behavior, and fail-closed evidence parsing are proven through
+ all four actual adapter transports; no APS runner bytes/version/digest/license/
+ SRI/updater/fallback/cache requirement exists in TS source or release evidence.
+13. Legacy globals, expandos, sentinels, duplicate listeners/wrappers, mutable
+ creative/diagnostics surfaces, old routes, old declarations, and compatibility
+ shape detection are absent after the hard cutover.
+14. Non-APS cache/ADM, native Prebid, publisher GPT, SRA, refresh, SPA, creative,
+ notification, and every other integration regression suite passes without an
+ unrelated feature rewrite.
+15. Format, lint, typecheck, adoption/architecture/absence checks, all adapter tests
+ and clippy targets, Vitest, bundle/artifact builds, Playwright, size, browser-time,
+ and retained-heap gates pass in attested clean-checkout quality, integration, and
+ real-GAM runs for the exact release SHA and release id.
+16. The binary cutover and 24-hour monitor complete or the exact prior immutable
+ artifact is restored cleanly; no runtime selector, percentage router, or dual
+ protocol is introduced.
+17. The emergency APS-disable path stops admission and returns local `404 no-store`
+ for both reserved APS routes; binary rollback is never represented as restoring
+ mutable upstream runner bytes.
+18. No analytics, persistence, billing, experimentation, deployment-routing, or new
+ external observability requirement was introduced.
diff --git a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
index 78a31b9b7..d4cb142bc 100644
--- a/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
+++ b/docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md
@@ -1,1252 +1,3137 @@
# APS Render Fix and TSJS Resilience Architecture — Design
-- **Status:** revision 10 — closes the ninth review round (assignment-id
- trusted path, control-build event parity, affinity lifetime, two-phase
- bridge ack, missing-slot termination, materialized cycle denominator,
- publisher-bound affinity token, admin route family, billing estimator
- source, independent RC attestation) and re-inlines the alternatives and
- risk registers so the document is genuinely self-contained.
+- **Status:** revision 27 — hard-cutover contract with complete `rc/july` TSJS
+ adoption
- **Date:** 2026-08-04
-- **Baseline:** `rc/july` @ `248fe9558` ("Fix APS PUC rendering and collapsed
- GAM shells"). All file:line citations refer to this commit.
-- **Inputs:** three code audits; design reviews of revisions 1–8; open issues
- #926, #941, #944, #962, #964, #977, #983, #989, #993; open PR #997.
-- **Normative gates:** Appendix A ships with this design; changes require
- reviewed decision records.
-- **Adoption stance for the baseline APS fixes (`248fe9558`):** contracts
- adopted (MessageChannel handshake semantics, collapsed-shell remediation,
- consolidated bridge branch), implementations rebuilt inside the target
- architecture; the baseline browser tests pass unmodified as the
- conformance pin.
-
-## 0. Release policy: coordinated hard cutover
-
-- One release: server, TSJS bundles, config, HTML under one **`release_id`**.
- No N/N−1; in-flight clients may fail at cutover — accepted and stated.
-- **Exact release matching**; mismatch is a refusal.
-- **Config is a release-time, content-verified input.** `format_version`
- exact-match. **`config_hash` = SHA-256 over the exact fetched envelope
- bytes**, bound through compiled release metadata (or a signed manifest
- with an embedded verification key); the binary verifies at startup and
- fails loudly on mismatch. Publish order: blob, then manifest. Rollback =
- redeploy the previous release with its own verified config; binding
- prevalidated.
-- Assets: embedded only; hashed pathnames for cache identity; unknown hash →
- `410`, `no-store`.
-- **Authenticated sticky affinity.** The router sets `ts-rel` on HTML
- responses — format
- `r1.......`:
- `kid ^[a-z0-9-]{1,16}$`; canonical decimal `exp`; `phost` = normalized
- publisher host; `release ^[a-z0-9._-]{1,40}$` from the allowlist;
- `cohort ∈ {canary, control}`; `assignment_id` = 32-hex CSPRNG (the
- pseudonymous **randomization unit**, §8); `sig` = unpadded base64url
- HMAC-SHA-256 over the domain-separated length-prefixed input
- `"ts-affinity-v1" || u32be(len)||field …` over **every** field
- including `phost` (so a valid token replayed against another publisher
- host fails); keys owned/rotated by the routing layer (active + previous,
- retained ≥ the affinity lifetime + skew); constant-time verification;
- test vectors checked in. Attributes `Secure; HttpOnly; SameSite=Lax;
-Path=/`. Cache keys use the post-validation release label only.
-- **Affinity lifetime covers the experiment.** `exp` TTL is set to the
- **maximum experiment window** in force (default 14 days — the billing
- gate's 7 d + 7 d extension), not 24 h, so a canary visitor never crosses
- over to control mid-experiment. Renewal on any HTML response before
- expiry re-signs the **same `assignment_id` and `cohort`** with a fresh
- `exp` (a browser session's arm is fixed for the experiment). Expired
- tokens are distinguished from forged ones (expired = valid sig, past
- `exp`) and, during an active experiment, expired-but-valid tokens are
- renewed to their original arm rather than reassigned.
-- **assignment_id reaches telemetry only by a trusted server path.** The
- cookie is `HttpOnly`; the client never reads it and never sends
- `assignment_id`. The router **strips any inbound assignment/cohort
- header**, validates `ts-rel`, and injects trusted internal metadata
- (`X-TS-Cohort`, `X-TS-Assignment`, `X-TS-Release`) on the proxied
- request; the client-events handler stamps those onto every row
- server-side. Ingest rejects any client-supplied assignment/cohort field.
- Tests: spoofed inbound header stripped; wrong-arm; null-rate; cookie
- expiry mid-session; cross-pool.
-- **State-dependent routing defaults:** during canary, valid tokens route by
- their binding; invalid/forged → control + reissue; expired-but-valid →
- renewed to their arm (above). **Forward cutover ("weight 100%")
- explicitly retires the old release:** for HTML **and** non-HTML request
- families, stale/invalid/old-release-bound tokens are reassigned/routed to
- the active release — 100% means 100%. **Rollback symmetrically retires
- the new release:** still-valid canary bindings are overridden on every
- request family, not merely defaulted.
-- **CSP-report affinity never depends on cookies:** the renderer is
- sandboxed without `allow-same-origin` (`aps/render.ts:4`), so its
- browser-generated reports are cross-origin to the publisher endpoint and
- carry no cookie. Release/cohort identity is encoded in the
- **server-generated report path's `policy_id`** (minted per
- `{release, policy version, cohort}`, registered in the header manifest);
- the router routes `/_ts/csp-reports/` by that registry.
-- Beacon and trace-auth transports use `credentials: "same-origin"` so the
- affinity cookie routes them; handlers derive no identity from cookies.
-- **Canary/control measurement (closing the empty-control-arm gap):** the
- control pool runs an **observation-only control build** — baseline render
- _decisions_, but with **every measurement-only lifecycle hook the
- Phase-3 gates read**. The control build implements a normative
- **event-parity contract**: it emits `request_cycle_started`,
- `attempt_started`, `bridge_request`, `bridge_response_sent`,
- `renderer_document_loaded`/`adm_document_loaded`, and `render_terminal`
- at the **same timing points** as the canary build, populated from
- baseline behavior (e.g. its own `slotRenderEnded`, its own bridge
- responses) — only the render-decision code differs, never the event
- definitions or their emission points. Both arms therefore populate the
- funnel and latency gates identically. Arms write to **arm-specific
- datasources**; the union view stamps a trusted `deployment_pool` from the
- write identity. The event-parity contract is itself gated by an **A/A
- test** (canary-build vs canary-build) that must show zero metric
- movement before any A/B canary begins.
-- Router weight over sticky cohorts is the sole activation primitive; flags
- are in-pool emergency kill switches. Cutover = weight 100% + CDN purge;
- rollback = weight back + re-purge. The affinity acceptance test covers
- HTML, assets, APIs, beacons, and CSP reports (via path identity).
-
-## 1. Problem statement
-
-APS demand is fully integrated server-side, yet APS creatives do not appear
-reliably. Four serial fixes (the `bid.meta` carrier, the decoupled shim,
-the `hb_adid` fallback, the baseline PUC/collapsed-shell fix) each survived
-review; the pattern is the finding: **multiple independent failure points,
-most failing silently**, with no client→server signal about which fired.
-The TSJS library (56 files, ~11,900 lines, two ~1,800-line monoliths,
-duplicated ES5/TS logic, inverted layering, ~100 error-swallowing catches)
-is the same problem structurally.
-
-### Non-goals
-
-- No change to the APS OpenRTB endpoint contract (including its deliberate
- absence of `nurl`/`burl`, §G4d).
-- No rewrite of the decoupled Prebid.js strategy.
-- No backward compatibility (§0); replacement surfaces in §7.4.
-
-## 2. Why APS does not render — evidence
-
-Flows: (a) SSAT via `window.tsjs.bids`; (b) GAM + client `trustedServer`
-Prebid adapter; (c) SPA `/_ts/page-bids`; (d) direct `/auction`. Only (d)
-renders an APS descriptor without GAM.
-
-### 2.1 Admission
-
-| # | Failure | Where |
-| --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
-| A1 | A configured `[auction].mediator` discards every direct-provider bid; APS reports `success, bid_count: N`, never wins. | `orchestrator.rs:412-431` |
-| A2 | `allow_script_creatives` defaults `false`, dropping every `tagtype: "script"` APS bid; counted but invisible (A4). | `aps.rs:161`, `:334`, `:793` |
-| A3 | Strict gates: exact `w`×`h` membership; required `ext.creativeurl`; any top-level `contextual` key rejects the whole response. | `aps.rs:675`, `:763-796`, `:859` |
-| A4 | Drop reasons reach only `/auction` `ext.orchestrator`; SSAT/page-bids discard them; logs and `ts-debug` exclude them. | `publisher.rs:1866-1875`, `telemetry.rs:808-826` |
-
-### 2.2 Identity
-
-| # | Failure | Where |
-| --- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- |
-| B1 | GAM caps key-values at 40 chars; the raw APS bid id as `hb_adid` can fail the bridge match, no log. | `publisher.rs:3366-3372`, `gpt/index.ts:1695` |
-| B2 | Two id universes: SSAT keys on the APS bid id; the client adapter on Prebid's `adId`. | `publisher.rs:3366`, `prebid/index.ts:982` |
-
-### 2.3 Render
-
-| # | Failure | Where |
-| --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
-| C1 | If GAM never serves the PUC, nothing renders and nothing is recorded; `renderApsCreative` reachable only from flow (d). | `gpt/index.ts:923-1180`, `core/request.ts:59` |
-| C2 | A renderer endpoint that never answers is a silent 10 s death (opaque iframe cannot read HTTP status). | `aps.rs:1247`, `aps/render.ts:30`, `:415-437` |
-| C3 | SafeFrame breaks slot attribution (top-document iframe walk cannot see nested creative windows). | `gpt/index.ts:180-215` |
-| C4 | Three hand-maintained schema copies with exact-key rejection: a server field addition blanks every APS ad. | `types.rs:188-211`, `aps/render.ts:46-63`, `:152-162`, `aps.rs:65-93` |
-| C5 | Fixed at baseline `248fe9558`: the duplicate renderer branch was consolidated. | `gpt/index.ts:1729` |
-| C6 | The renderer CSP can kill creatives after "ready" (no `object-src`, workers, `blob:`/`data:` frames). | `aps.rs:49` |
-| C7 | Renderer branches record nothing: no trace record, no notifications. | `gpt/index.ts:1628-1760` |
-
-### 2.4 Observability
-
-Zero client→server reporting; a bid that never painted is byte-identical
-server-side to one that painted.
-
-### 2.5 Failure → signal mapping (normative)
-
-The **tester cookie** (non-security, `tester_cookie.rs:3`) gates **debug
-content** (`tsjs.boot.debug`, response `ext.trusted_server.debug` — same
-class as the existing `ts-debug` comment). The **diagnostic credential**
-(§5.3) gates **telemetry volume**. Neither crosses into the other's role.
-
-| Failure | Client event/reason (§5.1) | Server row/counter (§5.6) | One-page-load surface |
-| ------- | ---------------------------------------------------- | ------------------------------------- | ------------------------------- |
-| A1 | — | `selection_summary.winner_source` | `boot.debug` selection summary |
-| A2 | — | `bid_drop{script_rendering_disabled}` | `boot.debug` drop summary |
-| A3 | — | `bid_drop{invalid_dimensions,w,h}` | `boot.debug` drop summary |
-| A4 | — (fixed by §5.6) | `bid_drop` rows on all paths | `boot.debug` / response `debug` |
-| B1/B2 | `bridge_request{matched:false}` (§6.8) | join via trace | console warn |
-| C1 | `gam_empty` then no `bridge_request` | join via trace | console warn |
-| C2 | `render_terminal{failed, renderer_document_no_load}` | `ts_ops_counters` | console warn |
-| C3 | `render_terminal{failed, bridge_id_mismatch}` | join via trace | console warn |
-| C4 | `render_terminal{failed, descriptor_invalid}` | schema corpus CI | console warn |
-| C5 | — (fixed at baseline) | — | — |
-| C6 | `runner_failed` + CSP buckets | `ts_csp_reports` | console warn |
-| C7 | full §5.1 sequence from renderer branches | join via trace | debug/warn |
-
-## 3. The GPT and baseline reality
-
-1. Bootstrap-first hybrid; the bundle's handoff/initial-load code is dead
- in production.
-2. #922 merge loss (`0dc9b19a9`); PR #997 is the apparent replacement.
-3. TS refreshes never pass `changeCorrelator: false`.
-4. `enableSingleRequest()` called blind after publisher `enableServices()`.
-5. Responsive-resolution ambiguity silently skips slots.
-6. Three independent `pubads().refresh` wrappers.
-7. GPT has no cancellation, no per-refresh identity, no completion-order
- guarantee; `slotRenderEnded` = code injected; `responseIdentifier`
- identifies responses only.
-8. `display()` under disabled initial load creates no request — for any
- caller (`gpt/index.ts:1175`, `ad_init.test.ts:1201-1263`).
-9. `slotRenderEnded` registration gated behind `!ts.servicesEnabled`
- (`gpt/index.ts:1091`).
-10. Baseline `248fe9558`: MessageChannel APS-PUC handshake
- (`aps.rs:65-125`, `aps/render.ts:415-437`), collapsed-shell resize
- (`gpt/index.ts:217`), C5 consolidated, real-PUC browser test.
-11. The bridge keeps consumed-id tombstones (`gpt/index.ts:1527`).
-12. The tester cookie is not a security control (`tester_cookie.rs:3`).
-13. Fastly constructs application state per request (`app.rs:146`); its
- platform counter is a 60 s window with separate lookup/increment
- (`rate_limiter.rs:40`).
-14. The baseline auction client collapses every failure into an empty
- array (`core/auction.ts:185-224`).
-15. A single `/auction` request can carry several slots; concurrent calls
- race today (`request.ts:31`) and share one fetch.
-
-## 4. Design gates
-
-### G1 — Trace identity, attempts, sampling, correlation
-
-- EC-derived auction ids (`publisher.rs:3237`) are never ingested.
- Initial-HTML telemetry precedes page JS (`telemetry.rs:148`,
- `publisher.rs:2452`); correlation is minted by whoever acts first: the
- server for `nav_gen 0` (trace + signed authorization in `tsjs.boot`);
- the client afterwards via `X-TSJS-Trace-Id` on page-bids (GET) and the
- `/auction` POST, echoed back with
- `ext.trusted_server.trace = {trace_id, auth, auction_id}`.
-- **Attempt identity (closing the parent/child collision):** every render
- attempt mints a client-side **`attempt_id`** (8-char `[a-z0-9]`,
- CSPRNG) and carries nullable **`parent_attempt_id`** (fallback children
- reference their parent). **The canonical attempt key is
- `(publisher_domain, trace_id, attempt_id)`** — `attempt_id` alone (≈ 2.8
- T values) is not globally unique at production volume, so
- `ts_render_attempts_v` keys and parent lookups use the full triple.
- Uniqueness within a trace is guaranteed by a **per-trace live-set
- collision check with retry** (the `NavigationSession` holds the issued
- set; a collision re-draws). The tuple
- `(trace_id, nav_gen, refresh_gen, slot)` remains a **grouping key only**.
- Exactly one terminal event per attempt key (G4c) is a tested invariant.
-- **Deterministic keyed sampling:** first 8 bytes of
- `HMAC-SHA-256(sampling_key, trace_id)` as u64 BE; `sampled` iff
- `u64 < floor(sample_rate × 2⁶⁴)`; `sample_rate` finite in `[0, 1]`.
-- **Cross-tier join:** equality key = the server telemetry
- **`auction_id`** (UUID; the client column is `Nullable(UUID)` and
- ingest validates canonical UUID syntax). Generations are client-side
- only. **Join grains are explicit, and the slot grain goes through a
- dedicated summary view.** `auction_events_raw` has multiple
- `row_kind=slot` rows per slot (bid, provider, drop, selection), so a
- `auction_id + slot` join multiplies rows; funnels therefore join against
- **`ts_auction_slot_summary_v` — one row per `(auction_id,
-canonical_slot)`** built from the `selection_summary` slot rows.
- Auction-level joins hit the one totals/summary row per `auction_id`;
- bid/drop/provider joins carry their real grain and are opt-in. **Client
- `slot` and server `slot_id` use the same canonical normalization**
- (lowercased, `-container` suffix stripped — the resolution rule of
- `gpt/index.ts:112-138`), applied on both sides and tested.
-- Cache-privacy invariant: traces/authorizations only in per-request
- auction-bearing responses; such HTML is `private, no-store`.
-- Envelope: per-trace groups `{trace_id, auth, events[]}`; events carry
- `{nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?,
-auction_id?, t_rel_ms?}`. **`t_rel_ms`** (time, relative, in
- milliseconds) is a bounded monotonic duration (`performance.now()`
- truncated to u32 ms, relative to
- navigation start) — the latency gates' basis; `received_at` is ingest
- time and is never used as event time. `flow` is closed:
- `ssat | prebid | page_bids | direct | fallback | system`.
-- Traces are navigation-scoped; attempt counts key on `attempt_id`.
-
-### G2 — Render identity
-
-- Cache-backed bids: `hb_adid` = PBS Cache UUID byte-for-byte
- (`publisher.rs:3355`, `gpt/index.ts:1772`). Markup bids: existing
- fallback chain. Renderer-only bids: server-minted token
- `^[a-z0-9]{12}$`, CSPRNG, in-auction collision retry, TTL 15 min,
- one-time consumption.
-- **Reservation store:** live registrations + tombstones (consumed /
- stale / disposed) share one structure, **union capacity 320**; expired
- entries pruned; **unexpired entries never evicted**; at capacity, new
- registration refused with `registry_full`. Late prior-navigation
- requests always meet suppression until original TTL (preserving
- `gpt/index.ts:1527`). Test: >320 registrations, late oldest-id request.
-- Client-Prebid keeps Prebid's `adId`; one store serves both paths.
- Non-APS cache-path byte-identity regression tests.
-
-### G3 — Runtime ABI (exact-release)
-
-- IIFE-per-bundle with inlined imports (`build-all.mjs:46`,
- `bundle.rs:23`); imports never share state (defect:
- `core/context.ts:11` vs `permutive/index.ts:102`). Kernel only in
- `tsjs-core`; `tsjs._internal = {release_id, registry}` frozen after
- boot; core services constructed at boot; plugins via
- `definePlugin({id, release, install})` with build-generated `release`;
- `registry.get` succeeds only on equality; mismatch quarantines
- (`abi_mismatch`/`bundle_partial`) loudly.
-- **Boundary enforcement:** `import/no-restricted-paths` for layering,
- plus a **custom scope-aware ESLint rule** for external-global access —
- standard `no-restricted-properties`/`no-restricted-syntax` cannot
- follow arbitrary aliasing, so the custom rule tracks member access to
- `googletag`/`pbjs` through `window`/`globalThis`/`self` **and
- same-file const aliases**; anything cleverer (cross-module smuggling)
- is caught by review, and the claim is scoped to exactly that. Adapters
- are the only access to external ad-tech globals; kernel/messaging
- necessarily touch `window.tsjs` and `postMessage`.
-
-### G4 — Render lifecycle
-
-**G4a — Physical request cycles.** Intents (both classes, one causal
-queue): any `display()` under disabled initial load is retired at
-issuance regardless of caller; hindsight zero-request intents expire at
-2 s with `intent_no_request`; **any later request-capable intent — same
-class or opposite — supersedes a pending uncertain intent**, and if the
-uncertain one could still be in flight, the next `slotRequested` is
-ambiguous → quarantine. Cycles open only on `slotRequested` (causal
-head; SRA = one per slot per batch) and close on `slotRenderEnded`
-(`responseIdentifier` dedups drain). **Each attributable `slotRequested`
-emits exactly one `request_cycle_started` event** — the materialized
-denominator the fill and attribution gates divide by (`attempt_started`
-is not equivalent: an attempt can fail before producing a physical
-request). One outstanding TS cycle per slot; one queued replacement.
-Attribution requires exactly one outstanding TS cycle and no overlap;
-otherwise `cycle_unattributable`, fail closed. **No timeout re-arm** —
-re-arm only on count-based drain, safe TS-owned destroy/redefine, or page
-end; unissued intents are NavigationSession children; physical state is
-RuntimeSession. Deterministic-harness CI +
-the release-gating real-GAM suite (Appendix A.3).
-
-**G4b — Two-phase acknowledgement (the nonce is minted by the claim, not
-carried in the request).** The baseline PUC request is `{message, adId}`
-
-- a `MessagePort` — it cannot carry a nonce or generations, and the nonce
- can only exist **after** a claim succeeds. So the protocol is two phases:
-
-* **Phase 1 — claim (on the incoming `Prebid Request`):** validate source
- ownership (§6.8), the reserved `adId` against the reservation store, and
- the internal current `nav_gen`/`refresh_gen` **held in the kernel**
- (not in the message); on success, **mint the per-attempt 128-bit CSPRNG
- nonce**, bind notifications, reply over the `MessagePort` (the renderer
- depends on this reply to settle its PUC promise), and — for the APS
- path — hand the nonce to the renderer document in the response.
-* **Phase 2 — acknowledgement (later document/runner messages):** validate
- source, nonce, token, `nav_gen`, `refresh_gen`. Navigation/supersession
- invalidates the nonce; late acks → `stale_navigation`. Deadlines:
- document 3 s, runner 10 s, adm 5 s.
-
-Per render path:
-
-1. **APS-PUC** (baseline transport): the Phase-1 reply travels over the
- `MessagePort`; the response also transfers the freshly minted nonce
- into the renderer document (`ports.length` checks, exact-key replies,
- one-shot latch, port close); the document then posts authenticated
- `renderer_document_loaded` and the accepted/failed result to the top
- window (Phase 2).
-2. **Generic ADM/cache-PUC:** **the acceptance observation lives in the
- trusted owner, not the creative document** — the owner observes its
- own iframe's `load`/`error` events and emits `adm_document_loaded`;
- the nonce never enters the bidder realm (an injected reporter would
- hand bidder-controlled code the acceptance credential and let it
- trigger `burl` early — revision 8's reporter is withdrawn).
-3. **Direct APS:** the kernel is the frame parent; the baseline
- parent-postMessage branch is already kernel-observed.
-4. **Direct ADM/cache:** as (2), owner-observed `load`/`error`.
-
-**G4c — Honest observations; one terminal event.** Observations:
-`gam_nonempty`, `gam_empty`, `gam_collapsed{action: resized | guarded}`,
-`renderer_document_loaded`, `runner_loaded`, `runner_failed`,
-`adm_document_loaded`. **The terminal is one discriminated event:
-`render_terminal{outcome: accepted | failed | no_bid | cancelled,
-reason?}` — exactly one per `attempt_id`** (replacing separate
-accepted/fail events the schema could not reconcile). A parent attempt
-whose GAM cycle ends empty emits `render_terminal{failed, gam_empty}`
-**before** its fallback child starts. Post-acceptance runner failure is
-an observation only (no billing-failure event exists — no honest
-producer). No observation claims paint. The baseline resize
-(`gpt/index.ts:217`) stays a sanctioned, guarded exception.
-
-**G4d — Notifications.** APS carries neither `nurl` nor `burl`
-(`aps.rs:839`) — excluded entirely. For carrying paths: bind per flow
-(PUC: owned matched bridge claim; direct: validated render start —
-server must preserve + macro-expand the URLs (`formats.rs:423` omits),
-client must parse + https-validate (`core/auction.ts:43` drops);
-fallback: attributed parent `gam_empty` immediately before child
-render). `nurl` at bind; `burl` at `accepted`; idempotency key
-`(trace_id, nav_gen, refresh_gen, slot, id_kind, id_value)`.
-**Dispatch mechanics (normative):** macros are expanded server-side
-only; the client fires exactly one
-`fetch(url, {method: "GET", mode: "no-cors", credentials: "omit",
-redirect: "follow", referrerPolicy: "no-referrer", keepalive: true})`
-and awaits its settlement. **There is no `Image()` fallback** — a
-detached image request would construct a separate credentialed,
-referrer-bearing request outside this privacy contract; a request that
-rejects (construction error) or resolves to a network failure emits
-`notification_sent{result: failed}` and stops. No retries. Every
-dispatch emits
-`notification_sent{kind, notif_id, result: queued | failed}` with the
-**server-minted `notif_id`** (12-char token delivered with the bid).
-Duplicates: hermetic exactly-once proof + production detection alarm +
-billing reconciliation (lossy telemetry cannot prove a zero).
-
-**G4e — Fallback.** Opt-in; child attempt (own `attempt_id`,
-`parent_attempt_id`, `flow = fallback`); renders only after the parent's
-attributed `render_terminal{failed, gam_empty}`; publisher-initiated or
-unattributable never triggers; timeouts never render.
-
-**G4f — Direct `/auction` lifecycle and the AuctionBatch.** A single
-`/auction` fetch may serve several slots, so cancellation is
-batch-aware: an **`AuctionBatch`** owns the fetch (its `AbortController`)
-and the child `RenderAttempt`s. Supersession cancels **children
-individually** (`render_terminal{cancelled}`); the fetch aborts only
-when every child is dead or the batch times out or its navigation
-disposes; every response bid is filtered through the **currently live**
-child identity before any effect. Tests: partial overlap, full overlap,
-timeout, navigation disposal, reversed responses. The auction client
-returns a discriminated result — `{ok: bids[]} | {error:
-"auction_timeout" | "network_error" | "http_error" | "invalid_response"}`
-(§3.14); only a parsed empty response is `no_bid`. **After the batch
-processes every response bid, each still-live child with no valid
-matching winner is terminated `render_terminal{no_bid}`** — a response
-carrying only slot X never leaves slot Y's child nonterminal, so
-`RequestAdsResult` always settles. Public API:
-`tsjs.requestAds(options): Promise`,
-`RequestAdsResult = {traceId, slots: [{slot, outcome, reason?}]}`,
-settling when every child attempt is terminal.
-
-**G4g — Mid-attempt configuration, honestly scoped.** Attempts snapshot
-configuration at creation. **Commit = the earliest irreversible action**
-(first of: notification dispatch, `bridge_response_sent`, first DOM
-insertion), with the generation/kill check immediately before each.
-**The kill switch's delivery is page-bound:** already-loaded pages have
-no push channel, so live switch state travels only on responses the
-page later fetches — page-bids and `/auction` responses carry
-`ext.trusted_server.switches`, and new HTML carries current state. The
-guarantee is therefore scoped: the switch affects attempts created
-after the page received switch state; SSAT attempts on already-loaded
-pages are unaffected by design, and the spec says so rather than
-implying a live channel that does not exist.
-
-### G5 — Deployment contracts
-
-- Config verification per §0. Assets pre-materialized at release
- publication (config is a release-time input); serving is lookup-only;
- unknown vector = build error; unknown hash = `410 no-store`; exact
- match = `public, max-age=31536000, immutable`.
-- **Internal route families — five** (renderer, client-events,
- CSP-report, `/_ts/trace-auth`, and the **admin issuance family**
- `/_ts/admin/*` — diagnostic-credential and probe-authorization, §5.3):
- all dispatch before auth/EC/publisher/integration filters (`app.rs:709`
- orders these wrong today); all methods + version prefixes reserved
- locally (405 + `Allow` + `no-store`; unknown version 404 `no-store`; no
- publisher fall-through, `adapter-spin app.rs:804`); no
- body/cookie/authorization forwarding to the publisher. **Per-family
- origin/auth policy:** client-events + trace-auth strict normalized
- same-origin; CSP admits opaque/`null` origins with path identity +
- limits; renderer is a public GET validated by version/path; **the admin
- family sits behind operator authentication with its own rate limits and
- exists on all adapters that expose an admin plane** (elsewhere it is
- absent, and diagnostic/probe modes are unavailable there — stated, not
- implied). All five families appear in the four-adapter parity matrix.
-- Ingest routes in all four adapters; Fastly has real sinks; others
- accept-count-drop (DR-5). §5.6 schemas deploy before writers.
-
-## 5. Observability
-
-### 5.1 Wire payload and field matrix
+- **Baseline:** `origin/rc/july` @ `905984e62` ("Prevent APS renderer document
+ clipping"), including `248fe9558` (PUC/MessageChannel and collapsed-shell
+ rendering) and `ed38f3e13` (PUC overflow prevention). File-and-line references
+ and commit hashes describe that baseline and are evidence, not permanent API
+ contracts.
+- **Compatibility:** this is a coordinated hard cutover. No backward-compatible
+ aliases, dual APIs, or N/N-1 browser/server protocol are required.
+- **Decision:** this document covers APS render correctness, the TSJS architecture
+ needed to make that correctness durable, and preservation or explicit
+ architectural replacement of every TSJS concept present on the baseline. It
+ does not add an external telemetry system or release experimentation.
+
+## 0. Scope and constraints
+
+### 0.1 Goals
+
+1. An accepted APS bid renders through every supported Trusted Server path:
+ SSAT/GPT, the Trusted Server Prebid adapter through GAM Universal Creative,
+ SPA page bids, and direct `/auction` rendering.
+2. Render ownership, identity, and completion are explicit. Races, stale SPA
+ work, ambiguous GPT events, duplicate creative requests, and timeouts settle
+ deterministically instead of failing silently.
+3. TSJS has one runtime kernel, bounded state, explicit lifetimes, and enforced
+ dependency boundaries. Integration bundles cannot accidentally create
+ independent copies of shared state.
+4. The Rust auction result, publisher projection, TypeScript parser, Prebid
+ registration, GPT targeting, Universal Creative bridge, and direct renderer
+ agree on one APS descriptor and identity contract.
+5. Security boundaries are testable: untrusted creative messages cannot claim a
+ different slot or attempt, replay a consumed capability, or revive work from a
+ prior navigation.
+6. Existing non-APS rendering behavior remains correct unless this design
+ explicitly replaces a shared lifecycle surface.
+7. Every TSJS behavior on the exact `rc/july` baseline is either preserved,
+ rebuilt behind the new architecture, or explicitly superseded by a named and
+ tested replacement contract. No TSJS behavior may disappear silently merely
+ because its old global, wrapper, bootstrap, or carrier is deleted.
+
+### 0.2 Non-goals
+
+- No change to analytics/telemetry schemas, durable data systems, billing,
+ experimentation, or deployment routing. Those belong to separate designs.
+- No change to the APS upstream OpenRTB endpoint contract, including APS's
+ deliberate absence of `nurl` and `burl`.
+- No rewrite of Prebid.js itself or of the decoupled Prebid strategy.
+- No refactor of unrelated integration internals. They receive only the thin
+ registration/bootstrap changes required by the new TSJS runtime, plus any
+ mechanical disposal or adapter injection needed to preserve their baseline
+ behavior.
+
+Existing local render tracing, GPT diagnostics, logging, counters, debug output,
+and telemetry integrations remain functional through the cutover. They may move
+behind the runtime event bus or the final diagnostics namespace, but their
+observable concepts and non-interference guarantees are in scope. Correctness must
+not depend on a new event reaching an external sink. Any new analytics contract
+requires a separate design.
+
+### 0.3 Architectural rules
+
+- Make invalid states unrepresentable where practical and reject them at the
+ boundary otherwise.
+- Every asynchronous operation belongs to a runtime, navigation, auction batch,
+ or render-attempt lifetime and has a deterministic disposer.
+- Every render attempt reaches exactly one terminal result in memory.
+- A timeout cancels or fails only the object it owns; shared work is aborted only
+ when no live child still needs it.
+- Ad-tech globals are accessed only through adapters.
+- Cross-window messages are versioned, exact-shaped, capability-bound, and
+ source/port checked.
+- No correctness path waits for logging, telemetry, notification delivery, or any
+ other side effect unrelated to rendering.
+
+### 0.4 `rc/july` TSJS concept-adoption contract
+
+The exact baseline is the whole observable TSJS system at
+`origin/rc/july@905984e62`, not only the three commits after this design branch's
+merge base. It includes `crates/trusted-server-js/lib/src/**`, its build and test
+tooling, the TSJS behavior embedded in `gpt_bootstrap.js` and
+`gpt_diagnostics_bootstrap.js`, and the browser tests that exercise those sources.
+The in-spec executable manifest in §0.5 records that tree and commit. If the local
+`origin/rc/july` ref moves, implementation stops before code
+changes, diffs the old and new tips across those paths, and updates this ledger and
+its tests deliberately. A moving branch is never absorbed implicitly.
+
+Each ledger entry has one of these dispositions:
+
+- **Preserve:** retain the observable behavior and its failure semantics.
+- **Rebuild:** retain the outcome but replace the old mechanism with the runtime,
+ adapter, service, or integration module named here.
+- **Supersede:** deliberately replace an old mechanism with a stricter named
+ contract. The ledger must state the behavioral change and prove either that the
+ new owner makes the old compensation unnecessary or that the new terminal
+ failure is complete, bounded, and preferable to a partial second runtime.
+- **Exclude:** keep the existing feature untouched because it is not TSJS work;
+ this disposition cannot be used for a file under the TSJS baseline inventory.
+
+Hard cutover authorizes removal of old mechanisms and names. It does not authorize
+silent loss of a ledger outcome. An observable outcome may change only through an
+explicit **Supersede** entry that names the old and final behavior, gives the
+architectural reason, and has boundary tests for the replacement contract. A source
+deletion is complete only when its ledger entry has a passing replacement contract
+or that explicit supersession proof.
+
+| ID | Baseline TSJS concept | Disposition and final owner |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `RCJ-CORE-01` | Core config/context, callback queue, auction parsing, direct request/rendering, SPA generation checks, and shared helpers continue to serve every enabled integration. | **Rebuild:** kernel, services, and composition root; exact behavioral corpus runs before and after the switch. |
+| `RCJ-CORE-02` | Programmatic ad-unit registration can drive direct `/auction`; core also exposes version/queue, placeholder render helpers, mutable generic config, and the local logger. | **Preserve/supersede:** §5.4 defines the exact final API; typed registration/request APIs and immutable config replace placeholders/mutable config; logger methods/default remain, while invalid levels now throw without mutation instead of being retained with warn fallback. |
+| `RCJ-BOOT-01` | The edge-injected `gpt_bootstrap.js` duplicates initial-load tracking, slot handoff, hydration scheduling, GPT definition/targeting/display/refresh, and can render initial ads without the main TSJS bundle. | **Rebuild/supersede:** the committed runtime owns all normal GPT behavior. Missing/partial bundles intentionally settle through the terminal non-rendering fallback in §5.3; no bootstrap may construct a degraded GPT runtime. |
+| `RCJ-TRACE-01` | Render tracing records one honest impression timeline, bounded history, current-slot state, DOM stamps/badges, local overlay, no stale auction attribution, and emits `tsjs:adRendered`. | **Rebuild/supersede:** lifecycle diagnostics subscriber and `tsjs.diagnostics.renderTrace`; its subscription replaces the mutable globals and CustomEvent, and no integration writes trace state directly. |
+| `RCJ-GPT-01` | A TS fallback and a later publisher `defineSlot` share one physical GPT slot and one initial request; ownership transfer prevents later TS destruction. | **Rebuild:** GPT adapter plus slot service handoff record; no integration-owned function sentinels or duplicate wrappers. |
+| `RCJ-GPT-02` | Responsive/hydrated slot resolution chooses the unique active placement, recovers DOM replacement, and never silently chooses an ambiguous sibling. | **Rebuild:** navigation-scoped aliases plus runtime-owned DOM binding/reconciliation. |
+| `RCJ-GPT-03` | Native publisher GPT calls, service state, SRA, disabled initial load, refresh options, targeting cleanup, and publisher-owned slots retain their native semantics. | **Preserve/Rebuild:** the sole GPT adapter owns interception and event fan-out; publisher activity never becomes TS-owned work. |
+| `RCJ-GPT-04` | A TS-owned PUC response may resize only its authenticated still-collapsed ordinary 1×1 GAM shell, never unrelated, anchor, fixed, sticky, or already-expanded frames. | **Preserve/Rebuild:** current render attempt owns one guarded resize after a response is successfully posted. |
+| `RCJ-PREBID-01` | The publisher-specific artifact is pure Prebid.js; the Trusted Server shim is a separate TSJS integration module, and the external bundle remains independently useful if that module fails. | **Preserve/Rebuild:** external artifact plus Prebid adapter/integration module; TS code is not vendored into the external Prebid artifact. |
+| `RCJ-PREBID-02` | Missing, late, duplicate, older, or partial Prebid artifacts fail safely: publisher queues drain, TS refresh handling is not installed without a real API, and installation is idempotent. | **Preserve/Rebuild:** artifact watchdog plus release-matched module transaction and bounded readiness queue. |
+| `RCJ-PREBID-03` | Adapter manifests distinguish module names from registered bidder codes/aliases; client-side bidder coverage, user-ID modules, EIDs, native bids, and publisher callbacks keep working. | **Preserve:** typed artifact contract and black-box artifact tests; TS-owned bid identities alone are replaced. |
+| `RCJ-PREBID-04` | Configured GAM-path exclusions remove only matching slots from the synthetic Prebid refresh auction while clearing stale TS keys and retaining every slot/options in the GPT refresh. | **Preserve/Rebuild:** one refresh policy in the Prebid integration module over the GPT adapter; global, explicit, mixed, all-excluded, and fail-open path cases remain exact. |
+| `RCJ-APS-01` | First-class APS OpenRTB admission, typed descriptor projection, direct rendering, Trusted Server Prebid-adapter rendering, and PUC rendering remain supported. | **Preserve/Rebuild:** Rust admission plus the shared render lifecycle described in §§3–4. |
+| `RCJ-APS-02` | `bid.meta`, generated Prebid `adId`, upstream bid-id fallback, and old `hb_adid` precedence carried APS identity through lossy boundaries. | **Supersede:** the server-minted `r1_` reservation is the only TS PUC authority; native Prebid IDs and PBS Cache UUIDs remain byte-preserved for their own purposes. |
+| `RCJ-APS-03` | PUC uses one-use ports, APS callbacks—not script load—determine success, renderer tombstones are bounded, and lifecycle callbacks cannot corrupt later attempts. | **Preserve/Rebuild:** bridge dispatcher, owner-control channel, reservation service, and terminal latch. |
+| `RCJ-APS-04` | The PUC document, renderer document, and descendant creative receive the winning dimensions without default margins, scrollbars, overflow, or clipping. | **Preserve:** exact CSS/DOM sizing contract in §4.4 and three-level browser assertions. |
+| `RCJ-CREATIVE-01` | Auction creative sanitization remains opt-in/default-off, rewriting retains its existing independent setting, and every delivery path observes the same configured processing boundary. | **Preserve:** creative integration module and server processing; this design does not silently enable sanitization or broaden rewriting. |
+| `RCJ-CREATIVE-02` | Opaque-origin click recovery accepts only validated absolute HTTP(S) navigation, persists the validated URL, rejects non-network schemes, and keeps creative sandbox isolation. | **Preserve/Rebuild:** creative integration module over shared origin/DOM helpers, with unit and real-browser sandbox coverage. |
+| `RCJ-CREATIVE-03` | `tscreative.installGuards/setConfig/getConfig`, `tsCreativeConfig`, automatic install, click-guard default-on, and render-guard default-off control the creative browser guards. | **Preserve/supersede:** `CreativeBootV1` retains the defaults and the integration module auto-installs transactionally; mutable/install command globals are deleted and immutable `tsjs.boot.creative` is the only inspection/config surface. |
+| `RCJ-DIAG-01` | GPT runtime diagnostics reports raw GPT observations, exact slot binding/replacement, request cycles/timing, bounded export, overlay/badges, and no lifecycle interference. | **Preserve/Rebuild:** diagnostics integration module consumes the GPT adapter event stream and exposes `tsjs.diagnostics.gpt`; it never installs a second GPT control wrapper. |
+| `RCJ-INT-01` | DataDome, Didomi, Google Tag Manager, Lockr, Osano, Permutive, Sourcepoint, and Testlight retain their current proxy guards, configuration, consent/segment, queue, and timing behavior. | **Preserve:** thin transactional integration modules plus complete pre/post-cutover black-box suites; internal feature behavior is otherwise unchanged. |
+| `RCJ-INT-02` | Shared script, beacon, DOM-insertion, scheduling, origin, and async helpers retain per-integration matching and failure isolation. | **Rebuild where shared:** helper factories with integration-owned configuration; one module failure cannot unwind another integration module or publisher code. |
+| `RCJ-QUAL-01` | Lint covers production source, tests, scripts, diagnostics, and build code; TypeScript and artifact checks cover the actual shipped combinations. | **Preserve/strengthen:** full-package lint/typecheck plus architecture, maximal-bundle, generated-artifact, browser, and retained-heap gates. |
+
+The commit clusters that exposed these concepts include the render-trace series
+starting at `966c8569c`; GPT recovery/handoff/responsive/native-behavior commits
+`4f45974e5`, `9b1985c8b`, `340d1efb4`, `0fdd13e7d`, `ca678fe69`, and
+`b200be53c`; Prebid decoupling/resilience and refresh commits `001ad385c`,
+`cdff89706`, `f3dc6ba70`, `60a85e661`, and `a007bd0d0`; creative hardening
+commits `1929dc83a`, `fde835110`, `9b21ba450`, `20977105f`, `1db074d4b`, and
+`3d9e2b693`; GPT diagnostics `11a4a7d25`; full-package lint `941473407`; APS
+admission/rendering commits from `f916ddf90` through `a08bebfbd`; and the final
+PUC/sizing chain `248fe9558`, `ed38f3e13`, `905984e62`. The executable tree
+inventory, not this illustrative hash list, is the completeness authority.
+
+### 0.5 In-spec baseline mapping manifest
+
+To keep this a one-file design, the completeness manifest is embedded here instead
+of creating another repository artifact. The implementation's first contract test
+extracts the `rcjuly-tsjs-manifest-v1` JSON block, enumerates each directory
+`includeRoot` plus every exact mapped file at the pinned commit with `git ls-tree`,
+and requires every enumerated file to match at least one `exact`, `prefix`, or
+`prefixes` mapping. A path receives the union of every matching row. Every
+file under `lib/src` must receive at least one non-`RCJ-QUAL-01` id. A mapping that
+matches no pinned path also fails, preventing stale rows. Moving the baseline runs
+this check before implementation and requires an explicit manifest/ledger update for
+every new, removed, or renamed path.
+
+```json rcjuly-tsjs-manifest-v1
+{
+ "version": 1,
+ "baseline": "905984e62a0858c53d9f0ff6dd3a1bf190cf311d",
+ "includeRoots": [
+ "crates/trusted-server-js/lib",
+ "crates/trusted-server-integration-tests/browser/tests"
+ ],
+ "mappings": [
+ {
+ "exact": [
+ "crates/trusted-server-js/lib/.gitignore",
+ "crates/trusted-server-js/lib/.prettierignore",
+ "crates/trusted-server-js/lib/.prettierrc.json",
+ "crates/trusted-server-js/lib/eslint.config.js",
+ "crates/trusted-server-js/lib/package-lock.json",
+ "crates/trusted-server-js/lib/package.json",
+ "crates/trusted-server-js/lib/tsconfig.json",
+ "crates/trusted-server-js/lib/vite.config.ts",
+ "crates/trusted-server-js/lib/vitest.config.ts"
+ ],
+ "ids": ["RCJ-QUAL-01"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-js/lib/build-all.mjs",
+ "crates/trusted-server-js/lib/src/index.ts"
+ ],
+ "ids": ["RCJ-CORE-01", "RCJ-QUAL-01"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-js/lib/build-prebid-external.mjs",
+ "crates/trusted-server-js/lib/test/build-prebid-external.test.mjs",
+ "crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs"
+ ],
+ "ids": ["RCJ-PREBID-01", "RCJ-PREBID-02", "RCJ-PREBID-03", "RCJ-QUAL-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/core/",
+ "ids": ["RCJ-CORE-01"]
+ },
+ {
+ "exact": ["crates/trusted-server-js/lib/src/core/log.ts"],
+ "ids": ["RCJ-CORE-02"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-js/lib/src/core/config.ts",
+ "crates/trusted-server-js/lib/src/core/index.ts",
+ "crates/trusted-server-js/lib/src/core/registry.ts",
+ "crates/trusted-server-js/lib/src/core/request.ts"
+ ],
+ "ids": ["RCJ-CORE-02"]
+ },
+ {
+ "exact": ["crates/trusted-server-js/lib/src/core/trace.ts"],
+ "ids": ["RCJ-TRACE-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/shared/",
+ "ids": ["RCJ-CORE-01", "RCJ-INT-02"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/integrations/aps/",
+ "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/integrations/creative/",
+ "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/integrations/gpt/",
+ "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/",
+ "ids": ["RCJ-DIAG-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/src/integrations/prebid/",
+ "ids": [
+ "RCJ-PREBID-01",
+ "RCJ-PREBID-02",
+ "RCJ-PREBID-03",
+ "RCJ-PREBID-04"
+ ]
+ },
+ {
+ "prefixes": [
+ "crates/trusted-server-js/lib/src/integrations/datadome/",
+ "crates/trusted-server-js/lib/src/integrations/didomi/",
+ "crates/trusted-server-js/lib/src/integrations/google_tag_manager/",
+ "crates/trusted-server-js/lib/src/integrations/lockr/",
+ "crates/trusted-server-js/lib/src/integrations/osano/",
+ "crates/trusted-server-js/lib/src/integrations/permutive/",
+ "crates/trusted-server-js/lib/src/integrations/sourcepoint/",
+ "crates/trusted-server-js/lib/src/integrations/testlight/"
+ ],
+ "ids": ["RCJ-INT-01", "RCJ-INT-02"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/core/",
+ "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"]
+ },
+ {
+ "exact": ["crates/trusted-server-js/lib/test/core/trace.test.ts"],
+ "ids": ["RCJ-TRACE-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/shared/",
+ "ids": ["RCJ-INT-02", "RCJ-QUAL-01"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-js/lib/test/fixtures/aps-renderer-v1.json"
+ ],
+ "ids": ["RCJ-APS-01", "RCJ-APS-03", "RCJ-QUAL-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/integrations/aps/",
+ "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/integrations/creative/",
+ "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/integrations/gpt/",
+ "ids": [
+ "RCJ-BOOT-01",
+ "RCJ-GPT-01",
+ "RCJ-GPT-02",
+ "RCJ-GPT-03",
+ "RCJ-GPT-04"
+ ]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/integrations/gpt_diagnostics/",
+ "ids": ["RCJ-DIAG-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-js/lib/test/integrations/prebid/",
+ "ids": [
+ "RCJ-PREBID-01",
+ "RCJ-PREBID-02",
+ "RCJ-PREBID-03",
+ "RCJ-PREBID-04"
+ ]
+ },
+ {
+ "prefixes": [
+ "crates/trusted-server-js/lib/test/integrations/datadome/",
+ "crates/trusted-server-js/lib/test/integrations/didomi/",
+ "crates/trusted-server-js/lib/test/integrations/google_tag_manager/",
+ "crates/trusted-server-js/lib/test/integrations/lockr/",
+ "crates/trusted-server-js/lib/test/integrations/osano/",
+ "crates/trusted-server-js/lib/test/integrations/permutive/",
+ "crates/trusted-server-js/lib/test/integrations/sourcepoint/"
+ ],
+ "ids": ["RCJ-INT-01", "RCJ-INT-02", "RCJ-QUAL-01"]
+ },
+ {
+ "exact": ["crates/trusted-server-core/src/integrations/gpt_bootstrap.js"],
+ "ids": ["RCJ-BOOT-01", "RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-core/src/integrations/gpt_diagnostics.rs",
+ "crates/trusted-server-core/src/integrations/gpt_diagnostics_bootstrap.js"
+ ],
+ "ids": ["RCJ-DIAG-01"]
+ },
+ {
+ "exact": ["crates/trusted-server-core/src/integrations/gpt.rs"],
+ "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-GPT-04"]
+ },
+ {
+ "exact": ["crates/trusted-server-core/src/integrations/prebid.rs"],
+ "ids": [
+ "RCJ-PREBID-01",
+ "RCJ-PREBID-02",
+ "RCJ-PREBID-03",
+ "RCJ-PREBID-04"
+ ]
+ },
+ {
+ "exact": ["crates/trusted-server-core/src/integrations/aps.rs"],
+ "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-core/src/integrations/datadome.rs",
+ "crates/trusted-server-core/src/integrations/datadome/protection.rs",
+ "crates/trusted-server-core/src/integrations/datadome/protection_scope.rs",
+ "crates/trusted-server-core/src/integrations/didomi.rs",
+ "crates/trusted-server-core/src/integrations/google_tag_manager.rs",
+ "crates/trusted-server-core/src/integrations/lockr.rs",
+ "crates/trusted-server-core/src/integrations/mod.rs",
+ "crates/trusted-server-core/src/integrations/osano.rs",
+ "crates/trusted-server-core/src/integrations/permutive.rs",
+ "crates/trusted-server-core/src/integrations/sourcepoint.rs",
+ "crates/trusted-server-core/src/integrations/testlight.rs"
+ ],
+ "ids": ["RCJ-INT-01", "RCJ-INT-02"]
+ },
+ {
+ "exact": ["crates/trusted-server-core/src/trace_cookie.rs"],
+ "ids": ["RCJ-TRACE-01"]
+ },
+ {
+ "exact": ["crates/trusted-server-core/src/tsjs.rs"],
+ "ids": ["RCJ-CORE-01", "RCJ-CORE-02", "RCJ-QUAL-01"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-integration-tests/browser/helpers/gpt-stub.ts"
+ ],
+ "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03", "RCJ-DIAG-01"]
+ },
+ {
+ "prefix": "crates/trusted-server-integration-tests/browser/tests/",
+ "ids": ["RCJ-CORE-01", "RCJ-INT-01", "RCJ-QUAL-01"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-integration-tests/browser/tests/nextjs/gpt-diagnostics.spec.ts"
+ ],
+ "ids": ["RCJ-DIAG-01"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-integration-tests/browser/tests/nextjs/navigation.spec.ts"
+ ],
+ "ids": ["RCJ-GPT-01", "RCJ-GPT-02", "RCJ-GPT-03"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-integration-tests/browser/tests/shared/aps-renderer.spec.ts"
+ ],
+ "ids": ["RCJ-APS-01", "RCJ-APS-02", "RCJ-APS-03", "RCJ-APS-04"]
+ },
+ {
+ "exact": [
+ "crates/trusted-server-integration-tests/browser/tests/shared/creative-sandbox.spec.ts"
+ ],
+ "ids": ["RCJ-CREATIVE-01", "RCJ-CREATIVE-02", "RCJ-CREATIVE-03"]
+ }
+ ]
+}
+```
+## 1. Problem statement and evidence
+
+APS demand is integrated server-side, but APS creatives do not render reliably.
+Four serial fixes—the `bid.meta` carrier, decoupled shim, `hb_adid` fallback, and
+the baseline PUC/collapsed-shell fix—each repaired one edge while leaving other
+independent failure points. The common failure is architectural: identity and
+state are copied across loosely coordinated server, GPT, Prebid, PUC, and iframe
+code, and many failures are swallowed.
+
+The TSJS library has the same structural problem: two large GPT/Prebid modules,
+duplicated ES5 and TypeScript behavior, imports in separately built IIFEs that do
+not share module state, global expandos, multiple GPT wrappers, and asynchronous
+work with no common owner.
+
+### 1.1 Supported flows
+
+| Flow | Auction source | Render owner | APS route |
+| --------- | ----------------------------------------------- | ------------------------ | ------------------------------------------------------- |
+| SSAT | `tsjs.boot.auctionProjection` | GPT integration | GAM Universal Creative → TS bridge → APS renderer |
+| Prebid | Trusted Server Prebid adapter | Prebid + GPT integration | GAM Universal Creative → TS bridge → APS renderer |
+| Page bids | `/_ts/page-bids` | GPT integration | GAM Universal Creative → TS bridge → APS renderer |
+| Direct | `/auction` | render service | TS-owned iframe → APS renderer |
+| Fallback | opt-in child of an attributable empty GAM cycle | render service | direct APS or direct ADM, according to the returned bid |
+
+### 1.2 Known failure surfaces
+
+| Area | Failure |
+| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Admission | A configured mediator can discard direct-provider bids; scripts can be rejected by policy; strict dimensions and APS response validation can drop bids without a useful local reason. |
+| Identity | PBS Cache UUID, upstream APS bid id, Prebid `adId`, GAM `hb_adid`, DOM id, and server slot id are different identities and have been conflated. GAM targeting values are capped at 40 characters. |
+| GPT | `display()` under disabled initial load does not request; event listeners can be installed too late; multiple refresh wrappers and concurrent requests race; SafeFrame obscures frame ancestry. |
+| Bridge | A `Prebid Request` can be duplicated, replayed, sent by a wrong frame, or arrive after navigation. A bare bid id is not enough to establish ownership. |
+| Renderer | The opaque sandbox cannot observe HTTP failure; descriptor validation exists in Rust, TypeScript, and embedded ES5; CSP or runner loading can fail after the iframe loads. |
+| Direct auction | One fetch can contain several slots, but current cancellation and result handling are not batch-aware; failures collapse to an empty array. |
+| Bootstrap | Server bootstrap and bundle initialization can both believe they own runtime setup; a hung bundle can race the no-bundle fallback. |
+| Lifecycle | There is no shared definition of attempt, ownership, supersession, terminal completion, or disposal. |
+
+## 2. Required behavior
+
+### 2.1 Outcome contract
+
+Each render attempt has one of four terminal outcomes:
+
+```ts
+type RenderFailureReason =
+ | 'auction_timeout'
+ | 'auction_disabled'
+ | 'consent_denied'
+ | 'slot_not_eligible'
+ | 'provider_timeout'
+ | 'provider_error'
+ | 'invalid_provider_response'
+ | 'mediation_failed'
+ | 'winner_not_renderable'
+ | 'internal_error'
+ | 'network_error'
+ | 'http_error'
+ | 'invalid_response'
+ | 'slot_unresolved'
+ | 'descriptor_invalid'
+ | 'invalid_dimensions'
+ | 'dimensions_out_of_range'
+ | 'no_render_source'
+ | 'registry_full'
+ | 'capability_registry_full'
+ | 'external_queue_full'
+ | 'external_ready_timeout'
+ | 'external_artifact_incompatible'
+ | 'prebid_admission_failed'
+ | 'prebid_contract_violation'
+ | 'prebid_selection_timeout'
+ | 'reservation_collision'
+ | 'identity_generation_failed'
+ | 'cycle_unattributable'
+ | 'slot_quarantined'
+ | 'gpt_request_failed'
+ | 'gpt_request_timeout'
+ | 'gpt_completion_timeout'
+ | 'reconciliation_capacity'
+ | 'gam_empty'
+ | 'bridge_claim_timeout'
+ | 'bridge_id_mismatch'
+ | 'owner_registration_timeout'
+ | 'owner_insertion_timeout'
+ | 'renderer_document_no_load'
+ | 'runner_no_load'
+ | 'runner_failed'
+ | 'cache_network_error'
+ | 'cache_http_error'
+ | 'cache_invalid_response'
+ | 'adm_document_no_load'
+ | 'abi_mismatch'
+ | 'bundle_partial'
+
+type RenderOutcome =
+ | { outcome: 'accepted' }
+ | { outcome: 'no_bid' }
+ | { outcome: 'failed'; reason: RenderFailureReason }
+ | {
+ outcome: 'cancelled'
+ reason: 'caller_aborted' | 'superseded' | 'navigation_disposed'
+ }
```
-{ v: 1, traces: [ { trace_id, auth, events: [
- { nav_gen, refresh_gen, seq, flow, attempt_id?, parent_attempt_id?,
- auction_id?, t_rel_ms?, t, ...fields } ] } ] }
+
+`accepted` means the path-specific completion authority reported success:
+
+- APS: the sandboxed renderer document accepted the descriptor and the queued APS
+ `prebid/creative/render` event invoked its success callback. APS owns the runner and
+ its promise that it invokes this callback only after committing the nested creative
+ iframe; Trusted Server cannot independently prove that promise for mutable upstream
+ bytes. Loading the runner script alone is not acceptance.
+- Direct ADM/cache: the TS-owned iframe fired its first `load` before timeout.
+- PUC ADM/cache: the TS-authored PUC owner reported its owned iframe's first
+ `load` over the bound owner channel.
+
+It does not claim that pixels were viewable. `slotRenderEnded{isEmpty:false}` is
+evidence that GAM injected a creative, not evidence that APS completed.
+
+`no_bid` is reserved for an explicit, successfully parsed server auction decision
+with no valid winner for that slot. An attributable GPT
+`slotRenderEnded{isEmpty:true}` is
+`failed{reason:'gam_empty'}` so an opt-in fallback can name its exact parent cause.
+Network, timeout, HTTP, parse, descriptor, bridge, and renderer failures are not
+converted to `no_bid`.
+
+Every attempt owns one terminal latch. All competing callbacks, ports, iframe
+events, timers, aborts, and navigation disposal race through that latch. The first
+valid terminal transition wins, disposes attempt resources, and makes every later
+signal inert.
+
+### 2.2 Identity model
+
+The implementation keeps these identities distinct:
+
+| Identity | Purpose | Rules |
+| ----------------------- | -------------------------------- | -------------------------------------------------------------- |
+| server slot id | auction and publisher projection | exact, case-sensitive, 1–256 UTF-8 bytes, no NUL/control |
+| programmatic slot id | direct-auction registration | validated `code`; same bound; exact request/result identity |
+| DOM/container alias | locating a page element | separate collision-detecting index; ambiguous aliases fail |
+| upstream bid id | provider provenance | 1–64 UTF-8 bytes, no NUL/control, unique in provider response |
+| candidate id | mediator round-trip | server-minted opaque 12-character token; never an ordering key |
+| PBS Cache UUID | cache transport lookup | preserved byte-for-byte as `cacheId`; never bridge authority |
+| native Prebid `adId` | non-TS Prebid renderer lookup | untouched for native bids; never entered in the TS store |
+| renderer reservation id | every TS-owned PUC capability | `r1_` plus 22 base64url characters; exact `hb_adid`/TS `adId` |
+| attempt id | in-page lifecycle ownership | `a1_` plus 22 base64url characters; navigation-unique |
+| lifecycle ticket | cross-window capability | `t1_` plus 22 base64url characters; one-use and attempt-bound |
+| renderer nonce | renderer-document capability | `n1_` plus 22 base64url characters; one-use and attempt-bound |
+
+Every TS-owned PUC source—APS, inline ADM, or cache—receives a renderer reservation
+id, and that id is copied exactly to GAM `hb_adid`. For the Trusted Server Prebid
+adapter bid, it also replaces the TS bid's generated `adId` before targeting;
+native Prebid bids are untouched. The server creates the id from 16 CSPRNG bytes
+encoded as unpadded base64url and prefixed with `r1_`; it retries a response-local
+collision at most eight times, then fails the bid with
+`identity_generation_failed`. The browser rejects a collision with any live/
+tombstoned reservation as `reservation_collision`. Cache UUID and upstream/provider
+bid id stay inside the tagged render source/provenance and are never fallback bridge
+credentials.
+
+Attempt ids require no unbounded issued-id set. At `NavigationSession` creation the
+browser obtains eight CSPRNG bytes with `crypto.getRandomValues` and keeps one
+unsigned 64-bit attempt ordinal as two 32-bit words. For each new attempt it
+increments the ordinal, concatenates the navigation prefix with the big-endian
+ordinal, base64url-encodes those 16 bytes without padding, and prefixes `a1_`. The
+fixed navigation prefix plus never-reused ordinal guarantees navigation-local
+uniqueness. Prefix-generation failure or ordinal exhaustion refuses the new attempt
+with `identity_generation_failed`; the ordinal never wraps. The active-attempt index
+contains at most one attempt per admitted slot and is therefore capped by
+`MAX_ACTIVE_SLOT_RECORDS = 256`; terminal settlement removes the strong entry, while
+generation plus the nonreused ordinal makes stale callbacks inert without retaining
+old ids.
+
+One runtime-owned capability registry stores lifecycle tickets and their tombstones
+with a shared capacity of 320. Before minting it prunes entries whose fixed
+three-second lifetime has expired; unexpired entries are never evicted. A ticket is
+16 fresh CSPRNG bytes encoded as the fixed `t1_` form and checked against every live/
+tombstoned ticket. A collision retries at most eight total draws, then fails the
+attempt with `identity_generation_failed`. Capacity exhaustion before a successful
+draw refuses the outer PUC response and fails the attempt with
+`capability_registry_full`. Consumption/disposal replaces the live entry with a
+tombstone carrying the same original expiry; it never extends the lifetime.
+
+Renderer nonces use the same eight-draw CSPRNG/collision rule in a separate live
+registry capped at 256, at most one `n1_` value per active attempt. They need no
+tombstone: the exact frame, port, attempt id, and generation remain mandatory, and
+attempt disposal closes the channel and removes the live nonce before any later
+attempt can act. Nonce capacity fails the attempt with `capability_registry_full`;
+collision exhaustion is `identity_generation_failed`. Neither registry falls back
+to timestamps, `Math.random`, truncation, or eviction.
+
+### 2.3 Slot registry and bounded reservations
+
+One runtime-scoped slot service owns the registry:
+
+- `WeakMap` for GPT object identity;
+- exact registered-slot-id index covering server and programmatic registrations;
+- exact GPT ad-unit-code index;
+- separate DOM alias index that rejects collisions;
+- active render reservation map plus consumed/stale tombstones;
+- request-intent and active-cycle state per slot.
+
+`MAX_ACTIVE_SLOT_RECORDS` is 256 across server-projected and programmatically
+registered records in one `NavigationSession`. The immutable server projection is
+validated against that total before the kernel commits; an oversized projection is
+an `abi_mismatch` boot failure. `addAdUnits` reserves capacity for its whole input
+before mutation and rejects the whole call with
+`AdUnitRegistrationError{code:'registry_capacity'}` when the remaining capacity is
+insufficient. Navigation disposal synchronously removes every record and secondary
+index owned by that navigation; reservations/tombstones retain only their separate
+bounded lifecycle below.
+
+Registration rejects a missing, empty, or greater-than-256-UTF-8-byte server or
+programmatic slot id before indexing and assigns each valid slot a monotonically
+increasing, navigation-local ordinal. An exact registered-slot-id collision is
+rejected rather than overwritten. GPT ad-unit-code and DOM-alias indexes retain
+collision state: a lookup must resolve exactly one record, and zero or multiple
+matches fail `slot_unresolved`; registration order is never used to choose among
+collisions.
+
+The reservation map and tombstones share a capacity of 320. An SSAT/page-bid render
+reservation has a fixed 15-minute lifetime measured by the runtime's monotonic clock
+from browser registration; consumption does not extend it. A Prebid bid awaiting
+client-side selection instead receives a ten-second admission lease. Exact selection
+atomically promotes that lease to a render reservation with a new fixed 15-minute
+lifetime measured from promotion; this occurs before targeting can expose the id and
+is the only expiry replacement. An admission lease is suppress-only and cannot
+satisfy a PUC claim; a request carrying that id before selection is refused,
+tombstones the lease, and records `prebid_contract_violation`. Unselected, aborted,
+or selection-timed-out entries
+become tombstones only through their original ten-second lease expiry. Expired
+entries are pruned.
+Unexpired entries are never evicted because eviction would allow a late creative
+request to escape TS ownership. At capacity, registration fails with
+`registry_full`. A consumed, stale, or disposed reservation remains a tombstone
+until its original expiry and never produces a second response. While live, each
+admission lease or render reservation records its exact slot, render source,
+immutable `WinnerContext{selectedCpm}`, navigation generation, expiry, and state.
+`selectedCpm` is copied from the fully validated selected projected bid and is
+finite and nonnegative. Prebid admission verifies that the frozen bid's `cpm` is
+exactly this stored value, and selection promotion preserves the same context
+rather than reconstructing it from Prebid. A successful PUC claim transfers the
+context into the `RenderAttempt` before replacing the live entry with a tombstone.
+The tombstone discards the render source and winner context and retains only the id,
+original expiry, terminal state, and minimum suppression metadata. Neither the
+descriptor nor any capability contains CPM.
+
+Ids are unique across all live/tombstoned entries, so lookup identifies one entry
+and then requires its exact active slot, cycle, and generation. The first compatible
+PUC claim acquires its source and winner context; a live or tombstoned TS id is
+suppressed before detailed validation.
+
+Direct `/auction` rendering does not round-trip through a PUC reservation. Its exact
+winner join creates the `RenderAttempt` with an immutable
+`WinnerContext{selectedCpm}` copied from that same validated projected winner before
+any rendering or cache fetch. Thus direct and PUC paths have the same CPM authority
+without inventing a bridge capability for direct rendering.
+
+The current `__tsRenderGeneration`, `__tsRenderBid`, and function-sentinel
+expandos are removed.
+
+### 2.4 GPT request-cycle ownership
+
+GPT events describe physical requests and are not promises. The runtime therefore
+tracks request intent separately from physical cycles:
+
+1. A TS operation records an intent before calling `display()` or `refresh()`.
+2. A TS operation never treats `display()` as request-capable while initial load is
+ disabled. It uses `display()` only to register the slot, then invokes exactly one
+ `refresh([slot], {changeCorrelator:false})`; the request intent and three-second
+ start deadline attach only to that refresh. If the adapter cannot invoke refresh
+ or the call throws, the attempt fails `gpt_request_failed`. A publisher-owned
+ `display()` remains publisher activity and starts no TS attempt or fallback.
+3. A physical cycle opens only on `slotRequested` and closes on the corresponding
+ `slotRenderEnded`.
+4. One TS-owned cycle may be outstanding per slot, with at most one queued
+ replacement.
+5. Attribution requires exactly one live compatible intent. Overlap or a later
+ request-capable intent that makes ownership ambiguous fails the affected TS
+ cycle with `cycle_unattributable`; it is never guessed from timing.
+6. `responseIdentifier` deduplicates completion but is not an ownership token.
+7. A cycle is re-armed only by counted completion or safe TS-owned
+ destroy/redefine—never by a timeout or navigation disposal that merely hopes GPT
+ has finished.
+
+`slotRequested` and `slotRenderEnded` listeners are installed unconditionally
+before any TS display/refresh call. SRA opens one logical cycle per participating
+slot. Publisher-initiated GPT activity remains publisher-owned and cannot trigger
+TS fallback.
+
+An operation waiting for GPT or Prebid readiness has its own fixed ten-second
+deadline measured from enqueue and fails `external_ready_timeout`; public
+`requestAds.timeoutMs` never shortens or extends it. After a request-capable
+`display()` or `refresh()` is
+invoked, `slotRequested` must arrive within three seconds or the attempt fails
+`gpt_request_timeout`. Once `slotRequested` arrives, its matching
+`slotRenderEnded` must arrive within ten seconds or the attempt fails
+`gpt_completion_timeout`. A timeout tombstones the reservation, closes owned ports,
+and settles the attempt, but does not pretend the physical GPT cycle completed.
+
+At `gpt_request_timeout`, no attributable physical cycle exists. The adapter
+immediately invokes the transactional TS-owned destroy/redefine contract in §5.7
+and permanently retires the old object. Failure defines no replacement and leaves
+the path quarantined; later TS work fails `gpt_request_failed`. A publisher-owned
+object enters page-lifetime quarantine and cannot
+accept new TS work until the publisher explicitly destroys that object or the page
+reloads; no later `slotRequested` or `slotRenderEnded` may release that quarantine
+because it cannot be attributed to the timed-out invocation. At
+`gpt_completion_timeout`, the already-open exact physical cycle stays retired or
+quarantined until its matching real completion, safe TS-owned destroy/redefine,
+publisher destruction, or reload. Late GPT events only drain an already attributable
+completion-timeout cycle; they cannot revive an attempt, start fallback, or re-arm a
+request-timeout quarantine.
+
+Navigation disposal does not manufacture a GPT completion. If the open slot is
+TS-owned, the adapter invokes the §5.7 transaction; a replacement is defined only
+when the current navigation still needs it and exact destruction succeeded. The
+retired object remains in the runtime `WeakMap` until its late completion drains and
+can never be matched to a replacement. Destroy failure leaves no second object and
+quarantines later TS work. If it is publisher-owned, the physical cycle
+is quarantined: new TS work for that GPT object fails `slot_quarantined` until the
+matching `slotRenderEnded`, publisher destruction, or full page reload. There is no
+timeout-based re-arm. In particular, an old completion after navigation but before
+the replacement's completion settles only the retired/quarantined cycle.
+
+### 2.5 SPA and concurrent work
+
+- `RuntimeSession` survives SPA navigations and owns the global lifetime plus
+ injected adapter/service disposers. Runtime-scoped slot and reservation services
+ own the slot-object map, bridge listener, reservations/tombstones, and physical
+ GPT cycle state; kernel code knows them only through interfaces.
+- `NavigationSession` owns route-specific slot aliases, request intents, auction
+ batches, attempts, timers, targeting history, and one internal immutable current
+ auction-projection snapshot.
+- A new navigation atomically replaces the prior `NavigationSession`; disposal
+ cancels its live attempts and prevents late callbacks from mutating the new one.
+- The initial session seeds its internal projection from the recursively frozen
+ `tsjs.boot.auctionProjection`. A later SPA session begins with no current
+ projection. Its page-bids controller accepts only one exact, fully validated
+ `BrowserAuctionProjectionV1` for the current navigation generation, deep-copies
+ and freezes it, transactionally registers all projected slots against the shared
+ 256-slot cap, then commits it to the session. A stale, duplicate, malformed, or
+ over-cap response commits no slot, targeting, bid, or projection and cannot retain
+ the prior navigation's data. Programmatic registrations admitted before that
+ response count against the same transaction. The immutable public boot object is
+ document-generation input and is never rewritten into a mutable current-state
+ carrier.
+- An `AuctionBatch` owns one `/auction` fetch and one child attempt per requested
+ slot. Supersession cancels children individually. The shared fetch is aborted
+ only when every child is terminal, the caller aborts all children, the batch
+ response deadline expires, or navigation disposes.
+- After a parsed response is processed, every still-live child receives the exact
+ server decision for its slot; missing, duplicate, or inconsistent decisions are
+ `invalid_response`, never inferred as `no_bid`.
+
+### 2.6 Fallback
+
+`SlotOperation` owns the public per-slot result and one primary `RenderAttempt`.
+Fallback is opt-in and, when eligible, becomes a second child attempt. It may start
+only after an attributable TS-owned GAM cycle terminates empty. Publisher-owned,
+ambiguous, timed-out, quarantined, or stale cycles never trigger fallback. Each
+child has its own immutable terminal result and local history. The operation
+settles once: with the primary result when no fallback runs, or with the fallback
+child result and `path:'fallback'` after the primary `gam_empty`. A child never
+overwrites its parent or sibling.
+
+## 3. APS wire and server contracts
+
+### 3.1 One descriptor
+
+The only APS render descriptor is:
+
+```ts
+interface ApsRendererV1 {
+ type: 'aps'
+ version: 1
+ accountId: string
+ bidId: string
+ creativeId?: string
+ tagType: 'iframe' | 'script'
+ creativeUrl: string
+ width: number
+ height: number
+ aaxResponse: string
+}
+
+interface AdmRenderSourceV1 {
+ type: 'adm'
+ version: 1
+ adm: string
+ width: number
+ height: number
+}
+
+interface CacheRenderSourceV1 {
+ type: 'cache'
+ version: 1
+ cacheId: string
+ fetchUrl: string
+ width: number
+ height: number
+}
+
+type BidRenderSourceV1 = ApsRendererV1 | AdmRenderSourceV1 | CacheRenderSourceV1
+
+interface CacheFetchPolicyV1 {
+ version: 1
+ baseUrl: string
+}
```
-| `t` | fields | allowed `flow` |
-| -------------------------- | --------------------------------------------- | ----------------------- |
-| `request_cycle_started` | slot | ssat, prebid, page_bids |
-| `bid_received` | slot, id_kind, source | render flows |
-| `targeting_set` | slot, id_kind | render flows |
-| `attempt_started` | slot, source? | render flows |
-| `bridge_request` | slot, id_kind, matched | ssat, prebid, page_bids |
-| `bridge_response_sent` | slot, source | ssat, prebid, page_bids |
-| `render_terminal` | slot, outcome, reason?, source? | render flows |
-| `gam_nonempty` | slot | ssat, prebid, page_bids |
-| `gam_empty` | slot | ssat, prebid, page_bids |
-| `gam_collapsed` | slot, action (`resized`\|`guarded`), reason? | ssat, prebid, page_bids |
-| `renderer_document_loaded` | slot | render flows |
-| `runner_loaded` | slot | render flows |
-| `runner_failed` | slot, reason | render flows |
-| `adm_document_loaded` | slot | render flows |
-| `fallback_start` | slot | fallback |
-| `notification_sent` | slot, kind (`nurl`\|`burl`), notif_id, result | render flows |
-| `client_queue_overflow` | dropped (count) | system |
-| `heartbeat` | probe_run_id, expected_seq, adapter, target | system |
-
-Render flows = `ssat | prebid | page_bids | direct | fallback`.
-`attempt_started` carries the attempt's `t_rel_ms` baseline and its
-`source` is **optional** (direct attempts start before a response selects
-a source); the latency metric is `render_terminal{accepted}.t_rel_ms −
-attempt_started.t_rel_ms` per attempt. **`render_terminal.source` is
-present iff a render source was actually bound** — absent for
-`no_bid`/`cancelled` and every pre-winner outcome (`auction_timeout`,
-`network_error`, `http_error`, `invalid_response`) as well as the
-pre-source reasons (`gpt_absent`, `pbjs_absent`, `slot_unresolved`,
-`intent_no_request`, `abi_mismatch`, `registry_full`, `bundle_partial`);
-the generated per-event/per-reason validity matrix (§6.7) encodes
-source-presence by whether a source was bound, not by a hand-list.
-Reason enum: `renderer_document_no_load`,
-`runner_no_load`, `runner_failed`, `descriptor_invalid`,
-`invalid_dimensions`, `dimensions_out_of_range`, `bridge_id_mismatch`,
-`cycle_unattributable`, `intent_no_request`, `stale_navigation`,
-`bridge_claim_timeout`, `gam_empty`, `no_render_source`,
-`slot_unresolved`, `gpt_absent`, `pbjs_absent`, `bundle_partial`,
-`fallback_cancelled`, `abi_mismatch`, `registry_full`,
-`currency_mismatch`, `auction_timeout`, `network_error`, `http_error`,
-`invalid_response`, `adm_document_no_load`.
-
-### 5.2 Transport, batching, and budgets (limiter-consistent)
-
-- Batches are capped by **both** 64 events **and** 12 KiB encoded
- payload (headroom under the 16 KiB ingest cap); a flush drains the
- queue as up to **4 sequential batches**.
-- Cadence: flush every **10 s** and on `visibilitychange`/`pagehide`.
- Worst-case honest traffic per tab: 6 flushes/min × ≤ 4 batches = ≤ 24
- requests/min transient, typically ≤ 6.
-- **Budgets derived from that worst case:** client-side trace budget
- ≤ 8 batches/min sustained (excess coalesces into the next flush);
- ingest per-address budget **60 req/min, burst 120** (≈ 5 active tabs
- plus pagehide bursts). The limiter can no longer reject honest
- steady-state traffic by construction; tests cover sustained
- single-tab, multi-tab (5), diagnostic pre-upgrade buffer flush, and
- pagehide bursts.
-- Transport: `fetch(..., {keepalive: true, credentials:
-"same-origin"})`; `pagehide` fallback `sendBeacon(url, new
-Blob([json], {type: "application/json"}))`. Queue bound 256; overflow
- uses the out-of-band saturating counter + one coalesced
- `client_queue_overflow` in the next flush (never enqueued into a full
- queue).
-
-### 5.3 Signed authorizations
-
-**Trace authorization** `v1...[.].` (`auth`
-ingest bound 256 bytes; all other strings 64):
-
-- `kid ^[a-z0-9-]{1,16}$`; keys ≥ 256-bit CSPRNG in the secret store;
- previous keys retained ≥ 24 h; missing key with the feature enabled →
- loud first-use failure.
-- `exp` canonical decimal; ±60 s skew; ≤ 15 min future. `mode`:
- `sampled | unsampled | diagnostic | probe`. **`dexp` is present iff
- `mode = diagnostic`** — the immutable diagnostic ceiling, signed into
- the token, set at upgrade to the credential's absolute expiry.
- **Every renewal of a diagnostic token re-derives
- `exp = min(now + 15 min, dexp)` and preserves `dexp`; past `dexp`,
- renewal fails** — diagnostic access is bounded by the credential
- forever, not just at upgrade (closing the indefinite-renewal hole).
- Tests: renewal-before-expiry capped, repeated renewal to the ceiling.
-- `sig` = unpadded base64url HMAC-SHA-256 over
- `"ts-trace-auth-v1" || u32be(len(origin)) || origin ||
-u32be(len(trace_id)) || trace_id || u32be(len(mode)) || mode ||
-u64be(exp) [|| u64be(dexp)]`; constant-time compare; per-group
- rejection at ingest. `unsampled` transmits nothing and is rejected if
- carried. **Probe issuance protocol:** the probe runner authenticates
- to `POST /_ts/admin/probe-authorization` (admin auth + CSRF) and
- receives a batch of pre-signed probe-mode tokens tagged
- `probe_run_id`; probe traffic is never sampled out and excluded from
- product metrics by mode.
-- Renewal: `GET /_ts/trace-auth` presenting the current still-valid
- token in `X-TSJS-Trace-Auth`; re-signs same trace + mode (+ `dexp`).
- **Diagnostic upgrade** is the sole mode transition:
- `POST /_ts/trace-auth/upgrade` presenting token + credential.
-
-**Diagnostic credential** `d1....` — full byte-level
-spec with vectors: `oh` = first 16 hex chars of SHA-256 of the
-externally visible origin (scheme+host+port, UTF-8); `sig` = unpadded
-base64url HMAC-SHA-256 over `"ts-diag-cred-v1" || u32be(len(origin)) ||
-origin || u64be(exp)`; same kid charset, key strength, rotation, and
-≥ 24 h previous-key retention; ±60 s skew; absolute expiry ≤ 60 min;
-constant-time compare; replayable short-lived bearer by design (bounded
-by expiry + origin). Issued `POST /_ts/admin/diagnostic-credential`
-(admin auth, CSRF: same-origin + custom header). Transport: `#tsdiag=`
-fragment → read synchronously, cleared via `history.replaceState`, held
-in memory only (RuntimeSession); pre-upgrade events buffer locally
-(bounded 256) and flush after upgrade. Forgery, wrong-origin,
-replay-past-expiry tests.
-
-Lazy cached initialization applies to every secret-backed component;
-failure with the feature enabled is that feature's loud error path.
-
-### 5.4 Ingest and rate limiting
-
-- `POST /_ts/client-events`: `application/json` only; no
- `Content-Encoding`; `204`, `no-store`; never echoes input. Pre-parse:
- body ≤ 16 KiB; ≤ 64 events; strings ≤ 64 (`auth` ≤ 256 B);
- `trace_id ^[0-9a-f]{32}$`; `attempt_id ^[a-z0-9]{8}$`; `auction_id`
- canonical UUID; integers `[0, 2³¹)`; `t_rel_ms` u32.
-- Same-origin (client-events, trace-auth): `Sec-Fetch-Site:
-same-origin` else normalized `Origin` equality; absent both →
- drop-and-count.
-- Limiter (per §5.2 budgets): trait `ClientEventLimiter`; Axum real
- token bucket (60/min, burst 120), map ≤ 65,536; Cloudflare/Spin
- best-effort ≤ 4,096/instance; TTL 10 min, cleanup on access + sweep;
- at capacity reject unseen identities (expired always reclaimable);
- unknown address → shared bucket 6/min; Fastly platform 60 s window at
- limit 120 (approximation; overshoot bounded only by in-flight
- concurrency — documented by the synchronized-burst test, no numeric
- multiple claimed). Limiter unavailable → drop early with `204`.
- Trusted address per adapter (Fastly platform IP; Axum rightmost XFF
- after `trusted_proxy_hops`, absent → socket peer; CF
- `CF-Connecting-IP`; Spin platform). Trace-auth and CSP routes carry
- their own buckets (10/min, burst 20).
-
-### 5.5 Sinks, canonical views, per-sink authenticated probes
-
-- Event key `(publisher_domain, trace_id, seq)`; canonical views
- `ts_client_events_v` (dedup) and `ts_render_attempts_v` (**keyed by
- `attempt_id`**); the arm-union views stamp `deployment_pool` from the
- write identity (§0). Dashboards/alerts query canonical views only.
-- The Fastly sink is fire-and-forget (`tinybird.rs:153`) —
- **per-datasource authenticated probes**. **Every probe-capable table
- carries `{probe_run_id, expected_seq, adapter, target}`** (added to
- `ts_csp_reports` and `ts_ops_counters` below, not only
- `ts_client_events`), so loss queries distinguish runs, retries, resets,
- and adapters. **These four fields are cryptographically bound to the
- issued probe authorization** — the admin-issued probe token
- (`POST /_ts/admin/probe-authorization`, §5.3) signs `probe_run_id`,
- `adapter`, and `target`, and the ingest handler stamps the row from the
- verified token rather than trusting submitted values. A secret-derived
- CSP probe `policy_id` is a bearer capability for _routing_ only; it is
- never treated as proof of authentic run metadata. **Persistence gates
- are scoped to sink-backed adapters** (DR-5); accept-count-drop adapters
- get HTTP-parity gates only. Freshness = probe lag ≤ 5 min; loss =
- `expected_seq` gaps < 0.1%; alert owner: release owner's on-call.
-- **Alert-delivery drill:** a synthetic canary page injects a known
- failure class at a known rate; the failure-detection alert must fire
- within one hour — dashboards and alert latency are tested, not
- assumed (Appendix A row).
-
-### 5.6 Physical schemas (deployed before writers)
-
-- **`ts_client_events`**: `received_at DateTime64, publisher_domain
-LowCardinality(String), release_id String, deployment_pool
-Enum(canary|control), assignment_id Nullable(FixedString(32)),
-trace_id FixedString(32), mode Enum(sampled|diagnostic|probe), nav_gen
-UInt32, refresh_gen UInt32, seq UInt32, flow
-Enum(ssat|prebid|page_bids|direct|fallback|system), attempt_id
-Nullable(FixedString(8)), parent_attempt_id Nullable(FixedString(8)),
-auction_id Nullable(UUID), t_rel_ms Nullable(UInt32), event Enum(§5.1),
-slot Nullable(String), id_kind Nullable(Enum), matched Nullable(UInt8),
-source Nullable(Enum), reason Nullable(Enum), outcome
-Nullable(Enum(accepted|failed|no_bid|cancelled)), action
-Nullable(Enum), kind Nullable(Enum), notif_id Nullable(FixedString(12)),
-result Nullable(Enum), dropped Nullable(UInt32), probe_run_id
-Nullable(String), expected_seq Nullable(UInt32), adapter
-Nullable(Enum), target Nullable(Enum)`. Sorting key `(publisher_domain,
-received_at, trace_id, seq)`; TTL 30 days; sink batch cap 512; startup
- validation; sink-unavailable → accept-count-drop.
-- **Auction rows** (`telemetry.rs:262`, `auction_events_raw.datasource`):
- add nullable `trace_id`, `mode`, `release_id`;
- `row_kind Enum(slot|totals|overflow)`; `bid_drop {row_kind, provider
-Nullable(LowCardinality(String)), slot Nullable(String), reason
-Enum(AuctionDropReason), width Nullable(UInt16), height
-Nullable(UInt16), count UInt32}` (32 slot-rows + one overflow row whose
- `count` = actual dropped bids); `selection_summary {row_kind, slot
-Nullable(String), winner_source Nullable(Enum(mediator|direct|none)),
-winner_provider Nullable(String), candidates_direct UInt16,
-candidates_mediator UInt16, dedup_hits UInt16, currency_rejected
-UInt16, provenance_invalid UInt16, mediator_superseded UInt16}` (8
- slot-rows + one totals row that survives truncation; saturating
- counters `0xFFFF`/`0xFFFFFFFF`).
- - **`AuctionDropReason` (closed, exhaustive over baseline producers,
- one shared typed enum — no string literals):**
- `script_rendering_disabled, invalid_dimensions,
-dimensions_out_of_range, missing_render_source, invalid_creative_url,
-unsupported_tagtype, render_payload_too_large,
-unexpected_response_shape, currency_mismatch, floor_rejected,
-provenance_invalid, duplicate_demand, missing_bid_id,
-duplicate_bid_id, bid_id_too_large, empty_seatbid,
-empty_seatbid_bids, unknown_impid, invalid_price,
-unsupported_media_type, creative_id_too_large,
-renderer_extension_serialization_failed, no_render_source,
-lost_to_higher_bid, unsupported_currency, missing_request_context,
-overflow` — covering every baseline producer at `aps.rs:740-929`
- (incl. `empty_seatbid_bids` at `:875`, `unsupported_currency`, and
- the response-level `missing_request_context`, whose totals/error-row
- disposition is `row_kind = totals` with a null slot) and
- `formats.rs:408-419`. `currency_mismatch` and `unsupported_currency`
- are **distinct** (config-vs-response mismatch vs an unsupported
- currency code). Producers emit a **shared typed enum, no string
- literals**, so the **compile-time exhaustiveness test is real**: every
- variant maps to a producer and every producer to a variant.
-- **`ts_csp_reports`**: `received_at DateTime64, publisher_domain
-LowCardinality(String), release_id String, policy_id
-LowCardinality(String), cohort LowCardinality(String), directive_bucket
-Enum(script|style|frame|img|connect|font|media|worker|other),
-source_bucket Enum(https_host_allowlisted|data|blob|inline|eval|other),
-count UInt32, probe_run_id Nullable(String), expected_seq
-Nullable(UInt32), adapter Nullable(Enum), target Nullable(Enum)`;
- sorting key `(publisher_domain, received_at, policy_id)`; TTL 30 days.
- Ingest: body ≤ 8 KiB, ≤ 10 reports/request, strings ≤ 256, nesting ≤ 4,
- both media types with separate validators, unused fields discarded, own
- limiter bucket. **Caps with values:** 10,000 reports/hour/publisher and
- 1,000/hour/cohort; overflow increments the `csp_overflow` ops counter
- (dropped reports counted, never parsed further).
-- **`ts_ops_counters`**: `received_at DateTime64, publisher_domain
-LowCardinality(String), release_id String, counter
-Enum(renderer_requests|renderer_unknown_version|renderer_auth_blocked|
-ingest_accepted|ingest_dropped|ingest_rate_limited|abuse_flagged|
-csp_overflow|probe), value UInt64, probe_run_id Nullable(String),
-expected_seq Nullable(UInt32), adapter Nullable(Enum), target
-Nullable(Enum)`; sorting key `(publisher_domain, received_at, counter)`;
- TTL 90 days.
-- **Sink plumbing:** one generic multi-target Tinybird sink trait;
- `RuntimeServices` (`platform/types.rs:158`) gains handles for
- client-events, CSP, and ops targets beside the auction sink; each
- target has its own dataset + token settings.
-- **Settings:** `[telemetry.client_events] collection_enabled,
-sink_enabled, sample_rate, api_host, dataset, token_secret,
-secret_store, max_body_bytes`; `[telemetry.csp_reports]` and
- `[telemetry.ops_counters]` (same transport shape);
- `[telemetry.trace_auth] secret_store, active_kid, previous_kids,
-sampling_key_secret`; `[telemetry.diagnostic] secret_store,
-active_kid`; `[telemetry.probe]` admin-issued run configuration.
-- APS parsing returns structured drop observations
- `{reason, slot, width?, height?}` (`aps.rs:722` loses slot/values);
- > 8192 → `dimensions_out_of_range` unclamped.
-
-### 5.7 Modes and SLIs
-
-Production (sink-backed): deterministic sampling (0.10). SLIs: pipeline
-availability (per-sink authenticated probe freshness ≤ 5 min, loss
-< 0.1%); failure detection (≥ 1% of sampled render attempts visible
-within one hour at ≥ 10,000/hour) — **verified by the injected-failure
-alert drill**, not only by probe persistence. Diagnostic:
-credential-gated, `dexp`-bounded, unsampled, full stream + console
-mirroring + debug envelopes.
-
-### 5.8 Server-side drop surfacing
-
-Bounded structured summary whenever any bid is dropped; `bid_drop` +
-`selection_summary` rows; `ts-debug` comment; tester-gated structured
-`debug` on page-bids and `/auction`. Startup warnings: APS +
-`allow_script_creatives = false`; mediator + direct providers without
-explicit `winner_selection`.
-
-## 6. APS delivery fixes
-
-### 6.1 Mediation
-
-Seven rules, arrival-independent, one shared helper across both
-lifecycles: (1) required `[auction].currency` (APS + non-USD = startup
-error; Prebid validates at parse, `prebid.rs:2433`); (2) candidate
-identity `source_candidate_id = (provider_name, upstream_bid_id)` with
-the upstream id **required, ≤ 64 chars, unique** — missing/duplicate/
-oversized → `bid_drop{missing_bid_id | duplicate_bid_id |
-bid_id_too_large}`, **no fingerprint fallback**; `candidate_id` is a
-server-minted 12-char `^[a-z0-9]{12}$` CSPRNG wire echo with in-auction
-collision retry, **never an ordering key**, and the mediator's echoed
-value is validated against the issued set on return (an echo matching no
-issued id is `provenance_invalid`); (3) mediator echoes
-`ext.trusted_server.candidate_id` — resolves → forwarded candidate,
-provenance `mediator`, **price authoritative from the mediator, every
-render-source and notification field from the stored candidate, deal
-fields out of scope**; any render-source difference → mediator-native;
-unresolvable echo → discarded + counted (`provenance_invalid`),
-mediator-native and direct candidates stay eligible; (4) floors both
-populations, echoes dedup twins; (5) **total order** decoded CPM desc →
-provenance rank (mediator first) → `source_candidate_id` asc; (6)
-required `winner_selection` (`mediator_only`: timeout → no winners
-unless `mediator_timeout_fallback = "direct"`; `merge_highest_cpm`:
-timeout → direct-only); (7) `selection_summary` reporting.
-
-### 6.2 Dimensions
-
-Exact membership (`aps.rs:675`); structured `bid_drop{invalid_dimensions,
-w, h}`; "request the sizes you accept."
-
-### 6.3 Script creatives
-
-Default `false`, loud (§5.8); **DR-2 is a deployment decision** (enable
-with security approval, or accept a quantified excluded share and gate
-Phase 3 on it).
-
-### 6.4 / 6.5
-
-Render identity per G2; fallback per G4e (child attempt).
-
-### 6.6 Renderer endpoint
-
-Unconditional route in every adapter (provider stays config-gated);
-startup auth-pattern validation; `/integrations/aps/renderer/v1`
-embedded, served `public, max-age=31536000, immutable` with a
-checked-in per-version header manifest (headers frozen with bytes);
-canary versions `no-store`; unknown version 404 `no-store`;
-three-message ack (G4b-1); aggregate route counters in
-`ts_ops_counters`; CSP rollout three instruments (enforced discovery /
-report-only tightening / enforced-cohort relaxation on a short-lived
-canary version, gated on runner acceptance, violation rate, render
-failure, with a kill switch); **policy identity in the server-selected
-`policy_id` path** (also the release/cohort carrier, §0); bucketed
-aggregation only with the §5.6 caps; never a sole rollback signal;
-three-browser capture (`playwright.config.ts:16`).
-
-### 6.7 One descriptor schema
-
-Tagged `BidRenderer` envelope (`types.rs:188-211`); wire-schema
-crate/xtask (no core↔js cycle, `Cargo.toml:45`) generates JSON-Schema,
-TS parser, ES5 inline fragment, the §5.1 validity matrix, and fixtures;
-staleness CI; semantic validators handwritten; outer tolerance only;
-exact AAX projection; shared positive + adversarial corpus through all
-three validators.
-
-### 6.8 Bridge hardening
-
-Order (normative — preserving the baseline stolen-capability defense,
-`gpt/index.ts:1584-1637`, **plus the read-only lookup the altered-id
-signal needs**):
-
-1. parse `e.data` (bare catch → return);
-2. **read-only source→active-slot lookup for every Prebid Request** — if
- the resolved slot expects a different ad id, emit
- `bridge_request{matched: false}` (the B1 signal) **without responding
- and without suppressing native Prebid** (a truncated `hb_adid` is not
- TS-reserved, so suppression would be wrong and the old order could
- never produce the signal);
-3. identify a TS-reserved ad id (live registry or tombstone, G2);
-4. if TS-reserved: `stopImmediatePropagation()` before validation;
-5. validate source ownership (known slot-root `WindowProxy` map;
- sender's parent chain to depth 5; never scanning the frame tree);
-6. validate nonce, token, `nav_gen`, `refresh_gen` (G4b);
-7. respond, or refuse with `bridge_id_mismatch`.
-
-Non-TS ids are otherwise untouched. Stolen-token test: neither TS nor
-native Prebid responds. Listener order: real-browser assertion.
-
-## 7. TSJS target architecture
-
-### 7.1 Layering
+`BidRenderSourceV1` is the only browser render-source union. A selected internal
+Rust `Bid` carries exactly one corresponding enum member; separate optional
+creative, renderer, and cache fields are removed. APS markup is not smuggled
+through `adm`, `meta`, or debug fields. Each tagged object rejects unknown keys.
+Limits are defined once and shared by the Rust producer, TypeScript parser, and
+embedded renderer validator:
+
+- nonempty `accountId` and optional nonempty `creativeId`, each at most 1,024
+ UTF-8 bytes;
+- nonempty `bidId` of at most 64 UTF-8 bytes;
+- numeric, finite, integral `width` and `height`, each in the inclusive shared
+ `RENDER_DIMENSION_MIN = 1` through `RENDER_DIMENSION_MAX = 4096` CSS-pixel range;
+- `creativeUrl` at most 4,096 UTF-8 bytes, HTTPS, no credentials, and not the
+ publisher origin;
+- canonical standard-base64 `aaxResponse`, decoded size at most 256 KiB;
+- exactly one decoded seat and one decoded bid;
+- decoded bid id, dimensions, `creativeurl`, and `tagtype` exactly match the
+ duplicated descriptor fields;
+- finite, nonnegative decoded price.
+
+A checked-in schema/corpus is the cross-language conformance source. Rust,
+TypeScript, and the ES5 renderer validator run the same positive and adversarial
+vectors. CI fails when generated ES5/schema output is stale. Semantic validation
+that cannot be represented in JSON Schema remains in small handwritten validators
+covered by the same corpus.
+
+For `adm`, markup is nonempty and at most 512 KiB. For `cache`, `cacheId` is the
+exact validated PBS Cache UUID. When cache rendering is enabled, the server emits
+one trusted `CacheFetchPolicyV1` at `tsjs.boot.cachePolicy` before core; `baseUrl` is
+the same immutable configuration snapshot used for auction projection and is an
+absolute HTTPS URL of at most 4,096 UTF-8 bytes with a host and fixed nonempty path
+but no credentials, query, or fragment. Core validates and freezes it before any
+integration module prepares. A cache source without a valid boot policy is `descriptor_invalid`
+and is never fetched.
+
+`fetchUrl` is constructed server-side from that exact base URL, is at most 4,096
+UTF-8 bytes, and has exactly one query parameter,
+`uuid=`; it has no credentials, fragment, or other query
+parameter. The browser parses both values and requires exact origin,
+port, and pathname equality with the frozen base, empty username/password/hash,
+exactly one `uuid`, decoded UUID equality with `cacheId`, and canonical search text
+`?uuid=${encodeURIComponent(cacheId)}`. It then fetches with `redirect:'error'`,
+`credentials:'omit'`, and `referrerPolicy:'no-referrer'`, enforces CORS, a
+five-second deadline, a 512 KiB body limit, successful HTTP status, and a JSON
+object response. The response requires an own, nonempty `adm` string of at most
+512 KiB. Optional `w` and `h` must occur together as integral numbers within the
+same 1–4096 range and must equal the source dimensions. Optional `price` must be a
+finite nonnegative number but is never rendering authority. Other OpenRTB bid keys
+are allowed and ignored; raw markup bodies, arrays/primitives, `width`/`height`
+aliases, accessors, and alternate wrapper shapes are rejected. The response need
+not echo `cacheId` because the exact request URL is its transport binding.
+`${AUCTION_PRICE}` in the ADM is expanded only as
+`String(attempt.winnerContext.selectedCpm)` from the context transferred by the
+consumed reservation or installed by exact direct-winner admission, never from the
+cached response's `price`, current projection, targeting, or a later winner. Only
+the exact token is replaced;
+`${AUCTION_PRICE:B64}` remains untouched. The resulting ADM enters the direct ADM
+lifecycle.
+Transport, status, or shape failure is respectively `cache_network_error`,
+`cache_http_error`, or `cache_invalid_response`; none becomes `no_bid`.
+
+### 3.2 Per-slot auction decisions
+
+Every server auction entry point produces exactly one decision for every requested
+slot, in request order:
+
+```ts
+type AuctionSlotFailureReason =
+ | 'auction_disabled'
+ | 'consent_denied'
+ | 'slot_not_eligible'
+ | 'provider_timeout'
+ | 'provider_error'
+ | 'invalid_provider_response'
+ | 'mediation_failed'
+ | 'winner_not_renderable'
+ | 'identity_generation_failed'
+ | 'internal_error'
+
+type SlotAuctionDecisionV1 =
+ | { slot: string; outcome: 'winner'; candidateId: string }
+ | { slot: string; outcome: 'no_bid' }
+ | { slot: string; outcome: 'failed'; reason: AuctionSlotFailureReason }
+
+interface AuctionDecisionSetV1 {
+ version: 1
+ auctionId: string
+ results: SlotAuctionDecisionV1[]
+}
+
+interface BrowserAuctionProjectionV1 {
+ version: 1
+ auction: AuctionDecisionSetV1
+ bids: Array<{
+ candidateId: string
+ slot: string
+ provider: string
+ upstreamBidId: string
+ cpm: number
+ currency: 'USD'
+ targeting: Record
+ rendererReservationId: string
+ renderSource: BidRenderSourceV1
+ }>
+}
+```
+`BrowserAuctionProjectionV1` is exact, deny-unknown, and bounded before any slot,
+reservation, targeting, or bid mutation. Its canonical UTF-8 JSON is at most
+`MAX_BROWSER_AUCTION_PROJECTION_BYTES = 8 * 1024 * 1024`; `auction.results` and
+`bids` each contain at most 256 entries; and all objects are plain own-data objects
+with no accessors. Canonical serialization uses the interface field order shown,
+request order for results, matching result order for bids, lexically sorted targeting
+keys, and no insignificant whitespace. `auctionId` matches
+`^[A-Za-z0-9._:-]{1,128}$`; candidate ids use
+the exact 12-character base64url form from §3.4 and are unique; result slots are
+unique, follow the §2.2 bound, and contain no NUL or ASCII control; every winner has
+exactly one bid with the same slot/candidate and non-winners have none;
+`rendererReservationId` uses the exact unique `r1_` form from §2.2; provider matches
+`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; and upstream bid ids are 1–64 UTF-8 bytes with
+no NUL or ASCII control. CPM is a finite nonnegative number and currency is exactly
+`USD`.
+
+Each bid's `targeting` member is a plain own-data object with at most 32 entries. A key
+matches `^[A-Za-z0-9_]{1,20}$`, is unique and case-sensitive, and cannot be
+`hb_adid`, which the runtime alone synthesizes from the reservation. A value is
+nonempty, contains at most 40 Unicode scalar values and 160 UTF-8 bytes, and contains
+no NUL or ASCII control. The producer applies the same rules. A winning candidate
+that cannot be projected becomes `winner_not_renderable`.
+
+Aggregate overflow has one exact server outcome. The producer first constructs and
+measures the complete canonical projection. If it exceeds 8 MiB, it transactionally
+converts every `winner` result in that auction to
+`failed{reason:'winner_not_renderable'}`, emits no projected winner bids, and retains
+the existing no-bid/failed results in request order. For `/auction`, the corresponding
+TS winner bids are likewise absent from `seatbid`; no unmatched decision or bid is
+emitted. The reduced projection is guaranteed to fit from the 256-result/id bounds.
+Initial HTML, page-bids, and direct response production use this same all-winners
+rule, never a completion-order or first-fit subset. Boot rejects any independently
+malformed or oversized value as `abi_mismatch`; page-bids and direct response
+admission reject it transactionally as `invalid_response` with no partial slot,
+reservation, targeting, or bid state.
+
+Wrong type, nonfinite, fractional, zero, or negative render dimensions are
+`invalid_dimensions`; an otherwise integral dimension outside 1–4096 is
+`dimensions_out_of_range`. This exact distinction and range apply in the Rust
+producer, TypeScript projection/source parser, ES5 APS renderer validator,
+programmatic banner sizes, cache `w`/`h`, PUC/renderer DOM construction, and every
+CSS/attribute layout assertion. No adapter may clamp an accepted dimension.
+
+Each provider response is normalized internally to exactly one
+`ProviderSlotOutcome`—candidate, no-bid, or typed failure—for every slot dispatched
+to that provider. A successful response that omits a dispatched slot is a provider
+no-bid; launch, transport, timeout, HTTP, parsing, validation, and mediation errors
+are failures for the affected dispatched slots. Final aggregation is deterministic:
+
+1. Pre-dispatch gating returns `auction_disabled`, `consent_denied`, or
+ `slot_not_eligible` directly. A slot with zero eligible providers is exactly
+ `failed{reason:'slot_not_eligible'}`, never a no-bid. Provider currency rejection
+ is `invalid_provider_response`; there is no separate render-layer currency reason.
+2. A selected deliverable candidate is `winner`, even if another provider failed.
+3. Failure to mint a unique renderer reservation for the selected candidate is
+ `failed{reason:'identity_generation_failed'}`. Any other failure to validate or
+ project the selected candidate is `failed{reason:'winner_not_renderable'}`.
+4. With no winner, the slot is `no_bid` only when at least one provider was
+ dispatched and every eligible/dispatched provider
+ completed successfully with no candidate.
+5. With no winner and any provider or mediation failure, the slot is `failed` with
+ the first applicable reason in this closed priority order: `internal_error`,
+ `mediation_failed`, `invalid_provider_response`, `provider_error`,
+ `provider_timeout`, `consent_denied`, `auction_disabled`, `slot_not_eligible`.
+ `winner_not_renderable` and `identity_generation_failed` are selected directly by
+ rule 3 and do not participate in multi-provider priority. Completion order is
+ irrelevant.
+
+`/auction` keeps ordinary OpenRTB winners in `seatbid` and places the decision set
+at `ext.trusted_server.slot_results`. Every TS winner bid has this exact nested
+extension; unknown keys inside `trusted_server` are invalid:
+
+```ts
+interface TrustedServerOpenRtbBidExtV1 {
+ candidate_id: string
+ slot_id: string
+ render_source: BidRenderSourceV1
+}
```
-kernel/ boot, config, queue, event bus, log, beacon, sessions
-adapters/ googletag.ts, pbjs.ts, messaging.ts ← only access to external ad-tech globals
-services/ slots (registry+handoff), auction client, render engine, consent
-integrations/ gpt, prebid, aps, creative, datadome, …
+
+The bid's standard `id` is the server-minted renderer reservation id from §2.2;
+the provider's upstream id remains provenance and, for APS, the descriptor's
+`bidId`. The bid's standard `impid` must equal the request impression id mapped to
+`slot_id`. A winner decision joins by exact `slot`, `candidateId`, `impid`, and
+`slot_id` to exactly one bid. A no-bid or failed decision joins none. Missing,
+duplicate, extra, or mismatched joins make the entire response invalid to TSJS.
+TSJS renders only `render_source`; it never infers a source from standard OpenRTB
+fields. If a bid also carries standard `adm`, it is permitted only for an ADM source
+and must equal `render_source.adm` byte-for-byte; an `adm` on APS/cache or a mismatch
+is invalid. `/_ts/page-bids` returns `BrowserAuctionProjectionV1`, and initial HTML
+stores that same value at `tsjs.boot.auctionProjection`. They do not carry a second
+legacy `{slots,bids}` interpretation. The deprecated `/__ts/page-bids` endpoint and
+its JS fallback are deleted at cutover.
+
+### 3.3 APS response admission
+
+APS response handling is deterministic and reports typed local drop reasons:
+
+- upstream bid id is required, bounded to 64 UTF-8 bytes, and unique within the
+ provider response;
+- malformed `contextual`, missing `creativeurl`, invalid tag type, invalid URL,
+ invalid dimensions, disallowed script, and malformed price are rejected per bid
+ where safe; one bad bid does not discard unrelated valid bids;
+- dimensions must exactly match one requested size; values are never clamped;
+- script creatives remain default-off and require an explicit security-approved
+ setting; iframe creatives remain supported by default;
+- the validated AAX projection used in `aaxResponse` is derived from the accepted
+ bid, not re-parsed from a later lossy structure.
+
+Drop reasons must remain visible in the existing debug/log surfaces for all auction
+entry points. This is not a new external telemetry contract.
+
+APS configuration accepts only canonical `account_id`; the `pub_id` deserialization
+alias and its integer coercion are deleted at the hard cutover.
+
+### 3.4 Mediation provenance
+
+This work preserves the configured mediator's existing candidate selection and
+timeout fallback behavior. It changes only the unsafe reconstruction boundary for
+renderer-bearing source candidates:
+
+1. Candidate provenance is `(provider_name, upstream_bid_id)`. Missing, duplicate,
+ or oversized upstream ids are rejected.
+2. Every candidate receives a response-unique, opaque, server-minted 12-character
+ base64url `candidate_id` from 9 CSPRNG bytes. Generation retries a
+ response-local collision at most eight times, then fails the affected slot with
+ `internal_error`; the id is never an ordering key.
+3. The mediation request carries it only at
+ `ext.trusted_server.candidate_id`. A mediator-selected source candidate must echo
+ exactly one known id. Missing, unknown, or duplicate echoes are
+ `mediation_failed` and cannot borrow render data from another candidate.
+4. The resolved candidate takes only the mediator-selected price and existing
+ selection metadata from the mediator. Provider, upstream id, render source,
+ dimensions, currency, and notifications come from the stored source candidate.
+ Mediator-native render sources are rejected as out of scope.
+5. Direct no-mediator selection keeps the repository's current highest-CPM rule;
+ an exact CPM tie is resolved deterministically by provider name and upstream bid
+ id. An APS bid explicitly declaring a non-USD currency is invalid. This design
+ adds no auction currency or winner-selection configuration requirement.
+
+### 3.5 Publisher projection and GAM targeting
+
+The publisher bid projection carries `renderSource: BidRenderSourceV1` intact on
+every path, plus the exact `candidateId` and `rendererReservationId`. Initial HTML
+stores the document-generation input at
+`tsjs.boot.auctionProjection: BrowserAuctionProjectionV1`. The initial
+`NavigationSession` seeds its internal current projection from that immutable boot
+value; an SPA page-bids response replaces only the new session's internal projection
+through the transaction in §2.5. It never mutates `tsjs.boot`. A winner decision
+must join exactly one projected bid and a no-bid/failed decision must join none.
+Targeting applies one identity rule from §2.2 and never truncates a value to fit GAM.
+If the chosen value cannot satisfy the 40-character targeting limit, the bid is
+rejected before targeting with an explicit local reason.
+
+Targeting cleanup is owner-and-value checked, not value-only. One runtime-owned
+journal stack per physical GPT slot and targeting key records an internal owner id,
+the exact installed string, and the predecessor value/owner for every TS write.
+The sole GPT adapter observes each live slot's `setTargeting` and `clearTargeting`
+calls and uses a closure-private reentrancy marker for TS-originated writes. Before
+forwarding any publisher-originated mutation it invalidates the affected key's TS
+restoration chain—or every chain for clear-all—regardless of whether the publisher
+writes the same string. This bookkeeping never changes the publisher call's
+arguments, return, throw, or order.
+Before a write, a mismatch between the actual GPT value and the current TS frame
+means publisher code changed the key; the runtime drops its restoration chain and
+preserves that publisher value. Otherwise the new attempt pushes a distinct owner
+frame before setting the value, even when its string equals the predecessor's.
+
+Supersession, empty render, terminal failure, and navigation disposal mutate GPT
+only when the disposing frame is current owner and the actual string still equals
+its installed string. A current provisional frame then restores its immediate live
+predecessor, or the original publisher value/absence. Disposing an older frame below
+a newer owner performs no GPT write and rebases the successor to the removed frame's
+predecessor. Acceptance promotes the new attempt's frames into its
+`CommittedRenderArtifact`; disposal of the prior accepted artifact uses that same
+non-top rebase. Thus two generations installing an identical string remain distinct:
+newer success cannot be cleared by older disposal, newer failure can reveal the
+still-live older value, and a publisher mutation is never overwritten.
+
+Publishing a TS-owned PUC bid is one ordered transaction. The browser first
+validates the winner, tagged source, slot join, and server-minted reservation id and
+prepares all targeting/bid objects without exposing them. It then inserts the
+reservation as live in the bounded store. Store capacity or collision therefore
+fails before a creative can observe the id.
+
+For SSAT/page-bids, only after successful insertion may it expose that same id as
+GAM `hb_adid`, publish other targeting, record GPT intent, and invoke a
+request-capable GPT operation—in that order. Any failure before request invocation
+tombstones the reservation, compare-restores targeting, and settles the attempt.
+
+For the Trusted Server Prebid adapter, the supported artifact is the content-addressed
+external bundle built from exactly lockfile-resolved Prebid.js 10.26.0. The external
+artifact contains no Trusted Server auction, admission, render, or refresh behavior.
+The TS-owned `PrebidAdapter` exposes one internal version-pinned
+`admitTrustedBid(preparedBid)` boundary:
+
+```ts
+interface PreparedTrustedBid {
+ readonly auctionId: string
+ readonly adUnitCode: string
+ readonly bid: Readonly<{
+ requestId: string
+ adId: string
+ cpm: number
+ width: number
+ height: number
+ ad: ''
+ ttl: 300
+ creativeId: string
+ netRevenue: true
+ currency: 'USD'
+ bidderCode: string
+ meta: Readonly<{
+ advertiserDomains: readonly string[]
+ tsAuctionId: string
+ tsBidId: string
+ tsAdmHash?: string
+ }>
+ }>
+}
+
+interface PrebidAdapter {
+ admitTrustedBid(
+ preparedBid: Readonly
+ ): 'admitted' | 'not_admitted'
+}
```
-Kernel imports nothing above it; adapters import kernel only; services
-import kernel + adapters; integrations import kernel + services, never
-each other; stateful services via the G3 registry only; enforced per
-G3's two rule families.
-
-### 7.2 Adapters
-
-`present | pending | timed_out` per external global; `timed_out`
-non-terminal; queued operations carry their own timeouts and expire with
-disposition reasons.
-
-### 7.3 Slot registry service
-
-Kernel-owned; `WeakMap` + div-id index;
-ownership, adoption, handoff claims, responsive resolution, the G4a
-causal intent queue (NavigationSession for unissued intents) and
-cycle/drain state (RuntimeSession), targeting history. No expandos
-(`__tsRenderGeneration`/`__tsRenderBid` deleted).
-
-### 7.4 Final global surface (hard cutover)
-
-| Legacy surface (removed at cutover) | Final shape |
-| --------------------------------------- | --------------------------------------------------------------------- |
-| `window.tsjs.que` | `window.tsjs.que` — unchanged |
-| `globalThis.tscreative` | `tsjs.creative.*` |
-| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` |
-| `requestAds` (void) | one async `tsjs.requestAds(options): Promise` (G4f) |
-| `window.__tsjs_*` flags, config globals | `tsjs.boot.*` |
-| install manifest | `tsjs.boot.manifest` (`{release_id, plugins: [{id, order}]}`) |
-| expandos / function sentinels | `SlotRecord` fields / kernel `WeakSet` |
-| `tsjs._internal` | kernel registry (G3), frozen after boot |
-| (new, public) | `tsjs.definePlugin({id, release, install})` |
-
-Bootstrap: field-wise idempotent init (`window.tsjs ||= {}; tsjs.que
-||= []; tsjs.boot ||= {}`; `publisher.rs:3665`'s clobber fixed);
-transactional ownership `unclaimed → installing → kernel | fallback`
-with an owner-generation counter; kernel installs inert and flips at one
-commit point; throws unwind to `failed`; the 10 s watchdog aborts the
-owner-generation-scoped controller, completes the shared unwind, then
-atomically transitions `failed → fallback`; late continuations and
-disposers validate the owner generation and self-discard; a bundle
-arriving after fallback committed defers (`bundle_partial`). Tests:
-throws per checkpoint and hung-resume-after-fallback.
-
-### 7.5 Messaging module
-
-All `postMessage` through one module: versioned envelopes, name
-constants, G4b nonces, §6.8 validation. Minimal module in Phase 1; full
-migration in Phase 4.
-
-### 7.6 Plugins and sessions
-
-`tsjs.definePlugin({id, release, install})`; **no plugin-level dispose
-hook** — `ctx.onDispose` only, exactly-once reverse order.
-`install(ctx): void | Promise` with `ctx.signal`, unwind on
-throw/reject/abort, per-disposer isolation, disposer-after-disposal
-invoked immediately, pending capacity 16 / 10 s → `bundle_partial`,
-release mismatch quarantined before install. Sessions: `RuntimeSession`
-(bridge listener + reservation store, history hook, pbjs subscriptions,
-adapters, beacon queue, cycle/drain state, in-memory diagnostic
-credential), `NavigationSession` (trace + auth + renewal timer,
-attempts, aliases, unissued intents, targeting history),
-`RenderAttempt`/`AuctionBatch`; enumerable disposal inventories. No
-empty `catch`; console logging retained (paired `warn` with the beacon
-reason; `debug`-level delivery/security failures promoted).
-
-### 7.7 Bootstrap
-
-`gpt_bootstrap.js` shrinks to a queue-and-flags stub; the bundle replays
-recorded calls; the no-bundle fallback is generated from the same
-TypeScript source (pinned by `gpt.rs:1174-1179`), activated per §7.4.
-
-### 7.8 GPT correctness carried with the restructure
-
-Unconditional early `slotRequested`/`slotRenderEnded` subscription
-(replacing `gpt/index.ts:1091`'s gate); restore #922/#997 (DR-3);
-`changeCorrelator: false` (configurable); `enableSingleRequest()` only
-when services are not already enabled; ambiguous responsive resolution
-emits `render_terminal{failed, slot_unresolved}` alongside its warning.
-
-### 7.9 Decomposition targets
-
-| Today | Target |
-| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
-| `gpt/index.ts` (~1850 LOC) | `slot_resolution`, `handoff`, `initial_load`, `ad_init`, `render_bridge`, `spa_navigation`, `beacons` + thin index |
-| `prebid/index.ts` (1671 LOC) | adapter, shim, refresh handler (onto the slot registry), eids, diagnostics |
-| `gpt/script_guard.ts` (634 LOC) | folded onto the 170-LOC `shared/script_guard.ts` factory |
-| `core/trace.ts` (model + UI) | `services/trace` + `integrations/trace_overlay` |
-| `core/global.d.ts` (`pbjs: TsjsApi`) | real Prebid types; `TsjsApi` split public vs internal |
-
-### 7.10 Performance (reproducible)
-
-Pinned workflow (`runs-on: ubuntu-24.04` — browser CI is `ubuntu-latest`
-today, `integration-tests.yml:155` — inside a pinned container digest);
-lockfile-resolved Playwright with recorded browser revision
-(`browser/package.json:10` is a caret range); pinned compressors
-(`gzip -9 -n`, `brotli -q 11`). Vectors: minimal `[core]`; reference
-`[core, creative, gpt, prebid, datadome]`; maximal all 13. Budgets vs
-checked-in baselines (+5% bytes; baseline records image/browser/tool
-versions and is invalid if any differ). Browser timing:
-`performance.mark("tsjs:bids-script")` →
-`performance.mark("tsjs:first-display")`; reference fixture; warm cache;
-local resources; 5 warm-ups, 50 samples, nearest-rank p90 ≤ baseline ×
-1.10; inconclusive (3-run agreement > 5%) → one rerun, then fail.
-**Retained-heap budget (named accurately — this measures retained, not
-transient allocation peak):** via the Playwright CDP session —
-`HeapProfiler.collectGarbage` then `Runtime.getHeapUsage`, sampled at
-five fixed points (post-boot, post-adInit, post-first-render,
-post-refresh, post-SPA-navigation) on the maximal vector; metric = max
-retained sample; gate ≤ baseline × 1.10; same rerun rule. (Transient
-allocation peak is out of scope; if a future leak needs it, add
-`HeapProfiler` continuous sampling as a separate budget.) Server
-benchmark: the G5 lookup path; 100 warm-ups, 1,000 iterations; median +
-p90 one-sided ≤ baseline × 1.10; 3-run 5% agreement or inconclusive.
-
-### 7.11 Toolchain
-
-TypeScript floor to the resolved 5.9 line; release-gating flags
-`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
-`verbatimModuleSyntax`, `noImplicitOverride`,
-`useUnknownInCatchVariables`. The gate is a **checked-in package.json
-script**, `"typecheck": "tsc -p tsconfig.json --noEmit"`, invoked so the
-lockfile-resolved compiler is used:
+All strings and numbers already satisfy their canonical auction/projection bounds;
+`adId` is the exact admitted `r1_` reservation, and no renderer descriptor, ADM, cache
+coordinate, or other capability crosses this boundary. The empty `ad` is deliberate:
+the TS bridge resolves the already-stored tagged render source only after the PUC
+claim. The adapter owns the bound Prebid object, registered `trustedServer` bidder
+adapter, and the exact 10.26.0 response-admission callback; neither the external
+artifact stamp nor publisher code exposes this method.
+
+All validation, reservation insertion, exact replacement of the TS bid's `adId`, and
+frozen bid-object construction complete before the call; no targeting or other
+publisher-visible mutation precedes it. The boundary returns exactly
+`admitted | not_admitted`, and the 10.26.0 artifact fixture proves `not_admitted`
+leaves no bid/event/targeting state. `not_admitted` tombstones the reservation and settles
+`failed{reason:'prebid_admission_failed'}`. A throw settles the same failure; detected
+partial publication tombstones the id, suppresses every later PUC request, settles
+`failed{reason:'prebid_contract_violation'}`, and fails the artifact-conformance
+gate. Runtime handling is fail-closed even though the same violation blocks future
+release. A Prebid version change requires a reviewed contract-fixture update.
+
+`admitted` moves the reservation into an `awaiting_prebid_selection` state; it does
+not create a render attempt or transfer permanent ownership. The Prebid adapter's
+early synchronous `auctionEnd` listener uses the supported artifact's exact
+auction-id/ad-unit winner query before publisher targeting callbacks. It promotes
+only the exact selected TS `adId` to a render attempt and atomically tombstones every
+other TS reservation admitted for that auction/ad unit as unselected. A ten-second
+watchdog from admission performs the same tombstoning and records
+`prebid_selection_timeout` if no matching `auctionEnd` arrives. Navigation or auction
+abort tombstones the whole admitted set immediately. Subsequent GPT/render failure
+follows the normal terminal lifecycle for the selected reservation. A fast Universal
+Creative request can never race ahead of reservation lookup, and a losing bid cannot
+hold capacity until the 15-minute tombstone expiry as a live entry.
+
+### 3.6 Static renderer and APS runner proxy endpoints
+
+`/integrations/aps/renderer/v1` and `/integrations/aps/runner.js` are always-reserved
+Trusted Server routes. When APS is enabled, `GET` returns the local static renderer
+or proxies the APS-hosted creative runner respectively. When APS is disabled, `GET`
+returns a local `404 no-store`; neither route ever falls through to a publisher
+origin. The family is dispatched before publisher auth, EC, and generic integration
+filters. All adapters expose the same method, routing, security-header, and failure
+semantics. Unsupported methods return local `405` with `Allow: GET`; unknown
+renderer versions and the abandoned `/integrations/aps/runner/v1.js` shape return a
+local `404 no-store`.
+
+The renderer v1 body and headers are immutable and served with a long-lived immutable
+cache policy; a renderer-body or CSP semantic change requires a new renderer route
+version. The document is static and contains no descriptor data. Its iframe
+`sandbox` attribute and response CSP `sandbox` directive contain exactly this token
+set, serialized in this order:
+
+```text
+allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation
+```
+
+They omit `allow-same-origin`, `allow-top-navigation`, downloads, modals,
+presentation, orientation lock, and storage-access escape. The exact renderer v1 CSP
+header is:
+
+```text
+default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline' 'self' https:; connect-src https:; frame-src https: data: blob:; img-src https: data: blob:; media-src https: data: blob:; style-src 'unsafe-inline' https:; font-src https: data:; worker-src https: blob:; form-action https:;
+```
+
+The broad HTTPS resource directives are confined below the opaque outer sandbox and
+are required for APS and bidder resources; `'self'` permits the local runner proxy in
+HTTP-based hermetic adapters as well as production HTTPS. The response also has
+exactly `Content-Type: text/html; charset=utf-8`,
+`Cache-Control: public, max-age=31536000, immutable`,
+`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. It deliberately
+omits both `X-Frame-Options` and a CSP `frame-ancestors` directive so publisher and GAM
+creative ancestors can embed it; the iframe/CSP sandbox, nonce, source-bound port,
+and descriptor validation are the embedding boundary. It does not forward publisher
+credentials or authorization in TS-owned fetches. Runner-created APS creative
+resources may use APS-origin cookies under ordinary browser policy. No cookie is
+authority. Any header or CSP change requires renderer v2; policy reporting is a
+separate observability design.
+
+The runner endpoint is a thin runtime transport proxy, not an artifact store. Its
+only upstream is the fixed APS URL
+`https://client.aps.amazon-adsystem.com/prebid-creative.js`; no request field,
+configuration value, query parameter, or header can select another target. Trusted
+Server issues a credential-free, referrer-free `GET`, does not forward browser
+cookies, authorization, client IP, or publisher headers, requests
+`Accept-Encoding: identity`, and disables redirect following in the platform client.
+
+The platform HTTP abstraction exposes an internal `ProxyResponseEvidenceV1` policy
+used only by this route. It preserves upstream status and security-relevant evidence
+for every occurrence of `Content-Type`, `Content-Encoding`, and `Content-Length`
+before the generic proxy removes headers or consumes the body. An adapter that
+exposes duplicate fields separately returns every raw value. An adapter runtime that
+combines duplicates may return its exact combined value only when the combination is
+visible and cannot be mistaken for a valid singleton; the closed grammars below
+reject any comma/list form for all three headers. It is forbidden to split a combined
+value or normalize it into an apparently valid singleton. All other upstream headers
+are irrelevant because the successful response drops them.
+
+The policy requests identity encoding, prevents redirect following, and returns the
+identity body as a bounded stream or bounded buffer without transforming bytes.
+Cloudflare inspects the initial Workers `Headers` values before the generic adapter
+strips encoding/length; concatenated duplicate values remain combined and therefore
+fail the singleton grammars. If the runtime erases encoding evidence or decodes a
+non-identity response without exposing that fact, the evidence is `unavailable` and
+the proxy rejects it. Fastly and Axum apply the same evidence/no-decompression
+contract. No adapter may reconstruct erased evidence.
+
+The entire upstream operation—from dispatch through the final response-body byte—has
+a five-second monotonic deadline. Deadline expiry cancels the platform pending
+request, discards every collected byte, and makes any unavoidable late platform
+continuation self-discard without constructing a response. It returns the same local
+failure as any other proxy error and cannot outlive the ten-second APS-completion
+deadline. Spin does not use its current eager `spin_sdk::http::send` path for this
+policy: it calls the WASI HTTP outgoing handler with supported request options and
+polls the response/body stream against a monotonic-clock pollable for the total
+deadline. Failure to set the transport timeouts or cancel/drop the pending resources
+is `unavailable` evidence and prevents APS from being enabled on that build.
+
+The proxy accepts only status `200`. `Content-Encoding` must be absent or exactly one
+case-insensitive `identity` token; lists and every other coding are rejected. Exactly
+one parseable `Content-Type` header is required. Its case-insensitive essence must be
+`application/javascript` or `text/javascript`, with either no parameters or only one
+case-insensitive `charset=utf-8` parameter; duplicate or unknown parameters and every
+other media type or charset are rejected. The exact body cap is
+`APS_RUNNER_MAX_RESPONSE_BYTES = 8 MiB` on every adapter. If exactly one canonical
+decimal `Content-Length` matching `^(0|[1-9][0-9]*)$` is present, it is rejected
+before collection when above the cap and must equal the final identity-body byte
+count. Missing length is allowed; duplicate, malformed, mismatched, or conflicting
+values fail. Collection stops and cancels the request as soon as streamed or buffered
+bytes exceed the cap. The complete body must decode as UTF-8 without replacement;
+validation never transforms the bytes.
+
+A successful response relays the upstream body bytes unchanged while replacing
+transport headers with
+`Content-Type: application/javascript; charset=utf-8`,
+`Access-Control-Allow-Origin: *`,
+`Cross-Origin-Resource-Policy: cross-origin`,
+`X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. Upstream
+cookies and all other response headers are dropped. Upstream fetch, status, media
+type, content encoding, redirect, length, size, or deadline failure returns a local
+empty `502 no-store`; vendor bodies are never exposed in logs or error responses.
+The adversarial parity corpus executes through each real adapter transport, including
+Cloudflare and Spin wasm, rather than injecting already-normalized core responses. An
+adapter that cannot preserve the raw evidence, common cap, or total deadline cannot
+enable APS and blocks the release.
+
+No APS runner bytes, digest, vendor-version record, redistribution license, update
+script, generated artifact, or offline fallback is stored in Trusted Server source or
+release artifacts. This design does not define runner caching behavior. The runner
+route is intentionally unversioned because it represents a live APS-owned dependency,
+not immutable TS-owned bytes.
+
+Proxying does not make the runner trusted TS code or prove that it rendered. APS
+remains the runtime owner of those executable bytes and its resolve/reject semantics
+are a narrow external trust dependency. The outer opaque iframe, validated
+descriptor, one-shot lifecycle port, and completion deadline contain execution and
+reject missing, late, or misbound signals; they cannot determine whether APS told the
+truth when it invoked `resolve`. The proxy never rewrites, inspects, or repairs the
+JavaScript body.
+
+The renderer accepts one parent-provided, nonce-bound descriptor plus the publisher
+origin captured by the kernel before iframe creation. Because its opaque origin has
+`location.origin === "null"`, it validates the supplied origin's shape and uses it
+only to repeat the descriptor's not-publisher-origin check. It clears the nonce from
+its URL and implements the TS side of `ApsRunnerContractV1`. Before runner load, it
+creates a one-shot Promise and queues exactly:
+
+```ts
+new CustomEvent('prebid/creative/render', {
+ detail: {
+ aaxResponse: renderer.aaxResponse,
+ seatBidId: renderer.bidId,
+ source: 'internal',
+ resolve,
+ reject,
+ },
+})
+```
+`resolve` and `reject` are the Promise's one-shot functions and are the only
+non-serializable fields. The renderer resolves `/integrations/aps/runner.js` against
+its own absolute Trusted Server document URL and loads only that route. It creates the
+script with `crossOrigin='anonymous'` and `referrerPolicy='no-referrer'`; it does not
+set SRI because the proxy relays a live APS-owned artifact rather than immutable
+TS-owned bytes. Under the APS conformance contract, the runner consumes the queued
+event and promises to call `resolve` only after its asynchronous handler commits the
+nested creative iframe, and to call `reject` for validation, load, or render failure.
+Promise resolution sends one completed result; rejection, proxy/CORS error, runner
+error, or script-load error sends one failed result over the transferred lifecycle
+port. Runner `load` is intermediate progress only. The static renderer owns no APS
+completion timer. Proxy/CORS/script-load failure maps to `runner_no_load`; callback
+rejection maps to `runner_failed`.
+
+A locally authored fictional fixture implements the exact proxy and
+queue/resolve/reject behavior; it is not a copy, transformation, or derivative of the
+APS body. Real-GAM tests exercise the live proxied APS dependency in all three
+browsers and are a release prerequisite. The APS runner is allowed to change
+upstream. Load failure, explicit rejection, and silence fail closed through the
+existing APS-completion deadline. A changed or compromised APS runner can invoke
+`resolve` prematurely or incorrectly; this is an accepted external-dependency risk
+that the outer renderer cannot detect. Real-browser DOM/network conformance reduces
+but does not eliminate it. V1 never loads the APS URL directly from the browser,
+executes a stored fallback, treats script `load` as completion, or uses a reusable
+global `postMessage` acknowledgement.
+
+## 4. Render lifecycle protocol
+
+### 4.1 State machine
+
+```text
+created
+ -> waiting_for_gam_and_claim | rendering_direct
+waiting_for_gam_and_claim
+ -> waiting_for_owner | failed | cancelled
+waiting_for_owner
+ -> waiting_for_insertion | failed | cancelled
+waiting_for_insertion
+ -> waiting_for_document | waiting_for_adm | failed | cancelled
+rendering_direct
+ -> waiting_for_document | waiting_for_adm | failed | cancelled
+waiting_for_document
+ -> waiting_for_aps_completion | failed | cancelled
+waiting_for_aps_completion
+ -> accepted | failed | cancelled
+waiting_for_adm
+ -> accepted | failed | cancelled
```
-npm --prefix crates/trusted-server-js/lib run typecheck
+
+Transitions are methods on `RenderAttempt`, not ad-hoc flag mutation. Each method
+checks the expected state and terminal latch. Timers are created at the transition
+whose deadline they enforce and are cleared by the transition that settles them.
+
+An accepted transition first atomically promotes durable DOM/targeting ownership
+from the attempt into one `CommittedRenderArtifact` owned by the exact slot and
+navigation. The attempt disposer removes only uncommitted resources; promotion
+detaches the committed iframe, targeting snapshot, and physical-slot metadata before
+the terminal latch disposes the attempt. Direct TS iframes are removed by artifact
+replacement or navigation disposal. PUC content remains owned by its physical GPT
+slot: for a TS-owned slot the artifact may dispose it only through safe GPT
+destroy/redefine, while publisher-owned slot DOM remains publisher-controlled and
+the artifact releases only TS metadata and compare-restorable targeting. Before a
+slot publishes another accepted artifact it disposes the prior artifact. Navigation
+disposes artifacts according to those same ownership/quarantine rules. Claim,
+registration, owner-control, and renderer-document ports/listeners that are no
+longer needed close after terminal settlement and are never promoted.
+
+### 4.2 Universal Creative claim
+
+The supported GAM creative pins Prebid Universal Creative 1.17.2 by exact artifact,
+not `latest` or a publisher-selectable version. Its cross-domain request is a JSON
+string decoding to exactly
+`{message:"Prebid Request",adId,adServerDomain}` and carries exactly one transferred
+response port. All three values are strings; `adId` and `adServerDomain` are
+nonempty. Object-form or extended payloads are rejected. Universal Creative owns
+this shape, so it cannot carry a TS nonce. The checked-in hermetic PUC fixture is
+generated from or pinned byte-for-byte to the supported source behavior.
+
+The bridge is one capture-phase dispatcher installed as the first reversible core
+effect in the synchronous activation barrier, before any integration-module
+activation and before any TS-owned GPT or Prebid script injection. This guarantees
+it precedes native non-capture listeners installed later by TS while leaving no
+dispatcher active during asynchronous preparation;
+publisher capture listeners that already ran are inside the publisher trust
+boundary. The runtime's dispatcher owns both the initial-request branch below and
+the owner-registration branch in §4.3; no second global listener exists. It performs
+this order:
+
+1. Perform only minimal, side-effect-free recognition. For a JSON string or a
+ clone-safe plain object, inspect own data properties only and extract string
+ `message`, `adId`, and an optional string `lifecycleTicket`. Do not read accessors
+ or traverse prototypes. Malformed or unrecognizable data is ignored.
+2. Route `message === 'TS Render Owner Register'` to §4.3. If
+ `message !== 'Prebid Request'`, ignore it. For `Prebid Request`, look up the
+ extracted `adId` in the global reservation/tombstone store before exact parsing,
+ port, or slot-local checks.
+3. For a non-TS id, do not suppress native Prebid. For a live or tombstoned TS id,
+ immediately call `stopImmediatePropagation()` so an extended/object-form request,
+ wrong port count, stolen capability, or replay cannot fall through.
+4. Only after suppression, require the supported exact JSON-string shape and exactly
+ one transferred port. A recognized TS id with invalid shape/port is generically
+ refused when a usable port exists; all available ports are closed. It never
+ reaches native Prebid.
+5. The first exact live claim during the compatible active GPT cycle acquires the
+ authoritative PUC `WindowProxy` from `MessageEvent.source` and stores that source
+ with its response port. This is the only source acquisition step; SafeFrame
+ ancestry is neither inspected nor guessed. Later owner messages must come from
+ this exact source. The unguessable reservation id, exact active slot/generation,
+ and one-time consumption authorize the first claim. Same-realm publisher code is
+ already inside the documented trust boundary.
+6. Join the buffered claim with an attributable nonempty `slotRenderEnded`. A claim
+ that arrives first is bounded by the owning GPT-cycle/attempt deadline, discloses
+ no render data, and holds only its source and port. A nonempty GAM result that
+ arrives first starts a three-second claim deadline. Only when both conditions are
+ true does the runtime atomically revalidate and consume the reservation.
+7. Empty GAM, navigation disposal, supersession, an incompatible cycle, or the
+ attempt deadline closes a buffered port, tombstones the reservation, and settles
+ the attempt. A second claim is generically refused; it never replaces the first.
+
+The successful outer response is an exact JSON string:
+
+```ts
+{
+ message: 'Prebid Response'
+ adId: string
+ renderer: string
+ rendererVersion: '3'
+ tsOwner: {
+ version: 1
+ status: 'ready'
+ kind: 'aps' | 'adm'
+ lifecycleTicket: string
+ }
+}
+```
+
+`renderer` is the checked-in TS dynamic-owner program. `lifecycleTicket` is
+`t1_` plus 22 unpadded base64url characters from 16 CSPRNG bytes, bound to the
+attempt, generation, source, and reservation, with a fixed three-second TTL from
+posting the outer response. No descriptor or ADM appears in the outer response. A
+recognized TS id that cannot be served receives the same response shape with
+`tsOwner:{version:1,status:'refused'}` and no other `tsOwner` keys; the dynamic owner
+rejects immediately, causing PUC to emit its ordinary `adRenderFailed`. If no usable
+response port exists, the listener can only suppress and close available ports.
+The claim deadline expiry is `bridge_claim_timeout`.
+
+### 4.3 Owner-control registration
+
+PUC executes a dynamic renderer in a hidden `__pb_renderer__` iframe, so a global
+message posted by that hidden frame cannot satisfy source binding. The TS owner must
+instead call PUC's supplied `h.sendMessage(type,payload,onResponse)` helper. PUC
+adds `adId`, serializes the request, sends it from the original PUC frame, and
+creates the response channel.
+
+The owner calls:
+
+```ts
+h.sendMessage(
+ 'TS Render Owner Register',
+ { version: 1, lifecycleTicket },
+ onRegistrationResponse
+)
+```
+
+The kernel therefore receives exact JSON
+`{message:"TS Render Owner Register",adId,version:1,lifecycleTicket}` from the
+captured PUC `WindowProxy` plus exactly one helper-created response port. It
+atomically consumes a live ticket and checks the exact `adId`, source, attempt, and
+generation. Success posts exact JSON
+`{message:"TS Render Owner Registered",adId,version:1,lifecycleTicket}` on that
+response port and transfers exactly one newly-created owner-control port. Refusal
+posts exact JSON `{message:"TS Render Owner Refused",adId,version:1}` with no
+transferred port. These are the only registration responses.
+
+This message is received by the same capture-phase dispatcher from §4.2. Its
+registration branch first looks up the minimally extracted `lifecycleTicket` in the
+runtime ticket/tombstone map. An unknown ticket is ignored for native/publisher
+listeners. For a live or tombstoned TS ticket it immediately calls
+`stopImmediatePropagation()` before exact JSON-shape or port validation, then checks
+the captured source, exact `adId`, ticket, attempt, generation, and exactly one port.
+A recognized invalid/replayed request is generically refused when a usable port
+exists and all available ports are closed. Ticket settlement, consumption, or
+attempt disposal replaces its live entry with a tombstone through the original fixed
+ticket expiry; expiry prunes either live or tombstoned state. The dispatcher itself
+is runtime-owned; attempt disposal performs that registry transition and removes
+attempt handlers, not the global dispatcher.
+
+The owner starts a three-second watchdog before invoking `h.sendMessage`, calls the
+helper's returned stop-listening disposer after the first response, and rejects on
+timeout, refusal, malformed data, or a port count other than one. The kernel owns
+the opposite control port and the owner owns the transferred port. Replay, wrong
+source, wrong port count, stale generation, or expiry cannot bind a channel. Kernel
+and owner each have a terminal latch; late registration, acknowledgement,
+settlement, or watchdog callbacks close their ports and remain inert.
+
+### 4.4 APS document and runner acknowledgement
+
+After registration, the kernel posts this exact control message and transfers
+exactly one renderer-document port:
+
+```ts
+{
+ message: 'TS APS Start'
+ version: 1
+ lifecycleTicket: string
+ rendererUrl: string
+ envelope: {
+ version: 1
+ nonce: string
+ publisherOrigin: string
+ renderer: ApsRendererV1
+ }
+}
+```
+
+The kernel owns the opposite document port. The owner creates exactly one iframe at
+the versioned renderer URL with the nonce in its fragment, reports exact
+`{message:"TS Owner Inserted",version:1,lifecycleTicket}` on the control port, and
+on iframe load transfers the envelope and document port once to that exact
+`contentWindow`. Direct APS uses the same document channel and envelope but has no
+PUC owner-control channel. The nonce is 128-bit CSPRNG, attempt-bound, and one-use.
+
+The static document sends only these exact document-port messages:
+
+- `{message:"TS APS Document Accepted",version:1,nonce}` after nonce and descriptor
+ validation;
+- `{message:"TS APS Runner Loaded",version:1,nonce}` when the runner script loads,
+ as nonterminal progress;
+- `{message:"TS APS Render Completed",version:1,nonce}` when the queued APS render
+ event invokes its one-shot success callback;
+- `{message:"TS APS Render Failed",version:1,nonce,reason}` where `reason` is
+ `descriptor_invalid | runner_no_load | runner_failed`.
+
+Insertion has a one-second deadline, document acceptance has a three-second
+deadline from iframe insertion, and the kernel is the sole owner of the ten-second
+APS-completion deadline beginning at document acceptance. Callback silence at that
+deadline maps to `runner_failed`; the static renderer never starts a competing
+completion timer. Script load never accepts. Failure, timeout, port error,
+supersession, or navigation disposal settles once. On a direct path the kernel
+removes its exact pending iframe. On a PUC path the iframe is remote DOM owned only
+by the dynamic owner; the kernel never claims it can remove that node and instead
+posts exactly one owner-control settlement:
+
+```ts
+type OwnerSettlementV1 =
+ | {
+ message: 'TS Owner Settled'
+ version: 1
+ lifecycleTicket: string
+ outcome: 'accepted'
+ }
+ | {
+ message: 'TS Owner Settled'
+ version: 1
+ lifecycleTicket: string
+ outcome: 'failed'
+ reason: RenderFailureReason
+ }
+ | {
+ message: 'TS Owner Settled'
+ version: 1
+ lifecycleTicket: string
+ outcome: 'cancelled'
+ reason: 'caller_aborted' | 'superseded' | 'navigation_disposed'
+ }
+```
+
+The PUC owner owns the remote node, its DOM handlers, and its side of the control
+port. An accepted settlement promotes that exact iframe as committed, removes its
+temporary handlers, closes the control port, and resolves the renderer Promise once.
+A failed or cancelled settlement removes the exact uncommitted iframe, removes its
+handlers, closes the port, and rejects once so PUC emits its ordinary render-failure
+event. The same cleanup applies to the PUC ADM/cache owner in §4.5.
+
+When registration accepts the transferred control port, before waiting for an APS or
+ADM start message, the owner arms one fail-closed 20-second settlement/channel
+watchdog. Start does not extend or rearm it. The deadline is longer than every
+kernel-owned insertion/document/render deadline and also covers registration-to-
+start loss. The owner cancels it on settlement. A malformed control message,
+`messageerror`, local owner disposal, or watchdog expiry performs the failed/
+cancelled cleanup above and rejects once; a silently closed or lost control channel
+is therefore bounded. This watchdog is remote resource cleanup only and cannot
+report acceptance to the kernel or change its already-terminal outcome. A
+settlement-post throw is isolated in the kernel because the remote watchdog owns
+this failure path.
+
+A caller `AbortSignal` remains attempt-owned after owner registration. If it wins
+the terminal latch, the kernel closes its renderer-document channel and sends the
+exact cancelled/`caller_aborted` settlement; direct rendering also removes the
+kernel-owned iframe. The PUC owner performs the remote cleanup just specified. A
+later insert, load, document message, APS callback, settlement, or watchdog is inert.
+
+The winning descriptor dimensions are also the exact layout contract across all
+three nested documents. Before inserting its renderer iframe, the PUC owner sets its
+own document root and body to zero margin/padding with hidden overflow, then creates
+one block iframe with matching positive width/height attributes and CSS pixels, zero
+border, and no scrollbars. The static renderer document has the same zero
+margin/padding and hidden-overflow root/body contract before loading the APS runner.
+The runner-created descendant creative is expected to occupy the same viewport. A
+300×250 winner therefore has 300×250 `clientWidth`, `clientHeight`, `scrollWidth`,
+and `scrollHeight` in the PUC owner and renderer documents, and a 300×250 descendant
+viewport, with no default eight-pixel body margin, clipping, or overflow. Equivalent
+assertions run for every boundary fixture dimension. This is layout correctness, not
+render completion; the callback contract above remains the acceptance authority.
+
+### 4.5 ADM/cache ownership
+
+Cache first resolves to bounded, validated ADM through the transport in §3.1. Direct
+and PUC ADM/cache use one TS-authored iframe constructor and this exact ordered
+sandbox value:
+
+```text
+allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation
+```
+
+It omits `allow-same-origin`, downloads, modals, presentation, pointer lock, and
+storage-access escape. The iframe has `referrerPolicy='no-referrer'`, exact positive
+source dimensions in both attributes and CSS pixels, zero border/margin, hidden
+overflow, `display:block`, `scrolling='no'`, title `Ad content`, and aria-label
+`Advertisement`.
+
+The constructor creates the iframe detached, installs one-shot `load`/`error`
+handlers and disposal state, builds the complete ADM document, assigns exactly one
+`srcdoc`, and only then appends the iframe once. It never appends an empty iframe,
+sets `src`, or performs a TS-authored replacement navigation. A `load` is accepted
+only when the exact frame is still the pending frame for the current attempt and
+navigation generation, the intended `srcdoc` was assigned before append, and the
+terminal latch is open. Initial `about:blank`, pre-assignment, removed-frame,
+replaced-frame, stale-generation, post-disposal, duplicate, and error events cannot
+accept. Error, removal before acceptance, or the five-second deadline fails
+`adm_document_no_load`. Real-browser tests force initial-blank, replacement,
+removal, error, timeout, supersession, and stale-load orderings.
+
+For PUC ADM/cache, the trusted TS-authored owner—not bidder creative code—uses the
+same registration protocol. The kernel sends exact
+`{message:"TS ADM Start",version:1,lifecycleTicket,source:AdmRenderSourceV1}` on the
+owner-control port with no transferred port. The owner reports
+`TS Owner Inserted` only after the shared constructor appends exactly one owned
+iframe, and reports exact
+`{message:"TS ADM Loaded",version:1,lifecycleTicket}` or
+`{message:"TS ADM Failed",version:1,lifecycleTicket}`. The kernel answers with one
+`OwnerSettlementV1`; only that settlement resolves or rejects the PUC renderer
+Promise. Insertion has a one-second deadline and load has a five-second deadline.
+Direct ADM uses the same constructor and accepts through its own terminal latch on
+the intended load. No secret or capability is injected into bidder-controlled
+markup.
+
+### 4.6 Channel ownership and parsing
+
+| Channel | Creator | Retained endpoint | Transferred endpoint | Lifetime |
+| --------------------- | ------------------------- | ------------------- | ----------------------------------------- | ------------------------------------------------- |
+| outer PUC response | PUC `prebidMessenger` | original PUC frame | kernel global listener | one ready/refused response or claim disposal |
+| registration response | PUC `h.sendMessage` | original PUC helper | kernel global listener | one registered/refused response or owner watchdog |
+| owner control | kernel after registration | kernel attempt | hidden TS dynamic owner | insertion through final owner settlement |
+| renderer document | kernel before APS start | kernel attempt | exact static APS renderer `contentWindow` | document acceptance through APS completion |
+
+Global window messages are JSON strings with the exact keys specified above. Port
+payloads are structured-clone objects with the exact keys specified above. Every
+parser rejects accessors, wrong prototypes, unknown keys, wrong literal/version,
+wrong port counts, oversized strings, and already-consumed capabilities before
+performing a state transition. The disposer clears handlers, closes both locally
+owned ports where possible, and makes queued callbacks generation-inert.
+
+The shared protocol corpus fixes these bounds and encodings:
+
+- before `JSON.parse`, an inbound global-dispatcher string is at most 4,096 UTF-8
+ bytes; a larger value is unrecognizable and causes no property access or state
+ lookup;
+- a TS `adId` is exactly the 25-character `r1_` reservation form; a lifecycle ticket,
+ renderer nonce, and attempt id are exactly the respective 25-character `t1_`,
+ `n1_`, and `a1_` forms from §2.2;
+- `adServerDomain` is nonempty and at most 2,048 UTF-8 bytes. It is retained only for
+ exact PUC-shape conformance and is never a fetch target or authority;
+- `publisherOrigin` and `rendererUrl` are at most 2,048 UTF-8 bytes. The former must
+ serialize an exact HTTP(S) origin with no path/query/fragment; the latter must equal
+ the current generation's absolute `/integrations/aps/renderer/v1` URL with no query
+ or fragment, after which the owner appends the exact `n1_` nonce fragment;
+- a navigation/refresh generation is a nonnegative safe integer; and
+- the generated dynamic-owner `renderer` program is at most 64 KiB UTF-8 and the
+ complete successful outer-response JSON is at most 72 KiB. Build tests enforce
+ both; refusal responses contain no renderer.
+
+Structured-clone port payloads use their exact field-level limits: APS descriptor
+256 KiB decoded AAX; ADM 512 KiB; `creativeUrl`, cache `baseUrl`, and cache
+`fetchUrl` 4,096 UTF-8 bytes; `publisherOrigin`, `rendererUrl`, and
+`adServerDomain` 2,048 UTF-8 bytes; server slot id 1–256 UTF-8 bytes with no NUL or
+ASCII control; and the fixed
+capability forms above. No generic unbounded string remains. Boundary-minus-one,
+boundary, boundary-plus-one, multi-byte UTF-8, duplicate-key, and malformed-encoding
+cases run through the producer plus both the global dispatcher and port parsers.
+
+### 4.7 Notifications
+
+APS has no `nurl` or `burl`; none is synthesized. Existing notifications on other
+bid formats remain nonblocking and exactly-once per accepted lifecycle transition.
+Notification transport failure cannot change a render outcome. Redesigning or
+measuring notification delivery is outside this spec.
+
+## 5. TSJS target architecture
+
+### 5.1 Layers
+
+```text
+kernel/ boot, registry, queue, event bus, sessions, disposal, logging
+adapters/ googletag, prebid, messaging
+services/ slots, auction batches, render lifecycle, consent
+integrations/ gpt, prebid, aps, creative, and existing publisher integrations
+composition/ the sole construction root for adapters, services, and integration modules
```
-(the script runs `tsc` from the package's own `node_modules/.bin`, so no
-`npx` path ambiguity). Dev toolchain bumps as individual CI-gated PRs;
-`prebid.js` excluded from casual bumps; monthly review.
-
-## 8. Rollout
-
-Single-release state machine per §0. **Statistical method:** sampled
-traces only (diagnostic reported separately); **randomization unit =
-`assignment_id`** (minted inside the affinity token, §0; persisted on
-client-event rows), so attempts cluster by session; the estimator is a
-**checked-in cluster bootstrap** (`scripts/gates/estimator.py`):
-resample assignment ids, 2,000 resamples, fixed seed recorded in the
-gate artifact, strata (publisher × slot) weighted by control-arm
-traffic share; one-sided 95% confidence bounds on **relative
-differences**; each gate is an independent go/no-go (no cross-gate
-multiplicity correction — stated). **"Missing telemetry counts as
-failure" is made enforceable for whole-missing traces:** the server
-writes a **sampled exposure row** (`ts_expected_attempts`, one per
-server-observed eligible APS win in a sampled trace) at auction time;
-gates **left-join client attempts to expected rows**, so a trace whose
-client events never arrive is a visible missing attempt (not an absent
-row that silently shrinks the denominator). A per-window
-client-transport completeness ratio below 90% makes the statistical gate
-**inconclusive** rather than passing on a biased sample. Per-flow floors;
-rare flows (direct, fallback) gate hermetically + real-GAM, never
-statistically. `cycle_unattributable` divides by all
-attribution-candidate TS cycles.
-
-**Billing is the one gate the cluster bootstrap cannot run**, because GAM
-aggregate reports expose only per-`ts_arm` totals, not session clusters.
-Two options, one chosen per DR: (a) source billing from **GAM Data
-Transfer / impression-level logs**, which carry the `ts_arm` key-value
-**and** a joinable impression/`attempt_id` correlator, and run the same
-cluster bootstrap over impression rows; or (b) if Data Transfer is
-unavailable, use a **separate pre-reviewed aggregate estimator** with the
-**day** as the randomization unit (a two-sample non-inferiority test over
-daily per-arm RPM across the billing window), declared in the gate
-artifact. Whichever is chosen, `ts_arm` is a **reserved, network-audited
-key proven untargeted by any production line item and A/A-validated
-before canary** (§0, §11), so it cannot perturb the metric it measures.
-
-**Phase 0 decision records:** DR-1 mediator presence; DR-2 script
-creatives (deployment decision); DR-3 #997 vs re-merge; DR-4
-candidate-id echo owner (`merge_highest_cpm` config-blocked until
-delivered); DR-5 non-Fastly sinks (splits Phase-2 gates, scopes
-persistence gates).
-
-Phases: **0** identity/schemas/toolchain/DRs + gate artifacts
-(pipes/scripts/workflows) + observation-only control build definition;
-**1** kernel/sessions/minimal messaging/cycle registry/transactional
-bootstrap — Phase-1 gates are **hermetic** (in-page counters; beacon
-transport does not exist until Phase 2); **2** trace + beacon + renewal
-
-- diagnostic upgrade + probe issuance + four-adapter ingest + per-sink
- probes + the alert drill; **3** APS delivery (schema crate + corpus;
- mediation; render token + reservation store; renderer route +
- three-message ack + CSP route; §6.8; G4a–G4g incl. AuctionBatch;
- `notification_sent`; fallback; DR-3 restoration; correlator + SRA);
- **4** structure (layering, plugins, adapters, registry, messaging,
- namespace; four-flow parity); **5** decomposition + bootstrap stub +
- parity rerun + **full Phase-3 statistical and real-GAM gates repeated
- on the exact immutable RC** (attested per A.3) before weight-up, then
- cutover per §0.
-
-## 9. Test acceptance matrix
-
-Hermetic CI blocks PRs; the real-GAM suite is release-gating (A.3).
-
-| Area | Coverage |
-| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Attempts | `attempt_id` uniqueness; parent/child fallback linkage; exactly one `render_terminal` per attempt; parent `failed{gam_empty}` precedes `fallback_start` |
-| Terminal model | discriminated outcomes incl. `no_bid`/`cancelled`; per-reason `source` nullability; validity-matrix conformance |
-| Request cycles | disabled-initial-load `display()` retired for any caller; TS-noop-refresh → TS-display and publisher variants (same-class supersession); SRA; `intent_no_request`; overlap quarantine; no timeout re-arm; stale discard; real-GAM overlap |
-| Ack per path | four G4b sequences; APS three-message; owner-observed ADM `load`/`error` with **nonce never in bidder realm**; bidder-synthesized message ignored; early-`burl` attempt fails; per-path deadlines; stale/replayed; after-disposal |
-| Bridge signal | truncated/replaced `hb_adid` → `bridge_request{matched:false}` with no response and native Prebid untouched; stolen TS ids fully suppressed (neither TS nor native responds); listener order |
-| AuctionBatch | multi-slot partial/full supersession; fetch aborts only when all children dead; stale-bid filtering through live child identity; timeout; navigation disposal; reversed responses; discriminated auction-client errors |
-| Diagnostic | `dexp` ceiling — renewal-before-expiry capped, repeated renewal to ceiling then failure; credential byte-level vectors; fragment clearing; in-memory storage; pre-upgrade buffering then flush; forgery/wrong-origin/replay |
-| Transport/limits | batch caps (64 events AND 12 KiB); ≤ 4 batches/flush; sustained single-tab, 5-tab, diagnostic flush, pagehide burst inside the 60/120 budget; overflow coalescing; Fastly synchronized-burst documented; unknown-address bucket |
-| Affinity | token vectors (format, rotation, constant-time); state-dependent defaults (canary vs post-cutover reassignment; no stale-cookie stragglers); coherence for HTML/assets/APIs/beacons; **CSP affinity via `policy_id` path, cookie-less renderer reports** |
-| Arms/measurement | observation-only control build emits comparable sampled telemetry (its zero-behavior-change gated by hermetic parity vs baseline); arm-specific datasources; union view stamps `deployment_pool`; `assignment_id` on rows; cluster-bootstrap fixture; GAM `ts_arm` key |
-| Latency/fill | `t_rel_ms` monotonic bounds; `attempt_started`→`render_terminal` durations; per-gate numerator/denominator queries (A.1); named source tables/joins |
-| Joins | `Nullable(UUID)` type equality; auction-level vs slot-level vs bid-level canonical joins; raw-join multiplication rejected |
-| Probes | authenticated probe rows (`probe_run_id`/`expected_seq`/`adapter`/`target`) per datasource; secret-derived CSP probe `policy_id` unforgeable; probe issuance protocol; persistence gates scoped to sink-backed adapters |
-| Alerting | injected-failure drill: alert fires ≤ 1 h |
-| Kill switch | switch state via HTML and response extensions; attempts created after delivery honor it before each irreversible action (incl. before `nurl`); SSAT-on-stale-page exemption documented and tested |
-| Drop enum | `empty_seatbid_bids` + `bid_id_too_large` mapped; shared typed enum across producers; compile-time exhaustiveness |
-| RC attestation | real-GAM workflow consumes the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}` and emits it in the attested output; gates parameterized by (release, pool, epoch); RC re-canary inherits each row's window |
-| Config/affinity | `config_hash` SHA-256 over exact bytes verified at startup (mismatch = fail); affinity HMAC vectors + rotation + constant-time |
-| Heap | CDP procedure at the five fixed points; GC before sample; rerun rule |
-| Lint | custom scope-aware rule catches `window`/`globalThis`/`self` member access and same-file aliases outside adapters (claim scoped to these shapes) |
-| Notifications | dispatch mechanics (no-cors GET, no-referrer, keepalive; `Image()` fallback; server-side macro expansion only); `notif_id` emission; duplicate alarm + reconciliation; hermetic exactly-once |
-| Mediation | required-unique bounded upstream ids (`missing`/`duplicate`/`bid_id_too_large`, no fingerprint); `candidate_id` echo; arrival-order shuffle invariance; authoritative-field rules; provenance fail-closed scope; strategy timeouts; both lifecycles; APS + non-USD startup error |
-| Render token | format/CSPRNG/retry/TTL/one-time; scope; union capacity 320 with `registry_full`; >320 then late oldest-id suppressed |
-| Trace auth | auth ≤ 256 B; encoding vectors; expiry/skew/max-future; renewal preserves mode; renewal-after-expiry fails; previous-key retention; deterministic sampling (exact u64 threshold; same trace → same mode concurrently) |
-| Internal routes | wrong-method 405 + `Allow` + `no-store`; unknown version 404; no fall-through; dispatch before filters; no forwarding; per-family origin policies |
-| CSP | both media types; opaque/null origin; policy-id path identity (forged body ignored); bucket caps + overflow counter; three-browser capture; per-version frozen header manifest |
-| Schema | staleness; adversarial corpus ×3 validators; outer tolerance vs exact AAX; generated validity matrix |
-| ABI/plugins/boot | one kernel; exact-release verdicts; object-form release check; partial-install unwind; abort-pending; disposer-after-disposal; hung-resume self-discard; fallback-then-late-bundle deferral |
-| Lifecycle | `timed_out → present`; disposal inventories; unissued intents cancelled by navigation; boot consume/freeze/delete; final-namespace smoke |
-| Delivery | unknown hash 410 `no-store`; exact-match immutable; release-time vector materialization (unlisted = build error); cutover rehearsal |
-| Adapter parity | ingest, CSP-report, trace-auth, renderer routes and drop surfacing equivalent across Fastly/Viceroy, Axum, Cloudflare, Spin |
-| Policy | script-creative warning; `invalid_dimensions` w/h; `dimensions_out_of_range` unclamped; `boot.debug` + response `debug` gating; diagnostic completeness; kill-switch snapshot semantics |
-| Perf | marks present; three vectors; heap CDP; inconclusive-rerun; pinned-environment baseline validity |
-
-## 10. Alternatives considered (complete)
-
-1. **Patch APS point-failures without telemetry** — rejected: four correct
- fixes produced no reliable ads; the next would be another guess.
-2. **Always direct-render APS** (skip GAM/PUC) — rejected: unilaterally
- changes GAM reporting/pacing; kept only as the attributed-`gam_empty`
- fallback.
-3. **Single module graph / shared chunks now** — rejected for this release:
- changes the delivery pipeline while everything else changes; successor
- option behind the same registry surface.
-4. **Full rewrite in one branch without phases** — rejected: the
- browser-spec safety net is thinnest exactly where behavior changes.
-5. **Dropping the ES5 bootstrap** — rejected: loses the pinned no-bundle
- guarantee; the generated fallback keeps it.
-6. **Timeout-triggered fallback rendering** — rejected: uncancelable GPT
- requests race late fills → double-render/double-bill.
-7. **Timeout-based quarantine re-arm** — rejected: recreates the
- stale-event bug.
-8. **N/N−1 compatibility machinery** — removed by the §0 policy decision.
-9. **Client-computed notification hashes** — rejected: no key without
- breaking the pseudonymization boundary; server-minted `notif_id`.
-10. **Fingerprint identities for id-less bids** — rejected: can merge
- distinct demand; rejection with closed reasons instead.
-11. **Plain readable cohort cookie** — rejected: dark-pool opt-in +
- cache-cardinality abuse; opaque authenticated token.
-12. **`billing_outcome` event** — removed: no honest post-accept producer.
-13. **Injected in-realm ADM reporter** — rejected: hands bidder code the
- acceptance credential; owner-observed `load`/`error` instead.
-14. **Cookie-routed CSP affinity** — rejected: opaque-origin reports carry
- no cookie; server-selected `policy_id` path identity.
-15. **Token-identity alone for arm attribution** — rejected: authorization
- is not a queryable row dimension; the router injects a trusted internal
- cohort header ingest stamps (§0).
-16. **Indefinite diagnostic renewal** — rejected: signed `dexp` ceiling.
-17. **Shared attempt identity for parent/child** — rejected: distinct
- `attempt_id`.
-18. **24 h affinity for a multi-day experiment** — rejected: the affinity
- lifetime now covers the maximum experiment window (§0).
-19. **`Image()` notification fallback** — rejected: it constructs a
- separate credentialed/referrer-bearing request outside the primary
- transport's privacy contract; a failed dispatch reports `failed`
- instead (§G4d).
-
-## 11. Risks (complete register)
-
-- **Hard-cutover blast radius** — accepted by policy; bounded by the §0
- runbook (probes, low-weight canary, monitored window, weight-back).
-- **Sticky-cohort routing is new infrastructure** the cutover depends on —
- Phase 0 work; its coherence and affinity-lifetime tests are
- release-gating.
-- **Mediator wire-contract change** (`candidate_id` echo) — DR-4 gates
- `merge_highest_cpm`; config validation enforces the block.
-- **Published notification triggers** become a contract for PBS-path
- demand — changing them later breaks SSP reporting.
-- **Required `[auction].currency`/`winner_selection`** in mediated
- deployments are a deliberate startup-error class under §0.
-- **Beacon abuse** — pre-parse caps, per-family origin policies,
- fail-closed numeric limits, signed modes, credentialed diagnostics.
-- **Registry/limiter memory** — explicit capacities, TTL reclamation,
- reject-at-capacity; Fastly overshoot documented, not claimed.
-- **CSP data is advisory** — never a sole automatic rollback signal.
-- **Sink blindness** — per-datasource authenticated probes.
-- **ABI freeze** — `tsjs._internal.registry` is load-bearing; exact-release
- verdicts are the contract.
-- **Schema generation** — checked-in artifacts + staleness CI.
-- **Observation-only control build** is new scoped work whose "zero
- behavior change" property is itself gated by hermetic parity vs baseline
- **and an A/A pre-canary** (the `ts_arm` GAM key must move no metric).
-- **`assignment_id` persistence** is pseudonymous but new — bounded by the
- affinity token lifetime and excluded from any identity join by schema
- review; it reaches telemetry only via the router-injected trusted header
- (§0), never a client field.
-- **`ts_arm` GAM targeting key** could itself perturb line-item matching —
- reserved and audited network-wide, proven untargeted by production line
- items, and A/A-tested before canary (§8).
-
-## 12. Success criteria
-
-1. APS creatives render in each configured flow, hermetically and in the
- attested real-GAM suite.
-2. Every §2 failure maps to its §2.5 signal; diagnostic mode names the
- failing class from one page load; §5.7 SLIs hold, including the alert
- drill.
-3. Both lint families (incl. the custom scope-aware rule) pass; stateful
- sharing only via the registry; exact-release mismatches quarantine
- loudly.
-4. No `src/` file exceeds ~500 lines; `gpt_bootstrap.js` is a stub or
- generated.
-5. Exactly one `render_terminal` per `attempt_id`; parent/child fallback
- attempts are distinct rows; attempt aggregation keys on `attempt_id`
- with the tuple as grouping only.
-6. The only TSJS-owned global is `window.tsjs` (§7.4); no expandos.
-7. §7.10 budgets hold, including the CDP heap procedure.
-8. No existing warning lost; issue-surfacing conditions log `warn`+ with
- the beacon reason.
-9. TypeScript floor and flags via the checked-in `typecheck` script;
- `prebid.js` pin documented.
-10. `nurl`/`burl` only on carrying paths at their binds with the
- normative dispatch mechanics; APS fires neither; hermetic
- exactly-once + production alarm + reconciliation.
-11. Trace-bearing responses `private, no-store`; authorizations signed,
- mode-carrying, renewal-preserving, diagnostic bounded by `dexp`;
- unsampled transmits nothing.
-12. Cutover rehearsed; config-hash verification enforced; post-cutover
- routing defaults flip (no stale-cookie stragglers).
-13. Phase-3 statistical and real-GAM gates pass on the attested
- immutable RC before weight-up.
-14. Appendix A shipped with this design; changes carry decision records.
-15. Baseline APS fix behaviors re-implemented; baseline browser tests
- pass unmodified.
-
-## 13. Open questions
-
-One: does Amazon expose any creative-completion acknowledgement that
-could add a post-`accepted` state under a new name (future
-enhancement)?
-
----
-
-## Appendix A — Normative rollout gates (initial values)
-
-Roles: **RO** release owner, **QA** QA owner, **OPS** on-call.
-Randomization unit: `assignment_id` (§0). Statistical gates: sampled
-traces, cluster bootstrap (§8), canonical views only, checked-in
-artifacts (`tinybird/pipes/gate_*.pipe`, `scripts/gates/*.sh`,
-`.github/workflows/*`). Every statistical row specifies
-numerator/denominator/source; missing rows count as failure. "Hold" =
-weight frozen; "Rollback" = weight back + re-purge. Changes require
-decision records. Low volume: inconclusive → extend once → Hold.
-
-### A.1 Phase gates
-
-| Phase | Gate | Artifact | Numerator / denominator (source) | Floor | Threshold | Window | Owner | Action |
-| ----- | ----------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------- | ----- | -------- |
-| 0 | Dark-pool health | `probe-pool.sh` | expected responses / probe requests | 1,000 | 100% | 24 h | OPS | Hold |
-| 0 | Schema validation | `schema-writes.sh` | accepted / deterministic synthetic rows (all tables) | 10,000 | **0 rejections** | 24 h | RO | Hold |
-| 0 | Asset identity | `asset-probe.sh` | correct status / probed hashes | all | 100% | once | QA | Hold |
-| 0 | Config binding | `config-hash.sh` | verified pools / pools | all | 100% | once | OPS | Hold |
-| 1 | Kernel/bootstrap (hermetic) | `bootstrap-ownership.spec` + counters | passing cases / cases (no beacon dependency) | all | 100% | CI | QA | Hold |
-| 2 | Ingest HTTP parity | `ingest-parity.sh` | passing / parity cases (4 adapters × 4 families) | all | 100% | CI | QA | Hold |
-| 2 | Persistence (sink-backed) | `gate_ingest.pipe` | accepted probe events / sent (probe tokens) | 10,000 | ≥ 99%; dedup exactly-once | 24 h | OPS | Hold |
-| 2 | Per-sink authenticated probes | `gate_probes.pipe` | on-time probe rows / expected (`probe_run_id×seq`), per datasource × sink-backed adapter | 1,000 each | lag ≤ 5 min; loss < 0.1% | 24 h | OPS | Hold |
-| 2 | Alert drill | `alert-drill.sh` | alerts ≤ 1 h / injected failure episodes | 3 episodes | 100% | 24 h | OPS | Hold |
-| 3 | Funnel ssat/prebid/page_bids | `gate_funnel.pipe` | per A.2 stage pairs (`ts_render_attempts_v` ⋈ slot-level auction rows) | 10,000/flow-arm | per A.2 | 24 h | RO | Rollback |
-| 3 | Direct/fallback conformance | hermetic + A.3 rows | passing / suite cases | all | 100% | CI + RG | QA | Hold |
-| 3 | Attribution soundness | `gate_cycles.pipe` | `cycle_unattributable` / **all attribution-candidate TS cycles** | 10,000 | < 0.5% | 24 h | RO | Rollback |
-| 3 | GAM fill | `gate_fill.pipe` | nonempty `slotRenderEnded` / TS request cycles, canary vs control | 10,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 24 h | RO | Rollback |
-| 3 | Latency | `gate_latency.pipe` | p95 of (`render_terminal{accepted}.t_rel_ms − attempt_started.t_rel_ms`) per arm | 10,000/arm | 1-sided 95% CB rel. diff ≤ +2% | 24 h | RO | Rollback |
-| 3 | Billing | `gate_billing.pipe` + GAM `ts_arm` | revenue per 1,000 attempts per arm (GAM report ⋈ attempts) | 100,000/arm | 1-sided 95% CB rel. diff ≥ −2% | 7 d (+7 d ext.) | RO | Rollback |
-| 3 | Duplicate `burl` alarm | `gate_dup_notif.pipe` + reconciliation | duplicate `notification_sent{burl}` per `notif_id` / dispatches; GAM-vs-server deltas | 1,000 | 0 observed; reconciliation within 1% | 24 h / billing wnd | RO | Rollback |
-| 4 | Layering + leaks | lint CI + `disposal-inventory.spec` | — | — | 0 exceptions / 0 leaks | CI | QA | Hold |
-| 4 | Four-flow parity | `flow-parity.spec` | passing / parity cases | all | 100% | CI | QA | Hold |
-| 5 | Parity rerun + budgets | `flow-parity.spec`; `perf.yml` | — | — | 100% / §7.10 tolerances | CI | QA | Hold |
-| 5 | RC re-canary | all Phase-3 rows on the attested RC | as Phase 3 | as Phase 3 | as Phase 3 | **each row's own window** | RO | Rollback |
-| 5 | Cutover monitor | `gate_slis.pipe` | probe freshness/loss; per-flow `render_terminal{failed}` rate vs pre-cutover canary | 10,000 attempts/flow | lag ≤ 5 min; loss < 0.1%; per-flow failed ≤ canary + 0.5 pt (flow-weighted; a below-floor flow holds, not passes) | 24 h | OPS | Rollback |
-
-### A.2 Expected stages per flow
-
-| Flow | Expected sequence | Stage thresholds (named denominators) |
-| --------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
-| ssat | `targeting_set → bridge_request → bridge_response_sent → renderer_document_loaded → render_terminal{accepted}` | `targeting_set`/eligible wins ≥ 98%; each later stage ≥ 95% of prior; document/`bridge_response_sent` ≥ 99%; runner fail+timeout ≤ 1% of document |
-| prebid | same (keyed by Prebid `adId`) | same |
-| page_bids | same (post-SPA-navigation) | same |
-| direct | `attempt_started → renderer_document_loaded → render_terminal{accepted}` | document ≥ 99% of attempts; accepted ≥ 95%; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) |
-| fallback | parent `render_terminal{failed, gam_empty}` → child `fallback_start → renderer_document_loaded → render_terminal{accepted}` | `fallback_start`/eligible parent `gam_empty` ≥ 99%; accepted ≥ 95% of starts; runner fail+timeout ≤ 1% of document (hermetic + real-GAM) |
-
-### A.3 Real-GAM suite (attested)
-
-| Field | Value |
-| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Workflow | `.github/workflows/real-gam-release.yml` (manual dispatch, release-gating; created in Phase 0) |
-| **Input** | **the immutable release manifest `{release_id, bundle hashes, binary hash, config_hash, pool}`** |
-| **Independent verification** | **the workflow does not trust its input**: it fetches the deployed pool's runtime `release_id`/`config_hash` from a trusted control-plane endpoint, hashes the actually-served bundle bytes, verifies the deploy-provider binary provenance and the pool/epoch, and **compares all of them to the manifest — any mismatch fails the run** (so a green run cannot claim manifest X while exercising deployment Y) |
-| **Output** | **OIDC-backed signed provenance** embedding the verified (release, pool, deployment epoch), not a caller-supplied echo |
-| Topologies | one per A.2 flow; publisher-overlap; disabled-initial-load formation; same-class supersession |
-| Browsers | Chromium, Firefox, WebKit (CSP/opaque rows); Chromium (funnel rows) |
-| Fixture | dedicated GAM test network + line items targeting `hb_bidder=aps`; fixture doc in repo |
-| Account/credential | owner recorded in the Phase-0 DR (operator-held; never in repo) |
-| Command | `npx playwright test --config real-gam.config.ts` |
-| Artifact | Playwright HTML report + trace zips, retained 90 days |
-| Retry policy | one automatic retry per flaky-tagged spec; failures after retry are gate failures |
-| Approval evidence | green attested run URL in the release checklist, signed off by RO |
+- Kernel imports no adapter, service, or integration.
+- Adapters import kernel contracts only and are the sole readers/writers of GPT,
+ Prebid, and cross-window messaging globals.
+- Services import kernel and adapter interfaces.
+- Integrations compose services and never import another integration.
+- Composition imports all layers, constructs one `RuntimeSession`, and injects
+ adapters/services through interfaces. Kernel sessions never import or construct a
+ concrete adapter or service.
+- Layering is enforced by ESLint restricted paths.
+- A custom scope-aware lint rule rejects GPT/Prebid global access outside adapters,
+ including same-file aliases of `window`, `globalThis`, `self`, `googletag`, or
+ `pbjs`.
+
+### 5.2 One runtime across IIFE bundles
+
+Each shipped integration remains a separately built IIFE with imports inlined, so
+module singletons cannot be the shared-runtime mechanism. `tsjs-core` installs the
+only runtime and keeps its service registry in the composition closure. During boot,
+the temporary `_registerIntegration` handshake collects each accepted module; the
+composition root alone invokes its exact frozen preparation/activation contexts.
+After commit the handshake is permanently refusing and `tsjs._internal` exposes only
+the frozen status described in §5.4.
+
+Every other bundle registers through:
+
+```ts
+tsjs._registerIntegration({ id, release, prepare })
+```
+
+This is a release-internal bundle handshake, not a publisher extension API. An
+**integration** remains the product capability; an **integration module** is only
+that integration's transactional TSJS implementation unit. The design introduces
+no separately installed or third-party plugin system.
+
+The build first emits every production bundle with the same fixed release sentinel,
+then computes one `releaseId`: 64 lowercase hexadecimal SHA-256 characters over a
+canonical ordered manifest containing every bundle id and its sentinel-normalized
+bytes. It replaces exactly one sentinel in each bundle and verifies none remains.
+This avoids a self-referential hash while changing the id for any logical bundle or
+ordering change. The same value is embedded in core and every integration bundle.
+Before core is injected, the server emits this exact manifest:
+
+```ts
+interface BootManifestV1 {
+ readonly version: 1
+ readonly releaseId: string
+ readonly integrations: readonly {
+ readonly id: string
+ readonly required: true
+ }[]
+}
+```
+
+Integration ids match `^[a-z0-9][a-z0-9_-]{0,63}$`, are unique, and appear in the actual
+server injection order. The list contains exactly the enabled integration bundles;
+all listed integrations are required for that page and there are at most 16. Core is injected first, then
+integration modules in manifest order. Registration requires exact id membership and `release`
+equality. Duplicate id, unknown id, wrong release, missing module, or registration
+after the boot deadline fails with `abi_mismatch` or `bundle_partial`.
+Integrations obtain stateful services from the registry; they never construct a
+second runtime, slot registry, GPT adapter, or bridge listener.
+
+Integration-module startup is a two-phase transaction. Registration stores code but
+does not execute it. In manifest order, core calls a synchronous or asynchronous
+`prepare(ctx)` whose only legal effects are validating frozen configuration,
+obtaining injected service interfaces, allocating private inert data/closures, and
+registering private-memory disposers. Preparation cannot read or write ad-tech
+globals, attach a listener/observer/wrapper, touch live DOM, inject a script, start a
+timer/fetch, schedule detached work, invoke publisher code, or call a stateful
+adapter/service method. The one Promise returned to and awaited by core, including
+its ordinary `await`/settlement continuations, is permitted; no continuation may be
+detached from that Promise or survive its settlement/abort. Preparation returns
+exactly one prepared module with a synchronous `activate(ctx)` function.
+
+After every required module prepares, core enters one synchronous activation barrier
+in manifest order. `activate(ctx)` may install only synchronously compare-restorable
+wrappers, listeners, observers, guards, and service subscriptions. It registers the
+disposer before each mutation and may stage bounded post-commit work through
+`ctx.afterCommit(fn)`, but cannot inject/load a script, start network/timers, schedule
+work, drain a publisher queue, or invoke publisher callbacks directly. The core
+dispatcher and correctness-critical GPT listeners are the first reversible core
+activations in this same barrier, not effects left live during asynchronous
+preparation. If any activation throws, core synchronously runs every activated and
+prepared disposer once in reverse order before committing fallback. Since the
+barrier never yields and activation cannot call publisher code, no publisher task
+can observe a partial generation.
+
+The activation barrier checks the same monotonic boot deadline immediately before
+and after every `activate` call and once more before kernel handoff. Elapsed time
+greater than or equal to 10,000 ms synchronously unwinds and commits fallback even
+when the timer task has not run. JavaScript cannot preempt an activation function
+that never returns; a malicious/nonreturning same-realm module can freeze the page
+and is an accepted platform limitation, not a second-runtime recovery case.
+
+After all activations succeed, core commits the complete kernel API, runs staged
+`afterCommit` callbacks in manifest order, and only then drains the preload queue.
+Those callbacks may synchronously start scripts, timers, readiness work, and baseline
+DOM scans; publisher code they intentionally invoke therefore sees the complete
+kernel. A callback throw is isolated to its module, runs that module's remaining
+disposers, records a bounded local runtime failure, and makes affected operations
+fail through their typed readiness/render result; it cannot roll back an already
+published kernel or create a fallback generation.
+
+`ctx.signal` aborts pending preparation. `ctx.onDispose(fn)` is the only disposal
+registration mechanism; a disposer registered after disposal runs immediately, and
+one failing disposer does not prevent the rest. Each module may call
+`ctx.afterCommit` at most once, so the at-most-16 pending modules stage at most 16
+callbacks. A second call by one module throws during activation, unwinds the barrier,
+and commits `bundle_partial`. The bootstrap deadline below is the only preparation
+deadline; modules share its remaining budget and do not start independent ten-second
+preparation clocks.
+
+### 5.3 Bootstrap ownership
+
+Bootstrap uses a generation-scoped state machine:
+
+```text
+unclaimed -> installing -> kernel
+ \-> failed -> fallback
+```
+
+Initial namespace capture is field-wise and does not replace a publisher-created
+`window.tsjs` object: `window.tsjs ||= {}; tsjs.que ||= []; tsjs.boot ||= {}`. The
+kernel remains externally inert and commits ownership only after all manifest
+integration modules prepare and synchronously activate in order. Before module
+work, bootstrap normalizes `que` to one actual Array and defines the `tsjs.que` data
+property as writable false/configurable true for the installing generation. It keeps
+the ingress Array's native `push`
+throughout `installing`. Thus ordinary assignment cannot redirect the queue, and
+callbacks pushed at any point during the shared deadline append to the same ingress
+Array instead of a one-time snapshot. A preexisting non-Array `que` contributes no
+callbacks and is replaced; an Array's existing own data entries are retained in
+index order.
+
+Kernel and fallback use the same synchronous, non-interleavable commit handoff in one
+JavaScript task. Its order is exact:
+
+1. create an empty actual-Array final executor, install its own immediate-execution
+ `push`, and freeze the Array;
+2. snapshot the ingress Array's callable own data entries in ascending index order;
+3. clear the ingress Array and replace its `push` with a forwarder to the final
+ executor for publishers retaining the old reference;
+4. redefine `tsjs.que` as the final executor with a writable-false,
+ configurable-false descriptor while installing all other complete committed
+ `tsjs` fields;
+5. for a kernel commit, run every staged `afterCommit` callback in manifest order;
+ fallback has none; and
+6. drain the snapshot FIFO.
+
+No browser task or microtask can interleave steps 1–6. Code intentionally invoked by
+an `afterCommit` callback sees the complete API and committed queue. A callback is therefore either
+in the snapshot or reaches the final executor through one of the two queue
+references, never lost or invoked twice. A callback that pushes while the snapshot
+drains executes immediately through the committed queue before draining continues;
+one throw is isolated. Both ingress and final values satisfy
+`Array.isArray(...) === true`. The old ingress identity remains a live forwarding
+queue; the public `tsjs.que` identity changes exactly once at commit.
+
+One ten-second watchdog begins immediately before core injection and covers core,
+registration, preparation, and the synchronous activation barrier for every required
+integration module. A preparation throw/rejection, activation throw, ABI mismatch,
+or deadline aborts the installing generation, synchronously unwinds registered
+disposers in reverse order, and then commits the generated no-bundle fallback.
+Bundles that arrive after
+fallback are rejected and quarantined; they cannot register into or replace the
+fallback generation. Late continuations verify their owner generation and
+self-discard.
+
+`gpt_bootstrap.js` becomes a queue-and-boot-data stub. The old bootstrap's
+initial-load hooks, handoff wrappers, hydration scheduler, slot definition,
+targeting, display, and refresh are deleted. Those behaviors run only after the
+complete runtime commits. This intentionally changes the missing/partial-bundle
+case: it no longer attempts a best-effort GPT render through a duplicated degraded
+runtime, and instead settles every known slot through the terminal fallback below.
+The fallback is generated from one TypeScript source and pinned by a staleness test;
+behavior is not hand-maintained in both ES5 and TypeScript.
+
+The fallback is a terminal, non-rendering shell, not a reduced second runtime. Its
+commit atomically records one immutable boot failure reason:
+
+- `abi_mismatch` for invalid manifest shape, duplicate/unknown integration id, wrong
+ release, duplicate registration, or incompatible ABI; or
+- `bundle_partial` for a missing required integration module, preparation
+ throw/rejection, activation throw, or the shared boot deadline.
+
+Before draining user work it installs `version:'1.0.0'`, the embedded `releaseId`, a
+safe frozen `TsjsBootV1`, the final `tsjs.requestAds` input validator, the
+validating-then-refusing `tsjs.addAdUnits`, the local `tsjs.log`, the
+immediate-executor `tsjs.que`, a permanently refusing internal
+`_registerIntegration`, and a frozen
+`tsjs._internal` value containing only `{state:'fallback',releaseId,reason}`. It
+constructs no runtime session, slot registry, GPT/Prebid adapter, bridge dispatcher,
+timer, listener, port, or iframe. It never exposes a compatibility API.
+
+The safe fallback boot uses the embedded release and
+`manifest:{version:1,releaseId,integrations:[]}`, independently retains the server
+auction projection only when that projection passes its exact shape/256-slot bounds,
+field grammars, render limits, and 8 MiB aggregate cap from §§3.1–3.2, and otherwise substitutes exactly
+`{version:1,auction:{version:1,auctionId:'fallback',results:[]},bids:[]}`. It
+retains a valid cache policy or omits it, and substitutes the creative/diagnostics
+disabled safe defaults from §§5.4/5.8 because no integration module commits. It never copies an accessor or
+unknown property. Fallback batch membership comes only from exact server slot ids in
+that validated immutable `tsjs.boot.auctionProjection` snapshot. Explicit valid ids present in that snapshot,
+and every omitted-slot snapshot entry in projection order, resolve once as
+`failed{path:'primary',reason:}`. An explicit id absent from the
+projection resolves `slot_unresolved`; an already-aborted signal resolves each known
+member as `cancelled{reason:'caller_aborted'}`. An empty projection plus omitted slots
+resolves `{slots:[]}`. Input-shape errors still reject with `RequestAdsInputError`.
+
+After installing those surfaces, fallback drains the preexisting callback queue FIFO
+exactly once with `this === tsjs`; one callback throw does not prevent later callbacks.
+Subsequent `que.push(fn)` executes a callable immediately once and ignores non-callable
+values. Every late module registration is refused without invoking integration code, and
+every late bundle continuation self-discards. Browser tests cover each failure
+checkpoint, queued and later `requestAds`, callback throws, already-aborted signals,
+and late bundles; no valid call remains pending.
+
+### 5.4 Public surface after cutover
+
+There are no compatibility aliases:
+
+| Baseline surface | Final surface |
+| ---------------------------------------- | ------------------------------------------------------------------------------- |
+| scattered `window.__tsjs_*` flags/config | `tsjs.boot.*` |
+| `tsjs.adSlots`/`tsjs.bids` | initial `tsjs.boot.auctionProjection`; internal navigation projection after SPA |
+| `tsjs.version === '0.1.0'` | `tsjs.version === '1.0.0'` plus `tsjs.releaseId` |
+| `globalThis.tscreative` | no callable equivalent; automatic creative module |
+| `globalThis.tsCreativeConfig` | `tsjs.boot.creative` |
+| void/callback `requestAds` | `tsjs.requestAds(options): Promise` |
+| placeholder `renderAdUnit` | `tsjs.requestAds({slots:[id]})` |
+| placeholder `renderAllAdUnits` | `tsjs.requestAds()` |
+| generic mutable `setConfig`/`getConfig` | immutable `tsjs.boot.*` plus typed integration config |
+| `tsjs.renders`/`renderLog`/`renderSeq` | `tsjs.diagnostics.renderTrace` |
+| `window` event `tsjs:adRendered` | `tsjs.diagnostics.renderTrace.subscribe(listener)` |
+| `tsjs.gptDiagnostics` | `tsjs.diagnostics.gpt` |
+| `window.__tsjs_prebid_bundle` | exact own `pbjs.__trustedServerArtifactV1` stamp |
+| integration install/patch sentinels | kernel integration registry/`WeakSet` |
+| GPT slot expandos | `SlotRecord` |
+
+`window.tsjs.que` remains the pre-load command queue because it is the bootstrap
+transport, not a legacy behavior alias.
+
+The complete committed public API is the following union; the pre-load
+`{que,boot}` transport is not a committed API generation:
+
+```ts
+interface CreativeBootV1 {
+ readonly version: 1
+ readonly enabled: boolean
+ readonly clickGuard: boolean
+ readonly renderGuard: boolean
+}
+
+interface TsjsBootV1 {
+ readonly abi: 1
+ readonly releaseId: string
+ readonly manifest: Readonly
+ readonly auctionProjection: Readonly
+ readonly cachePolicy?: Readonly
+ readonly creative: Readonly
+ readonly diagnostics: Readonly
+}
+
+interface TsjsCommandQueue {
+ readonly length: 0
+ push(callback: unknown): 0
+}
+
+interface TsjsApiBase {
+ readonly version: '1.0.0'
+ readonly releaseId: string
+ readonly boot: Readonly
+ readonly que: TsjsCommandQueue
+ readonly log: TsjsLog
+ /** Release-internal late-bundle sink; always returns false after commit. */
+ readonly _registerIntegration: (registration: unknown) => false
+ addAdUnits(
+ units: ProgrammaticAdUnit | readonly ProgrammaticAdUnit[]
+ ): AddAdUnitsResult
+ requestAds(options?: RequestAdsOptions): Promise
+}
+
+interface TsjsKernelApi extends TsjsApiBase {
+ readonly diagnostics: Readonly
+ readonly _internal: Readonly<{ state: 'kernel'; releaseId: string }>
+}
+
+interface TsjsFallbackApi extends TsjsApiBase {
+ readonly diagnostics?: never
+ readonly _internal: Readonly<{
+ state: 'fallback'
+ releaseId: string
+ reason: 'abi_mismatch' | 'bundle_partial'
+ }>
+}
+
+type TsjsApi = TsjsKernelApi | TsjsFallbackApi
+```
+
+`version` is the semantic public-API generation and changes only with a reviewed API
+contract; `releaseId` identifies the exact bundle set and equals
+`boot.releaseId`/`boot.manifest.releaseId`. Core recursively freezes the boot value
+before installing integrations. Integration-specific configuration is not a public
+mutable bag: the composition root validates each server-projected, deny-unknown
+config against that integration's typed schema and passes the frozen value only in
+its preparation/activation contexts. `_internal` is a frozen, non-enumerable status
+value; the service registry remains in the composition closure and is available to
+integration modules only through those contexts during startup.
+
+The final queue is the frozen actual empty Array from the commit handoff and contains
+no retained callbacks. Its own `push` invokes one callable
+immediately and exactly once with `this === tsjs`, returns `0`, ignores a
+non-callable, and isolates/logs a throw. Pre-load callbacks are snapshotted and
+drained FIFO only after the committed API is installed, so publisher callbacks never
+run against a half-installed generation. Native mutators, borrowed Array mutators,
+index assignment, `length` assignment, deletion, and property definition cannot
+change the frozen executor or retain a callback; failure follows ordinary strict- or
+sloppy-mode JavaScript semantics and `length` remains `0`.
+
+#### 5.4.1 Programmatic ad-unit registration
+
+The clean public core surface retains programmatic direct-auction registration:
+
+```ts
+interface ProgrammaticAdUnit {
+ code: string
+ mediaTypes: {
+ banner: { sizes: readonly (readonly [number, number])[] }
+ }
+ bids?: readonly {
+ bidder: string
+ params?: Readonly>
+ }[]
+}
+
+interface AddAdUnitsResult {
+ readonly registered: readonly string[]
+}
+
+type AdUnitRegistrationErrorCode =
+ | 'invalid_units'
+ | 'invalid_unit'
+ | 'invalid_code'
+ | 'duplicate_code'
+ | 'slot_collision'
+ | 'invalid_media_types'
+ | 'invalid_dimensions'
+ | 'dimensions_out_of_range'
+ | 'invalid_bids'
+ | 'invalid_bidder'
+ | 'invalid_params'
+ | 'request_body_too_large'
+ | 'registry_capacity'
+
+class AdUnitRegistrationError extends Error {
+ readonly code: AdUnitRegistrationErrorCode
+ readonly unitIndex?: number
+}
+
+class TsjsUnavailableError extends Error {
+ readonly code: 'runtime_unavailable'
+ readonly releaseId: string
+ readonly reason: 'abi_mismatch' | 'bundle_partial'
+}
+```
+
+Registration is synchronous, all-or-nothing, and navigation-scoped. `code` becomes
+the exact slot id and must satisfy the server slot-id UTF-8 bound from §4.6. The
+argument is one unit or a nonempty array of at most 256 plain data objects. Codes are
+nonempty and unique in the call; banner sizes are nonempty integral number pairs in
+the shared 1–4096 renderer range; bidder names are nonempty and at most 64 UTF-8
+bytes; params are plain JSON-compatible data with the same request-body cap as
+`/auction`. Accessors,
+unknown media types, duplicate codes, or a collision with any server-projected or
+already registered slot reject the whole call with a typed
+`AdUnitRegistrationError` before state changes. There is no merge-by-code behavior.
+The outer shape/count maps to `invalid_units`; a non-plain unit or unknown/accessor
+unit field to `invalid_unit`; code shape, in-call duplicate, and registry collision to
+`invalid_code`, `duplicate_code`, and `slot_collision`; media/banner shape to
+`invalid_media_types`; a nonnumeric/nonfinite/fractional/nonpositive dimension to
+`invalid_dimensions`; an integral dimension outside 1–4096 to
+`dimensions_out_of_range`; bid-array and bidder-name shape to
+`invalid_bids`/`invalid_bidder`; non-JSON, cyclic, accessor-bearing, or otherwise
+unserializable params to `invalid_params`; and encoded body overflow to
+`request_body_too_large`. `unitIndex` is the lowest failing input index when the
+failure belongs to a unit and is absent for outer/capacity errors. Validation order
+is the order just listed, then capacity reservation; repeated runs return the same
+code/index without reading publisher accessors.
+
+Successful units receive registration ordinals after the immutable server projection
+and participate in later `requestAds` snapshots. They use the direct auction/render
+path unless an explicit future design gives them a GPT mapping; registration alone
+never defines, displays, refreshes, or targets GPT. The old placeholder-writing
+methods are deleted rather than aliased. `tsjs.log` retains the existing bounded
+level/method surface, while runtime configuration is immutable boot data or typed
+integration-owned configuration. Fallback validates `addAdUnits` input and then throws
+`TsjsUnavailableError` with the committed boot failure; it constructs no registry.
+
+The retained logger has this exact hard-cutover surface:
+
+```ts
+type TsjsLogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug'
+
+interface TsjsLog {
+ setLevel(level: TsjsLogLevel): void
+ getLevel(): TsjsLogLevel
+ error(...values: readonly unknown[]): void
+ warn(...values: readonly unknown[]): void
+ info(...values: readonly unknown[]): void
+ debug(...values: readonly unknown[]): void
+}
+```
+
+The initial level is `warn`. `setLevel` accepts only the five exact strings above;
+an invalid runtime value throws `TypeError` without changing the current level.
+Missing/throwing console methods are swallowed at the logger boundary, log failures
+never change ad behavior, and the logger does not retain argument arrays. The
+fallback exposes the same logger and level behavior.
+
+### 5.5 Direct auction API
+
+```ts
+interface RequestAdsOptions {
+ slots?: readonly string[]
+ timeoutMs?: number
+ signal?: AbortSignal
+}
+
+type RequestAdsInputErrorCode =
+ | 'invalid_options'
+ | 'invalid_slots'
+ | 'empty_slots'
+ | 'duplicate_slot'
+ | 'invalid_timeout'
+ | 'invalid_signal'
+
+class RequestAdsInputError extends Error {
+ readonly code: RequestAdsInputErrorCode
+}
+
+type RequestAdsSlotResult =
+ | { slot: string; path: 'primary' | 'fallback'; outcome: 'accepted' }
+ | { slot: string; path: 'primary' | 'fallback'; outcome: 'no_bid' }
+ | {
+ slot: string
+ path: 'primary' | 'fallback'
+ outcome: 'failed'
+ reason: RenderFailureReason
+ }
+ | {
+ slot: string
+ path: 'primary' | 'fallback'
+ outcome: 'cancelled'
+ reason: 'caller_aborted' | 'superseded' | 'navigation_disposed'
+ }
+
+interface RequestAdsResult {
+ slots: RequestAdsSlotResult[]
+}
+```
+
+Every `RequestAdsOptions.slots` entry is an exact, case-sensitive registered slot id:
+either a server slot id from §2.2 or a programmatic `code` admitted by §5.4.1. The
+public API never interprets an entry as a GPT ad-unit path, DOM id, or DOM alias. An
+explicit id absent from the invocation snapshot resolves individually as
+`failed{reason:'slot_unresolved'}` while valid siblings proceed. Internal GPT-path or
+DOM-alias lookup that has zero or multiple matches also fails the affected slot as
+`slot_unresolved`; it never selects the first registration.
+
+When `slots` is omitted, `requestAds` synchronously snapshots every server-projected
+and programmatic slot registered in the current `NavigationSession`, ordered by its
+navigation-local registration ordinal. That immutable snapshot is the batch
+membership and result order; a slot registered after invocation is excluded. When
+`slots` is present, its validated input order is the result order. The `slot` field in
+every result is always the exact registered slot id.
+
+When `timeoutMs` is omitted, the shared auction-response deadline is exactly 10,000
+milliseconds. An explicit value replaces that default and must satisfy the bounds
+below; renderer/GPT path deadlines remain independently fixed by their lifecycle
+transitions.
+
+Omitted/`undefined` options are valid. Otherwise the argument must be a non-null
+plain object whose prototype is this realm's `Object.prototype` or `null`, containing
+only `slots`, `timeoutMs`, and `signal` as own enumerable data properties. Accessors
+or unknown keys are `invalid_options`; non-array slots, non-string/empty/>256-byte
+slot values, and more than 256 values are `invalid_slots`; an explicitly empty array
+is `empty_slots`; an exact duplicate is `duplicate_slot`; a noninteger timeout
+outside `100..30_000` is `invalid_timeout`; and a value that fails the platform
+`AbortSignal.prototype.aborted` brand getter is `invalid_signal`. These reject
+before attempts with `RequestAdsInputError`. Once an attempt is created, the
+returned promise resolves with per-slot terminal results and does not reject for
+auction or render failures. Unknown requested slots fail individually while valid
+siblings proceed. Omitted slots with an empty registry resolve an empty `slots`
+array. A registration collision cannot alter the snapshot or result ordering.
+
+The response deadline governs only the shared auction fetch. Once a response parses,
+path-specific renderer deadlines take over. The caller signal remains active until
+all child attempts settle.
+
+### 5.6 Adapters and external readiness
+
+GPT and Prebid adapters expose `present | pending | timed_out | incompatible`.
+`timed_out` and `incompatible` are not permanent global failures: a later valid
+external replacement can satisfy later operations.
+Each queued operation owns its own deadline and disposal; an expired operation is
+removed instead of running unexpectedly when the external library appears later.
+Each adapter queue holds at most 64 live operations, drains FIFO, and fails only the
+overflowing operation with `external_queue_full`. The operation deadline is exactly
+ten seconds from enqueue and is independent of the auction-fetch response deadline;
+expiry is `external_ready_timeout`. Readiness at the boundary races through the
+operation's terminal latch, so exactly one of dispatch or timeout wins.
+
+The GPT adapter owns early event subscription, command-queue interaction,
+`display`, `refresh`, targeting, and service-state inspection. The Prebid adapter
+owns commands, event subscription, bid-response registration, and Universal
+Creative integration. Tests use adapter fakes rather than mutable global objects.
+
+The decoupled Prebid artifact remains pure Prebid.js and retains its independent
+five-second queue-drain watchdog. The generated wrapper arms that watchdog as its
+first statement, before stamp inspection or module initialization. At 5,000 ms it
+looks up the then-current real Prebid object and calls its idempotent `processQueue()`
+at most once for that wrapper, whether or not the TS integration installed, so
+publisher callbacks are not held hostage by stamp conflict, a missing TS bundle, or
+a partial/duplicate artifact. Duplicate wrappers may reach the idempotent API, but
+black-box tests require each queued publisher callback to execute once. A later TS integration module may call the idempotent API again.
+The module first verifies the real Prebid API, then installs transactionally. It does
+not install the synthetic-refresh policy, clear targeting, or mutate publisher bids
+when only the injected `{que,cmd}` stub exists. The artifact manifest carries both
+module stems and runtime bidder codes, including aliases.
+
+The artifact exposes this frozen plain-data runtime stamp; the separately emitted
+build manifest contains the same fields plus filename/integrity metadata:
+
+```ts
+interface ExternalPrebidArtifactV1 {
+ readonly abi: 1
+ readonly artifactReleaseId: string
+ readonly prebidVersion: '10.26.0'
+ readonly moduleStems: readonly string[]
+ readonly bidderCodes: readonly string[]
+ readonly bidderAliases: readonly {
+ readonly code: string
+ readonly moduleStem: string
+ }[]
+ readonly userIdModules: readonly {
+ readonly moduleName: string
+ readonly configNames: readonly string[]
+ readonly eidSources: readonly string[]
+ }[]
+}
+```
+
+After arming the watchdog and before executing embedded Prebid module factories, the
+wrapper inspects the current `window.pbjs` and its own descriptor for
+`__trustedServerArtifactV1`. If that object already has the required real API plus a
+valid recursively frozen stamp, an exact same-release/content duplicate reuses it and
+skips module initialization/redefinition; a different valid release refuses only the
+new wrapper and likewise leaves the already-working object untouched. A different
+artifact can become active only by replacing the whole `window.pbjs` object.
+
+Otherwise the wrapper initializes its embedded pure Prebid modules. It then attempts
+to define one own non-enumerable, non-writable, non-configurable data property whose
+value is the recursively frozen `ExternalPrebidArtifactV1` only when the descriptor
+is absent. Define failure is caught locally. An accessor, inherited-only value,
+invalid stamp, or hostile/different non-configurable value is left untouched and
+records at most one bounded console warning when possible. None of these paths
+throws, cancels the already-armed watchdog, or prevents publisher Prebid from
+initializing; they make only TS readiness incompatible.
+
+This inert build description is the artifact's only Trusted Server handshake. Apart
+from the independent Prebid queue self-start watchdog specified above, the generated
+wrapper performs no auction, admission, render, targeting, or refresh behavior. The
+Prebid adapter reads the property only through `Object.getOwnPropertyDescriptor`,
+rejects an accessor or inherited value, and captures both the `pbjs` object and stamp
+identities in one `PrebidArtifactBinding`. Every operation rechecks both identities;
+replacement of `window.pbjs` invalidates only that binding and later readiness may
+bind the new object if it carries a valid stamp. No `window.__tsjs_*` stamp or
+fallback lookup exists.
+
+The artifact build contains exactly one 64-zero-character release sentinel in that
+runtime stamp. It hashes the emitted JavaScript after normalizing that one field back
+to the sentinel, writes the resulting 64 lowercase hexadecimal SHA-256 characters
+into the field, and verifies that no sentinel remains. The separately emitted build
+manifest records that same `artifactReleaseId` plus the ordinary SHA-256/SRI of the
+final bytes. Thus the embedded id has a non-self-referential preimage; it is
+diagnostic artifact identity, not a requirement to match the TSJS `releaseId`.
+
+`prebidVersion` must be exactly `10.26.0`; changing it requires the reviewed
+artifact-contract fixture update in §3.5. Module/code/config names are nonempty,
+unique in their array, and at most 128
+UTF-8 bytes; EID sources are lowercase, nonempty, unique per module, and at most 256
+UTF-8 bytes. The manifest admits at most 256 module stems, 512 bidder codes, 512
+alias rows, and 128 user-ID modules with at most 64 config names and 64 EID sources
+each. Every alias code appears in `bidderCodes`, every alias module appears in
+`moduleStems`, and each configured `client_side_bidder` must appear in
+`bidderCodes`. Arrays are lexically sorted so build/runtime fixtures compare exact
+content.
+
+A missing stamp, wrong `abi`, invalid release/version, malformed/oversized member,
+missing configured bidder, missing required user-ID module/EID mapping, or a real API
+missing a required method makes the current readiness operation
+`external_artifact_incompatible`. It records one bounded local diagnostic and does
+not install TS refresh interception or mutate publisher state. An older unstamped
+artifact remains ordinary publisher Prebid: its own 5,000 ms watchdog drains its
+queue exactly once, and the later TS module does not replay publisher callbacks.
+Replacement by a valid artifact can satisfy later operations. Compatibility requires
+both `abi:1` and exact Prebid 10.26.0; external artifacts are not pinned to a TSJS
+release id.
+
+### 5.7 GPT correctness retained during decomposition
+
+- Subscribe to `slotRequested` and `slotRenderEnded` before any TS request.
+- Pass `changeCorrelator: false` for TS refreshes unless the explicit configuration
+ says otherwise.
+- Call `enableSingleRequest()` only before services are enabled; never reconfigure a
+ publisher-owned GPT service after `enableServices()`.
+- Restore the intended initial-load behavior represented by issue #922/PR #997 and
+ pin it with tests.
+- Responsive-size ambiguity fails `slot_unresolved` and never silently skips or
+ chooses an arbitrary container.
+- A TS fallback slot is defined on the resolved inner div, never its outer
+ `-container`. An exact later publisher `defineSlot` receives that same live slot
+ even when its path/formats differ, with a local mismatch warning, because defining
+ a second physical slot would violate the one-placement invariant. A
+ hydration-renamed alias is accepted only when the original element is gone and
+ exactly one live, unclaimed TS fallback shares the configured prefix, exact GAM
+ path, and normalized formats. Ambiguity remains native and cannot transfer TS
+ ownership.
+- Successful handoff synchronously transfers ownership, removes the slot from the
+ TS destroy set, suppresses exactly the publisher's duplicate initial `display`,
+ and under disabled initial load suppresses exactly its duplicate first refresh.
+ A global refresh expands the live GPT slot list, filters only one-shot suppressed
+ slots, and forwards unrelated slots with the original options.
+- Publisher calls are not held until TS targeting is ready. A publisher-owned
+ display/refresh remains publisher work, and its failures cannot start TS fallback.
+- Every TS-owned GPT destroy/redefine uses one adapter transaction. It first marks
+ the exact old object and cycle retired, then calls `destroySlots([old])` and
+ requires a successful return before attempting `defineSlot` for a replacement.
+ A throw/false destroy leaves the old identity retired and its path/aliases
+ quarantined, defines no second physical slot, and makes current or next TS work
+ fail `gpt_request_failed` until publisher destruction or reload. If destroy
+ succeeds but replacement definition fails, the slot stays unbound and the same
+ failure is returned; bindings and ownership commit only after one replacement is
+ successfully defined. A stale generation after either call disposes any newly
+ created TS-owned replacement and cannot bind it. Request-timeout, completion-
+ timeout recovery, navigation replacement, and DOM reconciliation all call this
+ transaction rather than open-coding destroy/redefine.
+- Runtime-owned DOM reconciliation detects when a framework replaces the element of
+ a TS-owned live slot. It debounces changes, retires/destroys only the orphaned
+ TS-owned GPT object, resolves the unique current element, and rebinds within the
+ current navigation. One `MutationObserver` per `NavigationSession` watches
+ `childList` changes under `document.documentElement`; it is disconnected on
+ navigation disposal. Once an exact owned element becomes disconnected, that slot
+ opens a 5,000 ms reconciliation window on the monotonic clock. The first resolution
+ pass runs after 250 ms without another relevant mutation; if it is unresolved or
+ ambiguous, exactly one final pass runs at the 5,000 ms boundary. A pass that finds
+ one unique current element may win the slot's terminal reconciliation latch only
+ after the destroy/redefine transaction commits its replacement. Destroy or define
+ failure settles current work as `gpt_request_failed`; it is not counted as a
+ successful rebind. The window expiry racing a successful final pass goes through
+ the same latch: success wins only if the unique replacement was committed first;
+ otherwise the slot records `slot_unresolved` and runs the failed-reconciliation
+ disposer. That disposer
+ cancels the slot's active TS request cycle, tombstones its live render reservation,
+ compare-restores only targeting still equal to TS-installed values, clears every
+ exact/alias binding to the orphan, and asks the GPT adapter to destroy that exact
+ still-TS-owned object. Before the destroy call it marks the object/cycle retired in
+ weak identity state, so a throw or later GPT callback is quarantined and cannot
+ re-enter selection, fallback, trace attribution, or targeting. It then releases
+ the timer, candidate set, and all strong references. Successful destruction
+ settles a nonterminal current attempt as `failed{reason:'slot_unresolved'}`;
+ throw/false destruction settles it as `failed{reason:'gpt_request_failed'}` and
+ adds one bounded local warning. Neither outcome restores ownership.
+
+ A second disconnect after one successful rebind may open one final window; two
+ successful rebinds is the per-slot, per-navigation maximum. A further disconnect
+ immediately runs that same disposer with
+ `failed{reason:'reconciliation_capacity'}` and cannot rebind again before the next
+ `NavigationSession`. Navigation disposal uses the same physical-object/targeting/
+ cycle cleanup without emitting a new failure. Reconciliation never destroys a
+ transferred or otherwise publisher-owned slot, and ownership transfer racing any
+ pass wins the latch, cancels TS reconciliation state, removes TS destroy ownership,
+ and leaves physical/targeting cleanup to the publisher.
+
+- The Prebid refresh policy preserves the exact `excluded_gam_ad_unit_path_suffixes`
+ behavior: path matching is literal/case-sensitive suffix matching; missing,
+ non-string, or throwing `getAdUnitPath()` fails open; stale TS/Prebid keys are
+ cleared from every target; only eligible slots enter the synthetic auction; and
+ the complete target slot list plus original options still reaches GPT.
+- Use one adapter-level refresh interception and one slot-service request path;
+ remove the three independent integration wrappers without removing handoff or
+ exclusion semantics.
+- Preserve the baseline collapsed-shell resize as a guarded exception tied to the
+ current attempt. It runs only after a TS PUC response is posted and only when the
+ source is the exact connected iframe, width/height attributes and computed size
+ are still at most one pixel, dimensions are finite/positive, the frame/wrapper are
+ ordinary non-fixed/non-sticky display shells, and no anchor container is present.
+ Only that iframe and its still-collapsed immediate wrapper may be resized.
+
+### 5.8 Local diagnostics
+
+The kernel owns a bounded, failure-isolated diagnostics bus. Render attempts and the
+GPT adapter publish immutable observations after their correctness transition; a
+diagnostics subscriber can never delay, reject, retry, or mutate the source
+operation. Subscriber throws are logged locally and isolated from later subscribers.
+The internal bus admits at most 16 integration-module subscriptions, one for each
+manifest member; publisher code cannot register on that internal bus.
+
+`tsjs.diagnostics.renderTrace` replaces the mutable `tsjs.renders`, `renderLog`, and
+`renderSeq` globals and the `tsjs:adRendered` CustomEvent with read-only snapshot and
+subscription methods. The final schema is:
+
+```ts
+type RenderTracePathV1 = 'auction' | 'ssat' | 'gam-refresh'
+type RenderTraceServedFromV1 =
+ | 'inline'
+ | 'gam'
+ | 'debug-adm'
+ | 'pbs-cache'
+ | 'prebid'
+
+interface RenderTraceRecord {
+ readonly slotId: string
+ readonly path: RenderTracePathV1
+ readonly rendered: boolean
+ readonly elementId?: string
+ readonly auctionId?: string
+ readonly bidder?: string
+ readonly adId?: string
+ readonly bidId?: string
+ readonly creativeId?: string
+ readonly admHash?: string
+ readonly servedFrom?: RenderTraceServedFromV1
+ readonly gamEmpty?: boolean
+ readonly injected?: boolean
+ readonly visible?: boolean
+ readonly count: number
+ readonly seq: number
+ readonly at: number
+}
+
+interface RenderTraceDiagnostics {
+ current(): Readonly>>
+ history(): readonly Readonly[]
+ subscribe(listener: (record: Readonly) => void): () => void
+}
+
+interface TsjsDiagnostics {
+ readonly renderTrace: RenderTraceDiagnostics
+ readonly gpt?: GptDiagnosticsApi
+}
+
+class DiagnosticsSubscriberLimitError extends Error {
+ readonly code: 'subscriber_capacity'
+ readonly surface: 'renderTrace' | 'gpt'
+}
+```
+
+Snapshots are frozen copies, not references to the runtime store. Subscription is
+FIFO; unsubscribe is idempotent; a listener registered during dispatch begins with
+the next observation. `count` is the positive per-slot impression ordinal, `seq` is
+the positive runtime-global observation ordinal, and `at` is the initial record's
+`Date.now()` epoch milliseconds; enrichment retains all three. The initial record and
+every later enrichment commit to `current`/`history` first and return to the
+correctness/publisher stack without invoking public code. After commit, the
+diagnostics service snapshots the current subscriber ids and enqueues one frozen
+full-record copy in a 200-entry FIFO keyed by `seq`; another enrichment pending for
+that `seq` replaces both its queued snapshot and captured subscriber-id set without
+changing order. A listener added after the initial commit may therefore receive a
+later enrichment commit, but never the earlier snapshot. Overflow drops the oldest
+pending notification and increments a diagnostics-only counter. One owned
+zero-delay task drains the queue in observation order. A listener registered after a
+commit cannot receive that committed observation; unsubscribe before delivery
+suppresses its captured id; registration during delivery starts with the next
+observation. A slow/non-returning listener can block only that later diagnostics task,
+never the render/GPT transition that scheduled it, and a throw is isolated from later
+listeners. This asynchronous frozen delivery is the timing/detail replacement
+contract for the removed `tsjs:adRendered` event; no CustomEvent or compatibility
+alias is emitted.
+
+Each public diagnostics surface admits at most 32 live subscribers. A 33rd
+subscription throws `DiagnosticsSubscriberLimitError{code:'subscriber_capacity'}`
+without adding the listener; unsubscribe immediately returns capacity. The
+argument must be callable or `subscribe` throws `TypeError` before the capacity
+check. The
+composition root freezes `tsjs.diagnostics` only after all manifest diagnostics
+integration modules have registered their surfaces. Fallback exposes no diagnostics
+namespace because it constructs no runtime.
+
+- sequence numbers are runtime-global across separately built IIFEs;
+- current state is keyed by exact registered slot id and therefore capped by the
+ 256-record navigation registry. Slot/navigation disposal synchronously prunes its
+ current entry; history is document-runtime scoped, capped at 200, and evicts the
+ oldest row before append;
+- one physical impression is one history row; a later bridge/GPT/visibility signal
+ enriches that row in place and cannot weaken prior `rendered` or `injected` truth;
+- a publisher/GAM refresh with no current TS auction has no TS attribution;
+- `gam-only` means GAM reported fill without proof TS placed the creative, while
+ `ok` requires TS placement plus visibility;
+- DOM `data-ts-*` stamps remove absent/stale fields on every update; an old badge is
+ removed before the new status is considered; and
+- the boot-armed local overlay remains bounded, newest-first, click-to-export,
+ and noninteractive with the creative. Overlay/export failure cannot affect ads.
+
+Diagnostics enablement is resolved before core preparation and transported only
+through frozen boot data:
+
+```ts
+interface DiagnosticsBootV1 {
+ readonly version: 1
+ readonly renderTraceOverlay: boolean
+ readonly gpt: { readonly active: boolean }
+}
+```
+
+The server always emits this complete value, defaulting to
+`{version:1,renderTraceOverlay:false,gpt:{active:false}}`. Both objects must be
+non-null plain objects with exactly the shown own enumerable data properties;
+accessors, unknown/missing keys, or wrong prototypes/literals/types are
+`abi_mismatch`, not silent diagnostics disablement. The kernel copies the validated
+data and recursively freezes that copy before module preparation; copy/freeze
+failure is also `abi_mismatch`. `gpt.active:true` requires exactly one required `gpt_diagnostics`
+integration id in `BootManifestV1`, and `false` requires that module to be absent;
+the inverse mismatch is also `abi_mismatch` before any GPT diagnostics listener or
+buffer exists.
+
+The existing render-trace server toggle resolves
+`tsjs.boot.diagnostics.renderTraceOverlay`; TSJS does not read or mutate its cookie.
+GPT diagnostics remains deployment-disabled by default. When configured, one exact
+`ts_console=1|true` directive on an eligible GET document navigation enables the
+host session and `ts_console=0|false` disables it; values are case-sensitive and
+duplicate/unrecognized directives fail closed for that response. The server owns the
+host-only HttpOnly session cookie, removes the reserved directive before publisher or
+origin handling, preserves unrelated path/query/fragment data, and emits only the
+resolved `gpt.active` boolean. The old
+`window.__tsjs_gpt_diagnostics_active` flag and browser storage bootstrap are deleted.
+
+The GPT diagnostics integration module preserves the behavioral contract in
+`docs/superpowers/specs/2026-07-28-gpt-runtime-diagnostics-overlay-design.md` unless
+this design explicitly changes ownership or activation transport. It consumes raw
+facts from the sole GPT adapter rather than registering another control wrapper.
+
+When `gpt.active` is true, core installs the six documented GPT observations
+(`slotRequested`, `slotResponseReceived`, `slotRenderEnded`, `slotOnload`,
+`impressionViewable`, and `slotVisibilityChanged`) before any TS-owned GPT request.
+It starts a 512-entry FIFO pre-module fact buffer and replays it in order when the
+diagnostics module activates. Overflow evicts the oldest fact and increments one
+diagnostics-only counter; after replay, live facts fan out directly and the buffer is
+released. When inactive, no diagnostics buffer or four diagnostics-only listeners
+exist. The GPT adapter may still own `slotRequested` and `slotRenderEnded` listeners
+required for ordinary ad correctness under §5.7; inactive zero-side-effect tests
+measure that baseline and require zero diagnostics-added listeners, DOM, timers,
+observers, API, or network work.
+
+The GPT diagnostics store retains at most 64 observed GPT slot objects, ten request
+cycles per slot, and 128 callback-issue records. It evicts the
+least-recently-active slot or oldest cycle/issue before insert and increments the
+corresponding export counter. An evicted GPT slot can re-enter only on a future
+`slotRequested`; its monotonic request number is retained in a `WeakMap`, and earlier
+non-request callbacks remain unmatched. Its public API shares the 32-subscriber cap
+above. Exact slot identity/binding and element replacement, physical request cycles,
+callback truth, timing fields, frozen bounded export, Shadow DOM overlay, badge
+layers, SPA behavior, privacy, and non-interference remain. Diagnostic records are
+memory-only; neither diagnostics surface writes localStorage, sessionStorage,
+IndexedDB, or uploads data. The hard-cutover API is `tsjs.diagnostics.gpt`; the old
+flag/runtime expandos and `tsjs.gptDiagnostics` alias are deleted.
+
+GPT public subscriptions use a separate one-entry latest-snapshot notifier and never
+run from a GPT callback, adapter fan-out, store mutation, or binding observer. After
+each committed store/binding change, the controller builds one frozen
+`GptDiagnosticsExportV1`, snapshots current subscriber ids, and schedules one owned
+zero-delay task. A later change before delivery replaces that pending snapshot and
+subscriber-id set; this API signals current state rather than promising one callback
+per raw GPT fact. Registration after a commit cannot receive that commit unless a
+later change replaces the pending snapshot; unsubscribe before delivery suppresses
+the captured id. Listener throws are isolated, and a slow/non-returning listener can
+block only the diagnostics task. Module disposal cancels the task, clears the one
+pending snapshot and subscriber set, and delivers nothing later. `snapshot()` remains
+a synchronous frozen read with no subscriber invocation. Tests apply the same
+subscribe/unsubscribe/slow/throw rules as render trace plus 0/1/2-update coalescing.
+
+### 5.9 Creative and remaining integration preservation
+
+`CreativeBootV1` in §5.4 is exact plain boot data. The server always emits it. A
+disabled integration is exactly
+`{version:1,enabled:false,clickGuard:false,renderGuard:false}` and has no `creative`
+manifest member. When enabled but its config is absent, the server emits
+`{version:1,enabled:true,clickGuard:true,renderGuard:false}`; explicit configuration
+replaces the two guard booleans. `enabled:true` requires exactly one required
+`creative` manifest id and `false` requires its absence. An accessor, non-plain
+prototype, missing/unknown key, wrong literal/version/type, disabled non-false guard,
+or manifest mismatch is an `abi_mismatch` before any creative guard installs. The recursively
+frozen `tsjs.boot.creative` is the only final inspection/configuration surface.
+`globalThis.tscreative`, `globalThis.tsCreativeConfig`, `installGuards`, `setConfig`,
+and `getConfig` are deleted, not aliased; changing guard policy requires a new boot/
+document generation.
+
+The creative integration module prepares inertly and activates transactionally
+exactly once in the kernel barrier. Activation installs the click guard when
+`clickGuard` is true and the image/iframe dynamic-source guards when `renderGuard` is
+true, but performs no baseline DOM rewrite. Only when
+`clickGuard || renderGuard` is true, activation gives a still-loading document one
+owned `DOMContentLoaded` callback to perform the baseline idempotent rescan after the
+initial DOM completes; an already interactive/complete document gets no listener and
+performs that scan from one staged `afterCommit` callback. Its
+disposer removes that listener, observers, and owned DOM state and compare-restores a
+patched constructor/property/function only if the current value is still the exact
+wrapper installed by this generation. Disabled guards install no wrapper, observer,
+listener, scan, or DOM state, whether the integration itself is disabled or it is
+enabled with both guard booleans false. SPA navigation retains the document-scoped
+guards and their dynamic-node behavior; failed preparation/activation or full runtime
+disposal removes them once.
+
+Creative processing keeps its current independent policy controls. Auction
+sanitization remains explicit opt-in/default-off; rewriting retains its existing
+setting/default and still runs on every delivery path where that setting applies.
+The runtime click guard resolves and stores one validated absolute HTTP(S) URL before
+navigation, rejects `javascript:`, `data:`, `blob:`, malformed, and credentialed
+targets, and uses the established `/first-party/proxy-rebuild` GET redirect path to
+recover clicks from the opaque sandbox. Dynamic resource/click rewriting, iframe
+sandbox attributes, font/CORS handling, body/base handling, and the direct/SSAT/cache
+delivery boundaries remain covered by unit plus real-browser tests. This APS/TSJS
+work neither enables sanitization nor broadens creative privileges.
+
+Every other enabled TSJS integration becomes a thin transactional integration module without an
+internal feature rewrite. Its complete current unit suite runs unchanged against a
+pre-cutover fixture and a module-composed fixture. At minimum the parity corpus
+proves:
+
+| Integration | Required preserved behavior |
+| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| DataDome | dynamic script/preload matching and fixed first-party route rewriting preserve the upstream path |
+| Didomi | configured proxy path becomes the absolute `didomiConfig.sdkPath` without clobbering unrelated publisher config |
+| Google Tag Manager | script/preload rewriting plus Google Analytics `sendBeacon`/`fetch` rewriting preserve method, body, and unrelated traffic |
+| Lockr | script guard plus bounded SDK readiness polling rewrites only the initialized Lockr API host |
+| Osano | USP/GPP/TCF cookie mirroring retains marker ownership, timeout, readiness, retry, event, focus/visibility, clear, and non-clobber semantics |
+| Permutive | script guard, bounded SDK readiness, API-host rewriting, and at-most-100 normalized local segment values continue to feed auction context |
+| Sourcepoint | optional SDK guard and Sourcepoint-owned GPP cookie mirroring retain localStorage shapes, marker ownership, initial retry, visibility/focus updates, and safe clearing |
+| Testlight | preexisting and later callbacks bridge once into the final TSJS queue; invalid entries and one throwing callback do not block later work |
+
+The shared script/beacon/DOM-insertion guards keep integration-owned matchers and
+routes. A shared helper may centralize interception, but it cannot broaden one
+integration's matcher, reorder another integration's startup, stack interception,
+or leave a timer/listener after module disposal. Maximal-bundle tests load every
+server-declared integration module in real manifest order and assert both its behavior and
+exactly-once disposal, not merely successful registration.
+
+### 5.10 Error handling and bounded state
+
+No empty `catch` remains in the migrated kernel, adapters, or APS/GPT/Prebid paths.
+Boundary failures become typed results and a concise local warning; disposer and
+late-callback failures cannot escape into publisher code. Logs redact descriptors,
+AAX payloads, account ids, creative URLs, auction bodies, and capability values.
+
+Every collection has a named owner, capacity, and pruning rule. Tests exercise
+capacity, TTL, duplicate registration, replay, timeout, navigation replacement, and
+late continuation behavior with fake timers.
+
+### 5.11 Decomposition targets
+
+The implementation extracts cohesive behavior rather than mechanically splitting
+by line count:
+
+| Current area | Target responsibility |
+| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `gpt/index.ts` | thin integration composition over GPT adapter, slot service, initial-load policy, handoff, SPA navigation, and render bridge |
+| `prebid/index.ts` | thin integration composition over Prebid adapter, shim, refresh handler, eids, and APS registration |
+| `core/request.ts` | validation plus `AuctionBatch`; rendering delegated to render service |
+| `core/render.ts` | path-independent DOM helpers only; lifecycle lives in render service |
+| `core/trace.ts` | diagnostics subscriber over lifecycle/GPT observations; no mutable integration-owned trace registry |
+| APS maps in globals | runtime-owned bounded reservation service |
+| duplicated `script_guard.ts` | shared factory plus integration configuration |
+
+Other integrations receive an integration-module wrapper and service lookup where required, but
+their internal behavior is not otherwise rewritten.
+
+### 5.12 TypeScript and performance gates
+
+The lockfile compiler is the authority. CI runs a checked-in `typecheck` script with
+`strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
+`verbatimModuleSyntax`, `noImplicitOverride`, and
+`useUnknownInCatchVariables`. Production bundles contain no dynamic imports.
+
+Before implementation, CI records deterministic gzip/Brotli baselines for minimal,
+reference, and maximal integration sets; each may grow at most 5% unless separately
+approved. Boot-to-first-display p90 uses a pinned Chromium version, CI runner class,
+fixture, warmup count, and sample count and must stay within 10% of that pre-change
+baseline. Retained heap uses Chromium CDP only, with forced-GC checkpoints after
+boot, first render, refresh, and SPA navigation, and the same 10% limit. Correctness
+runs independently in Chromium, Firefox, and WebKit. Correctness failures are never
+waived by a performance pass.
+
+## 6. Security and privacy
+
+1. Renderer iframes omit `allow-same-origin`; cross-origin target `"*"` is permitted
+ only when transferring a one-use port to an exact, already-checked
+ `contentWindow`.
+2. The initial global PUC request contains the opaque renderer reservation
+ capability but no descriptor, ADM, lifecycle ticket, or nonce, and it establishes
+ no success. The first compatible claim acquires the PUC source; render authority
+ begins only after exact reservation/slot lookup, attributable nonempty GAM,
+ current generation, source binding, and atomic consumption.
+3. Lifecycle tickets and nonces are CSPRNG, one-use, TTL-bounded, never logged, and
+ invalidated on supersession/navigation.
+4. Exact-key message parsing prevents confused-deputy extensions. Unknown versions
+ are ignored or failed closed according to whether the message claims a TS
+ capability.
+5. Native Prebid messages with non-TS ids continue to native listeners. Any message
+ carrying a live or tombstoned TS id is suppressed before later validation.
+6. The upstream APS runner URL, every creative URL, and production renderer/proxy
+ routes must be HTTPS. HTTP is permitted only for loopback hermetic adapters; their
+ fixed local proxy route remains covered by CSP `'self'`. The runner executes only
+ through the fixed-target, anonymous-CORS Trusted Server proxy. The proxy relays the
+ APS body unchanged but never stores it in source, forwards publisher credentials,
+ accepts a caller-selected target, or executes a fallback. Runner-created APS-origin
+ resources may use their own origin cookies under browser policy. The renderer
+ document accepts no cookie as authority.
+7. Script creatives remain opt-in because they materially broaden executable
+ behavior. Enabling them requires a documented security review of the fixed
+ renderer CSP.
+8. The design adds no persistent identifier and no external event pipeline.
+
+## 7. Verification and acceptance
+
+### 7.1 Required test layers
+
+| Layer | Required proof |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Rust unit | APS parsing/admission, dimensions, scripts, AAX projection, mediation provenance/order/timeouts, targeting identity, descriptor serialization, endpoint headers/body |
+| `rc/july` parity | executable `905984e62` TSJS source/test/browser inventory; every `RCJ-*` row maps to pre-cutover evidence, a final owner, focused tests, and either retained or deliberately replaced observable behavior |
+| Cross-language corpus | every positive/adversarial descriptor has the same Rust, TS, and embedded ES5 result; stale generation fails |
+| TS unit | kernel ownership, integration-module transaction/unwind, terminal fallback, sessions, registries, selection/cycle/batch/latch APIs, adapter readiness, GPT handoff/reconciliation, Prebid artifact/refresh, creative security, diagnostics, and every remaining integration parity corpus |
+| Hermetic browser | all render paths, PUC bridge, three-level APS sizing, direct iframe races, owner/port/runner behavior, fallback, SafeFrame-shaped nesting, GPT handoff/hydration, creative clicks, diagnostics, and duplicate/replay/wrong-source/stale cases |
+| Real-GAM test network | SSAT APS-PUC, Prebid-adapter APS-PUC, page-bids APS-PUC, direct APS, direct ADM/cache, fallback after attributable empty GAM, SRA, refresh, SPA, handoff, hydrated DOM replacement, and collapsed shell |
+| Adapter parity | exact renderer sandbox/CSP/header bytes plus runner-proxy routing, five-second deadline, closed response parsing, bounded relay, header filtering, and failures match on all adapters |
+| Regression | non-APS Cache/ADM and notifications, pure external Prebid/native bids/EIDs/user IDs/refresh exclusions, publisher GPT/handoff/SRA/SPA, creative processing/click recovery, render trace/GPT diagnostics, and every remaining integration remain correct |
+| Quality | full-package TypeScript/lint including tests/scripts/build code, ESLint boundaries, format, clippy, Rust adapter suites, Vitest, artifact integration, Playwright, deterministic bundle/performance budgets |
+
+### 7.2 Mandatory race matrix
+
+Tests must cover at least:
+
+- duplicate simultaneous `Prebid Request` for the same id;
+- claim before/after attributable nonempty GAM, claim followed by empty GAM, and
+ navigation/supersession at each side of that two-condition join;
+- replay after consumption and after tombstone expiry boundary;
+- attempt-id navigation prefix failure, ordinal uniqueness and exhaustion without an
+ issued-id set; forced lifecycle-ticket/renderer-nonce collisions through the
+ eighth draw; 255/256/257 live nonces and 319/320/321 ticket/tombstone entries;
+ capacity versus expiry pruning; and proof that neither overflow path posts a usable
+ capability;
+- valid id from wrong slot/source, altered id from the expected source, and a native
+ Prebid id;
+- PUC registration before/after timeout, wrong source, zero/two ports, replay,
+ caller abort before/after registration/insertion/document acceptance, and owner
+ watchdog racing a late kernel response; every winner produces exactly one
+ encodable `OwnerSettlementV1` and one PUC Promise settlement; channel loss before
+ start and after insertion, settlement-post throw, and the 20-second remote cleanup
+ boundary prove the owner removes only its uncommitted iframe while accepted DOM
+ remains;
+- renderer document success followed by runner failure/timeout;
+- runner proxy stall and slow-drip across the five-second total deadline; redirect;
+ absent/duplicate/malformed/mismatched/over-limit `Content-Length`;
+ absent/accepted/parameterized/rejected `Content-Type`; absent/identity/listed/other
+ `Content-Encoding`; declared and streamed over-limit bodies; byte-preserving
+ success; stripped upstream headers; and empty, non-leaking `502 no-store` failure;
+- GPT/Prebid readiness at either side of its deadline, `slotRequested` at either
+ side of the request-start deadline, and `slotRenderEnded` at either side of the
+ completion deadline, including late real completion of an attributable
+ completion-timeout cycle and proof that future events never release an
+ unattributable request-timeout quarantine;
+- iframe `load`, `error`, removal, supersession, and navigation disposal ordering;
+- accepted-artifact promotion racing terminal disposal, replacement of an accepted
+ direct iframe, TS-owned GPT destroy/redefine, and publisher-owned GPT navigation;
+- two consecutive attempts installing identical targeting strings with newer
+ success, failure, and supersession; older-artifact disposal after newer promotion;
+ publisher different-value and same-value `setTargeting`, per-key clear, and
+ clear-all before each cleanup point;
+- old-navigation completion after a new slot with the same DOM id exists, both
+ before and after the replacement's completion, for TS- and publisher-owned slots;
+- two concurrent `/auction` calls with partial slot overlap, reversed responses,
+ one caller abort, full abort, and response timeout;
+- initial boot projection versus SPA navigation-owned projection, proving boot
+ remains recursively frozen; stale/duplicate/malformed page-bids responses;
+ page-bids racing programmatic registration at the 255/256/257 combined-slot
+ boundary; auction/provider/upstream/currency/CPM/targeting grammar and
+ byte/count boundaries; canonical projection just below/at/above 8 MiB with the
+ exact all-winners-to-`winner_not_renderable` reduction; and all-or-nothing
+ projection/slot/targeting commit;
+- `display()` under disabled initial load from TS and publisher callers;
+- exact and hydration-renamed late `defineSlot` handoff, mismatch/ambiguity,
+ duplicate publisher display, explicit/global initial-load-disabled refresh,
+ ownership transfer, SPA disposal, and unrelated slots/options;
+- DOM replacement before and after GPT request start, debounced TS-owned orphan
+ reconciliation at 249/250 ms and 4,999/5,000 ms, first/final pass success,
+ two-success capacity, expiry/rebind latch ordering, ambiguous replacement,
+ transfer racing replacement, exact orphan destruction/quarantine, targeting
+ compare-restore, cycle/reservation cleanup, throwing/false GPT destroy and failed
+ replacement definition in reconciliation/request-timeout/completion-timeout/
+ navigation paths, proof no second physical slot is defined, navigation disposal,
+ and proof that publisher-owned slots are never destroyed;
+- SRA completion ordering, duplicate `responseIdentifier`, missing completion, and
+ publisher/TS overlap;
+- missing/stub/late/duplicate/older/partial external Prebid artifacts, artifact
+ queue-watchdog versus late module activation, ABI and release-identity boundaries,
+ every manifest collection/string capacity, malformed/unsorted/duplicate members,
+ sentinel-normalized versus final-byte integrity, exact 10.26.0, own data-property
+ binding, same-release duplicate reuse without module re-execution, different-valid-
+ release refusal without disturbing the active object, absent/accessor/inherited/
+ invalid/hostile non-configurable stamp handling after the watchdog arms, publisher
+ callback delivery exactly once on every conflict path, bound `pbjs`/stamp replacement,
+ `PreparedTrustedBid` admission/non-publication, bidder aliases, client-side
+ adapter gaps, user-ID/EID manifest gaps, replacement by a later valid artifact,
+ absence of TS auction/admission/render/refresh behavior from the external artifact,
+ and native publisher queue/bid
+ survival;
+- explicit/global Prebid refresh with normal, all-excluded, and mixed slots;
+ literal path case/trailing-slash mismatch; missing/non-string/throwing
+ `getAdUnitPath`; cleanup of excluded slots; original option identity; and the
+ initial-TS-refresh bypass;
+- reservation capacity with an unexpired oldest entry and late request for that
+ entry; navigation slot capacity across repeated programmatic calls at totals
+ 255/256/257, mixed server/programmatic records, all-or-nothing overflow, and full
+ disposal/reuse on the next navigation; immutable winner-CPM retention across a
+ replaced projection before a delayed cache claim, Prebid lease promotion, ignored
+ cache-response price/current targeting, and separate direct-cache expansion;
+- integration-module prepare throw/reject/abort and activation throw at each
+ checkpoint, late preparation continuation after fallback, and `afterCommit` throw;
+ 9,999/10,000/10,001 ms synchronous activation returns plus the pre/post-call and
+ pre-handoff monotonic checks; nonreturning activation documented as unpreemptable;
+ duplicate `afterCommit` registration and 15/16-module callback capacity;
+ publisher GPT activity and script/creative DOM activity before/during/after a later
+ module failure prove preparation is inert, activation cannot yield, rollback is
+ same-task, and post-commit work sees only the full kernel; queued and later
+ `requestAds`, callback throws, already-aborted signals, refusing late integration
+ registration, and proof that no second runtime, listener, port, timer, request,
+ script, wrapper, guard, or iframe survives a fallback commit;
+- exact kernel/fallback `TsjsApi` own surfaces; semantic version and release-id
+ equality; boot deep-copy/freeze and malformed-field safe fallback; actual-Array
+ queue identity; pushes before/during/at activation and commit completion; retained ingress
+ references after swap; snapshot-versus-forward exactly-once behavior; nested push
+ ordering; frozen final-queue `length:0` under native/borrowed mutators, index and
+ length assignment, deletion, and property definition in strict and sloppy callers;
+ immediate post-load return values, `this`, non-callables, and callback throws;
+- main bundle absence after server GPT projection, proving the old degraded bootstrap
+ renderer is deliberately gone: no GPT definition/targeting/display/refresh occurs,
+ every known slot settles with the committed fallback reason, the queue drains once,
+ and a late bundle cannot revive rendering;
+- explicit `requestAds` selection with exact server-projected and programmatic slot
+ ids, an unknown id beside a valid sibling, GPT-path and DOM-alias collisions, and
+ omitted-slot snapshot membership/order while another slot registers after
+ invocation;
+- programmatic `addAdUnits` single/array registration, boundary sizes/counts,
+ accessors/unknown keys/media, malformed params, duplicate/colliding ids,
+ all-or-nothing rollback, navigation disposal, registration after a `requestAds`
+ snapshot, dimension type and 0/1/4096/4097 boundaries, 63/64/65-byte bidder names,
+ direct rendering, fallback refusal, absence
+ of placeholder methods, logger default/all levels, invalid-level non-mutation, and
+ missing/throwing console methods;
+- direct and PUC ADM initial `about:blank`, pre-assignment, intended `srcdoc`, error,
+ removal, replacement, duplicate load, supersession, disposal, stale-generation,
+ and deadline orderings, proving only the current intended navigation accepts; and
+- render-trace record/update reordering, one-impression enrichment, weaker-signal
+ non-regression, 200-entry pruning, stale attribution/DOM-field/badge removal,
+ navigation pruning of `current`, 32/33 subscriber boundaries and capacity reuse,
+ 199/200/201 pending notification bounds, same-sequence coalescing, post-commit
+ asynchronous frozen subscription detail/timing, subscribe/unsubscribe races,
+ slow/throwing listeners, absence of `tsjs:adRendered`,
+ hidden/gam-only/ok truth, overlay/export failure, and cross-IIFE sequence order;
+- GPT diagnostics activation before/after early buffered callbacks, exact raw-event
+ replay, exact `tsjs.boot.diagnostics` schema, query/session enable-disable and
+ fail-closed inputs, accessor/prototype/unknown/missing/version rejection and
+ manifest-activation mismatch, active six-listener versus inactive correctness-listener counts,
+ 511/512/513 fact-buffer bounds, 63/64/65 slots, 9/10/11 cycles, 127/128/129 issues,
+ 32/33 public subscribers, 0/1/2-update latest-snapshot coalescing,
+ subscribe/unsubscribe/disposal races and slow/throwing listeners, slot element
+ replacement, timing/frozen-export bounds, overlay disposal, inactive
+ zero-diagnostics-side-effect behavior, and diagnostics failure during live ads;
+- creative processing across sanitize/rewrite policy combinations and every delivery
+ path; exact default/explicit `CreativeBootV1` validation; automatic immediate and
+ `DOMContentLoaded` install; disabled and enabled-with-both-guards-false
+ zero-side-effect behavior; idempotent rescan;
+ exact-wrapper disposal; absence of mutable/install globals; opaque sandbox click
+ recovery; absolute HTTP(S), credentials, malformed and non-network schemes;
+ dynamic URLs; replaced elements; and redirect/browser navigation failure; and
+- every remaining integration module alone and in the maximal manifest, including missing globals,
+ readiness/timeouts, malformed consent/storage, matcher false positives, callback
+ throws, startup failure, reverse-order disposal, and cross-integration isolation; and
+- every protocol string/body limit at boundary-minus-one, boundary, and
+ boundary-plus-one UTF-8 bytes, including multibyte, duplicate-key, malformed
+ encoding, exact 1/4096 renderer dimension bounds across Rust/TS/ES5/cache/PUC DOM,
+ and exact capability-form cases through both dispatcher and port parsers.
+
+### 7.3 Real-GAM pass criteria
+
+The checked-in test-network fixture and hermetic fakes use fictional ids and no
+production demand. Real network ids, GAM creative configuration, and secrets are
+injected by the protected CI/manual environment and never checked into this
+repository.
+Each required flow must demonstrate the expected DOM and lifecycle result, not an
+analytics row:
+
+- APS paths: one creative request, one bridge claim where applicable, one renderer
+ iframe, one APS runner load, one APS render-completion callback, one accepted
+ result, no duplicate render. The PUC owner, static renderer, and descendant creative
+ each have the exact winning viewport with zero default margin, no clipping, and no
+ overflow.
+- Empty GAM fallback: parent settles empty/failure before exactly one child render.
+- Direct ADM/cache: exact owned iframe reaches one accepted result.
+- Failure fixtures: wrong id, invalid descriptor, missing claim, missing owner,
+ missing document acknowledgement, and runner failure each reach the specified
+ terminal reason within the specified timeout.
+- After SPA replacement, no old attempt mutates the current slot or targeting.
+
+The suite records browser console, network metadata, DOM, and GPT-event evidence as
+CI artifacts. Network capture excludes APS runner and creative response bodies so a
+test artifact cannot become an accidental vendor-code archive. It requires no
+external analytics, billing, or experiment result.
+
+## 8. Delivery and rollout
+
+This work is assembled through test-only constructors while being built, then cuts
+over once through the existing APS/TSJS release mechanism. No runtime flag,
+old/new selector, compatibility branch, or dual protocol is introduced in any
+deployable artifact.
+
+1. **Contract first:** land descriptor corpus, lifecycle types, adapter interfaces,
+ and failing tests without changing production behavior.
+2. **Kernel and services:** introduce runtime/integration-module ownership, sessions, slot
+ registry, auction batch, and render lifecycle behind test-only construction.
+3. **Server APS path:** make admission, mediation, descriptor projection, targeting,
+ and renderer route conform to the contract.
+4. **Browser integrations:** migrate APS, GPT, Prebid, direct auction, fallback,
+ local diagnostics, creative processing, every remaining TSJS integration, and
+ bootstrap to the shared services/integration modules while their `RCJ-*` parity corpus stays
+ green.
+5. **Delete legacy paths:** remove expandos, duplicate bridge branches, old
+ `requestAds`, legacy globals, duplicated bootstrap behavior, and unused flags
+ from the release candidate.
+6. **Pre-production:** pass all hermetic suites and the protected real-GAM network
+ in Chromium, Firefox, and WebKit; archive its console, network, DOM, and GPT-event
+ evidence with the release artifact.
+7. **Binary production cutover:** deploy the verified artifact through the
+ repository's normal release mechanism. This design adds no percentage router or
+ canary-selection infrastructure. Hold an exclusive production deployment window,
+ attest the active immutable artifact, and re-check it immediately before cutover;
+ any mismatch blocks and regenerates evidence. Retain that immediately prior
+ artifact and roll back the whole cutover on renderer errors, elevated request
+ failures, CSP/security errors, or non-APS regressions.
+8. **Post-cutover:** monitor existing operational signals for 24 hours. The deployed
+ artifact already contains no temporary development selector.
+
+Binary rollback restores Trusted Server code but cannot restore older live APS runner
+bytes. If the proxied runner becomes unavailable, incompatible, or produces suspect
+completion results, the emergency containment action is to disable
+`[integrations.aps]`; this stops new APS admission and makes both reserved APS routes
+return their local `404 no-store` response. APS remains disabled until controlled
+real-browser conformance passes again.
+
+This document does not prescribe new rollout telemetry. If existing operational
+signals are insufficient for a deployment decision, that blocks rollout and is
+resolved operationally or in a separate observability spec; it does not justify
+adding a hidden analytics subsystem here.
+
+## 9. Decisions and rejected alternatives
+
+1. **Patch only the current GPT bridge:** rejected; it leaves duplicated runtime
+ state, SPA races, direct-auction concurrency, and bootstrap ownership unresolved.
+2. **Full TSJS rewrite:** rejected; extract the kernel and migrate behavior in tested
+ slices so current contracts remain the oracle until cutover.
+3. **Use `slotRenderEnded` as render success:** rejected; it observes GAM creative
+ injection, not APS runner completion.
+4. **Use a bid id as the only bridge credential:** rejected; ids can collide, be
+ truncated, replayed, or arrive from a wrong frame.
+5. **Put the lifecycle nonce in `Prebid Request`:** impossible; Universal Creative
+ owns that message. The nonce is minted only after a validated claim.
+6. **Trust creative-document callbacks for ADM/cache:** rejected; bidder-controlled
+ markup must not hold the acceptance capability. The TS-authored owner observes
+ its iframe.
+7. **Evict live reservations at capacity:** rejected; a late request could fall
+ through to native Prebid or claim the wrong work. Refuse new registration.
+8. **Abort a shared auction fetch when one slot is superseded:** rejected; sibling
+ attempts may still need the response.
+9. **Keep legacy globals/API aliases:** rejected; backward compatibility is not a
+ requirement and dual paths would preserve the architecture defect.
+10. **Add analytics or experiment infrastructure to prove rollout:** rejected as a
+ separate project. Rendering is proven by contract, browser, and real-GAM
+ conformance; release uses a pre-production gate and binary artifact rollback.
+11. **Check in or release a pinned APS runner:** rejected. Trusted Server owns only
+ the fixed-target transport proxy and renderer contract; APS owns the live runner
+ bytes.
+
+## 10. Risks and mitigations
+
+| Risk | Mitigation |
+| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| PUC behavior differs from mocks | vendor and checksum the exact supported PUC 1.17.2 behavior, exercise its `h.sendMessage` channel, and gate on real GAM |
+| Same-realm publisher code can interfere | explicitly trust TS-authored owner code; capability checks defend unrelated frames, replays, and stale work, not arbitrary same-realm compromise |
+| A module activation never returns | activation is generated first-party code with boundary tests; elapsed returning calls fail through monotonic checks, but JavaScript cannot preempt a nonreturning same-thread function |
+| Strict parsing rejects a future APS field | descriptor is versioned; outer transport remains tolerant; add a reviewed version/corpus update rather than silently accepting new semantics |
+| CSP blocks a legitimate APS creative | three-browser real-GAM suite; script creatives remain opt-in; CSP changes are explicit security work |
+| Hard cutover breaks stale pages | accepted compatibility stance; verify pre-production and retain the prior immutable artifact for binary rollback |
+| Kernel extraction changes unrelated integrations | per-integration pre/post behavior corpus, adapter fakes, current full suites, behavioral maximal-bundle test, and exact disposal assertions |
+| `rc/july` moves after the design is approved | pin `905984e62`; stop before code changes, diff all inventoried TSJS/bootstrap/browser paths, and update the ledger/tests explicitly |
+| Diagnostics change ad behavior or overclaim a render | one-way observation bus, bounded early-event replay, isolated subscribers, honest `gam-only`/`ok` rules, inactive zero-side-effect tests, and no correctness dependency |
+| Bounded registries refuse traffic under extreme churn | explicit reservation `registry_full` and slot `registry_capacity`, lifecycle pruning, capacity stress tests; never trade correctness for eviction |
+| GPT event attribution remains ambiguous | fail the TS attempt deterministically and never trigger fallback from ambiguous/publisher-owned activity |
+| Late async work mutates new SPA state | generation checks, owned disposers, terminal latch, and adversarial reversed-order tests |
+| Browser tests report iframe load but not APS success | require the bound APS render-completion callback and inspect network/DOM evidence |
+| APS runner becomes unavailable or stops the callback | load/rejection/silence fail the attempt; real-browser conformance blocks release and APS disablement is the emergency containment path |
+| APS runner reports completion incorrectly | accepted external trust risk; protected conformance checks DOM/network behavior, but cannot prove future mutable bytes; suspect behavior disables APS |
+| Existing operational signals are weak | do not invent telemetry in this spec; hold deployment or write a separate observability design |
+
+## 11. Success criteria
+
+The design is complete when all of the following are true:
+
+1. The five supported render flows have explicit owners, identity rules, deadlines,
+ and terminal behavior.
+2. APS descriptor production and all three validators agree on the full corpus.
+3. Mediation cannot detach price from provenance or attach the wrong renderer.
+4. GAM receives a valid, non-truncated identity for every accepted TS renderer bid.
+5. Duplicate, replayed, wrong-source, stale-navigation, and late lifecycle messages
+ cannot render or settle twice.
+6. Direct multi-slot auctions settle every requested slot and obey child-versus-batch
+ cancellation.
+7. The runtime has one slot registry, one bridge listener, one adapter instance per
+ external library, and one explicit owner for every timer/listener/port/iframe.
+8. Legacy expandos, duplicate bridge branches, old globals, old `requestAds`, and
+ duplicated bootstrap logic are absent from the final bundle. The `pub_id` config
+ alias, `/__ts/page-bids`, its JS retry, and the unversioned APS renderer path are
+ absent from server routes, tests, and documentation. Every bootstrap failure
+ checkpoint commits the terminal non-rendering shell, drains work exactly once,
+ and cannot construct or admit a second runtime.
+9. APS renderer and runner-proxy routing, security headers, bounded relay, and failure
+ behavior are proven through each real adapter transport, are equivalent across all
+ four adapters, and never fall through to publishers. APS runner bytes are neither
+ stored in the repository nor required to be identical across different upstream
+ fetches.
+10. Rust, TypeScript, ESLint, Vitest, hermetic Playwright, adapter parity, and
+ real-GAM conformance suites pass.
+11. Non-APS Cache/ADM rendering, native Prebid handling, publisher-owned GPT, refresh,
+ SRA, and SPA regression suites pass.
+12. No analytics, persistence, billing, experimentation, or deployment-routing
+ artifact is added by the implementation plan.
+13. `requestAds` accepts only exact server-projected or transactionally registered
+ programmatic slot ids, omitted selection is an immutable invocation-time
+ registration-order snapshot, and ambiguous internal aliases fail closed without
+ affecting valid siblings.
+14. Direct and PUC ADM acceptance is possible only for the exact current frame's one
+ intended `srcdoc` navigation; initial blank, replacement, removal, stale, late,
+ and duplicate events cannot accept.
+15. Every `RCJ-*` ledger entry maps to an executable pre-cutover fixture, one final
+ owner, focused final tests, and a preserved/rebuilt/superseded disposition; the
+ final manifest has no unmapped TSJS source or browser contract.
+16. Late GPT handoff, hydrated/ responsive DOM replacement, Prebid partial-artifact
+ recovery and refresh exclusions, creative security, render trace, GPT
+ diagnostics, and every remaining TSJS integration pass their complete parity
+ suites after the hard cutover.
+17. PUC owner, renderer document, and descendant creative dimensions are exact and
+ unclipped for the winning size, while collapsed-shell correction cannot resize an
+ unrelated, anchor, fixed, sticky, disconnected, or already-expanded frame.
+18. Programmatic ad units register transactionally into the navigation slot service,
+ participate in deterministic direct-auction snapshots, and render through the
+ same lifecycle; placeholder rendering and mutable generic runtime configuration
+ are absent rather than silently retained as a second path.
+19. The committed `TsjsApi` kernel/fallback surfaces, semantic version, exact
+ release identity, queue, logger, immutable boot data, and diagnostics presence are
+ executable contracts; creative guards auto-install from `CreativeBootV1` with no
+ mutable/install global API.
+20. Attempt ids require no issued-id history, and reservation/ticket/nonce registries
+ refuse capacity or collision exhaustion without exposing a reusable capability.
+21. The external Prebid artifact remains free of TS auction/render behavior, exposes
+ only its exact own frozen 10.26.0 build stamp, and the TS-owned adapter admits a
+ fully prepared bid without partial publication.
+
+## 12. Open implementation decisions
+
+These choices may be resolved in the implementation plan without changing the
+architecture:
+
+- exact source-file boundaries inside `kernel/`, `adapters/`, and `services/`;
+- whether the canonical descriptor schema is generated from Rust metadata or a
+ small neutral schema file, provided all validators share the corpus and staleness
+ check;
+- the repository's existing feature/config switch used only while the new path is
+ under construction;
+- exact operational thresholds for the existing binary deployment mechanism.
+
+The `RCJ-*` ledger membership, behavioral dispositions, public diagnostics
+namespace, Prebid artifact independence, and integration parity requirement are not
+open implementation decisions.
+
+They may not be resolved by adding compatibility shims, a second runtime, external
+telemetry requirements, durable persistence, experiment routing, or a weaker
+lifecycle success definition.
From 88f1432e33f310a202177feb656eba21ff0173de Mon Sep 17 00:00:00 2001
From: Aram Grigoryan <132480+aram356@users.noreply.github.com>
Date: Wed, 5 Aug 2026 21:57:01 -0700
Subject: [PATCH 013/194] Establish APS and TSJS implementation baseline
---
.github/workflows/integration-tests.yml | 35 ++-
.github/workflows/test.yml | 9 +
.../tests/shared/tsjs-performance.spec.ts | 228 ++++++++++++++
crates/trusted-server-js/lib/build-all.mjs | 69 ++++-
crates/trusted-server-js/lib/package.json | 1 +
.../lib/scripts/check-bundle-budgets.mjs | 137 +++++++++
.../lib/scripts/check-rc-july-adoption.mjs | 166 +++++++++++
.../trusted-server-js/lib/src/core/auction.ts | 10 +-
.../trusted-server-js/lib/src/core/config.ts | 3 +-
.../lib/src/core/registry.ts | 2 +-
.../trusted-server-js/lib/src/core/request.ts | 14 +-
.../trusted-server-js/lib/src/core/types.ts | 205 +++++++------
.../lib/src/integrations/creative/click.ts | 2 +-
.../lib/src/integrations/gpt/index.ts | 15 +-
.../src/integrations/gpt_diagnostics/api.ts | 8 +-
.../integrations/gpt_diagnostics/badges.ts | 12 +-
.../integrations/gpt_diagnostics/binding.ts | 12 +-
.../integrations/gpt_diagnostics/observer.ts | 14 +-
.../integrations/gpt_diagnostics/overlay.ts | 22 +-
.../src/integrations/gpt_diagnostics/store.ts | 36 +--
.../lib/src/integrations/osano/index.ts | 2 +-
.../lib/src/integrations/prebid/index.ts | 16 +-
.../lib/src/integrations/sourcepoint/index.ts | 26 +-
.../lib/src/integrations/testlight/index.ts | 3 +-
.../src/shared/dom_insertion_dispatcher.ts | 8 +-
.../test/contract/rc-july-adoption.test.mjs | 29 ++
.../lib/test/core/auction.test.ts | 49 +--
.../lib/test/core/registry.test.ts | 2 +-
.../lib/test/core/request.test.ts | 5 +-
.../lib/test/core/trace.test.ts | 6 +-
.../performance/aps-tsjs-prechange.json | 86 ++++++
.../lib/test/integrations/aps/render.test.ts | 44 +--
.../test/integrations/creative/click.test.ts | 4 +-
.../google_tag_manager/script_guard.test.ts | 12 +-
.../lib/test/integrations/gpt/ad_init.test.ts | 91 +++---
.../lib/test/integrations/gpt/index.test.ts | 16 +-
.../gpt_diagnostics/binding.test.ts | 2 +-
.../gpt_diagnostics/index.test.ts | 6 +-
.../gpt_diagnostics/observer.test.ts | 18 +-
.../gpt_diagnostics/store.test.ts | 64 ++--
.../lib/test/integrations/osano/index.test.ts | 2 +-
.../test/integrations/prebid/index.test.ts | 279 +++++++++++-------
.../integrations/sourcepoint/index.test.ts | 2 +-
.../lib/test/shared/beacon_guard.test.ts | 9 +-
crates/trusted-server-js/lib/tsconfig.json | 9 +-
scripts/dispatch-workflow-run.mjs | 166 +++++++++++
scripts/integration-tests-browser.sh | 65 ++--
47 files changed, 1525 insertions(+), 496 deletions(-)
create mode 100644 crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts
create mode 100644 crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs
create mode 100644 crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs
create mode 100644 crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs
create mode 100644 crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json
create mode 100644 scripts/dispatch-workflow-run.mjs
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 4973afe44..55db2a176 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -1,4 +1,6 @@
name: "Integration Tests"
+run-name: >-
+ Integration Tests / ${{ inputs.evidence_id || format('PR {0}', github.event.pull_request.number) }}
permissions:
contents: read
@@ -9,6 +11,11 @@ on:
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
+ inputs:
+ evidence_id:
+ description: Unique identifier used to bind this run to an evidence artifact
+ required: true
+ type: string
env:
ORIGIN_PORT: 8888
@@ -155,7 +162,7 @@ jobs:
browser-tests:
name: browser integration tests
needs: prepare-artifacts
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
@@ -244,3 +251,29 @@ jobs:
name: playwright-traces
path: crates/trusted-server-integration-tests/browser/test-results/
retention-days: 7
+
+ - name: Record TSJS pre-change performance evidence
+ if: github.event_name == 'workflow_dispatch'
+ working-directory: crates/trusted-server-integration-tests/browser
+ env:
+ WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }}
+ INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
+ VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy.toml
+ TEST_FRAMEWORK: nextjs
+ TSJS_PERF_MODE: baseline
+ TSJS_PERF_OUTPUT: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json
+ TSJS_PERF_MACHINE_CLASS: github-hosted:ubuntu-24.04
+ TSJS_EVIDENCE_ID: ${{ inputs.evidence_id }}
+ run: >-
+ npm exec -- playwright test
+ tests/shared/tsjs-performance.spec.ts
+ --project=chromium
+
+ - name: Upload TSJS pre-change performance evidence
+ if: github.event_name == 'workflow_dispatch' && always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: tsjs-performance-${{ inputs.evidence_id }}
+ path: crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json
+ if-no-files-found: error
+ retention-days: 30
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9ded6b860..163bf45f3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -247,5 +247,14 @@ jobs:
- name: Build bundle
run: npm run build
+ - name: Typecheck full TSJS package
+ run: npm run typecheck
+
+ - name: Lint full TSJS package
+ run: npm run lint
+
+ - name: Verify rc/july adoption manifest
+ run: node --test test/contract/rc-july-adoption.test.mjs
+
- name: Run unit tests
run: npm test -- --run
diff --git a/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts
new file mode 100644
index 000000000..f88f57f55
--- /dev/null
+++ b/crates/trusted-server-integration-tests/browser/tests/shared/tsjs-performance.spec.ts
@@ -0,0 +1,228 @@
+import { execFileSync } from "node:child_process";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, isAbsolute, resolve } from "node:path";
+import { test, expect, type Browser, type Page } from "@playwright/test";
+
+const REPO_ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], {
+ encoding: "utf8",
+}).trim();
+const TSJS_CRATE = resolve(REPO_ROOT, "crates/trusted-server-js");
+const CORE_BUNDLE = resolve(TSJS_CRATE, "dist/tsjs-core.js");
+const BUILD_METRICS = resolve(TSJS_CRATE, "dist/tsjs-build-metrics-v1.json");
+const WARMUPS = 5;
+const SAMPLES = 50;
+const PERCENTILE = 90;
+
+type HeapCheckpoint =
+ | "afterBoot"
+ | "afterFirstRender"
+ | "afterRefresh"
+ | "afterSpaNavigation";
+
+interface PerfApi {
+ addAdUnits(unit: {
+ code: string;
+ mediaTypes: { banner: { sizes: Array<[number, number]> } };
+ }): void;
+ renderAdUnit(code: string): void;
+}
+
+function fixtureDocument(): string {
+ return `
+
+TSJS deterministic performance fixture v1
+
+`;
+}
+
+async function openFixture(
+ browser: Browser,
+): Promise<{ page: Page; close(): Promise }> {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.setContent(fixtureDocument(), { waitUntil: "load" });
+ await page.addScriptTag({ path: CORE_BUNDLE });
+ return { page, close: () => context.close() };
+}
+
+async function render(page: Page): Promise {
+ return page.evaluate(() => {
+ const perfWindow = window as unknown as {
+ tsjs: PerfApi;
+ __tsjsPerf: { bootStartedAt: number; firstDisplayAt: number | null };
+ };
+ perfWindow.tsjs.addAdUnits({
+ code: "perf-slot",
+ mediaTypes: { banner: { sizes: [[300, 250]] } },
+ });
+ perfWindow.tsjs.renderAdUnit("perf-slot");
+ const firstDisplayAt =
+ perfWindow.__tsjsPerf.firstDisplayAt ?? performance.now();
+ return firstDisplayAt - perfWindow.__tsjsPerf.bootStartedAt;
+ });
+}
+
+async function collectHeap(page: Page): Promise {
+ const session = await page.context().newCDPSession(page);
+ try {
+ await session.send("HeapProfiler.collectGarbage");
+ const usage = await session.send("Runtime.getHeapUsage");
+ return usage.usedSize as number;
+ } finally {
+ await session.detach();
+ }
+}
+
+function p90(values: number[]): number {
+ const ordered = [...values].sort((left, right) => left - right);
+ return ordered[Math.ceil((PERCENTILE / 100) * ordered.length) - 1]!;
+}
+
+function packageVersion(packagePath: string): string {
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as {
+ version: string;
+ };
+ return packageJson.version;
+}
+
+test.describe("TSJS deterministic performance evidence", () => {
+ test("records bundle, p90 display, and forced-GC heap baselines", async ({
+ browser,
+ browserName,
+ }) => {
+ test.setTimeout(180_000);
+ const mode = process.env.TSJS_PERF_MODE;
+ test.skip(
+ mode !== "baseline" && mode !== "gate",
+ "performance evidence run only",
+ );
+ expect(browserName).toBe("chromium");
+
+ for (let index = 0; index < WARMUPS; index += 1) {
+ const fixture = await openFixture(browser);
+ try {
+ await render(fixture.page);
+ } finally {
+ await fixture.close();
+ }
+ }
+
+ const displaySamplesMs: number[] = [];
+ for (let index = 0; index < SAMPLES; index += 1) {
+ const fixture = await openFixture(browser);
+ try {
+ displaySamplesMs.push(await render(fixture.page));
+ } finally {
+ await fixture.close();
+ }
+ }
+
+ const heapFixture = await openFixture(browser);
+ const retainedHeapBytes = {} as Record;
+ try {
+ retainedHeapBytes.afterBoot = await collectHeap(heapFixture.page);
+ await render(heapFixture.page);
+ retainedHeapBytes.afterFirstRender = await collectHeap(heapFixture.page);
+ await heapFixture.page.evaluate(() => {
+ const perfWindow = window as unknown as { tsjs: PerfApi };
+ perfWindow.tsjs.renderAdUnit("perf-slot");
+ });
+ retainedHeapBytes.afterRefresh = await collectHeap(heapFixture.page);
+ await heapFixture.page.evaluate(() => {
+ location.hash = "performance-fixture-navigation";
+ const oldSlot = document.getElementById("perf-slot");
+ const replacement = document.createElement("div");
+ replacement.id = "perf-slot";
+ oldSlot?.replaceWith(replacement);
+ const perfWindow = window as unknown as { tsjs: PerfApi };
+ perfWindow.tsjs.renderAdUnit("perf-slot");
+ });
+ retainedHeapBytes.afterSpaNavigation = await collectHeap(
+ heapFixture.page,
+ );
+ } finally {
+ await heapFixture.close();
+ }
+
+ const outputArgument = process.env.TSJS_PERF_OUTPUT;
+ expect(outputArgument, "TSJS_PERF_OUTPUT is required").toBeTruthy();
+ const outputPath = isAbsolute(outputArgument!)
+ ? outputArgument!
+ : resolve(REPO_ROOT, outputArgument!);
+ const buildMetrics = JSON.parse(readFileSync(BUILD_METRICS, "utf8")) as {
+ schemaVersion: number;
+ sets: Record;
+ };
+ expect(buildMetrics.schemaVersion).toBe(1);
+
+ const npmVersion = execFileSync("npm", ["--version"], {
+ encoding: "utf8",
+ }).trim();
+ const artifact = {
+ schemaVersion: 1,
+ mode,
+ source: {
+ ref: execFileSync("git", ["branch", "--show-current"], {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ }).trim(),
+ sha: execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: REPO_ROOT,
+ encoding: "utf8",
+ }).trim(),
+ },
+ environment: {
+ node: process.version,
+ npm: npmVersion,
+ typescript: packageVersion(
+ resolve(
+ REPO_ROOT,
+ "crates/trusted-server-js/lib/node_modules/typescript/package.json",
+ ),
+ ),
+ chromium: browser.version(),
+ ciMachineClass:
+ process.env.TSJS_PERF_MACHINE_CLASS ??
+ (process.env.CI
+ ? "github-hosted:ubuntu-latest"
+ : `local:${process.platform}-${process.arch}`),
+ fixture: "tsjs-core-placeholder-v1",
+ },
+ sampling: {
+ warmups: WARMUPS,
+ samples: SAMPLES,
+ percentile: PERCENTILE,
+ },
+ bundles: buildMetrics.sets,
+ performance: {
+ bootToFirstDisplayMs: {
+ samples: displaySamplesMs,
+ p90: p90(displaySamplesMs),
+ },
+ retainedHeapBytes,
+ },
+ evidence: {
+ evidenceId: process.env.TSJS_EVIDENCE_ID ?? null,
+ workflowRunId: process.env.GITHUB_RUN_ID
+ ? Number(process.env.GITHUB_RUN_ID)
+ : null,
+ },
+ };
+
+ mkdirSync(dirname(outputPath), { recursive: true });
+ writeFileSync(outputPath, `${JSON.stringify(artifact, null, 2)}\n`);
+ expect(displaySamplesMs).toHaveLength(SAMPLES);
+ expect(Object.values(retainedHeapBytes)).toHaveLength(4);
+ });
+});
diff --git a/crates/trusted-server-js/lib/build-all.mjs b/crates/trusted-server-js/lib/build-all.mjs
index 2bfee01b1..17b12c11d 100644
--- a/crates/trusted-server-js/lib/build-all.mjs
+++ b/crates/trusted-server-js/lib/build-all.mjs
@@ -15,7 +15,9 @@
*/
import fs from 'node:fs';
+import { createHash } from 'node:crypto';
import path from 'node:path';
+import { brotliCompressSync, constants as zlibConstants, gzipSync } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import { build } from 'vite';
@@ -23,6 +25,37 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srcDir = path.resolve(__dirname, 'src');
const distDir = path.resolve(__dirname, '..', 'dist');
const integrationsDir = path.join(srcDir, 'integrations');
+const metricsFile = 'tsjs-build-metrics-v1.json';
+
+const REFERENCE_INTEGRATIONS = ['creative', 'gpt', 'prebid'];
+
+function compress(bytes) {
+ return {
+ gzipBytes: gzipSync(bytes, { level: 9, mtime: 0 }).byteLength,
+ brotliBytes: brotliCompressSync(bytes, {
+ params: {
+ [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT,
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 11,
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength,
+ },
+ }).byteLength,
+ };
+}
+
+function measureBundleSet(files) {
+ const separator = Buffer.from('\n;\n', 'utf8');
+ const parts = files.flatMap((file, index) => {
+ const bytes = fs.readFileSync(path.join(distDir, file));
+ return index === files.length - 1 ? [bytes] : [bytes, separator];
+ });
+ const bytes = Buffer.concat(parts);
+ return {
+ files,
+ rawBytes: bytes.byteLength,
+ ...compress(bytes),
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ };
+}
// Clean dist directory
fs.rmSync(distDir, { recursive: true, force: true });
@@ -39,7 +72,7 @@ const integrationModules = fs.existsSync(integrationsDir)
);
})
.sort()
- : [];
+ : [];
console.log('[build-all] Discovered integrations:', integrationModules);
@@ -89,5 +122,39 @@ const builtFiles = fs
.filter((f) => f.startsWith('tsjs-') && f.endsWith('.js'))
.sort();
+const referenceFiles = ['tsjs-core.js', ...REFERENCE_INTEGRATIONS.map((name) => `tsjs-${name}.js`)];
+for (const file of referenceFiles) {
+ if (!builtFiles.includes(file)) {
+ throw new Error(`[build-all] Reference bundle file was not built: ${file}`);
+ }
+}
+
+const metrics = {
+ schemaVersion: 1,
+ compression: {
+ concatenationSeparator: '\\n;\\n',
+ gzipLevel: 9,
+ gzipMtime: 0,
+ brotliMode: 'text',
+ brotliQuality: 11,
+ },
+ modules: builtFiles.map((file) => {
+ const bytes = fs.readFileSync(path.join(distDir, file));
+ return {
+ file,
+ rawBytes: bytes.byteLength,
+ sha256: createHash('sha256').update(bytes).digest('hex'),
+ };
+ }),
+ sets: {
+ minimal: measureBundleSet(['tsjs-core.js']),
+ reference: measureBundleSet(referenceFiles),
+ maximal: measureBundleSet(builtFiles),
+ },
+};
+
+fs.writeFileSync(path.join(distDir, metricsFile), `${JSON.stringify(metrics, null, 2)}\n`);
+
console.log('[build-all] Built files:', builtFiles);
console.log(`[build-all] Total: ${builtFiles.length} modules`);
+console.log(`[build-all] Wrote deterministic metrics: ${metricsFile}`);
diff --git a/crates/trusted-server-js/lib/package.json b/crates/trusted-server-js/lib/package.json
index 427fef1e1..e8e732736 100644
--- a/crates/trusted-server-js/lib/package.json
+++ b/crates/trusted-server-js/lib/package.json
@@ -10,6 +10,7 @@
"dev": "vite build --watch",
"test": "vitest run",
"test:watch": "vitest",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint --fix . --max-warnings=0",
"format": "prettier --check \"**/*.{ts,tsx,js,json,css,md}\"",
diff --git a/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs
new file mode 100644
index 000000000..fe79ea538
--- /dev/null
+++ b/crates/trusted-server-js/lib/scripts/check-bundle-budgets.mjs
@@ -0,0 +1,137 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const libDir = path.resolve(scriptDir, '..');
+const defaultBaselinePath = path.join(
+ libDir,
+ 'test',
+ 'fixtures',
+ 'performance',
+ 'aps-tsjs-prechange.json'
+);
+const metricsPath = path.resolve(libDir, '..', 'dist', 'tsjs-build-metrics-v1.json');
+const SET_NAMES = ['minimal', 'reference', 'maximal'];
+const SIZE_NAMES = ['rawBytes', 'gzipBytes', 'brotliBytes'];
+const MAX_GROWTH = 1.05;
+
+function fail(message) {
+ throw new Error(`[bundle-budgets] ${message}`);
+}
+
+function readJson(file, label) {
+ if (!fs.existsSync(file)) fail(`${label} does not exist: ${file}`);
+ try {
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
+ } catch (error) {
+ fail(`${label} is not valid JSON (${file}): ${error instanceof Error ? error.message : error}`);
+ }
+}
+
+function assertPositiveInteger(value, label) {
+ if (!Number.isSafeInteger(value) || value <= 0) fail(`${label} must be a positive integer`);
+}
+
+function validateSets(sets, label) {
+ if (!sets || typeof sets !== 'object' || Array.isArray(sets)) {
+ fail(`${label} must be an object`);
+ }
+ for (const setName of SET_NAMES) {
+ const set = sets[setName];
+ if (!set || typeof set !== 'object' || Array.isArray(set)) {
+ fail(`${label}.${setName} must be an object`);
+ }
+ if (!Array.isArray(set.files) || set.files.length === 0) {
+ fail(`${label}.${setName}.files must be a non-empty array`);
+ }
+ for (const [index, file] of set.files.entries()) {
+ if (typeof file !== 'string' || !/^tsjs-[a-z0-9_]+\.js$/.test(file)) {
+ fail(`${label}.${setName}.files[${index}] is not a canonical TSJS bundle filename`);
+ }
+ }
+ if (new Set(set.files).size !== set.files.length) {
+ fail(`${label}.${setName}.files contains a duplicate`);
+ }
+ for (const sizeName of SIZE_NAMES) {
+ assertPositiveInteger(set[sizeName], `${label}.${setName}.${sizeName}`);
+ }
+ if (typeof set.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(set.sha256)) {
+ fail(`${label}.${setName}.sha256 must be 64 lowercase hexadecimal characters`);
+ }
+ }
+}
+
+function parseArgs(argv) {
+ const options = { baselineOnly: false, baselinePath: defaultBaselinePath };
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (argument === '--baseline-only') {
+ options.baselineOnly = true;
+ } else if (argument === '--baseline') {
+ const value = argv[index + 1];
+ if (!value) fail('--baseline requires a path');
+ options.baselinePath = path.resolve(value);
+ index += 1;
+ } else {
+ fail(`unknown argument: ${argument}`);
+ }
+ }
+ return options;
+}
+
+export function checkBundleBudgets({
+ baselineOnly = false,
+ baselinePath = defaultBaselinePath,
+} = {}) {
+ const baseline = readJson(baselinePath, 'baseline');
+ const metrics = readJson(metricsPath, 'build metrics');
+ if (baseline.schemaVersion !== 1) fail('baseline.schemaVersion must equal 1');
+ if (metrics.schemaVersion !== 1) fail('build metrics schemaVersion must equal 1');
+ validateSets(baseline.bundles, 'baseline.bundles');
+ validateSets(metrics.sets, 'buildMetrics.sets');
+
+ const failures = [];
+ for (const setName of SET_NAMES) {
+ const expected = baseline.bundles[setName];
+ const actual = metrics.sets[setName];
+ if (JSON.stringify(actual.files) !== JSON.stringify(expected.files)) {
+ failures.push(
+ `${setName}.files changed: expected ${JSON.stringify(expected.files)}, got ${JSON.stringify(actual.files)}`
+ );
+ }
+ for (const sizeName of SIZE_NAMES) {
+ const limit = baselineOnly ? expected[sizeName] : Math.floor(expected[sizeName] * MAX_GROWTH);
+ if (actual[sizeName] > limit) {
+ failures.push(
+ `${setName}.${sizeName} is ${actual[sizeName]} bytes; limit is ${limit} from baseline ${expected[sizeName]}`
+ );
+ }
+ }
+ if (baselineOnly && actual.sha256 !== expected.sha256) {
+ failures.push(`${setName}.sha256 differs from the pre-change build`);
+ }
+ }
+
+ if (failures.length > 0) fail(`budget check failed:\n- ${failures.join('\n- ')}`);
+
+ return {
+ baselineOnly,
+ baselinePath,
+ maxGrowthPercent: baselineOnly ? 0 : 5,
+ sets: metrics.sets,
+ };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ const result = checkBundleBudgets(parseArgs(process.argv.slice(2)));
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
+ } catch (error) {
+ process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
+ process.exitCode = 1;
+ }
+}
diff --git a/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs b/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs
new file mode 100644
index 000000000..f80eb1b1d
--- /dev/null
+++ b/crates/trusted-server-js/lib/scripts/check-rc-july-adoption.mjs
@@ -0,0 +1,166 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const MANIFEST_FENCE = /```json rcjuly-tsjs-manifest-v1\n([\s\S]*?)\n```/g;
+const LEDGER_ID = /\| `(RCJ-[A-Z]+-[0-9]+)`/g;
+const QUALITY_ID = 'RCJ-QUAL-01';
+const SOURCE_ROOT = 'crates/trusted-server-js/lib/src/';
+
+function sorted(values) {
+ return [...values].sort((left, right) => left.localeCompare(right));
+}
+
+function gitLines(repositoryRoot, args) {
+ const output = execFileSync('git', args, {
+ cwd: repositoryRoot,
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ return output.split('\n').filter(Boolean);
+}
+
+function gitObjectExists(repositoryRoot, objectName) {
+ try {
+ execFileSync('git', ['cat-file', '-e', objectName], {
+ cwd: repositoryRoot,
+ stdio: 'ignore',
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function extractManifest(specSource) {
+ const matches = [...specSource.matchAll(MANIFEST_FENCE)];
+ if (matches.length !== 1 || typeof matches[0]?.[1] !== 'string') {
+ throw new Error(`expected exactly one rcjuly-tsjs-manifest-v1 block, found ${matches.length}`);
+ }
+
+ const manifest = JSON.parse(matches[0][1]);
+ if (
+ manifest === null ||
+ typeof manifest !== 'object' ||
+ manifest.version !== 1 ||
+ typeof manifest.baseline !== 'string' ||
+ !Array.isArray(manifest.includeRoots) ||
+ !Array.isArray(manifest.mappings)
+ ) {
+ throw new Error('rc/july adoption manifest has an invalid outer shape');
+ }
+
+ return manifest;
+}
+
+function mappingMatches(file, mapping) {
+ return (
+ (Array.isArray(mapping.exact) && mapping.exact.includes(file)) ||
+ (typeof mapping.prefix === 'string' && file.startsWith(mapping.prefix)) ||
+ (Array.isArray(mapping.prefixes) && mapping.prefixes.some((prefix) => file.startsWith(prefix)))
+ );
+}
+
+function mappingIdsForFile(file, mappings) {
+ const ids = new Set();
+ for (const mapping of mappings) {
+ if (!mappingMatches(file, mapping)) continue;
+ for (const id of mapping.ids ?? []) ids.add(id);
+ }
+ return ids;
+}
+
+export function auditRcJulyAdoption({ repositoryRoot, specPath }) {
+ const specSource = fs.readFileSync(specPath, 'utf8');
+ const manifest = extractManifest(specSource);
+ const files = new Set();
+
+ for (const includeRoot of manifest.includeRoots) {
+ for (const file of gitLines(repositoryRoot, [
+ 'ls-tree',
+ '-r',
+ '--name-only',
+ manifest.baseline,
+ '--',
+ includeRoot,
+ ])) {
+ files.add(file);
+ }
+ }
+
+ for (const mapping of manifest.mappings) {
+ for (const file of mapping.exact ?? []) {
+ if (gitObjectExists(repositoryRoot, `${manifest.baseline}:${file}`)) files.add(file);
+ }
+ }
+
+ const orderedFiles = sorted(files);
+ const unmappedFiles = orderedFiles.filter(
+ (file) => !manifest.mappings.some((mapping) => mappingMatches(file, mapping))
+ );
+ const qualityOnlySourceFiles = orderedFiles.filter((file) => {
+ if (!file.startsWith(SOURCE_ROOT)) return false;
+ const ids = mappingIdsForFile(file, manifest.mappings);
+ return ![...ids].some((id) => id !== QUALITY_ID);
+ });
+ const deadMappings = manifest.mappings
+ .map((mapping, index) => ({ index, mapping }))
+ .filter(({ mapping }) => !orderedFiles.some((file) => mappingMatches(file, mapping)))
+ .map(({ index }) => index);
+
+ const manifestIds = new Set(manifest.mappings.flatMap((mapping) => mapping.ids ?? []));
+ const ledgerIds = new Set([...specSource.matchAll(LEDGER_ID)].map((match) => match[1]));
+ const manifestOnlyIds = sorted([...manifestIds].filter((id) => !ledgerIds.has(id)));
+ const ledgerOnlyIds = sorted([...ledgerIds].filter((id) => !manifestIds.has(id)));
+
+ return {
+ baseline: manifest.baseline,
+ fileCount: orderedFiles.length,
+ mappingCount: manifest.mappings.length,
+ manifestIdCount: manifestIds.size,
+ ledgerIdCount: ledgerIds.size,
+ unmappedFiles,
+ qualityOnlySourceFiles,
+ deadMappings,
+ manifestOnlyIds,
+ ledgerOnlyIds,
+ };
+}
+
+export function assertRcJulyAdoption(result) {
+ const failures = [];
+ if (result.fileCount !== 144) failures.push(`expected 144 files, found ${result.fileCount}`);
+ if (result.mappingCount !== 38) {
+ failures.push(`expected 38 mappings, found ${result.mappingCount}`);
+ }
+ if (result.manifestIdCount !== 23 || result.ledgerIdCount !== 23) {
+ failures.push(
+ `expected 23 manifest/ledger ids, found ${result.manifestIdCount}/${result.ledgerIdCount}`
+ );
+ }
+ for (const key of [
+ 'unmappedFiles',
+ 'qualityOnlySourceFiles',
+ 'deadMappings',
+ 'manifestOnlyIds',
+ 'ledgerOnlyIds',
+ ]) {
+ if (result[key].length > 0) failures.push(`${key}: ${JSON.stringify(result[key])}`);
+ }
+ if (failures.length > 0) throw new Error(failures.join('\n'));
+}
+
+const scriptPath = fileURLToPath(import.meta.url);
+if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
+ const repositoryRoot = path.resolve(path.dirname(scriptPath), '../../../..');
+ const specPath = path.join(
+ repositoryRoot,
+ 'docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md'
+ );
+ const result = auditRcJulyAdoption({ repositoryRoot, specPath });
+ assertRcJulyAdoption(result);
+ process.stdout.write(`${JSON.stringify(result)}\n`);
+}
diff --git a/crates/trusted-server-js/lib/src/core/auction.ts b/crates/trusted-server-js/lib/src/core/auction.ts
index 489d66f4d..f00171527 100644
--- a/crates/trusted-server-js/lib/src/core/auction.ts
+++ b/crates/trusted-server-js/lib/src/core/auction.ts
@@ -45,9 +45,9 @@ export interface AuctionBid {
/** Matches the `impid` in the response — corresponds to adUnit `code`. */
impid: string;
/** Creative HTML (already rewritten with proxy URLs by the server). */
- adm: string;
+ adm?: string | undefined;
/** Typed APS renderer descriptor, when the bid does not carry `adm`. */
- renderer?: ApsRendererV1;
+ renderer?: ApsRendererV1 | undefined;
/** CPM price. */
price: number;
/** Creative width. */
@@ -61,11 +61,11 @@ export interface AuctionBid {
/** Advertiser domains. */
adomain: string[];
/** Server-side auction ID used for render tracing. */
- auctionId?: string;
+ auctionId?: string | undefined;
/** Upstream OpenRTB bid ID used for render tracing. */
- bidId?: string;
+ bidId?: string | undefined;
/** Trace hash of the delivered creative markup. */
- admHash?: string;
+ admHash?: string | undefined;
}
// ---------------------------------------------------------------------------
diff --git a/crates/trusted-server-js/lib/src/core/config.ts b/crates/trusted-server-js/lib/src/core/config.ts
index c0bbe7428..7026c4420 100644
--- a/crates/trusted-server-js/lib/src/core/config.ts
+++ b/crates/trusted-server-js/lib/src/core/config.ts
@@ -1,5 +1,6 @@
// Global configuration storage for the tsjs runtime (logging, debug, etc.).
-import { log, LogLevel } from './log';
+import { log } from './log';
+import type { LogLevel } from './log';
export interface Config {
debug?: boolean;
diff --git a/crates/trusted-server-js/lib/src/core/registry.ts b/crates/trusted-server-js/lib/src/core/registry.ts
index 9af4a0a34..06401a5e6 100644
--- a/crates/trusted-server-js/lib/src/core/registry.ts
+++ b/crates/trusted-server-js/lib/src/core/registry.ts
@@ -17,7 +17,7 @@ export function addAdUnits(units: AdUnit | AdUnit[]): void {
// Convenience helper to grab the first banner size off an ad unit.
export function firstSize(unit: AdUnit): Size | null {
const sizes = unit.mediaTypes?.banner?.sizes;
- return sizes && sizes.length ? sizes[0] : null;
+ return sizes && sizes.length ? sizes[0]! : null;
}
// Return a snapshot array of all registered ad units.
diff --git a/crates/trusted-server-js/lib/src/core/request.ts b/crates/trusted-server-js/lib/src/core/request.ts
index f22c1dbfc..4018ac5e8 100644
--- a/crates/trusted-server-js/lib/src/core/request.ts
+++ b/crates/trusted-server-js/lib/src/core/request.ts
@@ -10,21 +10,21 @@ import { isEffectivelyVisible, recordRender, stampCreativeTrace } from './trace'
export type RequestAdsCallback = () => void;
export interface RequestAdsOptions {
- bidsBackHandler?: RequestAdsCallback;
- timeout?: number;
+ bidsBackHandler?: RequestAdsCallback | undefined;
+ timeout?: number | undefined;
}
type RenderCreativeInlineOptions = {
slotId: string;
// Accept unknown input here because bidder JSON is untrusted at runtime.
creativeHtml: unknown;
- creativeWidth?: number;
- creativeHeight?: number;
+ creativeWidth?: number | undefined;
+ creativeHeight?: number | undefined;
seat: string;
creativeId: string;
- auctionId?: string;
- bidId?: string;
- admHash?: string;
+ auctionId?: string | undefined;
+ bidId?: string | undefined;
+ admHash?: string | undefined;
};
// Entry point matching Prebid's requestBids signature; uses unified /auction endpoint.
diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts
index 336bfa93a..19dc966bc 100644
--- a/crates/trusted-server-js/lib/src/core/types.ts
+++ b/crates/trusted-server-js/lib/src/core/types.ts
@@ -6,18 +6,18 @@ export interface Banner {
}
export interface MediaTypes {
- banner?: Banner;
+ banner?: Banner | undefined;
}
export interface Bid {
bidder: string;
- params?: Record;
+ params?: Record | undefined;
}
export interface AdUnit {
code: string;
- mediaTypes?: MediaTypes;
- bids?: Bid[];
+ mediaTypes?: MediaTypes | undefined;
+ bids?: Bid[] | undefined;
}
/** Minimal shape of a server-side auction slot injected into `window.tsjs.adSlots`. */
@@ -26,28 +26,28 @@ export interface AuctionSlot {
gam_unit_path: string;
div_id: string;
formats: Array<[number, number]>;
- targeting?: Record;
+ targeting?: Record | undefined;
}
/** Debug-only copy of server-side bid fields exposed for pipeline inspection. */
export interface AuctionDebugBidData {
- slot_id?: string;
- price?: number | null;
- currency?: string;
- creative?: string | null;
- adomain?: string[] | null;
- bidder?: string;
- width?: number;
- height?: number;
- nurl?: string | null;
- burl?: string | null;
- bid_id?: string | null;
- ad_id?: string | null;
- creative_id?: string | null;
- cache_id?: string | null;
- cache_host?: string | null;
- cache_path?: string | null;
- metadata?: Record;
+ slot_id?: string | undefined;
+ price?: number | null | undefined;
+ currency?: string | undefined;
+ creative?: string | null | undefined;
+ adomain?: string[] | null | undefined;
+ bidder?: string | undefined;
+ width?: number | undefined;
+ height?: number | undefined;
+ nurl?: string | null | undefined;
+ burl?: string | null | undefined;
+ bid_id?: string | null | undefined;
+ ad_id?: string | null | undefined;
+ creative_id?: string | null | undefined;
+ cache_id?: string | null | undefined;
+ cache_host?: string | null | undefined;
+ cache_path?: string | null | undefined;
+ metadata?: Record | undefined;
}
export type ApsTagType = 'iframe' | 'script';
@@ -58,7 +58,7 @@ export interface ApsRendererV1 {
version: 1;
accountId: string;
bidId: string;
- creativeId?: string;
+ creativeId?: string | undefined;
tagType: ApsTagType;
creativeUrl: string;
aaxResponse: string;
@@ -82,27 +82,27 @@ export interface ApsPrebidRendererEntry {
/** Bid targeting data from the server-side auction, injected into `window.tsjs.bids`. */
export interface AuctionBidData {
- hb_pb?: string;
- hb_bidder?: string;
- hb_adid?: string;
- hb_cache_host?: string;
- hb_cache_path?: string;
+ hb_pb?: string | undefined;
+ hb_bidder?: string | undefined;
+ hb_adid?: string | undefined;
+ hb_cache_host?: string | undefined;
+ hb_cache_path?: string | undefined;
/** Trace-only OpenRTB bid identifier. */
- hb_bid_id?: string;
+ hb_bid_id?: string | undefined;
/** Trace-only server-side auction identifier. */
- hb_auction_id?: string;
+ hb_auction_id?: string | undefined;
/** Trace-only OpenRTB creative identifier. */
- hb_crid?: string;
+ hb_crid?: string | undefined;
/** Trace hash of delivered creative markup. */
- hb_adm_hash?: string;
- nurl?: string;
- burl?: string;
+ hb_adm_hash?: string | undefined;
+ nurl?: string | undefined;
+ burl?: string | undefined;
/** Typed winning-bid renderer capability. */
- renderer?: AuctionBidRenderer;
+ renderer?: AuctionBidRenderer | undefined;
/** Winning creative width used by the inline render bridge. */
- w?: number;
+ w?: number | undefined;
/** Winning creative height used by the inline render bridge. */
- h?: number;
+ h?: number | undefined;
/**
* Sanitized winning creative markup for local rendering through the pbRender
* bridge. Present whenever the winning bid carried a creative that passed the
@@ -111,9 +111,9 @@ export interface AuctionBidData {
* back to the PBS Cache coordinates. This is NOT gated by
* `inject_adm_for_testing`.
*/
- adm?: string;
+ adm?: string | undefined;
/** Debug-only bid field mirror. Only present when `[debug] inject_adm_for_testing = true`. */
- debug_bid?: AuctionDebugBidData;
+ debug_bid?: AuctionDebugBidData | undefined;
}
/** How a creative reached the page for a [`RenderRecord`]. */
@@ -124,17 +124,17 @@ export interface RenderRecord {
slotId: string;
path: 'auction' | 'ssat' | 'gam-refresh';
rendered: boolean;
- elementId?: string;
- auctionId?: string;
- bidder?: string;
- adId?: string;
- bidId?: string;
- creativeId?: string;
- admHash?: string;
- servedFrom?: RenderServedFrom;
- gamEmpty?: boolean;
- injected?: boolean;
- visible?: boolean;
+ elementId?: string | undefined;
+ auctionId?: string | undefined;
+ bidder?: string | undefined;
+ adId?: string | undefined;
+ bidId?: string | undefined;
+ creativeId?: string | undefined;
+ admHash?: string | undefined;
+ servedFrom?: RenderServedFrom | undefined;
+ gamEmpty?: boolean | undefined;
+ injected?: boolean | undefined;
+ visible?: boolean | undefined;
count: number;
seq: number;
at: number;
@@ -177,46 +177,46 @@ export type GptDiagnosticsBindingReason =
export interface GptDiagnosticsBinding {
status: 'bound' | 'unbound' | 'ambiguous';
- reason?: GptDiagnosticsBindingReason;
+ reason?: GptDiagnosticsBindingReason | undefined;
}
export interface GptDiagnosticsDurations {
- requestToResponseMs?: number;
- responseToRenderMs?: number;
- requestToRenderMs?: number;
- renderToLoadMs?: number;
- renderToViewableMs?: number;
+ requestToResponseMs?: number | undefined;
+ responseToRenderMs?: number | undefined;
+ requestToRenderMs?: number | undefined;
+ renderToLoadMs?: number | undefined;
+ renderToViewableMs?: number | undefined;
}
export interface GptDiagnosticsRequestCycle {
requestNumber: number;
- requestedAtMs?: number;
- responseAtMs?: number;
- renderAtMs?: number;
- loadAtMs?: number;
- viewableAtMs?: number;
+ requestedAtMs?: number | undefined;
+ responseAtMs?: number | undefined;
+ renderAtMs?: number | undefined;
+ loadAtMs?: number | undefined;
+ viewableAtMs?: number | undefined;
durations: GptDiagnosticsDurations;
- isEmpty?: boolean;
- size?: Size;
- isBackfill?: boolean;
- slotContentChanged?: boolean;
+ isEmpty?: boolean | undefined;
+ size?: Size | undefined;
+ isBackfill?: boolean | undefined;
+ slotContentChanged?: boolean | undefined;
incompleteSequence: boolean;
}
export interface GptDiagnosticsSlotExport {
runtimeSlotNumber: number;
- slotElementId?: string;
- adUnitPath?: string;
+ slotElementId?: string | undefined;
+ adUnitPath?: string | undefined;
binding: GptDiagnosticsBinding;
- currentVisibilityPercentage?: number;
- maximumVisibilityPercentage?: number;
+ currentVisibilityPercentage?: number | undefined;
+ maximumVisibilityPercentage?: number | undefined;
requests: GptDiagnosticsRequestCycle[];
}
export interface GptDiagnosticsCallbackIssue {
kind: GptDiagnosticsCallbackKind;
runtimeSlotNumber: number;
- slotElementId?: string;
+ slotElementId?: string | undefined;
timestampMs: number;
disposition: GptDiagnosticsCallbackDisposition;
reason: string;
@@ -260,13 +260,20 @@ export interface TsjsApi {
addAdUnits(units: AdUnit | AdUnit[]): void;
renderAdUnit(codeOrUnit: string | AdUnit): void;
renderAllAdUnits(): void;
- setConfig?(cfg: Record): void;
- getConfig?(): Record;
- requestAds?(opts?: { bidsBackHandler?: () => void; timeout?: number }): void;
- requestAds?(
- callback: () => void,
- opts?: { bidsBackHandler?: () => void; timeout?: number }
- ): void;
+ setConfig?: ((cfg: Record) => void) | undefined;
+ getConfig?: (() => Record) | undefined;
+ requestAds?:
+ | {
+ (opts?: { bidsBackHandler?: (() => void) | undefined; timeout?: number | undefined }): void;
+ (
+ callback: () => void,
+ opts?: {
+ bidsBackHandler?: (() => void) | undefined;
+ timeout?: number | undefined;
+ }
+ ): void;
+ }
+ | undefined;
log?: {
setLevel(l: 'silent' | 'error' | 'warn' | 'info' | 'debug'): void;
getLevel(): 'silent' | 'error' | 'warn' | 'info' | 'debug';
@@ -278,45 +285,45 @@ export interface TsjsApi {
// ── Server-side auction runtime (populated by TS edge injection) ──────────
/** Ad slot definitions injected at open. */
- adSlots?: AuctionSlot[];
+ adSlots?: AuctionSlot[] | undefined;
/** Winning bid targeting data injected before . */
- bids?: Record;
+ bids?: Record | undefined;
/**
* Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated
* `hb_adid`. The Universal Creative bridge consumes each entry at most once.
*/
- apsPrebidRenderers?: Record;
+ apsPrebidRenderers?: Record | undefined;
/** Initialises GPT slots with server-side bid targeting and calls refresh(). */
- adInit?: () => void;
+ adInit?: (() => void) | undefined;
/** Render-trace registry: latest render per slot. */
- renders?: Record;
+ renders?: Record | undefined;
/** Append-only history of every render. */
- renderLog?: RenderRecord[];
+ renderLog?: RenderRecord[] | undefined;
/** Monotonic render generation for cancelling stale async work. */
- renderGeneration?: number;
+ renderGeneration?: number | undefined;
/** Page-global render sequence counter. */
- renderSeq?: number;
+ renderSeq?: number | undefined;
/** GPT slot objects TS defined — used to destroy stale slots on SPA navigation. */
- prevGptSlots?: unknown[];
+ prevGptSlots?: unknown[] | undefined;
/** Guards one-time-per-page enableSingleRequest/enableServices calls. */
- servicesEnabled?: boolean;
+ servicesEnabled?: boolean | undefined;
/** Maps actualDivId → slotId for slotRenderEnded billing lookup. */
- divToSlotId?: Record;
+ divToSlotId?: Record | undefined;
/**
* Win/billing beacons already fired, keyed by `slotId|bidIdentity|kind|url`.
* Used by the GPT render bridge so a bid's nurl/burl fire at most once even
* across repeated Prebid Universal Creative requests for the same adId.
*/
- firedBeacons?: Record;
+ firedBeacons?: Record | undefined;
/** Slot-level GPT targeting keys TS applied on the previous route. */
- prevSlotTargetingKeys?: Record;
+ prevSlotTargetingKeys?: Record | undefined;
/**
* One-shot bypass for the slim-Prebid refresh wrapper: true only while
* adInit() runs its internal refresh of server-side-targeted slots, so the
* wrapper passes that refresh straight to GPT instead of starting a
* client-side auction that would clear the just-applied TS targeting.
*/
- adInitRefreshInProgress?: boolean;
+ adInitRefreshInProgress?: boolean | undefined;
/**
* Whether the publisher disabled GPT initial load through
* `googletag.setConfig()` or `googletag.pubads().disableInitialLoad()`.
@@ -326,13 +333,13 @@ export interface TsjsApi {
* from a `refresh()`; adInit() uses this to refresh its own freshly defined
* slots so they are not left blank.
*/
- gptInitialLoadDisabled?: boolean;
+ gptInitialLoadDisabled?: boolean | undefined;
/** Late publisher claims for TS-created GPT slots, keyed by actual div ID. */
- gptSlotHandoffs?: Record;
+ gptSlotHandoffs?: Record | undefined;
/** True only while TS calls a GPT function that the handoff wrappers observe. */
- gptSlotHandoffInternal?: boolean;
+ gptSlotHandoffInternal?: boolean | undefined;
/** Guards SPA pushState hook installation. */
- spaHookInstalled?: boolean;
+ spaHookInstalled?: boolean | undefined;
/**
* Monotonic count of committed SPA navigations, incremented synchronously by
* the SPA auction hook the moment it accepts a route change. The deferred
@@ -345,7 +352,7 @@ export interface TsjsApi {
* unchanged, and an `/a → /b → /a` round trip (where the URL compares
* equal again) still advances it.
*/
- navGeneration?: number;
+ navGeneration?: number | undefined;
/**
* Defers the initial `adInit()` until after React hydration: window `load`,
* then a double `requestAnimationFrame`. Called by the server-injected
@@ -358,7 +365,9 @@ export interface TsjsApi {
* [`navGeneration`] with the SPA auction hook; `gpt_bootstrap.js` installs
* a minimal fallback for pages where the bundle fails to load.
*/
- scheduleInitialAdInit?: (initialBids?: Record) => void;
+ scheduleInitialAdInit?:
+ | ((initialBids?: Record | undefined) => void)
+ | undefined;
/** Read-only GPT lifecycle diagnostics API, present only in an activated tab. */
- gptDiagnostics?: GptDiagnosticsApi;
+ gptDiagnostics?: GptDiagnosticsApi | undefined;
}
diff --git a/crates/trusted-server-js/lib/src/integrations/creative/click.ts b/crates/trusted-server-js/lib/src/integrations/creative/click.ts
index 61b55d56c..f350c4a65 100644
--- a/crates/trusted-server-js/lib/src/integrations/creative/click.ts
+++ b/crates/trusted-server-js/lib/src/integrations/creative/click.ts
@@ -96,7 +96,7 @@ function equalCanon(a: Canon, b: Canon): boolean {
const bk = Object.keys(b.params).sort();
if (ak.length !== bk.length) return false;
for (let i = 0; i < ak.length; i++) {
- const k = ak[i];
+ const k = ak[i]!;
if (k !== bk[i] || a.params[k] !== b.params[k]) return false;
}
return true;
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts
index 406ba3cb2..7be05e10c 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts
@@ -315,7 +315,7 @@ function patchCommandQueue(tag: Partial): void {
// Only applicable when cmd is an array (pre-GPT-load case).
if (Array.isArray(queue)) {
for (let i = 0; i < queue.length; i++) {
- queue[i] = wrapCommand(queue[i]);
+ queue[i] = wrapCommand(queue[i]!);
}
log.debug('GPT shim: command queue patched', { pendingCommands: queue.length });
} else {
@@ -1087,8 +1087,11 @@ export function installTsAdInit(): void {
(
slotToRefresh as GoogleTagSlot & { __tsRenderGeneration?: number }
).__tsRenderGeneration = renderGeneration;
- (slotToRefresh as GoogleTagSlot & { __tsRenderBid?: AuctionBidData }).__tsRenderBid =
- pending;
+ (
+ slotToRefresh as GoogleTagSlot & {
+ __tsRenderBid?: AuctionBidData | undefined;
+ }
+ ).__tsRenderBid = pending;
}
});
// One-shot bypass: this internal refresh delivers the just-applied
@@ -1408,9 +1411,9 @@ function expandAuctionPriceMacro(markup: string, cpm: number): string {
/** A decoded PBS Cache bid: the renderable creative plus its render metadata. */
export interface CachedBid {
adm: string;
- width?: number;
- height?: number;
- price?: number;
+ width?: number | undefined;
+ height?: number | undefined;
+ price?: number | undefined;
}
/**
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts
index 4665113e6..ab869d322 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/api.ts
@@ -24,10 +24,10 @@ type ApiWindow = Window & {
};
interface ApiOptions {
- window?: ApiWindow;
- document?: Document;
- now?: () => Date;
- schedule?: (callback: () => void) => void;
+ window?: ApiWindow | undefined;
+ document?: Document | undefined;
+ now?: (() => Date) | undefined;
+ schedule?: ((callback: () => void) => void) | undefined;
}
type ApiListener = (snapshot: GptDiagnosticsExportV1) => void;
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts
index cc9121f05..970bf48e4 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/badges.ts
@@ -19,17 +19,17 @@ interface BadgeBindings {
}
type BadgeWindow = Window & {
- MutationObserver?: typeof MutationObserver;
- ResizeObserver?: typeof ResizeObserver;
+ MutationObserver?: typeof MutationObserver | undefined;
+ ResizeObserver?: typeof ResizeObserver | undefined;
};
const BADGE_MAX_WIDTH_PX = 260;
const BADGE_EDGE_GUTTER_PX = 4;
interface BadgeOptions {
- window?: BadgeWindow;
- document?: Document;
- scheduleFrame?: (callback: () => void) => void;
+ window?: BadgeWindow | undefined;
+ document?: Document | undefined;
+ scheduleFrame?: ((callback: () => void) => void) | undefined;
}
function defaultScheduleFrame(callback: () => void): void {
@@ -115,7 +115,7 @@ export class GptDiagnosticsBadgeManager {
private readonly unsubscribeBindings: () => void;
private readonly slotElementIds = new Set();
private slots: GptDiagnosticsStoreSlotSnapshot[] = [];
- private layer?: HTMLElement;
+ private layer: HTMLElement | undefined;
private mutationObserver?: MutationObserver;
private resizeObserver?: ResizeObserver;
private scheduled = false;
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts
index 489e7beb1..1a2cc048f 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/binding.ts
@@ -8,20 +8,20 @@ interface BindingStore {
}
type BindingWindow = Window & {
- CSS?: typeof CSS;
+ CSS?: typeof CSS | undefined;
HTMLElement: typeof HTMLElement;
- MutationObserver?: typeof MutationObserver;
+ MutationObserver?: typeof MutationObserver | undefined;
};
interface BindingOptions {
- document?: Document;
- window?: BindingWindow;
- scheduleFrame?: (callback: () => void) => void;
+ document?: Document | undefined;
+ window?: BindingWindow | undefined;
+ scheduleFrame?: ((callback: () => void) => void) | undefined;
}
export interface GptDiagnosticsBindingView {
binding: GptDiagnosticsBinding;
- element?: HTMLElement;
+ element?: HTMLElement | undefined;
visible: boolean;
}
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts
index a8339ffc3..12c5d64ae 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/observer.ts
@@ -18,10 +18,10 @@ interface GptEvent {
}
interface GptRenderEvent extends GptEvent {
- isEmpty?: boolean;
+ isEmpty?: boolean | undefined;
size?: unknown;
- isBackfill?: boolean;
- slotContentChanged?: boolean;
+ isBackfill?: boolean | undefined;
+ slotContentChanged?: boolean | undefined;
}
interface GptVisibilityEvent extends GptEvent {
@@ -48,11 +48,11 @@ interface GptCommandQueue {
interface GoogletagLike {
cmd: GptCommandQueue;
- pubads?: () => GptPubAdsService;
+ pubads?: (() => GptPubAdsService) | undefined;
}
export interface GptObserverWindow {
- googletag?: GoogletagLike;
+ googletag?: GoogletagLike | undefined;
}
interface ObserverLogger {
@@ -60,8 +60,8 @@ interface ObserverLogger {
}
interface ObserverOptions {
- window?: GptObserverWindow;
- logger?: ObserverLogger;
+ window?: GptObserverWindow | undefined;
+ logger?: ObserverLogger | undefined;
}
function normalizeSize(value: unknown): Size | undefined {
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts
index cacdbff9b..22614d74c 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts
@@ -19,16 +19,16 @@ interface OverlayBindings {
}
type OverlayWindow = Window & {
- MutationObserver?: typeof MutationObserver;
+ MutationObserver?: typeof MutationObserver | undefined;
};
interface OverlayOptions {
- window?: OverlayWindow;
- document?: Document;
- scheduleFrame?: (callback: () => void) => void;
- onExport?: () => void;
- onShadowRoot?: (root: ShadowRoot) => void;
- onBadgeLayerChange?: (layer: HTMLElement | undefined) => void;
+ window?: OverlayWindow | undefined;
+ document?: Document | undefined;
+ scheduleFrame?: ((callback: () => void) => void) | undefined;
+ onExport?: (() => void) | undefined;
+ onShadowRoot?: ((root: ShadowRoot) => void) | undefined;
+ onBadgeLayerChange?: ((layer: HTMLElement | undefined) => void) | undefined;
}
const PANEL_STYLES = `
@@ -191,12 +191,12 @@ export class GptDiagnosticsOverlay {
private readonly document: Document;
private readonly scheduleFrame: (callback: () => void) => void;
private readonly onExport: () => void;
- private readonly onShadowRoot?: (root: ShadowRoot) => void;
- private readonly onBadgeLayerChange?: (layer: HTMLElement | undefined) => void;
+ private readonly onShadowRoot: ((root: ShadowRoot) => void) | undefined;
+ private readonly onBadgeLayerChange: ((layer: HTMLElement | undefined) => void) | undefined;
private readonly unsubscribeStore: () => void;
private readonly unsubscribeBindings: () => void;
- private host?: HTMLElement;
- private panel?: HTMLElement;
+ private host: HTMLElement | undefined;
+ private panel: HTMLElement | undefined;
private lifecycleObserver?: MutationObserver;
private visualReady = false;
private mountWaitStarted = false;
diff --git a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts
index 669f28ddb..c5efe25cd 100644
--- a/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts
+++ b/crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/store.ts
@@ -22,29 +22,29 @@ const CALLBACK_KINDS: GptDiagnosticsCallbackKind[] = [
];
export interface GptDiagnosticsSlotLike {
- getSlotElementId?(): string;
- getAdUnitPath?(): string;
+ getSlotElementId?: (() => string) | undefined;
+ getAdUnitPath?: (() => string) | undefined;
}
export interface GptRenderFacts {
- isEmpty?: boolean;
- size?: Size;
- isBackfill?: boolean;
- slotContentChanged?: boolean;
+ isEmpty?: boolean | undefined;
+ size?: Size | undefined;
+ isBackfill?: boolean | undefined;
+ slotContentChanged?: boolean | undefined;
}
export interface GptDiagnosticsStoreSlotSnapshot {
runtimeSlotNumber: number;
- slotElementId?: string;
- adUnitPath?: string;
- currentVisibilityPercentage?: number;
- maximumVisibilityPercentage?: number;
+ slotElementId?: string | undefined;
+ adUnitPath?: string | undefined;
+ currentVisibilityPercentage?: number | undefined;
+ maximumVisibilityPercentage?: number | undefined;
requests: GptDiagnosticsRequestCycle[];
}
export interface GptDiagnosticsBindingInput {
runtimeSlotNumber: number;
- slotElementId?: string;
+ slotElementId?: string | undefined;
}
export interface GptDiagnosticsStoreSnapshot {
@@ -63,16 +63,16 @@ type MutableRequestCycle = GptDiagnosticsRequestCycle;
interface MutableSlotRecord {
runtimeSlotNumber: number;
- slotElementId?: string;
- adUnitPath?: string;
- currentVisibilityPercentage?: number;
- maximumVisibilityPercentage?: number;
+ slotElementId?: string | undefined;
+ adUnitPath?: string | undefined;
+ currentVisibilityPercentage?: number | undefined;
+ maximumVisibilityPercentage?: number | undefined;
requests: MutableRequestCycle[];
}
interface StoreOptions {
- now?: () => number;
- schedule?: (callback: () => void) => void;
+ now?: (() => number) | undefined;
+ schedule?: ((callback: () => void) => void) | undefined;
}
type StoreListener = () => void;
@@ -451,7 +451,7 @@ export class GptDiagnosticsStore {
return;
}
- attach(record, candidates[0]);
+ attach(record, candidates[0]!);
this.incrementDisposition(kind, 'matched');
this.notify();
}
diff --git a/crates/trusted-server-js/lib/src/integrations/osano/index.ts b/crates/trusted-server-js/lib/src/integrations/osano/index.ts
index 8bd3daae1..ab12df202 100644
--- a/crates/trusted-server-js/lib/src/integrations/osano/index.ts
+++ b/crates/trusted-server-js/lib/src/integrations/osano/index.ts
@@ -273,7 +273,7 @@ function readGppSignal(win: OsanoWindow): Promise {
return;
}
- const applicableSections = data.applicableSections;
+ const applicableSections = data.applicableSections as number[] | undefined;
if (typeof data.gppString === 'string' && data.gppString.length > 0) {
const writes = [{ name: GPP_COOKIE_NAME, value: data.gppString }];
const clears: string[] = [];
diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts
index 207bbeae1..df0cccc63 100644
--- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts
+++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts
@@ -52,9 +52,9 @@ const pbjs: PbjsGlobal = (
* user ID modules were compiled into it.
*/
interface ExternalPrebidBundleManifest {
- adapters?: string[];
- bidderCodes?: string[];
- userIdModules?: string[];
+ adapters?: string[] | undefined;
+ bidderCodes?: string[] | undefined;
+ userIdModules?: string[] | undefined;
}
function sanitizeManifestList(value: unknown): string[] | undefined {
@@ -132,11 +132,11 @@ const PENDING_PUBLISHER_DELIVERY_TTL_MS = 5000;
/** Configuration options for the Prebid integration. */
export interface PrebidNpmConfig {
/** Auction endpoint path. Defaults to '/auction'. */
- endpoint?: string;
+ endpoint?: string | undefined;
/** Server-side bid timeout in milliseconds. Defaults to 1000. */
- timeout?: number;
+ timeout?: number | undefined;
/** Enable Prebid.js debug logging. Defaults to false. */
- debug?: boolean;
+ debug?: boolean | undefined;
}
/**
@@ -1082,7 +1082,9 @@ export function installPrebidNpm(config?: Partial): typeof pbjs
if (hasUserIdApi && !auctionEids) {
clearPrebidEidsCookie();
}
- const payload = buildAdRequest(validBidRequests, { eids: auctionEids });
+ const payload = buildAdRequest(validBidRequests, {
+ eids: auctionEids,
+ } as Parameters[1]);
return {
method: 'POST',
url: auctionEndpoint,
diff --git a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts
index f323468bb..9e181c94d 100644
--- a/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts
+++ b/crates/trusted-server-js/lib/src/integrations/sourcepoint/index.ts
@@ -3,9 +3,11 @@ import { log } from '../../core/log';
import { installSourcepointGuard } from './script_guard';
type SourcepointWindow = Window & {
- __tsjs_sourcepoint?: {
- rewriteSdk?: boolean;
- };
+ __tsjs_sourcepoint?:
+ | {
+ rewriteSdk?: boolean | undefined;
+ }
+ | undefined;
};
function shouldInstallSourcepointGuard(): boolean {
@@ -30,33 +32,33 @@ const GPP_SOURCE_SOURCEPOINT = 'sp';
const INITIAL_RETRY_DELAY_MS = 500;
interface SourcepointGppData {
- gppString?: string;
- applicableSections?: number[];
+ gppString?: string | undefined;
+ applicableSections?: number[] | undefined;
}
interface SourcepointConsentStringEntry {
- sectionId?: number;
+ sectionId?: number | undefined;
}
interface SourcepointSectionPayload {
- consentString?: string;
- applicableSections?: number[];
- consentStrings?: SourcepointConsentStringEntry[];
+ consentString?: string | undefined;
+ applicableSections?: number[] | undefined;
+ consentStrings?: SourcepointConsentStringEntry[] | undefined;
}
interface SourcepointConsentPayload {
- gppData?: SourcepointGppData;
+ gppData?: SourcepointGppData | undefined;
[key: string]: unknown;
}
interface MirroredSourcepointConsent {
gppString: string;
- applicableSections?: number[];
+ applicableSections?: number[] | undefined;
}
let initialized = false;
let initialRetryDone = false;
-let retryTimer: ReturnType | undefined;
+let retryTimer: number | undefined;
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null;
diff --git a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts
index 3db00ce05..7f2598b17 100644
--- a/crates/trusted-server-js/lib/src/integrations/testlight/index.ts
+++ b/crates/trusted-server-js/lib/src/integrations/testlight/index.ts
@@ -1,7 +1,8 @@
import type { TsjsApi } from '../../core/types';
import { installQueue } from '../../core/queue';
import { log } from '../../core/log';
-import { resolvePrebidWindow, PrebidWindow } from '../../shared/globals';
+import { resolvePrebidWindow } from '../../shared/globals';
+import type { PrebidWindow } from '../../shared/globals';
type TestlightCallback = () => void;
diff --git a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts
index 5fb9283be..1046d951d 100644
--- a/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts
+++ b/crates/trusted-server-js/lib/src/shared/dom_insertion_dispatcher.ts
@@ -42,11 +42,11 @@ interface RegisteredDomInsertionHandler extends DomInsertionHandler {
}
interface DomInsertionDispatcherState {
- appendChildWrapper?: AppendChildMethod;
- baselineAppendChild?: AppendChildMethod;
- baselineInsertBefore?: InsertBeforeMethod;
+ appendChildWrapper?: AppendChildMethod | undefined;
+ baselineAppendChild?: AppendChildMethod | undefined;
+ baselineInsertBefore?: InsertBeforeMethod | undefined;
handlers: Map;
- insertBeforeWrapper?: InsertBeforeMethod;
+ insertBeforeWrapper?: InsertBeforeMethod | undefined;
nextSequence: number;
orderedHandlers: RegisteredDomInsertionHandler[];
version: number;
diff --git a/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs b/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs
new file mode 100644
index 000000000..a3334145e
--- /dev/null
+++ b/crates/trusted-server-js/lib/test/contract/rc-july-adoption.test.mjs
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { auditRcJulyAdoption } from '../../scripts/check-rc-july-adoption.mjs';
+
+const { test } = process.env.VITEST ? await import('vitest') : await import('node:test');
+
+const testDirectory = path.dirname(fileURLToPath(import.meta.url));
+const repositoryRoot = path.resolve(testDirectory, '../../../../..');
+const specPath = path.join(
+ repositoryRoot,
+ 'docs/superpowers/specs/2026-08-04-aps-render-fix-and-tsjs-resilience-design.md'
+);
+
+test('the pinned rc/july TSJS baseline is completely mapped by the spec ledger', () => {
+ const result = auditRcJulyAdoption({ repositoryRoot, specPath });
+
+ assert.equal(result.baseline, '905984e62a0858c53d9f0ff6dd3a1bf190cf311d');
+ assert.equal(result.fileCount, 144);
+ assert.equal(result.mappingCount, 38);
+ assert.equal(result.manifestIdCount, 23);
+ assert.equal(result.ledgerIdCount, 23);
+ assert.deepEqual(result.unmappedFiles, []);
+ assert.deepEqual(result.qualityOnlySourceFiles, []);
+ assert.deepEqual(result.deadMappings, []);
+ assert.deepEqual(result.manifestOnlyIds, []);
+ assert.deepEqual(result.ledgerOnlyIds, []);
+});
diff --git a/crates/trusted-server-js/lib/test/core/auction.test.ts b/crates/trusted-server-js/lib/test/core/auction.test.ts
index 55dba7bb6..a47080105 100644
--- a/crates/trusted-server-js/lib/test/core/auction.test.ts
+++ b/crates/trusted-server-js/lib/test/core/auction.test.ts
@@ -4,7 +4,7 @@ import { buildAdRequest, parseAuctionResponse, sendAuction } from '../../src/cor
import envelope from '../fixtures/aps-renderer-v1.json';
function apsRenderer(creativeId?: string) {
- const bid = envelope.seatbid[0].bid[0];
+ const bid = envelope.seatbid[0]!.bid[0]!;
return {
type: 'aps' as const,
version: 1 as const,
@@ -42,14 +42,17 @@ describe('auction/buildAdRequest', () => {
const result = buildAdRequest(units);
expect(result.adUnits).toHaveLength(1);
- expect(result.adUnits[0].code).toBe('div-1');
- expect(result.adUnits[0].mediaTypes.banner?.sizes).toEqual([
+ expect(result.adUnits[0]!.code).toBe('div-1');
+ expect(result.adUnits[0]!.mediaTypes.banner?.sizes).toEqual([
[300, 250],
[728, 90],
]);
- expect(result.adUnits[0].bids).toHaveLength(2);
- expect(result.adUnits[0].bids[0]).toEqual({ bidder: 'appnexus', params: { placementId: 123 } });
- expect(result.adUnits[0].bids[1]).toEqual({ bidder: 'rubicon', params: {} });
+ expect(result.adUnits[0]!.bids).toHaveLength(2);
+ expect(result.adUnits[0]!.bids[0]).toEqual({
+ bidder: 'appnexus',
+ params: { placementId: 123 },
+ });
+ expect(result.adUnits[0]!.bids[1]).toEqual({ bidder: 'rubicon', params: {} });
});
it('builds from Prebid BidRequest objects (adUnitCode + bidder)', () => {
@@ -81,13 +84,13 @@ describe('auction/buildAdRequest', () => {
const unit1 = result.adUnits.find((u) => u.code === 'div-gpt-1');
expect(unit1).toBeDefined();
expect(unit1!.bids).toHaveLength(2);
- expect(unit1!.bids[0].bidder).toBe('appnexus');
- expect(unit1!.bids[1].bidder).toBe('rubicon');
+ expect(unit1!.bids[0]!.bidder).toBe('appnexus');
+ expect(unit1!.bids[1]!.bidder).toBe('rubicon');
const unit2 = result.adUnits.find((u) => u.code === 'div-gpt-2');
expect(unit2).toBeDefined();
expect(unit2!.bids).toHaveLength(1);
- expect(unit2!.bids[0].bidder).toBe('openx');
+ expect(unit2!.bids[0]!.bidder).toBe('openx');
});
it('handles empty units array', () => {
@@ -139,7 +142,7 @@ describe('auction/buildAdRequest', () => {
const result = buildAdRequest(units);
expect(result.adUnits).toHaveLength(1);
- expect(result.adUnits[0].mediaTypes).toEqual({});
+ expect(result.adUnits[0]!.mediaTypes).toEqual({});
});
it('deduplicates by code/adUnitCode', () => {
@@ -150,9 +153,9 @@ describe('auction/buildAdRequest', () => {
const result = buildAdRequest(units);
expect(result.adUnits).toHaveLength(1);
- expect(result.adUnits[0].bids).toHaveLength(2);
- expect(result.adUnits[0].bids[0].bidder).toBe('a');
- expect(result.adUnits[0].bids[1].bidder).toBe('b');
+ expect(result.adUnits[0]!.bids).toHaveLength(2);
+ expect(result.adUnits[0]!.bids[0]!.bidder).toBe('a');
+ expect(result.adUnits[0]!.bids[1]!.bidder).toBe('b');
});
});
@@ -245,8 +248,8 @@ describe('auction/parseAuctionResponse', () => {
],
});
- expect(bids[0].renderer).toEqual(renderer);
- expect(bids[0].creativeId).toBe('aps-fictional-slot');
+ expect(bids[0]!.renderer).toEqual(renderer);
+ expect(bids[0]!.creativeId).toBe('aps-fictional-slot');
});
it('ignores unrelated or malformed renderer extensions while retaining ordinary adm', () => {
@@ -265,8 +268,8 @@ describe('auction/parseAuctionResponse', () => {
],
});
- expect(bids[0].renderer).toBeUndefined();
- expect(bids[0].adm).toBe('ordinary
');
+ expect(bids[0]!.renderer).toBeUndefined();
+ expect(bids[0]!.adm).toBe('ordinary
');
});
it('handles multiple seatbids with multiple bids', () => {
@@ -307,11 +310,11 @@ describe('auction/parseAuctionResponse', () => {
const bids = parseAuctionResponse(body);
expect(bids).toHaveLength(1);
- expect(bids[0].seat).toBe('unknown');
- expect(bids[0].adm).toBe('');
- expect(bids[0].width).toBe(300);
- expect(bids[0].height).toBe(250);
- expect(bids[0].adomain).toEqual([]);
+ expect(bids[0]!.seat).toBe('unknown');
+ expect(bids[0]!.adm).toBe('');
+ expect(bids[0]!.width).toBe(300);
+ expect(bids[0]!.height).toBe(250);
+ expect(bids[0]!.adomain).toEqual([]);
});
});
@@ -365,7 +368,7 @@ describe('auction/sendAuction', () => {
})
);
expect(bids).toHaveLength(1);
- expect(bids[0].price).toBe(2.5);
+ expect(bids[0]!.price).toBe(2.5);
});
it('returns empty array on network error', async () => {
diff --git a/crates/trusted-server-js/lib/test/core/registry.test.ts b/crates/trusted-server-js/lib/test/core/registry.test.ts
index f06527e42..51b0e3a84 100644
--- a/crates/trusted-server-js/lib/test/core/registry.test.ts
+++ b/crates/trusted-server-js/lib/test/core/registry.test.ts
@@ -24,6 +24,6 @@ describe('registry', () => {
const all = getAllUnits();
expect(all.length).toBe(1);
- expect(firstSize(all[0])!.join('x')).toBe('320x50');
+ expect(firstSize(all[0]!)!.join('x')).toBe('320x50');
});
});
diff --git a/crates/trusted-server-js/lib/test/core/request.test.ts b/crates/trusted-server-js/lib/test/core/request.test.ts
index b1625f9da..2fc652053 100644
--- a/crates/trusted-server-js/lib/test/core/request.test.ts
+++ b/crates/trusted-server-js/lib/test/core/request.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import envelope from '../fixtures/aps-renderer-v1.json';
+import type { addAdUnits } from '../../src/core/registry';
/** Test view of the global scope with a mockable `fetch`. */
const testGlobal = globalThis as unknown as { fetch: ReturnType };
@@ -77,7 +78,7 @@ describe('request.requestAds', () => {
});
it('dispatches a valid APS descriptor to the opaque static renderer route', async () => {
- const apsBid = envelope.seatbid[0].bid[0];
+ const apsBid = envelope.seatbid[0]!.bid[0]!;
const renderer = {
type: 'aps',
version: 1,
@@ -134,7 +135,7 @@ describe('request.requestAds', () => {
expect(document.querySelector('#slot1 span')).not.toBeNull();
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ renderer }), '*');
- const message = postMessage.mock.calls[0][0] as { nonce: string };
+ const message = postMessage.mock.calls[0]![0] as { nonce: string };
window.dispatchEvent(
new MessageEvent('message', {
data: { message: 'trusted-server/aps/renderer-ready', nonce: message.nonce },
diff --git a/crates/trusted-server-js/lib/test/core/trace.test.ts b/crates/trusted-server-js/lib/test/core/trace.test.ts
index 2181340f2..3317f67e1 100644
--- a/crates/trusted-server-js/lib/test/core/trace.test.ts
+++ b/crates/trusted-server-js/lib/test/core/trace.test.ts
@@ -69,7 +69,7 @@ describe('trace/recordRender', () => {
const record = recordRender({ slotId: 'slot-ev', path: 'auction', rendered: true });
expect(listener).toHaveBeenCalledTimes(1);
- const event = listener.mock.calls[0][0] as CustomEvent;
+ const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toEqual(record);
window.removeEventListener(RENDER_EVENT_NAME, listener);
@@ -297,8 +297,8 @@ describe('trace/floating panel', () => {
const panels = document.querySelectorAll(`#${TRACE_PANEL_ID}`);
expect(panels).toHaveLength(1);
// Second render of the same slot bumps the count and appends a history row.
- expect(panels[0].textContent).toContain('TS Render Trace · 1/1 slots ok');
- expect(panels[0].textContent).toContain('×2');
+ expect(panels[0]!.textContent).toContain('TS Render Trace · 1/1 slots ok');
+ expect(panels[0]!.textContent).toContain('×2');
});
it("keeps GAM's fill signal and drops ? placeholders on an unattributed refresh", () => {
diff --git a/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json
new file mode 100644
index 000000000..77e672aa7
--- /dev/null
+++ b/crates/trusted-server-js/lib/test/fixtures/performance/aps-tsjs-prechange.json
@@ -0,0 +1,86 @@
+{
+ "schemaVersion": 1,
+ "mode": "baseline",
+ "source": {
+ "ref": "spec/aps-tsjs-resilience-design",
+ "sha": "1b9753efa01a7985ae8ea804b1997a3cf3b3a7b4"
+ },
+ "environment": {
+ "node": "v24.12.0",
+ "npm": "11.6.2",
+ "typescript": "5.9.3",
+ "chromium": "145.0.7632.6",
+ "ciMachineClass": "local:darwin-arm64",
+ "fixture": "tsjs-core-placeholder-v1"
+ },
+ "sampling": {
+ "warmups": 5,
+ "samples": 50,
+ "percentile": 90
+ },
+ "bundles": {
+ "minimal": {
+ "files": ["tsjs-core.js"],
+ "rawBytes": 23317,
+ "gzipBytes": 8687,
+ "brotliBytes": 7686,
+ "sha256": "1e027cfb238cb6eed090b7addcdba5042059737cb319fbdef1b50e286689b851"
+ },
+ "reference": {
+ "files": ["tsjs-core.js", "tsjs-creative.js", "tsjs-gpt.js", "tsjs-prebid.js"],
+ "rawBytes": 107265,
+ "gzipBytes": 33428,
+ "brotliBytes": 25236,
+ "sha256": "8b9a440310ad358c292864dfa2e088c895c59199c2518fe9a3044236e459d19d"
+ },
+ "maximal": {
+ "files": [
+ "tsjs-core.js",
+ "tsjs-creative.js",
+ "tsjs-datadome.js",
+ "tsjs-didomi.js",
+ "tsjs-google_tag_manager.js",
+ "tsjs-gpt.js",
+ "tsjs-gpt_diagnostics.js",
+ "tsjs-lockr.js",
+ "tsjs-osano.js",
+ "tsjs-permutive.js",
+ "tsjs-prebid.js",
+ "tsjs-sourcepoint.js",
+ "tsjs-testlight.js"
+ ],
+ "rawBytes": 187224,
+ "gzipBytes": 53799,
+ "brotliBytes": 37790,
+ "sha256": "ce5fa29ba8914ad7074221ae777b12c0f496c00b19c42171e42a3d6005c378d3"
+ }
+ },
+ "performance": {
+ "bootToFirstDisplayMs": {
+ "samples": [
+ 11.300000011920929, 11.400000005960464, 11, 10.599999994039536, 11.599999994039536,
+ 13.199999988079071, 11.799999982118607, 11.100000023841858, 11, 11.799999982118607, 11.5,
+ 10.5, 10.800000011920929, 11.899999976158142, 11.199999988079071, 10.600000023841858,
+ 10.799999982118607, 11.099999994039536, 10.800000011920929, 10.699999988079071, 10.5,
+ 11.200000017881393, 10.599999994039536, 10.5, 10.800000011920929, 10.5, 10.300000011920929,
+ 10.199999988079071, 10.800000011920929, 10.700000017881393, 10.299999982118607,
+ 10.800000011920929, 10.800000011920929, 11.099999994039536, 15.699999988079071,
+ 10.399999976158142, 10.599999994039536, 10.5, 10.800000011920929, 10.599999994039536,
+ 10.699999988079071, 10.599999994039536, 11.599999994039536, 11.699999988079071,
+ 11.599999994039536, 10.5, 10.699999988079071, 10.400000005960464, 11.099999994039536,
+ 11.700000017881393
+ ],
+ "p90": 11.700000017881393
+ },
+ "retainedHeapBytes": {
+ "afterBoot": 1210116,
+ "afterFirstRender": 1213316,
+ "afterRefresh": 1213316,
+ "afterSpaNavigation": 1220364
+ }
+ },
+ "evidence": {
+ "evidenceId": null,
+ "workflowRunId": null
+ }
+}
diff --git a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts
index 04ad4fece..2ee02cb56 100644
--- a/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts
+++ b/crates/trusted-server-js/lib/test/integrations/aps/render.test.ts
@@ -34,7 +34,7 @@ function encodeEnvelopeAtSize(size: number): string {
}
function descriptor(overrides: Partial = {}): ApsRendererV1 {
- const bid = envelope.seatbid[0].bid[0];
+ const bid = envelope.seatbid[0]!.bid[0]!;
return {
type: 'aps',
version: 1,
@@ -93,19 +93,19 @@ describe('APS renderer validation', () => {
['sibling seat', { seatbid: [...envelope.seatbid, envelope.seatbid[0]] }],
[
'sibling bid',
- { seatbid: [{ bid: [...envelope.seatbid[0].bid, envelope.seatbid[0].bid[0]] }] },
+ { seatbid: [{ bid: [...envelope.seatbid[0]!.bid, envelope.seatbid[0]!.bid[0]!] }] },
],
[
'markup',
{
seatbid: [
- { bid: [{ ...envelope.seatbid[0].bid[0], adm: '' }] },
+ { bid: [{ ...envelope.seatbid[0]!.bid[0]!, adm: '' }] },
],
},
],
[
'notification',
- { seatbid: [{ bid: [{ ...envelope.seatbid[0].bid[0], nurl: 'https://notify.example' }] }] },
+ { seatbid: [{ bid: [{ ...envelope.seatbid[0]!.bid[0]!, nurl: 'https://notify.example' }] }] },
],
[
'unknown extension',
@@ -114,8 +114,8 @@ describe('APS renderer validation', () => {
{
bid: [
{
- ...envelope.seatbid[0].bid[0],
- ext: { ...envelope.seatbid[0].bid[0].ext, userSyncs: [] },
+ ...envelope.seatbid[0]!.bid[0]!,
+ ext: { ...envelope.seatbid[0]!.bid[0]!.ext, userSyncs: [] },
},
],
},
@@ -153,8 +153,8 @@ describe('APS renderer validation', () => {
const canonical = encodeBytes(new TextEncoder().encode(`${JSON.stringify(envelope)} `));
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const finalDataIndex = canonical.length - 3;
- const canonicalIndex = alphabet.indexOf(canonical[finalDataIndex]);
- const nonCanonical = `${canonical.slice(0, finalDataIndex)}${alphabet[canonicalIndex + 1]}==`;
+ const canonicalIndex = alphabet.indexOf(canonical[finalDataIndex]!);
+ const nonCanonical = `${canonical.slice(0, finalDataIndex)}${alphabet[canonicalIndex + 1]!}==`;
expect(atob(nonCanonical)).toBe(atob(canonical));
expect(validateApsRenderer(descriptor({ aaxResponse: canonical }))).toBeDefined();
@@ -167,7 +167,7 @@ describe('APS renderer validation', () => {
`${window.location.origin}/creative`,
])('rejects an unsafe creative URL', (creativeUrl) => {
const invalidEnvelope = structuredClone(envelope);
- invalidEnvelope.seatbid[0].bid[0].ext.creativeurl = creativeUrl;
+ invalidEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = creativeUrl;
expect(
validateApsRenderer(descriptor({ creativeUrl, aaxResponse: encodeEnvelope(invalidEnvelope) }))
).toBeUndefined();
@@ -192,9 +192,9 @@ describe('APS renderer validation', () => {
const atLimit = `${prefix}${'a'.repeat(4096 - prefix.length)}`;
const overLimit = `${atLimit}x`;
const atLimitEnvelope = structuredClone(envelope);
- atLimitEnvelope.seatbid[0].bid[0].ext.creativeurl = atLimit;
+ atLimitEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = atLimit;
const overLimitEnvelope = structuredClone(envelope);
- overLimitEnvelope.seatbid[0].bid[0].ext.creativeurl = overLimit;
+ overLimitEnvelope.seatbid[0]!.bid[0]!.ext.creativeurl = overLimit;
expect(
validateApsRenderer(
@@ -298,7 +298,7 @@ describe('direct APS rendering', () => {
'*'
);
- const message = postMessage.mock.calls[0][0] as { nonce: string };
+ const message = postMessage.mock.calls[0]![0] as { nonce: string };
window.dispatchEvent(
new MessageEvent('message', {
data: {
@@ -324,10 +324,10 @@ describe('direct APS rendering', () => {
expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
const slot = document.getElementById('fictional-slot')!;
- const rendererFrame = slot.querySelector('iframe')!;
+ const rendererFrame = slot.querySelector('iframe')!;
const postMessage = vi.spyOn(rendererFrame.contentWindow!, 'postMessage');
rendererFrame.dispatchEvent(new Event('load'));
- const sent = postMessage.mock.calls[0][0] as { nonce: string };
+ const sent = postMessage.mock.calls[0]![0] as { nonce: string };
const foreignFrame = document.createElement('iframe');
document.body.appendChild(foreignFrame);
@@ -363,7 +363,7 @@ describe('direct APS rendering', () => {
expect(document.querySelector('#fictional-slot iframe')).toBeNull();
expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
- const iframe = document.querySelector('#fictional-slot iframe')!;
+ const iframe = document.querySelector('#fictional-slot iframe')!;
iframe.dispatchEvent(new Event('error'));
expect(document.querySelector('#fictional-slot span')).not.toBeNull();
expect(document.querySelector('#fictional-slot iframe')).toBeNull();
@@ -373,7 +373,7 @@ describe('direct APS rendering', () => {
vi.useFakeTimers();
try {
expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
- const iframe = document.querySelector('#fictional-slot iframe')!;
+ const iframe = document.querySelector('#fictional-slot iframe')!;
iframe.dispatchEvent(new Event('load'));
vi.advanceTimersByTime(10_000);
@@ -391,20 +391,20 @@ describe('direct APS rendering', () => {
try {
const baselineTimers = vi.getTimerCount();
expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
- const firstFrame = document.querySelector('#fictional-slot iframe')!;
+ const firstFrame = document.querySelector('#fictional-slot iframe')!;
const firstPostMessage = vi.spyOn(firstFrame.contentWindow!, 'postMessage');
firstFrame.dispatchEvent(new Event('load'));
- const firstSent = firstPostMessage.mock.calls[0][0] as { nonce: string };
+ const firstSent = firstPostMessage.mock.calls[0]![0] as { nonce: string };
const timersAfterFirst = vi.getTimerCount();
expect(timersAfterFirst).toBeGreaterThan(baselineTimers);
expect(renderApsCreative({ slotId: 'fictional-slot', renderer: descriptor() })).toBe(true);
- const secondFrame = document.querySelector('#fictional-slot iframe')!;
+ const secondFrame = document.querySelector('#fictional-slot iframe')!;
expect(firstFrame.isConnected).toBe(false);
expect(vi.getTimerCount()).toBe(timersAfterFirst);
const postMessage = vi.spyOn(secondFrame.contentWindow!, 'postMessage');
secondFrame.dispatchEvent(new Event('load'));
- const sent = postMessage.mock.calls[0][0] as { nonce: string };
+ const sent = postMessage.mock.calls[0]![0] as { nonce: string };
window.dispatchEvent(
new MessageEvent('message', {
@@ -467,14 +467,14 @@ describe('Universal Creative APS source', () => {
undefined,
window
);
- const iframe = document.body.querySelector('iframe')!;
+ const iframe = document.body.querySelector('iframe')!;
expect(iframe.src).toMatch(/\/integrations\/aps\/renderer#tsaps=[A-Za-z0-9_-]{22}$/);
expect(iframe.getAttribute('sandbox')).toBe(APS_RENDERER_SANDBOX);
expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin');
const postMessage = vi.spyOn(iframe.contentWindow!, 'postMessage');
iframe.dispatchEvent(new Event('load'));
- const sent = postMessage.mock.calls[0][0] as { nonce: string; renderer: ApsRendererV1 };
+ const sent = postMessage.mock.calls[0]![0] as { nonce: string; renderer: ApsRendererV1 };
expect(sent.renderer).toEqual(renderer);
let settled = false;
diff --git a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts
index 314123c16..fae4eb407 100644
--- a/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts
+++ b/crates/trusted-server-js/lib/test/integrations/creative/click.test.ts
@@ -64,7 +64,7 @@ describe('creative/click.ts', () => {
await vi.runAllTimersAsync();
expect(fetchMock).toHaveBeenCalled();
- const call = fetchMock.mock.calls[0];
+ const call = fetchMock.mock.calls[0]!;
expect(call[0]).toBe('/first-party/proxy-rebuild');
const payload = JSON.parse(call[1]?.body as string);
expect(payload).toEqual({
@@ -203,7 +203,7 @@ describe('creative/click.ts', () => {
await vi.runAllTimersAsync();
expect(openMock).toHaveBeenCalled();
- const navigated = String(openMock.mock.calls[0][0]);
+ const navigated = String(openMock.mock.calls[0]![0]);
expect(navigated.startsWith(REBUILD_PREFIX)).toBe(true);
expect(navigated).toContain('add=%7B%22bar%22%3A%222%22%7D');
expect(navigated).not.toBe(absolute(FIRST_PARTY_CLICK));
diff --git a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts
index 971d396f4..933ece79c 100644
--- a/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts
+++ b/crates/trusted-server-js/lib/test/integrations/google_tag_manager/script_guard.test.ts
@@ -372,10 +372,10 @@ describe('GTM Beacon Guard', () => {
originalFetch = window.fetch;
sendBeaconSpy = vi.fn(() => true);
- navigator.sendBeacon = sendBeaconSpy;
+ navigator.sendBeacon = sendBeaconSpy as typeof navigator.sendBeacon;
fetchSpy = vi.fn(() => Promise.resolve(new Response('', { status: 200 })));
- window.fetch = fetchSpy;
+ window.fetch = fetchSpy as typeof window.fetch;
resetBeaconGuardState();
});
@@ -397,7 +397,7 @@ describe('GTM Beacon Guard', () => {
navigator.sendBeacon('https://www.google-analytics.com/g/collect?v=2&tid=G-JGPCNWGVHC', '');
- const calledUrl = sendBeaconSpy.mock.calls[0][0];
+ const calledUrl = sendBeaconSpy.mock.calls[0]![0];
expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect');
expect(calledUrl).not.toContain('google-analytics.com');
});
@@ -407,7 +407,7 @@ describe('GTM Beacon Guard', () => {
navigator.sendBeacon('https://analytics.google.com/g/collect?v=2&tid=G-DQMZGMPHXN', '');
- const calledUrl = sendBeaconSpy.mock.calls[0][0];
+ const calledUrl = sendBeaconSpy.mock.calls[0]![0];
expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect');
expect(calledUrl).not.toContain('analytics.google.com');
});
@@ -417,7 +417,7 @@ describe('GTM Beacon Guard', () => {
await window.fetch('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST');
- const calledUrl = fetchSpy.mock.calls[0][0];
+ const calledUrl = fetchSpy.mock.calls[0]![0];
expect(calledUrl).toContain('/integrations/google_tag_manager/g/collect');
expect(calledUrl).not.toContain('google-analytics.com');
});
@@ -435,7 +435,7 @@ describe('GTM Beacon Guard', () => {
navigator.sendBeacon('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST&cid=123', '');
- const calledUrl = sendBeaconSpy.mock.calls[0][0];
+ const calledUrl = sendBeaconSpy.mock.calls[0]![0];
expect(calledUrl).toContain('v=2&tid=G-TEST&cid=123');
});
});
diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts
index 9469d94aa..1b3d63ff2 100644
--- a/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts
+++ b/crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts
@@ -4,9 +4,10 @@ import { resolve } from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
import envelope from '../../fixtures/aps-renderer-v1.json';
+import type { GptSlotHandoff, TsjsApi } from '../../../src/core/types';
function apsRenderer() {
- const bid = envelope.seatbid[0].bid[0];
+ const bid = envelope.seatbid[0]!.bid[0]!;
return {
type: 'aps' as const,
version: 1 as const,
@@ -74,10 +75,14 @@ interface PrebidResponseMessage {
// `tsjs` is declared globally as the full `TsjsApi` (core/types.ts). Omitting
// it from `Window` before re-adding it as a `Partial` avoids the intersection
// that would force every fixture below to satisfy the whole `TsjsApi` shape.
+type TestGptSlotHandoff = Omit & { formats: number[][] };
+type TestTsjsApi = Omit, 'gptSlotHandoffs'> & {
+ gptSlotHandoffs?: Record | undefined;
+};
type TestWindow = Omit