diff --git a/docs/superpowers/plans/2026-11-11-server-side-identity-all-adapters.md b/docs/superpowers/plans/2026-11-11-server-side-identity-all-adapters.md new file mode 100644 index 000000000..c42e33237 --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-server-side-identity-all-adapters.md @@ -0,0 +1,769 @@ +# Server-side identity on every adapter 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. + +**Goal:** File the missing tickets, move the existing issues under the new +epics (#1558 and IABTechLab/edgezero#468), link every dependency, and tie +every open PR to its ticket, in the wave order of Part A. + +**Architecture:** This plan's tasks are GitHub operations, not code. Task 1 +moves existing issues, Tasks 2 to 4 create the new tickets (EdgeZero first, +because Trusted Server tickets depend on it), Task 5 links every blocked-by +dependency, Tasks 6 and 7 post the per-PR fixes in Part C, and Task 8 checks +coverage. Code lands later in each ticket's own implementation PR, in Part A's +wave order, with its own plan when it needs one. + +**Tech Stack:** `gh` CLI and GitHub GraphQL (issue types, sub-issues, +blocked-by links, project fields) against IABTechLab/trusted-server and +IABTechLab/edgezero. + +**Spec:** [server-side identity +design](../specs/2026-11-11-server-side-identity-all-adapters-design.md) + +## Global Constraints + +- One-account mode: every write acts as the PR author's login. +- Platform order: Fastly, then Axum, then Cloudflare, then Spin (DEC-4). Fastly + and Axum ship without Cloudflare or Spin: no Fastly or Axum item is blocked by + a Cloudflare or Spin item, and Cloudflare and Spin work has its own tickets. A + Fastly or Axum ticket may still make Cloudflare and Spin reject identity + configuration, answer 501, report a capability as absent or mount the shared + app (T1, T6, T21, IABTechLab/edgezero#283), because that needs no Cloudflare + or Spin work. +- Every feature is built once in core and works on every adapter in DEC-4's + order (DEC-16, SH-5). A capability an adapter lacks becomes an EdgeZero ask; + no ticket adds identity code to one adapter crate. +- Only issues move, and they move under the new epics: #1558 for Trusted + Server and IABTechLab/edgezero#468 for EdgeZero. Existing epics (#1236, + #1505, IABTechLab/trusted-server#90, IABTechLab/edgezero#88, + IABTechLab/edgezero#391) are never moved under them and receive no new + issues; #1236 is linked from #1558's Related section. An issue that leaves + an old epic moves with `addSubIssue(input:{..., replaceParent: true})`. +- Issue bodies state the requirement, its acceptance and its dependencies, + and nothing about deployments or their state. +- EdgeZero references are written `IABTechLab/edgezero#N`; a bare `#N` means + Trusted Server. +- EdgeZero issue bodies state behavior and acceptance only. +- Issue types are set with `updateIssue`: Feature `IT_kwDOBPEB8s4A35yA`, Task + `IT_kwDOBPEB8s4A35x7`, Bug `IT_kwDOBPEB8s4A35x9`. +- Project `PVT_kwDOBPEB8s4BFKrl`. Priority field + `PVTSSF_lADOBPEB8s4BFKrlzg22nQg` (P0 `c92c6358`, P1 `dca9fbdd`, P2 `afab7bc8`, + P3 `71861fe7`). Size field `PVTSSF_lADOBPEB8s4BFKrlzg22m2Q` (XS `771e90ab`, S + `01b13689`, M `084dacdb`, L `762f39e7`, XL `27016a48`). +- Dependencies use `addBlockedBy(input:{issueId, blockingIssueId})`. GitHub + links issues only, so a PR or a decision in "Blocked by" goes in the issue + body. +- An issue Part B names for reuse is reused, never duplicated. +- EdgeZero code is never changed from here; EdgeZero items are requests. +- Issue and comment text uses no em dashes and no names of people; comments + start with `CREATOR: `. + +## Review Focus + +1. A duplicate issue for work an existing issue already tracks. Every create + step in Tasks 2 to 4 runs a search first. +2. A Part C item posted to a PR whose head has moved. Tasks 6 and 7 compare + the head with Part C before posting and re-check the item if it moved. +3. A blocked-by link that GitHub refuses across repositories. Task 5 records + it in the issue body instead and lists it in its readback. +4. A bare `#N` meant for EdgeZero inside a Trusted Server issue. Task 8 scans + the posted bodies. +5. A body that goes beyond its requirement, acceptance and dependencies. Task + 8 reads every body. + +--- + +## Part A: merge order + +### Stacking, read 2026-11-12 + +Trusted Server: + +| PR | Head | Base on GitHub | Fix | +| ------------------- | ------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #1557 | `76e1dd5f6` | main | None | +| #1525 | `cd8977cad` | main | None; #1526 does not need it | +| #1526 | `e529e5576` | #1525's branch | Retarget to main; its own diff applies cleanly there | +| #1516 | `8b9c45d5e` | main | Merged on main as `1810c763a` on 2026-11-12 | +| #1503 | `5e10c24e4` | main | None; needs neither #1506 nor new EdgeZero | +| #1449 | `fd33f2c74` | main | Outside this plan (DEC-19) | +| #1506 | `9ec4711d3` | main | Rebase after #1557 and #1526; #1516 has merged | +| #1487 | `1600781eb` | main | Rebase after #1526 and #1506: 8 and 17 files conflict | +| #1488 | `7f55776f3` | #1487's branch | Take #1566's two #1488 commits | +| #1489 | `d3780ba13` | #1488's branch | None | +| #1566 | `117a58e0e` | #1488's branch | Move its two #1488 commits down; rebuild the Axum part on T21 and EdgeZero (Part C). 1 file conflicts with #1489, 19 with #1506 | +| #1527 | `a020daeec` | #1526's branch | Close once #1506 covers its tests (DEC-1) | +| #1528 | `ea779fb47` | #1527's branch | Close once #1531 carries A1 | +| #1543 | `e5e6ea7fa` | #1449's branch | Outside this plan (DEC-19) | +| #1424 | `2423a2ed2` | main | Conflicting, head `rc/edgezero-431-demo`; close at parity | +| #1499 | `084fdbba9` | #1424's branch | Close at parity | +| #1531 | `88a6ba596` | #1506's branch | Retargeted without a rebase: it forks from main with its own cutover and the RC tag pin, and 15 files conflict with #1506 at `9ec4711d3`. Rebase onto #1506; it then delivers A1 | +| #1532 | `852b875fe` | #1531's branch | Delivers #1529 (A4); restack after #1531 | +| #1533, #1534 | `82d9a0e66`, `646c39a34` | #1532's and #1533's branches | RC-baseline copies of #1525 and #1526: drop from the chain and close | +| #1536, #1535, #1537 | `296e05405`, `0666a9b4b`, `4a838272c` | #1534's, #1536's and #1535's branches | Not restacked: mostly Axum-only stores, store selection and wiring that EZI-2b, T6 and T21 replace. Their tests go to A2, A8 and T5 and #1535's code to T19 and T20; each closes when those land | +| #1539, #1538, #1540 | `8d41cb06c`, `3111c2456`, `db1ba337a` | #1537's, #1539's and #1538's branches | Restack on #1531 (A1). #1539 and #1538 apply cleanly; #1540 conflicts in `docs/guide/native-ec-persistence.md` since its `db1ba337a` merged the AWS examples. #1539 and #1543 both add `.dockerignore`, so whichever lands second reconciles it | +| #1541 | `86797e0b4` | main | Close at parity | + +EdgeZero: + +| PR | Head | Base on GitHub | Fix | +| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| IABTechLab/edgezero#275 | `14a6ca8b` | main | Absorb IABTechLab/edgezero#460, IABTechLab/edgezero#406's `9f090e57` and one copy of the CI trigger change before merging | +| IABTechLab/edgezero#460 | `cec19886` | IABTechLab/edgezero#275's branch | Merge into that branch before IABTechLab/edgezero#275 merges | +| IABTechLab/edgezero#406, IABTechLab/edgezero#407, IABTechLab/edgezero#409, IABTechLab/edgezero#414, IABTechLab/edgezero#422, IABTechLab/edgezero#439 | various | linear chain | Move IABTechLab/edgezero#414's `4ea99467` to IABTechLab/edgezero#406 and its `9a985c99` to IABTechLab/edgezero#407 | +| IABTechLab/edgezero#463 | `6f9fa8bc` | IABTechLab/edgezero#422's branch | Drop the IABTechLab/edgezero#439 commits it carries | +| IABTechLab/edgezero#464 | `6fe32531` | IABTechLab/edgezero#439's branch | Drop IABTechLab/edgezero#454's prototype commits | +| IABTechLab/edgezero#389, IABTechLab/edgezero#401, IABTechLab/edgezero#444, IABTechLab/edgezero#452 | various | main | Land after IABTechLab/edgezero#275 merges; they conflict with it in 9, 6, 4 and 6 files, modify/delete included | +| IABTechLab/edgezero#466 | `214dd4fa` | main | Land after IABTechLab/edgezero#444; they conflict in `edgezero-cli` `config.rs` and `lib.rs` | +| IABTechLab/edgezero#469 | `1d5bf2ff` | main | JA4 interface design for IABTechLab/edgezero#283; draft | +| IABTechLab/edgezero#287 | `ce25ef5a` | main | Last; shares 51 files with IABTechLab/edgezero#275, 26 of them under `crates/` | + +### Waves + +A wave starts when everything it waits for has landed. Within a wave, rows +run in priority order, then DEC-4's platform order. TS is Trusted Server and +EZ is EdgeZero (`IABTechLab/edgezero#N`). Kinds: New (ticket), Move (existing +issue under its epic), PR fix (changes the PR needs), Merge, Release (a +revision on EdgeZero's integration branch that Trusted Server pins, DEC-5), +Close. + +| Wave | Item | Pri | Kind | Action | Waits for | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 0 | TS T1 | P0 | New | Answer identify and batch sync locally on every adapter | none | +| 0 | TS #1557 | P1 | Merge | Sourcepoint consent | none | +| 0 | TS #1526, #1522 | P1 | PR fix, Merge | Retarget to main, fix its round-1 review; delivers #1522 | none | +| 0 | TS #1516 | P1 | Merged | Merged on main as `1810c763a` | none | +| 0 | TS #1189 | P1 | Close | Fixed on main by `56a19229f` (#1157), which deleted the ungated `ingest_*` functions; ask its author to close it | none | +| 0 | IABTechLab/edgezero#460 | P1 | PR fix | Part C fixes, then fold into IABTechLab/edgezero#275's branch with `9f090e57` and one CI trigger change | none | +| 0 | IABTechLab/edgezero#406, IABTechLab/edgezero#407, IABTechLab/edgezero#414 | P1 | PR fix | Move `4ea99467` to IABTechLab/edgezero#406 and `9a985c99` to IABTechLab/edgezero#407 | none | +| 0 | IABTechLab/edgezero#463 | P1 | PR fix | Rebase off the commits it carries | none | +| 0 | TS #1503, #1432 | P2 | Merge | `ts ec` commands; delivers #1432 | none | +| 0 | TS #1233 | P2 | Move | `ts-ec` to creative assets | none | +| 1 | TS #1506 | P1 | PR fix, Merge | Rebase; repin after the fold-in, or the fallback below; delivers #1507 | #1557, #1526, IABTechLab/edgezero#460 fold-in | +| 1 | IABTechLab/edgezero#275 | P1 | PR fix, Merge | Resolve its review, then merge | IABTechLab/edgezero#460 fold-in | +| 2 | TS #1487 | P1 | PR fix, Merge | Part C fixes | #1526, #1506 | +| 2 | IABTechLab/edgezero#406 to IABTechLab/edgezero#439; IABTechLab/edgezero#397, IABTechLab/edgezero#410, IABTechLab/edgezero#437, IABTechLab/edgezero#432 | P1 | PR fix, Merge | Resolve reviews, retarget to main, merge in order; delivers IABTechLab/edgezero#397, IABTechLab/edgezero#410, IABTechLab/edgezero#437 and IABTechLab/edgezero#432 | IABTechLab/edgezero#275 | +| 2 | EZ EZI-2a, EZI-3 | P1 | New | Record contract with Fastly; ClientInfo contract | IABTechLab/edgezero#275 | +| 2 | TS T5 | P1 | New | Conformance suite for Fastly and Axum | #1507 | +| 2 | TS T4, #1192 | P2 | New, Move | Measure Axum's per-request HTTP client (T4); Fastly per-request cost (#1192) | #1507 | +| 2 | TS #1556 | P2 | Move | Regression test that identity backends verify certificates, then close | #1507 | +| 2 | IABTechLab/edgezero#389, IABTechLab/edgezero#401, IABTechLab/edgezero#444, IABTechLab/edgezero#452, then IABTechLab/edgezero#466 | P2 | PR fix | Rebase onto main and redo the RC-only merges | IABTechLab/edgezero#275 (IABTechLab/edgezero#466 also IABTechLab/edgezero#444) | +| 3 | TS #1488 | P1 | PR fix, Merge | Part C fixes | #1487 | +| 3 | EZ R1 | P1 | Release | IABTechLab/edgezero#275 through IABTechLab/edgezero#439 | wave 2's EdgeZero chain | +| 3 | EZ EZI-1 | P1 | New | After-response work for Fastly and Axum | IABTechLab/edgezero#275, IABTechLab/edgezero#406 | +| 3 | IABTechLab/edgezero#463 | P1 | Merge | Rebased | IABTechLab/edgezero#422, IABTechLab/edgezero#439 | +| 3 | EZ EZI-4a | P1 | New | Axum geo bound from EdgeZero's manifest | PR IABTechLab/edgezero#439 | +| 3 | IABTechLab/edgezero#90, IABTechLab/edgezero#92, IABTechLab/edgezero#283 | P1 | Move | Fastly and Axum ClientInfo; JA4 | EZI-3; IABTechLab/edgezero#397 | +| 3 | TS #1553, #1246 | P2 | Move | After-response CAS retry; Lockr IDs to KV | PR #1487 | +| 4 | TS #1489 | P1 | PR fix, Merge | Cap mirrored-ID expiry | #1488 | +| 4 | EZ EZI-2b | P1 | New | DynamoDB and celld providers on Axum | EZI-2a, IABTechLab/edgezero#410, IABTechLab/edgezero#437; PR IABTechLab/edgezero#463 | +| 4 | EZ R2, R2b | P1 | Release | R2: EZI-1 for Fastly and Axum; R2b: EZI-3, ClientInfo, JA4 and Axum's geo binding | EZI-1 (R2); EZI-3, IABTechLab/edgezero#90, IABTechLab/edgezero#92, IABTechLab/edgezero#283, EZI-4a (R2b) | +| 4 | TS A1 | P1 | New, PR fix | Rebase #1531 onto #1506, repin to R1; #1528 closes | R1, #1507 | +| 4 | TS T3 | P1 | New | SharedID cleanup | PR #1488 | +| 4 | TS #1555 | P2 | Move | Opt-out EIDs | PR #1488 | +| 4 | TS #1310, IABTechLab/trusted-server#90, IABTechLab/trusted-server#353 | P2 | Close | Superseded by #1488's ID5 provider | #1488 merged | +| 5 | EZ R1b | P1 | Release | EZI-2a and EZI-2b | EZI-2a, EZI-2b | +| 5 | TS #1529 (A4), A6, A3 | P1 | Move, New | CloudFront IP and geo via #1532; production image via #1539 and #1538; SIGTERM drain | A1 (A3 also R2; #1529 also R2b) | +| 5 | TS T2 | P1 | New | EIDs in `user.eids`; after the provider stack, so open PRs need no test churn | #1489 merged | +| 5 | TS T21 | P1 | New | One identity app in core for every adapter | T1, #1507, PR #1489 | +| 5 | TS T15 | P1 | New | Identity-only tsjs | PR #1489 | +| 5 | TS A9, #1141 | P2 | New, Move | `TRUSTED_SERVER__` guard; local config handoff | A1 | +| 5 | TS #1551 | P2 | Move | Pull sync on a full row | PR #1489 | +| 5 | TS #1527, #1528, #1523, #1533, #1534 | P2 | Close | Tests moved to #1506; A1 on #1531; RC copies of #1525 and #1526 | #1506, A1 | +| 6 | TS T6 | P1 | New | Shared record backend, Fastly first | R1b, #1522 | +| 6 | TS T11, T12, T13 | P1 | New | Providers checked with ID5; abuse protection; SharedID creation and write-back, all in the shared app | T21, R2 (T11 also PR #1487); R2b (T12); T3, PR #1488 (T13) | +| 7 | TS A2 | P1 | New | Identity on Axum through the shared app, a new PR with #1536's and #1537's tests | T5, T6, T21, A1, #1529 | +| 7 | TS T19 | P1 | New | EC route in the shared app: mint and write back on a same-origin call; pixel and JSON responses | T11, T12, T13, T21 | +| 8 | TS A10, A7, A8 | P1 | New | Identity features pass on Axum; reference infrastructure; SIGTERM test in CI | A2 plus their Part B blockers | +| 8 | TS T20 | P1 | New | Injected script calls the EC route on consent and merges the IDs into Prebid | T15, T19 | +| 9 | TS A5, T22 | P1 | New | Load test; LiveRamp per DEC-9 | A2, A7, A10 (T22: T11, A10, PR #1488, DEC-9) | +| 9 | TS #1535, #1536, #1537 | P2 | Close | Their code and tests now in T19, T20, A2 and A8 | A2, A8, T19, T20 | +| 9 | TS #1541, #1424, #1499 | P2 | Close | The RC line is on main | A1, A2, A4, A6, A7 | +| 10 | EZ EZI-2p, EZI-1c, EZI-4c; IABTechLab/edgezero#91 | P2 | New, Move | Portable DynamoDB and celld providers; Cloudflare parts in EdgeZero | Part B blockers | +| 11 | EZ EZI-2c | P2 | New | Cloudflare record store | EZI-2a, EZI-2p; DEC-8 | +| 12 | EZ R3 | P2 | Release | Cloudflare integration point | waves 10 and 11 | +| 13 | TS #1518, T8 | P2 | Move, New | Geo source check; identity on Cloudflare through the shared app | R3 (T8 also T6, T1, T5, T21; DEC-8) | +| 14 | TS T17 | P2 | New | Identity features pass on Cloudflare | T8, R3, T11 to T13, T19 | +| 15 | EZ EZI-1s, EZI-2s, EZI-3s, EZI-4s | P3 | New | Spin parts in EdgeZero | Part B blockers; DEC-8 for EZI-2s | +| 16 | EZ R4; IABTechLab/edgezero#89 | P3 | Release, Move | Spin integration point; ClientInfo on every adapter | wave 15, R3 | +| 17 | TS T9 | P3 | New | Identity on Spin through the shared app | T8, R4; DEC-8, DEC-14 | +| 18 | TS T18 | P3 | New | Identity features pass on Spin | T9, R4, T17 | +| any | TS #1142 | P2 | Move | Health and startup contract on all four adapters; A7 carries Axum's readiness signal | none | +| any | TS #1549 | P2 | Move | No `user.id` for a row-less EC | DEC-12; PR #1487 | +| any | TS T16, TS #1245, IABTechLab/edgezero#438, IABTechLab/edgezero#287 | P3 | New, Move, Merge | `ts-ec` refresh; LiveRamp sync spec; local GeoIP (no open PR); IABTechLab/edgezero#287 last | DEC-13; DEC-9; none; IABTechLab/edgezero#275 | +| any | TS #1139, #1525 | P3 | Move, PR fix | Store inventory on all four adapters; HTTP auction endpoints, which #1526 does not need | none | +| any | IABTechLab/edgezero#464 | P3 | PR fix, Merge | Rebase off the commits it carries, then merge when its review closes; no MVP ticket needs it | IABTechLab/edgezero#439 | +| any | EZ EZI-5, EZI-5c, EZI-5s; TS T10 | P3 | New | Rate limiting, punted past the MVP | DEC-15 | + +Waves 0 to 9 are the MVP (DEC-2): they complete Fastly and Axum. None of them +waits on a Cloudflare or Spin item, so the MVP ships without waves 10 to 18 +(DEC-4). Waves 10 to 14 (Cloudflare), waves 15 to 18 (Spin) and the `any` rows +follow the MVP; an `any` row starts when its decision or reviewer is ready. + +Fallback for wave 1: if the fold-in has not landed two weeks after #1557 and +#1526 have both merged on main (#1516 merged on 2026-11-12), #1506 merges pinned +to IABTechLab/edgezero#275's head as it stands (allowed by AX-7) and keeps its +readthrough rejection until the repin. + +The two longest chains: + +- Axum identity: IABTechLab/edgezero#460, IABTechLab/edgezero#275, + IABTechLab/edgezero#406 to IABTechLab/edgezero#439, IABTechLab/edgezero#463, + EZI-2b, R1b, T6, A2, A10, A5. A1 runs beside it on R1, before the record + contract lands. +- SharedID: IABTechLab/edgezero#460, #1506, #1487, #1488, T3, T13 on Fastly, + then A10 on Axum. +- JA4: IABTechLab/edgezero#275, EZI-3, IABTechLab/edgezero#283 (section 13.1 of + IABTechLab/edgezero#469), R2b, T12, T19, A10. +- LiveRamp: T22 waits on DEC-9 as well as T11 and A10. + +## Part B: tickets + +Every ticket in waves 0 to 9 is MVP: P1 is required for it, and P2 can slip past +its first release. The Cloudflare and Spin tickets (T8, T9, T17, T18, the EZI +`c` and `s` tickets, R3, R4, IABTechLab/edgezero#91, IABTechLab/edgezero#89 and +#1518) and the `any` rows are post-MVP. + +"Blocked by" is set as a GitHub dependency between issues. A PR or a decision +there goes in the issue body. "Blocks" is the inverse and is not set +separately. No ticket mixes Fastly or Axum work with Cloudflare or Spin work +(DEC-4), except the shared app, rejection, 501 and absence paths in Global +Constraints. + +### EdgeZero, under IABTechLab/edgezero#468 + +The integration branch is itself an EdgeZero ask (DEC-5): it carries only +work headed for EdgeZero main, merged in Part A's order. R1 creates it, +protected against force-push and deletion, and R1 to R4 are tags on it that +Trusted Server pins. + +New: + +| ID | Ticket | Reqs | Type | Size | Pri | Blocked by | Blocks | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------- | ---- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| EZI-2a | `KvStore` record contract with listing strong within a partition, the Fastly provider and the local persistent store (`PersistentKvStore`) | EZI-2a | Feature | L | P1 | PR IABTechLab/edgezero#275 | EZI-2b, EZI-2c, EZI-2s, R1b | +| EZI-2b | DynamoDB and celld providers on Axum meet the record contract, each key's partition being its DynamoDB partition key | EZI-2b | Feature | L | P1 | EZI-2a, IABTechLab/edgezero#410, IABTechLab/edgezero#437; PR IABTechLab/edgezero#463 | R1b, EZI-2p | +| EZI-2p | Portable DynamoDB and celld providers over EdgeZero's outbound HTTP, verified on Fastly, with the WASM credential source and a conditional-write latency budget stated | EZI-2p | Feature | XL | P2 | EZI-2b; PR IABTechLab/edgezero#275 | EZI-2c, EZI-2s | +| EZI-3 | ClientInfo contract: portable client IP and TLS metadata with explicit absence, the contract part of IABTechLab/edgezero#89 | EZI-3 | Feature | M | P1 | PR IABTechLab/edgezero#275 | IABTechLab/edgezero#90, IABTechLab/edgezero#91, IABTechLab/edgezero#92, IABTechLab/edgezero#283, EZI-3s, IABTechLab/edgezero#89, R2b | +| EZI-1 | Bounded work after the response on Fastly and Axum, drained on Axum SIGTERM | EZI-1 | Feature | M | P1 | PRs IABTechLab/edgezero#275, IABTechLab/edgezero#406 | EZI-1c, EZI-1s, R2 | +| EZI-5 | Rate limiter: Fastly Edge Rate Limiting, and an in-memory backend for Axum (after the MVP) | EZI-5 | Feature | S | P3 | PR IABTechLab/edgezero#275; DEC-15 | EZI-5c, EZI-5s, T10 | +| EZI-1c | After-response work on Cloudflare (`waitUntil`) | EZI-1 | Feature | S | P2 | EZI-1 | R3 | +| EZI-2c | On Cloudflare, the portable DynamoDB and celld providers pass the record contract, and Workers KV meets it or reports `KvError::Unsupported` | EZI-2c | Feature | L | P2 | EZI-2a, EZI-2p; DEC-8 | R3 | +| EZI-4c | Geo with country and region through `ctx.geo()` on Cloudflare | EZI-4 | Feature | S | P2 | IABTechLab/edgezero#432 | R3 | +| EZI-5c | Rate limiter backend for Cloudflare (after the MVP) | EZI-5 | Feature | S | P3 | EZI-5; DEC-15 | none | +| EZI-4a | Axum geo bound from EdgeZero's manifest, so `ctx.geo()` needs no application boot profile | EZI-4 | Feature | S | P1 | PR IABTechLab/edgezero#439 | R2b | +| EZI-1s | After-response capability error on Spin | EZI-1 | Task | XS | P3 | EZI-1 | R4 | +| EZI-2s | On Spin, the portable DynamoDB and celld providers pass the record contract, and Spin's KV meets it or reports `KvError::Unsupported` | EZI-2s | Feature | M | P3 | EZI-2a, EZI-2p; DEC-8 | R4 | +| EZI-3s | Spin ClientInfo, the Spin part of EZI-3 | EZI-3 | Task | S | P3 | EZI-3 | R4, IABTechLab/edgezero#89 | +| EZI-4s | Geo with country and region through `ctx.geo()` on Spin, from a trusted ingress header or a KV- or API-backed provider | EZI-4 | Feature | S | P3 | IABTechLab/edgezero#432 | R4 | +| EZI-5s | Rate limiter backend for Spin (after the MVP) | EZI-5 | Feature | S | P3 | EZI-5; DEC-15 | none | +| R1 | Create the protected integration branch and tag IABTechLab/edgezero#275 through IABTechLab/edgezero#439 | EZI-6 | Task | S | P1 | IABTechLab/edgezero#397, IABTechLab/edgezero#410, IABTechLab/edgezero#437, IABTechLab/edgezero#432; PRs IABTechLab/edgezero#275 to IABTechLab/edgezero#439 | A1 | +| R1b | Integration-branch tag with the record contract: EZI-2a and EZI-2b | EZI-6 | Task | S | P1 | EZI-2a, EZI-2b | T6 | +| R2 | Integration-branch tag with the Fastly and Axum parts of EZI-1 | EZI-6 | Task | S | P1 | EZI-1 | A3, A10, T11 | +| R2b | Integration-branch tag with EZI-3, the Fastly and Axum ClientInfo, the JA4 accessor and Axum's geo binding | EZI-6 | Task | S | P1 | EZI-3, IABTechLab/edgezero#90, IABTechLab/edgezero#92, IABTechLab/edgezero#283, EZI-4a | A10, T12, #1529 | +| R3 | Integration-branch tag with the Cloudflare parts | EZI-6 | Task | S | P2 | EZI-1c, EZI-2c, EZI-4c, IABTechLab/edgezero#91; DEC-8 | R4, T8, T17, #1518 | +| R4 | Integration-branch tag with the Spin parts | EZI-6 | Task | S | P3 | EZI-1s, EZI-2s, EZI-3s, EZI-4s, R3 | T9, T18 | + +Existing, moved under IABTechLab/edgezero#468. IABTechLab/edgezero#89 to +IABTechLab/edgezero#92 leave IABTechLab/edgezero#88 and the last six leave +IABTechLab/edgezero#391 or have no parent; both old epics stay where they are: + +| Issue | Title | Type (set) | Size | Pri | Blocked by | Blocks | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---- | --- | ------------------------------------------------------------------------------------- | --------------------------------------------------- | +| IABTechLab/edgezero#89 | As a developer I want to access client metadata in handlers (EZI-3) | Story (keep) | S | P3 | EZI-3, IABTechLab/edgezero#90, IABTechLab/edgezero#91, IABTechLab/edgezero#92, EZI-3s | none | +| IABTechLab/edgezero#90 | Fastly ClientInfo | Task (keep) | S | P1 | EZI-3 | R2b, IABTechLab/edgezero#89 | +| IABTechLab/edgezero#92 | Axum ClientInfo | Task (keep) | S | P1 | EZI-3, IABTechLab/edgezero#397 | R2b, IABTechLab/edgezero#89 | +| IABTechLab/edgezero#91 | Cloudflare ClientInfo | Task (keep) | S | P2 | EZI-3 | R3, IABTechLab/edgezero#89 | +| IABTechLab/edgezero#397 | Axum: safe production diagnostics and trusted-proxy handling (delivered by PR IABTechLab/edgezero#409) | Task (keep) | M | P1 | none | R1, IABTechLab/edgezero#92, IABTechLab/edgezero#283 | +| IABTechLab/edgezero#410 | Native AWS configuration, secret and KV store providers (PR IABTechLab/edgezero#414) | Task (keep) | L | P1 | none | EZI-2b, R1 | +| IABTechLab/edgezero#437 | Native celld configuration and KV store providers (PR IABTechLab/edgezero#422) | Task (keep) | M | P1 | none | EZI-2b, R1 | +| IABTechLab/edgezero#432 | Axum: protected CloudFront Geo metadata provider (PR IABTechLab/edgezero#439) | Story (keep) | M | P1 | none | R1, EZI-4c, EZI-4s | +| IABTechLab/edgezero#438 | Axum: explicit local GeoIP provider (no open PR) | Story (keep) | M | P3 | none | none | +| IABTechLab/edgezero#283 | Expose JA4 through `ctx.ja4()` (design in PR IABTechLab/edgezero#469); R2b needs Fastly's runtime JA4 and Axum's JA4 from a trusted proxy header (the design's section 13.1); IABTechLab/edgezero#91 carries Cloudflare's Bot Management JA4 and EZI-3s Spin's absence; closes with R2b | Task (keep) | M | P1 | EZI-3, IABTechLab/edgezero#397 | R2b | + +IABTechLab/edgezero#89 stays the umbrella story: EZI-3 carries its contract, and +it completes with the last adapter's mapping. IABTechLab/edgezero#397, +IABTechLab/edgezero#410 and IABTechLab/edgezero#432 leave +IABTechLab/edgezero#391, which stays an epic in place; R1 is linked from it. + +### Axum deployable, under #1558 + +New: + +| ID | Ticket | Reqs | Type | Size | Pri | Blocked by | Blocks | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------- | ---- | --- | ------------------------------- | -------------------------------- | +| A1 | Serve Axum in production mode from main: rebase #1531 onto #1506 (#1528 closes), with startup, stores and client IP from EdgeZero; repin to R1 | AX-1, AX-2, AX-7 | Feature | L | P1 | R1, #1507 | A2, A3, A6, A7, A9, #1529, #1141 | +| A2 | Identity on Axum through T21's shared app: a new PR that takes #1536's and #1537's tests but not their Axum-only stores, store selection or wiring, and switches the production image's store features (`aws-dynamodb-ec`, `celld-ec` in #1539) to EZI-2b's providers; partner APIs included; passes T5's suite | ID-1, ID-2, ID-3, ID-8, WB-1, SH-5 | Feature | M | P1 | T5, T6, T21, A1, #1529 | A5, A7, A8, A10 | +| A3 | Drain after-response tasks on SIGTERM | AX-1, ID-5 | Feature | S | P1 | R2, A1 | none | +| A5 | Load-test the record store at the target page-load rate with provider refreshes | AX-4 | Task | M | P1 | A2, A7, A10 | none | +| A6 | Build the production image from source (#1539, #1538): its entrypoint starts the production server with `STOPSIGNAL SIGTERM`; no publishing or attestation | AX-5 | Feature | S | P1 | A1 | A7 | +| A7 | Reference infrastructure: DynamoDB, a readiness signal that reports degraded identity without failing the load balancer health check, JSON logs, two or more tasks, IPv6 CloudFront ranges, geo on, CloudFront's JA4 header forwarded, and an example rate-based rule at the load balancer | AX-3, AX-6, ID-6, ID-7 | Feature | L | P1 | A1, A2, A6, #1529 | A5 | +| A8 | Run the SIGTERM-during-record-write test in CI (`scripts/test-native-ec-image.mjs` from #1536) | AX-1 | Task | S | P1 | A2 | none | +| A9 | CI guard: runtime code reads only `TRUSTED_SERVER__` environment names | AX-2 | Task | XS | P2 | A1 | none | +| A10 | Identity features pass on Axum from the shared app: providers checked with ID5, abuse protection, SharedID creation and write-back, the mirror cookie and the EC route, including Safari's cookie lifetime | ID-4, ID-7, ID-9, WB-3, WB-4, SID-1, SID-2, SID-6 | Feature | M | P1 | A2, R2, R2b, T11, T12, T13, T19 | A5, T22 | + +Existing, moved under #1558: + +| Issue | Title | Type (set) | Size | Pri | Blocked by | Blocks | +| ----- | ------------------------------------------------------------ | ----------- | ---- | --- | ---------- | ------ | +| #1529 | Extract the protected CloudFront native Geo integration (A4) | Task (keep) | M | P1 | A1, R2b | A2, A7 | +| #1141 | Make Axum consume the local config store | Bug (keep) | S | P2 | A1 | none | +| #1142 | Uniform cross-adapter health and startup-failure contract | Task (keep) | M | P2 | none | none | +| #1139 | Implement adapter stores or retire dead store manifests | Bug (keep) | M | P3 | none | none | + +A1 replaces the local configuration bridge: #1528 starts Axum from +`EDGEZERO__ADAPTER__CONFIG_DIR` and never reads the old +`TRUSTED_SERVER_CONFIG_*` names, so #1141 then points `ts config push --local` +and `ts serve` at that directory; #1531 starts Axum the same way. #1142 and +#1139 cover all four adapters, so they sit outside the MVP; A7 carries Axum's +readiness signal. #1566 overlaps A2 and A10; its Axum part is rebuilt on T21 +(Part C). T7, T14 and A11 are folded into A2 and A10, because the shared app +(T21) brings partner APIs, the mirror cookie and the EC route to every adapter. + +### Trusted Server, under #1558 + +New: + +| ID | Ticket | Reqs | Type | Size | Pri | Blocked by | Blocks | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------- | ---- | --- | -------------------------- | ------------------------------- | +| T1 | Answer identify and batch sync locally on every adapter: run them with a record store, 501 without one | ID-3 | Task | S | P0 | none | T8, T21 | +| T2 | EIDs in OpenRTB 2.6 `user.eids`; every EID-absence test checks both `user.eids` and `user.ext.eids` | SID-4 | Feature | M | P1 | PR #1489 | none | +| T3 | SharedID cleanup: legacy path, cross-source dedupe, re-filing, SID-6 gate, template-cache deny list | SID-1, SID-2, SID-3, SID-6, WB-5 | Feature | M | P1 | PR #1488 | T13 | +| T4 | Measure Axum's per-request HTTP client against a pooled one, and keep it per request unless the measurement shows the Next.js regression gone (SH-1's exception) | SH-1 | Task | S | P2 | #1507 | none | +| T5 | Identity conformance suite that runs against each adapter, Fastly and Axum first | ID-1, SH-5 | Task | M | P1 | #1507 | A2, T8 | +| T6 | One record backend over EdgeZero `KvStore`; delete the Fastly `ec_kv.rs`; adapters without a supported store reject identity configuration at startup, with a test | ID-2 | Feature | L | P1 | R1b, #1522 | A2, T8 | +| T8 | Identity on Cloudflare through T21's shared app with EZI-2c's store, passing T5's suite; build state once; drop the first-request `Env` capture and config `block_on` | ID-1, ID-2, ID-3, ID-5, ID-8, WB-1, SH-1, SH-3, SH-4, SH-5 | Feature | L | P2 | T6, R3, T1, T5, T21; DEC-8 | T9, T17 | +| T9 | Identity on Spin, the same scope as T8; port the config `block_on` removal from RC commit `933de5921` | ID-1, ID-2, ID-3, ID-5, ID-8, WB-1, SH-1, SH-3, SH-5 | Feature | L | P3 | T8, R4; DEC-8, DEC-14 | T18 | +| T10 | Rate limits after the MVP: batch sync per partner, pull sync, provider lookups and record creation on Fastly; on Axum off by default and, when turned on, per task divided by the configured task count | ID-6 | Feature | M | P3 | EZI-5, T21; DEC-15 | none | +| T11 | Providers in the shared app, awaited before finalization; only follow-up writes after the response; verified on Fastly with ID5 | ID-4, ID-5, WB-1 | Feature | M | P1 | T21, R2; PR #1487 | A10, T17, T19, T22 | +| T12 | In the shared app, refuse record creation when a trusted JA4 from `ctx.ja4()` contradicts the User-Agent's browser family, allow an unknown or missing JA4, and own the family rule's data; delete the direct Fastly JA4 read; verified on Fastly | ID-7 | Feature | M | P1 | R2b, T21 | A10, T17, T19 | +| T13 | SharedID in the shared app: create one when the reader has none and `storage.type` is `cookie`, and write it back with `_cst`, under SID-2 and SID-6; verified on Fastly | WB-3, SID-2, SID-5, SID-6 | Feature | M | P1 | T3, T21; PR #1488 | A10, T17, T19 | +| T15 | Identity-only tsjs mode | WB-6 | Feature | M | P1 | PR #1489 | T20 | +| T16 | `ts-ec` refresh for returning readers without breaking cacheability | WB-2, WB-5 | Task | S | P3 | DEC-13 | none | +| T17 | Identity features pass on Cloudflare from the shared app: providers, abuse protection, SharedID creation and write-back, the mirror cookie and the EC route | ID-4, ID-7, ID-9, WB-3, WB-4, SID-1, SID-2, SID-6 | Feature | M | P2 | T8, R3, T11, T12, T13, T19 | T18 | +| T18 | Identity features pass on Spin, the same scope as T17 | ID-4, ID-7, ID-9, WB-3, WB-4, SID-1, SID-2, SID-6 | Feature | M | P3 | T9, R4, T17 | none | +| T19 | EC route in the shared app, verified on Fastly: a same-origin `POST` to `/_ts/api/v1/ec` mints, records, runs the providers, takes the posted consent and withdrawals, and answers with the mirror's view as JSON; a `GET` answers Prebid's `pixelUrl` with a 204 and only re-sets an existing SharedID (reconciliation from #1535) | ID-9, WB-1, WB-3, WB-4 | Feature | M | P1 | T11, T12, T13, T21 | A10, T17, T20 | +| T20 | The injected script calls the EC route with a same-origin `POST` when the CMP reports consent and on every later change, sends consent changes in order with one call in flight, letting a newer change replace a waiting one but never a waiting withdrawal, which later changes wait behind, discards a result overtaken by a newer consent change, removes the IDs it merged on a withdrawal, sends the TCF string and the GPP string (Sourcepoint's capture from #1535), copies a localStorage `_pubcid_optout` into the cookie, and merges the returned IDs into Prebid | ID-9, WB-6, SID-2 | Feature | M | P1 | T15, T19 | none | +| T21 | One identity app in core for every adapter: mounts identify, batch sync, admin lookup and the EC route and runs the EC lifecycle in one router each adapter mounts, building its state once (SH-1); adapter crates supply platform services through EdgeZero where it offers them, and Fastly's limiter and geo stay their existing bridges until their asks land | SH-5, SH-1, ID-1, ID-3, ID-6 | Feature | L | P1 | T1, #1507; PR #1489 | A2, T8, T10, T11, T12, T13, T19 | +| T22 | LiveRamp per DEC-9: set the Custom ID, enable LiveRamp, and pass the provider and mirror checks with it on Fastly and Axum | ID-4, WB-4 | Feature | S | P1 | T11, A10; PR #1488; DEC-9 | none | + +Existing, moved under #1558: + +| Issue | Title | Type (set) | Size | Pri | Blocked by | Blocks | +| ----- | --------------------------------------------------------------------------- | ----------- | ---- | --- | ---------------- | ------ | +| #1522 | Make shared EC persistence awaitable (delivered by PR #1526) | Task (keep) | M | P1 | none | T6 | +| #1549 | Do not send a cookie EC with no row as `user.id` | Bug (keep) | S | P2 | PR #1487; DEC-12 | none | +| #1553 | Retry an after-response write that loses its CAS | Task (keep) | S | P2 | PR #1487 | none | +| #1551 | Stop pull sync from re-calling a partner for a row that is full | Bug (keep) | S | P2 | PR #1489 | none | +| #1555 | Client and browser EIDs for provider-owned sources after a provider opt-out | Bug (keep) | S | P2 | PR #1488 | none | +| #1556 | Certificate checks on identity backends | Bug (keep) | XS | P2 | #1507 | none | +| #1518 | Verify EdgeZero geolocation adoption in EC and auction consent paths | Task (keep) | M | P2 | R3 | none | +| #1233 | Stop forwarding `ts-ec` to static creative assets | Task (keep) | S | P2 | none | none | +| #1432 | `ts ec show` and `ts ec eids` (delivered by PR #1503) | Task (keep) | S | P2 | none | none | +| #1245 | Spec for non-browser LiveRamp sync | Task | S | P3 | DEC-9 | none | +| #1246 | Write Lockr IDs to the KV store | Task (keep) | M | P2 | PR #1487 | none | +| #1192 | Reduce Fastly adapter per-request fixed cost | Task (keep) | M | P2 | #1507 | none | + +Not moved: + +- IABTechLab/trusted-server#90 is an epic, so Task 1 Step 3 links it from #1558 + and it is not moved; it and IABTechLab/trusted-server#353 close when PR #1488 + merges. +- #1507 delivers the cutover and stays under #1505; it blocks A1, T4, T5, + T21, #1192 and #1556. +- Proposed closures: #1523 with #1527; IABTechLab/trusted-server#353 and #1310 + once PR #1488 merges; #1189, which `56a19229f` (#1157) fixed on main. +- #1556 needs only a regression test: #1506 rejects + `proxy.certificate_check = false` and drops the setting from pull sync. +- Out of these epics: #1484 (auction), #1140 (Cloudflare config), #1164 + (Spin tooling), IABTechLab/trusted-server#518 (cleanup). +- Outside this plan at lower priority (DEC-19): binary attestation and package + publishing, #1449, #1463 and #1543. + +Requirements met inside open PRs, not by a ticket: ID-8 on main and SH-2 +(#1487), SID-1 to SID-3 and SID-6 for the provider, SID-5 (#1488), WB-4 +expiry (#1489). Part C lists them. + +## Part C: fixes to existing PRs + +Each item names the head it was checked at. Post it as a comment on that PR +(Tasks 6 and 7); once posted, the PR thread is the source of truth. Task 6 +re-checks each item at the PR's current head before posting. + +### Trusted Server + +- **#1506 at `9ec4711d3` (AX-7).** Rebase after #1557 and #1526; #1516 has + merged. Keep EdgeZero pinned to IABTechLab/edgezero#275's head or an + integration-branch tag (DEC-5): an RC-tag pin pushed to this branch on + 2026-11-12 was reverted. Repin after the fold-in, or under wave 1's fallback; + drop the `origin_readthrough_enabled` rejection in `config.rs` once cache tags + return. The current pin already carries IABTechLab/edgezero `b97efec1`, which + stops Spin sending `Content-Length: 0` on bodiless DELETE and OPTIONS. Port + #1527's native-client, response-owner and managed Fastly delivery tests if + missing. +- **#1526 at `e529e5576` (ID-2, SH-3).** Retarget to main. Address its round-1 + review, including the finalize-under-`block_on` test gap. +- **#1525 at `cd8977cad`.** Address its round-1 review. +- **#1487 at `1600781eb` (ID-8, SH-2, SH-3).** Every item from + `4e7a6e02b` is still open. Rebase onto #1526 and #1506 (8 and 17 files + conflict). Await lookup collection in the async finalize: `main.rs:699` + calls `block_on` inside a function #1526 wraps in `block_on` + (`main.rs:550`, `:577`), and `run_after_response_lookups` does the same at + `:793` and `:811`. Move per-request lookups out of the `thread_local` + (`app.rs:414-424`). Keep saving `/auction` body EIDs (ID-8): the test at + `finalize.rs:1365` asserts they are dropped and the PR's own spec now calls + that intended, so the comment argues against that spec line. Send lookups + through EdgeZero's `HttpClient`: #1506 deletes `platform/http.rs`, and its + `outbound_client().start_batch_until` has the same start-then-gather shape. +- **#1489 at `d3780ba13` (WB-4).** Cap every mirrored ID's expiry at 30 days + where the mirror is built (`mirror.rs:236-247`), not in validation, because + SID-5 keeps SharedID `Never` in the record. An unstated expiry still becomes + `Expiry::Never` (`coordinator/validate.rs:135-138`), pull sync is its only + production source (`pull_sync.rs:145`), and such IDs reach the cookie with no + `exp` under a one-year `Max-Age`. The 30-day clamp on stated expiries comes + from #1488. Answer its open threads, including the one on `any_due` skipping + cookie-read opt-outs. +- **#1566 at `117a58e0e` (SID-1, SID-3, SID-5, ID-2, SH-5).** Move `0a2a00aaf` + (SharedID name, source and retention) and `0c13d4272` (drops the blanket + provider rejection) into #1488. Rebuild the Axum part on T21's shared app and + EdgeZero capabilities: no Axum-only `EcKvStore` (`ec_kv.rs:87`; ID-2, DEC-3), + no Trusted Server rate limiter (none on Axum, DEC-11), no User-Agent-only gate + (EZI-3, ID-7), and no detached `block_on` task (EZI-1). Local development uses + EdgeZero's local store (EZI-2a). It then serves A2 and A10. +- **#1488 at `7f55776f3` (SID-1, SID-2, SID-3, SID-5, SID-6).** Take #1566's + `0a2a00aaf`, which reads the SharedID name from `managed_user_ids`, defaults + the source to `pubcid.org` and keeps the stored value (still open here: + `sharedid.rs:46-59`, `common.rs:94,110,129-136`). Take `0c13d4272`: the + blanket `reject_providers_without_a_kind` (`config.rs:348`) makes + `ts config validate`, `push` and `diff` reject every enabled provider. SID-6 + holds for the provider path. LiveRamp's Custom ID waits on DEC-9, which gates + enabling LiveRamp, not this merge. Answer its open threads. +- **#1531 at `88a6ba596` (AX-1, AX-2, AX-7), which carries A1 in place of + #1528.** It now targets #1506's branch but forks from main with its own + cutover rebuild (`82d7e3be8`) and the RC tag pin, so 15 files conflict with + #1506 at `9ec4711d3`. Rebase onto #1506's head, drop that rebuild and the pin, + and repin to R1 (DEC-5). Startup, stores and the client IP come from EdgeZero + (IABTechLab/edgezero#406, IABTechLab/edgezero#414, IABTechLab/edgezero#409). + Carry #1528's open items where they apply: the CLI test (`AxumDevServerConfig` + lacks `ingress`), the type error, the CodeQL alerts and the per-request + `AtomicBool` in the Fastly `AppState`. +- **RC units, forward-ported (DEC-6).** #1531 goes on #1506 for A1 and #1532 on + #1531 for #1529. #1539, #1538 and #1540 go on #1531 for A6 and A7: #1539 and + #1538 apply cleanly, and #1540 at `db1ba337a` conflicts in + `docs/guide/native-ec-persistence.md`. #1533 and #1534 close as copies of + #1525 and #1526. #1536 and #1537 are not restacked: their Axum-only stores, + store selection and wiring give way to EZI-2b, T6 and T21, and their tests + move into A2, A8 and T5. #1535 (Sourcepoint GPP capture and server consent + reconciliation) feeds T19 and T20: its reconciliation runs in the EC route and + its GPP capture in the injected script, in place of a separate `/_ts/consent` + route wired into each adapter (DEC-16). #1532 takes its geo provider from + EdgeZero's manifest once IABTechLab/edgezero#439 binds it, dropping + `TS_NATIVE_GEO_PROFILE_FILE`. The notes below were checked at the RC tag + `v0.1.1-rc-axum.202611.2` (`7c6e68d94`), which contains every unit. They are + forward-port notes: each applies to the PR that ports its unit to main, and + Task 6 posts it there once that PR exists, checked at its head. On the RC unit + PRs themselves Task 6 posts a note only after re-checking it at the unit's + head, read on 2026-11-12 as #1532 `852b875fe`, #1536 `296e05405`, #1537 + `4a838272c` and #1539 `8d41cb06c`, and on 2026-11-13 as #1540 `db1ba337a` and + #1541 `86797e0b4`. #1532: drop the core change that maps a US request with no + region to `Unknown`, which fails every US Cloudflare request closed (AX-3), + and show that the same trusted country and region reach EC creation and + auction consent on Axum. #1536: do not port the adapter-local DynamoDB store + (ID-2, AX-4). #1537: do not port its Axum celld store or storage selection, + which EdgeZero's providers and logical stores replace (EZI-2b, DEC-16); its + example config also breaks startup on Axum, Cloudflare and Spin. #1539: fix + the failing Axum test. #1540: trust IPv6 CloudFront ranges, keep the geo its + example inputs select but bind it through EdgeZero's manifest (EZI-4a) instead + of `TS_NATIVE_GEO_PROFILE_TOML`, key the DynamoDB table by partition instead + of the one partition key and one allocator item its IAM policy allows (AX-4), + and add an example rate-based rule at the load balancer (AX-3, AX-6, DEC-11). + All units: replace `TS_*` names (AX-2). #1541: drop the commits of closed + #1545. +- **Proposed closures.** #1527 once its tests move (DEC-1); #1528 once #1531 + carries A1; #1533 and #1534, the RC copies of #1525 and #1526; #1535, #1536 + and #1537 once T19, T20, A2 and A8 carry their code and tests; #1310, which + #1488's ID5 provider supersedes; #1424, #1499 and #1541 at parity. + +### EdgeZero, requests to the PR authors + +- **IABTechLab/edgezero#275 at `14a6ca8b`.** Absorb IABTechLab/edgezero#460, + `9f090e57` from IABTechLab/edgezero#406 (the CLI template emits Fastly-only + image code that IABTechLab/edgezero#275 gates) and one copy of the CI trigger + change, which today sits in IABTechLab/edgezero#406, IABTechLab/edgezero#460 + and IABTechLab/edgezero#414. Optionally guard all five egress unwind sites + against a panicking logger; `0769cbfd` in IABTechLab/edgezero#409 covers + three. +- **IABTechLab/edgezero#460 at `cec19886`.** Validate tags (printable ASCII, + non-empty) and make Cloudflare reject tags instead of silently using the + default. Fix the red Cloudflare wasm clippy; the fix exists only in RC + commit `ebf622c2`. It removes `Copy` from an IABTechLab/edgezero#275 public + type, so it lands before R1. +- **IABTechLab/edgezero#406 at `6d1dda16`.** Take + `run_production_app_with_options_and_initializer` from `4ea99467`; the + Trusted Server RC's native startup calls it. +- **IABTechLab/edgezero#407 at `d6f74308`.** Take the flaky-test fix + `9a985c99`. +- **IABTechLab/edgezero#414 at `3733482f`.** Drop those two commits once they + move down. +- **IABTechLab/edgezero#469 at `1d5bf2ff`.** Bring section 13.1, JA4 from a + trusted proxy header recorded as `Ja4Source::TrustedProxyHeader`, into the + work that ships in R2b. Axum's ID-7 needs JA4 from a hop that + IABTechLab/edgezero#409 or IABTechLab/edgezero#439 checks, such as + CloudFront's `CloudFront-Viewer-JA4-Fingerprint`; an unchecked peer stays + `NotExposed` (EZI-3, DEC-18). +- **IABTechLab/edgezero#439 at `3a0e5eb4`.** Bind the geo provider from the + manifest, so an application reads `ctx.geo()` without its own boot profile + (EZI-4a, DEC-16); #1532 composes it from a Trusted Server profile today. +- **IABTechLab/edgezero#463 at `6f9fa8bc` and IABTechLab/edgezero#464 at + `6fe32531`.** Rebase as Part A's table says. +- **IABTechLab/edgezero#389 at `0653e6e3` and IABTechLab/edgezero#401 at + `1a6ae861`.** Land after IABTechLab/edgezero#275 merges, redoing the RC-only + merges `7c07b8e7` (IABTechLab/edgezero#389) and `2510ecc9` + (IABTechLab/edgezero#401) and the RC-only follow-up commit `501a2dce` + (IABTechLab/edgezero#401). +- **IABTechLab/edgezero#444 at `217983c0` and IABTechLab/edgezero#466 at + `cb48c6b3`.** Land IABTechLab/edgezero#444 first, after + IABTechLab/edgezero#275. +- **Every stack PR.** Refresh the CI results cited in the descriptions. + +### Cutover follow-ups outside these epics + +These belong to #1505 and IABTechLab/edgezero#436: + +- Cache-tag validation: the IABTechLab/edgezero#460 item above. +- TLS chain-only verification: reuse IABTechLab/edgezero#81, stating that + verification is never turned off. +- Idle bound for streamed bodies: reuse IABTechLab/edgezero#82. +- Fire-and-forget send and a published outbound test double: new. +- Spin host override: mostly in IABTechLab/edgezero#275; only the probe + evidence is missing. + +--- + +## Tasks + +### Task 1: Move the existing issues under their epics + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: Part B's "Existing, moved" tables and "Not moved" notes. +- Produces: 16 issues under #1558 and 10 under IABTechLab/edgezero#468, each + typed, sized and prioritized; the Related sections of IABTechLab/edgezero#468 + and #1558 updated; comments on IABTechLab/edgezero#391 and + IABTechLab/edgezero#88. + +- [ ] **Step 1: Move each issue** + +For each row, first read the issue's current parent with +`gh api graphql -f query='{repository(owner:"",name:""){issue(number:){parent{number}}}}' --jq '.data.repository.issue.parent.number'`. +The expected parent is IABTechLab/edgezero#88 for IABTechLab/edgezero#89 to +IABTechLab/edgezero#92, IABTechLab/edgezero#391 for IABTechLab/edgezero#397, +IABTechLab/edgezero#410 and IABTechLab/edgezero#432, and none for every other +row. Stop and report a row whose parent differs. Then run +`gh api graphql -f query='mutation($p:ID!,$c:ID!){addSubIssue(input:{issueId:$p,subIssueId:$c,replaceParent:true}){issue{number}}}' -F p="$(gh issue view --repo / --json id --jq .id)" -F c="$(gh issue view --repo / --json id --jq .id)"`, +and read the parent again. +Expected: the new epic's number, and every moved issue's parent is now that +epic. + +- [ ] **Step 2: Type, Priority and Size** + +Set "Type (set)" through `updateIssue` where it is not "keep". Add the issue +to the project through `addProjectV2ItemById` if it is not there, then set +Priority and Size through `updateProjectV2ItemFieldValue` with the IDs in +Global Constraints. +Expected: each mutation returns the item. + +- [ ] **Step 3: Update the EdgeZero epics' text** + +Edit IABTechLab/edgezero#468's Related section to say that IABTechLab/edgezero#391 stays +the Axum production epic and that the six issues moved from it or with no +parent are tracked here, and add IABTechLab/trusted-server#90 to #1558's +Related section, each with +`gh issue edit --repo / --body-file `. Post a +`CREATOR:` comment on IABTechLab/edgezero#391 and on IABTechLab/edgezero#88 +naming the issues that moved to IABTechLab/edgezero#468, with +`gh issue comment --repo IABTechLab/edgezero --body-file `. +Expected: each command prints the issue or comment URL, and +`gh issue view --repo /` shows the new Related text. + +### Task 2: File the EdgeZero tickets under IABTechLab/edgezero#468 + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: Part B's EdgeZero table and the spec's EZI requirements. +- Produces: an issue number per row, recorded in a `CREATOR:` comment on + IABTechLab/edgezero#468. + +- [ ] **Step 1: Search before each row** + +For each row in table order, run +`gh issue list -R IABTechLab/edgezero --state all --search ""`. +Expected: no open issue other than those Part B links. + +- [ ] **Step 2: Create** + +Run `gh issue create -R IABTechLab/edgezero --title "" --body-file `. +The body has Behavior (the spec's EZI text), Acceptance (its Accept line), +Blocked by (every PR and decision in that column, by reference) and Related +(IABTechLab/trusted-server#1558). +Expected: one issue URL per row. + +- [ ] **Step 3: Type, parent, Priority and Size** + +Set the row's Type through `updateIssue`, add the issue under +IABTechLab/edgezero#468 through `addSubIssue`, add it to the project, and set +Priority and Size. +Expected: each mutation returns the issue or item. + +- [ ] **Step 4: Record the numbers** + +Post a `CREATOR:` comment on IABTechLab/edgezero#468 mapping each ID to its +issue, and one on IABTechLab/edgezero#391 linking R1, each with +`gh issue comment --repo IABTechLab/edgezero --body-file `. +Expected: two comment URLs. + +### Task 3: File the Axum tickets under #1558 + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: Part B's Axum table and the spec's requirements. +- Produces: an issue number per A row, recorded in a `CREATOR:` comment on + #1558, which Task 5 consumes. + +Same steps as Task 2, against IABTechLab/trusted-server and #1558, for the +nine new rows in table order. Each body has Summary (Ticket), Requirements +(Reqs, linked to the spec section), Acceptance (each requirement's Accept +line) and Blocked by. Record the map in a `CREATOR:` comment on #1558. + +### Task 4: File the Trusted Server tickets under #1558 + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: Part B's Trusted Server table and the spec's requirements. +- Produces: an issue number per T row, recorded in a `CREATOR:` comment on + #1558, which Task 5 consumes. + +Same steps as Task 3, for the T rows in table order (T1 to T22; T7 and T14 +are folded into A2 and A10), under #1558. Record the +map in a `CREATOR:` comment on #1558. + +### Task 5: Link the dependencies + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: every "Blocked by" cell in Part B and the issue numbers from + Tasks 1 to 4. +- Produces: one GitHub blocked-by link per issue pair. + +- [ ] **Step 1: Link each pair** + +For every issue named in a "Blocked by" cell, run +`gh api graphql -f query='mutation($i:ID!,$b:ID!){addBlockedBy(input:{issueId:$i,blockingIssueId:$b}){issue{number}}}' -F i= -F b=`. +Expected: the blocked issue's number. If GitHub refuses a cross-repository +pair, add a "Blocked by IABTechLab/edgezero#N" line to the issue body instead +and list the pair in Step 2's readback. + +- [ ] **Step 2: Read back** + +For each issue, run +`gh api graphql -f query='{repository(owner:"",name:""){issue(number:){blockedBy(first:50){nodes{number repository{nameWithOwner}}}}}}' --jq '.data.repository.issue.blockedBy.nodes'` +and compare the list with Part B. +Expected: every issue pair present, plus the list of pairs recorded only in +bodies. + +### Task 6: Tie out the Trusted Server PRs + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: Part C's Trusted Server items and the issue numbers from Tasks 1 + to 4. +- Produces: one `CREATOR:` comment per mapped PR. + +- [ ] **Step 1: Check heads** + +For each Part C Trusted Server item, run +`gh pr view --repo IABTechLab/trusted-server --json headRefOid --jq .headRefOid`. +Expected: the head Part C names. If it moved, re-check the item against the +new head before posting. + +- [ ] **Step 2: Post** + +Post one `CREATOR:` comment per PR with its Part C item and its ticket, with +`gh pr comment --repo IABTechLab/trusted-server --body-file `. Use +`Part of #` for #1532 to #1529, #1526 to #1522, #1506 to #1507, #1531 +to A1, #1536, #1537 and #1566 to A2, #1535 to T19 and T20, #1539 and #1538 to +A6, and #1540 to A7. Use `Blocks #` for the provider stack, as +Part B's Blocked by cells say: #1487 blocks T11, #1246, #1549 and #1553; +#1488 blocks T3, T13, T22 and #1555; and #1489 blocks T2, T15, T21 and #1551. +#1525 and #1541 get their Part C item with no ticket; #1503 has no item. For +proposed closures, ask the author; do not close another author's PR. +Expected: one comment URL per PR. + +### Task 7: Tie out the EdgeZero PRs + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: Part C's EdgeZero items and the issue numbers from Task 2. +- Produces: one `CREATOR:` comment per mapped PR. + +Same steps as Task 6, against IABTechLab/edgezero (`gh pr view --repo +IABTechLab/edgezero` and `gh pr comment --repo IABTechLab/edgezero`), for +the EdgeZero items in Part C. Link IABTechLab/edgezero#406 to EZI-1, +IABTechLab/edgezero#463 to EZI-2b, IABTechLab/edgezero#469 to IABTechLab/edgezero#283, +IABTechLab/edgezero#439 to IABTechLab/edgezero#432 and EZI-4a, and +IABTechLab/edgezero#275, IABTechLab/edgezero#460, IABTechLab/edgezero#407 and +IABTechLab/edgezero#414 to R1. IABTechLab/edgezero#389, +IABTechLab/edgezero#401, IABTechLab/edgezero#444, IABTechLab/edgezero#466 and +IABTechLab/edgezero#464 get their Part C item with no ticket. + +### Task 8: Verify coverage + +**Files:** none; GitHub only. + +**Interfaces:** + +- Consumes: the results of Tasks 1 to 5. +- Produces: a pass, or a list of gaps posted as a `CREATOR:` comment on #1558. + +- [ ] **Step 1: Count sub-issues** + +Run `gh api graphql -f query='{repository(owner:"IABTechLab",name:""){issue(number:){subIssues(first:100){totalCount}}}}' --jq '.data.repository.issue.subIssues.totalCount'` +for #1558 and IABTechLab/edgezero#468. +Expected: 45 and 32. + +- [ ] **Step 2: Check that Fastly and Axum stand alone** + +For every issue in waves 0 to 9, read its blocked-by list and confirm that none +of its blockers is in wave 10 or later or in an `any` row (a Cloudflare or Spin +ticket, IABTechLab/edgezero#91, R3, R4, EZI-2p, EZI-5, EZI-5c, EZI-5s, T10, T16, +#1139, #1142, #1245, #1549, IABTechLab/edgezero#438 or IABTechLab/edgezero#464), reading the list as Task 5 +Step 2 does. +Expected: no such blocker (DEC-4). + +- [ ] **Step 3: Check bodies** + +For every created or moved issue, read its body with +`gh issue view --repo / --json body --jq .body` and confirm +three things: +each "Blocked by" names an issue, PR or decision; no Trusted Server body +contains a bare `#N` below 1000 (EdgeZero references use +`IABTechLab/edgezero#N`); and a created body holds only its requirement, +acceptance and dependencies. +Expected: no gaps. diff --git a/docs/superpowers/specs/2026-11-11-server-side-identity-all-adapters-design.md b/docs/superpowers/specs/2026-11-11-server-side-identity-all-adapters-design.md new file mode 100644 index 000000000..b00dd2f38 --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-server-side-identity-all-adapters-design.md @@ -0,0 +1,862 @@ +# Server-side identity on every adapter: design + +Status: draft, in review in #1559. Read at Trusted Server main `598a85170` and +EdgeZero main `e11ea308` (crates at `v0.0.11`) on 2026-11-11 and 2026-11-12. +Epics: #1558 (Trusted Server) and IABTechLab/edgezero#468 (EdgeZero). Plan: +[server-side identity +plan](../plans/2026-11-11-server-side-identity-all-adapters.md). + +## Goals + +1. Axum is deployable from main as a production service on AWS, behind + CloudFront and a load balancer. +2. Server-side identity works on every adapter whose platform can meet the + record contract in [Platform support](#platform-support), delivered in the + order Fastly, Axum, Cloudflare, Spin (DEC-4). Fastly and Axum are the MVP + (DEC-2) and ship complete without any Cloudflare or Spin work. An adapter + that cannot meet the contract rejects identity configuration at startup with + a clear error and never runs identity without a record. +3. SharedID is read, stored and passed to bidders the way Prebid defines it, + and shared state is passed through each adapter the right way. +4. Identity is written back as first-party cookies on the HTML document + response, and on a same-origin EC route that the script Trusted Server + injects into the page calls once the reader consents. + +Target deployment profile: a publisher that runs client-side Prebid and wants +identity without the auction orchestrator. Its pages pass through Trusted +Server, so cookies can be set on the document response, which Safari does not +cap. + +## Relationship to other work + +- This is a program-level design across Trusted Server and EdgeZero. It is not + the design that #1236 asks for. #1236 keeps its own scope; A1 to A10 under + #1558 deliver the AX requirements. #1236's own open choices (key rotation, + backup and restore, which Fastly-only features stay unsupported on Axum) stay + with #1236. +- Each ticket's implementation PR carries its own design notes, and a plan + when it needs one. This design fixes the requirements, the platform support + matrix and the order. +- The EdgeZero cutover is #1506 (DEC-1). + +## Where things stand + +Two lines of code carry Axum today. + +- **Main.** Axum is a dev server. It binds loopback, reads config and secrets + from env vars only, takes the client IP from the socket peer, has no geo, + and stops on Ctrl-C only. Only Fastly has server-side identity. Axum, + Cloudflare and Spin build an `EcContext` for consent alone, with no record + store and no `ts-ec` cookie. They build a partner registry per request, for + `/_ts/admin/eids` only. +- **Axum RC line.** Tag `v0.1.1-rc-axum.202611.2` (`7c6e68d94`) was the head of + draft #1541 when read on 2026-11-11; #1541 has moved since. It adds production + startup, the EC record in DynamoDB and celld, CloudFront geo, a container + image and AWS examples. It branches from main at `2dd968aee` (2026-10-20), + carries 74 commits that are not on main and is 29 commits behind it. It pins + EdgeZero tag `v0.0.12-rc-axum.202611.1`, which is IABTechLab/edgezero#431 + (marked DO NOT MERGE). It has none of the provider stack: no server-side ID5 + or LiveRamp, no SharedID write-back and no mirror cookie. + +Capability by adapter on main, and the open PR that adds each elsewhere: + +| Capability | Fastly | Axum | Cloudflare | Spin | +| ----------------------------------- | -------------- | ------------------------------------- | -------------------------------- | -------------------------------- | +| EC minted and `ts-ec` set | main | #1536 (RC line) | none | none | +| `EcKvStore` backend for the record | main (KV, CAS) | #1536 DynamoDB, #1537 celld (RC line) | none | none | +| Identify | main | #1536 (RC line) | none | none | +| Batch sync, pull sync, admin lookup | main | admin lookup answers 501 locally | admin lookup answers 501 locally | admin lookup answers 501 locally | +| Auction EIDs from the record | main | #1536 (RC line) | none | none | +| Server-side providers | #1487 | refused at startup on #1487 | refused at startup on #1487 | refused at startup on #1487 | +| Trusted client IP and geo | main | #1528, #1532 (EdgeZero RC revisions) | `cf-connecting-ip`, country only | TCP peer, no geo | +| After-response work | main | none | none | none | +| Rate limiting | main (ERL) | none | none | none | +| First-party cookie write-back | main | RC line | none | none | + +EdgeZero main provides none of these for Axum: production startup, SIGTERM +drain, trusted client IP, AWS config and secret stores, DynamoDB, geo, or +concurrent outbound HTTP with deadlines. Open PRs IABTechLab/edgezero#275 +through IABTechLab/edgezero#439 add them. No EdgeZero PR adds a portable +after-response hook, conditional writes on the `KvStore` trait +(IABTechLab/edgezero#463's celld store has them on a separate API), a portable +client-IP accessor, geo +on Fastly, Cloudflare or Spin, or a rate limiter. EdgeZero main has no geo +module; `ctx.geo()` exists only on the IABTechLab/edgezero#439 stack, and only +Axum fills it there. + +Main has no server-side providers. The single page-load write, the navigation +permit and the startup refusal of providers off Fastly exist only on #1487 and +the PRs stacked on it. + +### How the EC record write is split + +The record logic is shared. `KvIdentityGraph` in +`crates/trusted-server-core/src/ec/kv.rs` owns the compare-and-swap retry +loops, consent tombstones and entry validation for every adapter. On #1487 it +also owns the page-load write. An adapter supplies only the `EcKvStore` trait +in `crates/trusted-server-core/src/ec/kv_backend.rs`, which has five +operations: + +- a lookup that returns a generation marker +- a strongly consistent `key_exists` +- `insert` with `Add`, `Overwrite` or `IfGenerationMatch` +- a strongly consistent prefix listing +- delete + +Each backend is written by hand because EdgeZero's `KvStore` gives none of +these guarantees. The Fastly backend calls the Fastly SDK directly +(`crates/trusted-server-adapter-fastly/src/ec_kv.rs`). The RC line adds DynamoDB +and celld backends inside the Axum adapter. EdgeZero's Fastly store drops what +the record needs: at EdgeZero main and at IABTechLab/edgezero#275's head its +read calls `lookup` without the generation, `exists` is a read, and inserts +carry no mode. So the Fastly backend can move onto EdgeZero only once EZI-2a +covers the Fastly provider. IABTechLab/edgezero#463's `CelldRecordStore` has +the semantics on a separate API; IABTechLab/edgezero#414's DynamoDB provider +has strong existence and listing but no CAS and keeps every key in one +partition; IABTechLab/edgezero#410 and IABTechLab/edgezero#437 rule out +conditional writes today; and the `edgezero-store-aws` and +`edgezero-store-celld` crates compile only for native targets. + +The target is one `EcKvStore` implementation in core over EdgeZero's +`KvStore` (DEC-3). The per-adapter backends are then deleted, and the DynamoDB +and celld conditional logic lives in EdgeZero's providers. + +## Platform support + +Identity runs on an adapter only where the record contract and geo are +supported. Elsewhere, startup rejects identity configuration (ID-2). Delivery +follows DEC-4: Fastly, Axum, Cloudflare, Spin. + +| Capability | Fastly | Axum | Cloudflare | Spin | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Record contract (generation read, create-if-absent, CAS, strong exists, list) | Supported. Fastly KV generations through EZI-2a; DynamoDB or celld through EZI-2p after the MVP | Supported. DynamoDB or celld through EZI-2b | DynamoDB or celld through EZI-2p and EZI-2c. Workers KV is eventually consistent with no CAS, and EdgeZero has no Durable Objects or D1 store on main, IABTechLab/edgezero#275 or the RC tags | DynamoDB or celld through EZI-2p and EZI-2s. Spin's own `spin-sdk` 7.0.0 `key_value` has get, set, delete, exists and get_keys only, with no prefix listing; the SDK exposes `wasi:keyvalue/atomics`, which defines a CAS resource, only as raw bindings in its doc-hidden `experimental` module | +| After-response work | Supported (after send) | EZI-1, drained on SIGTERM | EZI-1 over `waitUntil` | Unsupported; the work runs before the response (DEC-14) | +| Rate limiter | Supported (Edge Rate Limiting) | None in the MVP; the load balancer limits (DEC-11, DEC-15) | EZI-5, backend per DEC-15 | EZI-5, backend per DEC-15 | +| Geo with region | Supported | CloudFront headers (A4, IABTechLab/edgezero#439) | Country only today; region needs EZI-4 | None; needs EZI-4 | +| TLS fingerprint (JA4) | Runtime TLS through EdgeZero's `ctx.ja4()` (EZI-3) | `ctx.ja4()` from a trusted proxy header such as CloudFront's (EZI-3) | `ctx.ja4()` from Bot Management, otherwise absent | Absent (`NotExposed`) | +| Trusted client IP | Supported | IABTechLab/edgezero#409 | `cf-connecting-ip`; EZI-3 accessor | TCP peer; EZI-3 accessor | + +Cloudflare and Spin move to Supported only when EZI-2c, EZI-2s, EZI-4c and +EZI-4s deliver for them (DEC-8 picks their store). Until then their startup +rejects identity configuration, and Goal 2 is met for them by that rejection. + +## Requirements + +Each requirement states behavior, then `Accept:` with the check that proves +it. "Supported adapter" means one where [Platform support](#platform-support) +marks the record contract and geo as supported. + +### AX: Axum deployable + +- **AX-1 Production serving.** The service binds a configured address, exits + non-zero before listening when config, secrets or a required store fails to + load, and on SIGTERM stops accepting, finishes in-flight requests and + after-response tasks, and exits within a configured grace shorter than the + ECS stop timeout. Accept: an ECS-style stop during a request with a pending + record write completes the request and the write, and CI runs that test; + startup with a missing secret exits non-zero before the port opens. State: + the RC line has startup, SIGTERM and connection drain, and + `scripts/test-native-ec-image.mjs` from #1536 passes locally but not in CI. + Draining after-response tasks needs EZI-1; IABTechLab/edgezero#406 drains + connections only. +- **AX-2 Managed config and secrets.** Config and secrets load from AWS stores + through EdgeZero store providers. Environment overrides use the full + `TRUSTED_SERVER__` prefix only. Accept: an Axum startup test loads config + and secrets through the providers in IABTechLab/edgezero#414, and no runtime + code reads a `TS_*` or `TRUSTED_SERVER_CONFIG_*` / `TRUSTED_SERVER_SECRET_*` + name. State: main reads `TRUSTED_SERVER_CONFIG_*` and + `TRUSTED_SERVER_SECRET_*`, which #1528 removes; the RC adds + `TS_NATIVE_GEO_PROFILE_FILE`, `TS_NATIVE_BINDINGS_TOML` and + `TS_APPCONFIG_TOKEN`. +- **AX-3 Trusted IP and geo.** The client IP comes only from trusted CloudFront + and load balancer hops, IPv4 and IPv6. Country and US region come from + CloudFront headers only from a trusted hop. The reference deployment enables + geo, because an unknown country fails closed and issues no EC. Accept: an + untrusted peer with forged forwarded and geo headers gets its own IP and no + geo; a trusted IPv6 CloudFront hop is accepted; the reference deployment + applies with geo on. State: IABTechLab/edgezero#409, IABTechLab/edgezero#439 + and #1532 accept IPv6; the reference Terraform in #1540 trusts IPv4 ranges + only. At `db1ba337a` its example inputs turn on a CloudFront front door with + geo, which reaches the application through `TS_NATIVE_GEO_PROFILE_TOML` rather + than EdgeZero's geo binding (EZI-4a). +- **AX-4 Record store fit for production.** Writes are spread across partition + keys by listing prefix: an EC row's partition is its IP hash, and a + withdrawal marker's is the marker prefix and its EC ID, so no item is updated + by every insert and listing stays strong within a partition (EZI-2a). + Retries are bounded. Accept: a load test at the target page-load rate with + provider refreshes (A5 sets the rate) shows no throttling, and rows from + different IP hashes land in different partitions. State: #1536 puts every + row under one partition key and updates one allocator item per insert, and + IABTechLab/edgezero#414's DynamoDB store keeps every key in one partition. +- **AX-5 Production image.** An image built from source runs the production + server. Accept: its entrypoint starts the production server with + `STOPSIGNAL SIGTERM`. Publishing and attesting the image are outside this + design (DEC-19). State: #1539 builds the production image; #1543 publishes + the dev-server image (`STOPSIGNAL SIGINT`). +- **AX-6 Reference infrastructure.** The reference stack wires the DynamoDB + table, port 443 egress for vendor APIs, a readiness signal, JSON logs, two or + more tasks and an example rate-based rule at the load balancer, since Axum's + own limits are off by default (DEC-11). A store outage only degrades identity + (Failure handling), so the load balancer health check stays green while + identity reports itself degraded. IABTechLab/edgezero#275 serves Axum on a + current-thread runtime, so capacity comes from task count. Accept: the stack + applies with those properties, the rate rule refuses traffic over its + threshold, and with DynamoDB unreachable pages keep serving while identity + reports degraded. State: #1540 at `db1ba337a` has the 443 egress (IPv4 only), + a `/health` check, one task, a CloudFront front door for geo that its example + inputs select, and a DynamoDB table whose IAM policy allows one partition key + and one allocator item (AX-4); EdgeZero's readiness probe does not poll + dependencies. +- **AX-7 Ships from main.** Main never pins an EdgeZero RC tag + (IABTechLab/edgezero#431, IABTechLab/edgezero#462) or a revision that exists + only on an RC branch, such as `456313675`, which #1528 pins today. Main may + pin IABTechLab/edgezero#275's head under DEC-1, or a tag on EdgeZero's + protected integration branch under DEC-5, which carries only work headed for + EdgeZero main. Accept: `Cargo.lock` on main resolves every EdgeZero crate to + a released tag, IABTechLab/edgezero#275's head or an integration-branch tag. + +### ID: Server-side identity on every adapter + +- **ID-1 One EC lifecycle.** On every supported adapter a consented document + navigation or EC route call (ID-9) mints the EC, creates the record and sets + `ts-ec` together. A cookie is never issued without a record. An existing EC is + kept when geo is unknown. Accept: the conformance suite (T5) asserts on each + supported adapter that a consented navigation creates the record and sets + `ts-ec` in the same response, that a store failure sets no `ts-ec`, and that + an existing EC survives unknown geo; on an unsupported adapter, startup with + identity configuration fails. +- **ID-2 One shared record store.** The identity logic stays in core and + becomes awaitable (#1526). It runs on one `EcKvStore` over EdgeZero's + `KvStore` (DEC-3). A store that cannot meet the contract makes startup + reject identity configuration with a clear error. Accept: no adapter crate's + non-test code implements `EcKvStore`, and a store reporting + `KvError::Unsupported` for the contract fails startup with identity enabled. +- **ID-3 Partner APIs answered locally.** Identify, batch sync, pull sync and + admin lookup run on every adapter with a record store. Without one, identify + and batch sync return a local error and never reach the publisher origin. + Admin lookup already answers 501 locally; pull sync is outbound only. Accept: + in each adapter's tests a capturing origin receives no identify or batch-sync + request; with a store they run, without one they return 501. +- **ID-4 Providers on every supported adapter.** Lookups start at navigation + alongside the origin fetch, share one deadline, and are awaited before + finalization, so their IDs reach the record and the cookies of the same + document response. A page load writes the record once. Accept: a mock + provider that answers within the deadline has its ID in the record and in + the mirror cookie of that navigation's response; one that misses the + deadline is skipped and does not delay the response past it. +- **ID-5 After-response work.** Only pull sync and guarded follow-up writes run + after the response is sent: after send on Fastly, a task drained on SIGTERM + on Axum, `waitUntil` on Cloudflare. On Spin the work runs before the response + (DEC-14; DEC-16 rules out skipping it). Accept: on Fastly, Axum and Cloudflare + a follow-up write recorded after send is in the record before the next + navigation, and on Axum SIGTERM waits for it (A3). On Spin the follow-up write + lands before the response. +- **ID-6 Rate limiting (after the MVP, DEC-15).** In the MVP, Fastly keeps + main's limits on batch sync and pull sync and #1487's per-provider lookup + limit, and Axum has none because the deployer's load balancer limits + traffic (DEC-11). Until EZI-5, Fastly's limiter stays the existing + `fastly::erl` bridge in the Fastly adapter, and T21 keeps it when it moves + the handlers into core. After the MVP, provider lookups, batch sync, pull + sync and record creation are rate limited on every supported adapter except + Axum, where every limit stays off by default; batch sync is keyed per partner + and the other keys follow DEC-15, and an Axum limit turned on in + configuration runs per task, divided by the configured task count. Accept: in + the MVP, Fastly's existing limits still refuse or skip over-limit requests + after T21 and Axum's default configuration applies no limit; after the MVP, + per-adapter tests hit each limit, and an Axum limit turned on for two tasks + refuses at half the limit. State: Fastly Edge Rate Limiting is the only + limiter; record creation is limited nowhere; #1487 keys lookups on the + IP-derived EC prefix, so everyone behind one IPv4 address or IPv6 /64 shares + a budget. +- **ID-7 Abuse resistance.** Where a trusted JA4 is available, record creation + is refused when the JA4's TLS client family contradicts the User-Agent's + browser family; a JA4 the rule does not recognize, or no trusted JA4 at all, + does not block (DEC-18). The family rule and its data live in core with T12, + which owns keeping them current; JA4 comes only from EdgeZero's `ctx.ja4()` + (EZI-3). Accept: on each supported adapter a scripted client with a browser + User-Agent and a non-browser JA4 creates no record, a current Chrome, Safari + and Firefox JA4 that no list names still creates one, and a request with no + trusted JA4 is not refused for lacking one. State: Fastly checks only that a + JA4 exists plus a browser User-Agent platform; main's known-browser table + (`ec/device.rs`) feeds analytics only and needs an HTTP/2 fingerprint that + Axum behind CloudFront lacks; the RC's Axum checks only the User-Agent, with + request filters, because it has no trusted JA4 (EZI-3); Cloudflare and Spin + create no records yet. CloudFront sends JA4 to the origin only in the + `CloudFront-Viewer-JA4-Fingerprint` header, which an origin request policy + must forward. IABTechLab/edgezero#469's draft design reports Axum as + `NotExposed` and leaves the trusted proxy header to its section 13.1. +- **ID-8 Body EIDs keep being saved.** EIDs sent in the `/auction` body are + saved to the record. Accept: on each supported adapter a body EID is in the + record after the request. State: main saves them on Fastly; the provider + stack and the RC's Axum lifecycle do not. +- **ID-9 EC route.** A same-origin route, `/_ts/api/v1/ec` by default, lets the + script Trusted Server injects into proxied pages report consent and receive + identity, so a reader who consents after the page loads gets an EC on that + page view instead of the next navigation. A `POST` whose `Origin` matches the + page mints the EC when the reader has none and consent allows, creates the + record, runs the providers as a navigation does, and sets `ts-ec`, the + SharedID cookie and the mirror cookie on its own response. It carries the + consent strings the script reads from the CMP, the TCF string from `__tcfapi` + and the GPP string from `__gpp` (Sourcepoint's capture from #1535 where + present), which replace the consent cookies for that request; a withdrawal + expires `ts-ec`, the SharedID cookie and the mirror cookie and tombstones the + record in that response. The `POST` answers with JSON holding exactly what + WB-4 would put in the mirror cookie for that request, and the SharedID only + under SID-2 and SID-6; the script merges it into Prebid, and nothing else in + the record reaches the page. A plain `GET` answers Prebid's SharedID + `pixelUrl` with an empty 204: it never mints, calls a provider or changes + consent, and only re-sets an existing SharedID cookie from the record (WB-3). + The script sends consent changes in order with at most one call in flight. + While a call is in flight, a newer change replaces a waiting one, except a + waiting withdrawal, which is never replaced: later changes wait behind it. So + every withdrawal reaches the server, after any grant in progress and before + any later grant. The script discards a result that arrives while a newer + consent change is waiting, and a withdrawal removes the IDs it merged into + Prebid. Every response carries `Cache-Control: no-store`; a rate limit on the + route waits with ID-6 (DEC-15). The route is served from the page's own host, + which WebKit's cap on cookies from non-document responses exempts when the IP + matches the page's (read from WebKit's source; the Accept tests it). Accept: + on Fastly and Axum, a consented same-origin `POST` with no `ts-ec` creates the + record and sets `ts-ec` and the SharedID cookie in that response; its JSON + omits a non-mirrored partner's ID, an ID whose vendor's TCF consent is denied + and an expired ID; a withdrawal issued while a grant call with no EC and + delayed providers is in flight leaves no identity cookie, no live record and + no ID in Prebid; with grant A in flight, a withdrawal B and then a grant C + reach the server as A, B, C, so B expires the identity cookies and tombstones + A's record and C is handled as a first consent with no `ts-ec`; a cross-site + `Origin`, a `GET` and a client ID-7 refuses mint nothing and call no provider; + an unchanged returning reader gets no `Set-Cookie`; and in Safari, `ts-ec` and + the SharedID cookie set by the route keep their one-year `Max-Age` on Fastly + and on Axum behind CloudFront. State: on main only a real-browser navigation + through the publisher proxy mints an EC, and no such route exists. #1535 (RC + line) posts Sourcepoint's GPP string to a separate, non-minting `/_ts/consent` + route, wired into each adapter's router. + +### SH: Shared state passed the right way + +- **SH-1 Built once.** Settings, the partner registry, store clients and the + HTTP client are built once per process or isolate. Accept: an adapter test + shows settings, the partner registry and store clients built once across two + requests, and the HTTP client too, except Axum's, which stays per request + until a measurement shows the Next.js regression gone; after that measurement + Axum's client is held to the same rule. State: Cloudflare and Spin rebuild app + state per request; Axum builds the partner registry per request (`app.rs:495` + at #1506's head `9ec4711d3`) and the HTTP client per request on purpose, + because a pooled client regressed a Next.js flow (main `platform.rs:620-623`). + Keep that client choice unless a measurement shows the regression gone. +- **SH-2 Per-request state in the request.** Per-request values travel in the + request context, never in a `thread_local` or in retained app state. Accept: + no `thread_local!` or atomic in app state holds a per-request value. State: + #1487 (and #1488, #1489 on it) keeps lookups in a `thread_local`; #1527 and + #1528 keep a per-request `AtomicBool` in the Fastly `AppState`. +- **SH-3 No executor nesting.** No `block_on` runs inside async code. Accept: + the remaining `block_on` calls are only at adapter entry points. State: #1526 + wraps finalize in `block_on` (Fastly `main.rs:550`) and #1487 calls + `block_on` inside finalize (`main.rs:699`), which panic together. Core pull + sync and Cloudflare and Spin config reads also call `block_on` on main; + #1526, #1506 and the RC remove the pull-sync one, RC commit `933de5921` + removes Spin's, and Cloudflare's remains everywhere. +- **SH-4 No first-request capture.** Cloudflare does not keep the first + request's `Env` in a set-once `thread_local`. Accept: two requests with + different `Env` values each see their own. +- **SH-5 One app for every adapter.** The identity routes (identify, batch + sync, admin lookup and the EC route), pull sync, the EC lifecycle, provider + lookups and cookie write-back run from one app built in core, which every + adapter mounts and which builds its state once (SH-1). An adapter crate + supplies only platform services: through EdgeZero where it offers them, and + otherwise through the adapter's existing platform bridge until the EdgeZero + ask lands (today Fastly's limiter, ID-6, and Fastly's geo, EZI-4). A + capability an adapter lacks is an EdgeZero ask, never new code in that + adapter (DEC-16). Accept: no adapter crate's non-test code calls EC + generation, finalization or an identity handler directly, and the same + conformance suite (T5) runs on each supported adapter. State: at #1506's head + only the Fastly adapter wires identity (EC generation and finalization are + called from its `app.rs` and `main.rs`); Axum, Cloudflare and Spin each + build their own router without it. + +### SID: SharedID handled the way Prebid defines it + +- **SID-1 Cookie name from config.** The server reads SharedID from the cookie + named by the publisher's Prebid `storage.name`, held in + `managed_user_ids[].storage.name`. Prebid has no default; `_sharedid` and + `_pubcid` are common. Accept: with `storage.name = "_pubcid"` a `_pubcid` + cookie is read on every supported adapter, and a cookie named `sharedId` is + not. State: main reads only `sharedId` (`constants.rs:10`), Prebid's module + name, on Fastly for the record and on every adapter for `/_ts/admin/eids`. +- **SID-2 Opt-out.** A `_pubcid_optout` cookie stops SharedID reads, storage, + sending, creation and write-back. Prebid also reads the opt-out from + localStorage, which the server cannot see, so the injected script copies a + localStorage `_pubcid_optout` into the cookie (T20). Accept: with + `_pubcid_optout` present nothing SharedID-related is read, stored, sent, + created or written, and a localStorage-only opt-out stops creation from the + next request on. +- **SID-3 One canonical EID.** SharedID is stored and sent as source + `pubcid.org`, atype 1, once. Accept: a bidder request built from cookie, + `ts-eids`, body and record that carry the same UUID has exactly one EID + `{source: "pubcid.org", atype: 1}`. State: a `pubcid.org` partner already + stores and sends the right EID from `ts-eids` and the body; the `sharedId` + cookie path is hard-wired to `sharedid.org` (`ec/prebid_eids.rs:241`); + browser-written records default to atype 3; the outbound merge dedupes only + within one source. +- **SID-4 OpenRTB placement.** EIDs go in `user.eids` for OpenRTB 2.6, and in + `user.ext.eids` only for a named downstream that needs it. Accept: the + bidder, Prebid Server and APS requests carry `user.eids`, and every test + that asserts EIDs are absent checks both fields. State: every builder writes + only `user.ext.eids` (`auction/openrtb.rs:372`, + `integrations/prebid.rs:2882`, `integrations/aps.rs:1131`). +- **SID-5 Retention.** The record keeps a SharedID for as long as the record + lives, and the stored value wins over a different browser value. When neither + has one, the server creates it (DEC-7, WB-3). + Accept: a stored SharedID survives a request carrying a different cookie + value. State: main does this on Fastly (`ec/kv.rs:134-174`); #1488 keeps a + cookie-read SharedID for 24 hours and lets the cookie replace it. +- **SID-6 EID consent.** Reading SharedID into the record, storing it, sending + it and writing it back each need EID permission (TCF Purposes 1 and 4, + through the consent module's EID predicate in `consent/mod.rs`), in + addition to EC permission and the SID-2 opt-out. EC permission alone + (`consent_allows_ec`, Purpose 1) is not enough. Where consent is required + but missing, permission is denied. Accept: on each supported adapter, with a + populated record, an absent or different browser cookie, Purpose 1 granted + and Purpose 4 denied, no SharedID is read, sent or written back. + +### WB: First-party write-back + +- **WB-1 Document and EC route responses only.** Identity cookies are set on + top-level HTML document responses and on EC route responses (ID-9), on every + supported adapter. Accept: identity `Set-Cookie` headers appear on navigation + and EC route responses and never on asset or other API responses. State: main + mints `ts-ec` on Fastly navigations, and the RC adds Axum. +- **WB-2 `ts-ec` lifetime.** `ts-ec` keeps + `Domain=.; Path=/; Secure; SameSite=Lax; HttpOnly; Max-Age=31536000`. + Whether returning readers get a refresh is DEC-13. Accept: once DEC-13 is + decided, its rule holds and a returning reader whose cookie needs no refresh + gets no `Set-Cookie`. State: set only for a new or rotated EC. +- **WB-3 SharedID cookie.** When SID-2 and SID-6 allow it, the server sets the + SharedID cookie from the record if the request has none or a different value. + When neither the record nor the request has one, the server creates a SharedID + in the format Prebid writes (a UUID v4), stores it in the record and sets it + the same way, because a browser may block Prebid.js from loading at all + (DEC-7). It creates and writes back a SharedID only when the configured + `storage.type` is `cookie` (Prebid's `cookie&html5` counts as `cookie`): with + `html5` Prebid never reads the cookie, so a server-made SharedID would split + the reader's identity (SID-3). It uses Prebid's storage name and the Domain + Prebid writes with, is not HttpOnly, and is `Secure; SameSite=Lax` for one + year. It keeps Prebid's `_cst` companion consistent, since a missing or + different one makes Prebid rewrite the cookie from script, which Safari caps. + Prebid's `extend` with a `pixelUrl` keeps working alongside it. Accept: record + present and cookie absent gives both cookies with those attributes; a + consented first visit with no SharedID anywhere gets one created, stored and + set, and a reader with `_pubcid_optout` gets none; with `storage.type = html5` + the server neither creates nor sets a SharedID; an equal cookie with a + consistent `_cst` gives no `Set-Cookie`; an equal cookie with a missing + or different `_cst` re-sets only the companion; Purpose 1 denied or + `_pubcid_optout` expires both; Purpose 4 denied with Purpose 1 granted gives + no `Set-Cookie` and leaves the browser's cookie alone. State: no branch writes + it. +- **WB-4 Mirror cookie.** `__Host-ts-eids-mirror` as in #1489: opt-in per + partner (DEC-10), dropped per vendor when TCF consent is denied, needs + `bidstream_enabled` and Purpose 4, 3,584-byte budget, host-only. Every + mirrored ID expires within 30 days. Accept: an ID written with no stated + expiry is mirrored with an expiry of at most 30 days. State: in #1489 such an + ID never expires (`Expiry::Never`); closed #1461 capped mirrored IDs at 24 + hours. +- **WB-5 Cacheable pages.** An unchanged cookie gets no `Set-Cookie`, and a + response carrying `Set-Cookie` is not stored in a shared cache. Accept: a + returning reader with unchanged cookies gets no `Set-Cookie`, and the + template-cache deny list uses the configured SharedID name. State: main + enforces the cache rule on every adapter (`response_privacy.rs:122-148`) and + does not re-set `ts-ec`, but re-sets the `ts-ec-pull-complete` marker about + hourly, which #1489 retires; the deny list names only `sharedId`. +- **WB-6 Identity-only page script.** A tsjs mode captures Prebid's IDs without + the auction shim, reads the mirror cookie and merges the IDs into Prebid. + Accept: in that mode Prebid receives the mirrored IDs and no Trusted Server + bidder is added to ad units. State: the `ts-eids` capture runs only inside + the shim, and nothing reads the mirror. + +### EZI: asks of EdgeZero + +Behavior and acceptance only. EZI IDs are distinct from #1506's EZ-n +requirement IDs. Each ask is split by platform, in DEC-4 order: the Fastly +and Axum parts ship in R1, R1b, R2 and R2b, except EZI-2p and EZI-5, and never +wait on a Cloudflare or Spin part. + +- **EZI-1 After-response work.** A task registered during a request runs after + the response is delivered, bounded in time. Accept: on Fastly and Axum the + task runs after delivery, and on Axum SIGTERM waits for registered tasks up + to the grace; later, on Cloudflare the task runs after delivery, and Spin + returns a capability error. +- **EZI-2a Record contract, the Fastly provider and the local store.** `KvStore` + gains a read that returns a generation marker, writes in `Add`, `Overwrite` + and `IfGenerationMatch` modes with a precondition-failed outcome, strongly + consistent existence, and prefix listing that is strongly consistent within + a partition: the caller declares how a key maps to its partition, and a + listing whose prefix is a whole partition reads strongly. A store that cannot + meet this reports `KvError::Unsupported`. Accept: an EdgeZero contract suite + passes against the Fastly provider under Viceroy and against EdgeZero's local + persistent store (`PersistentKvStore`), which the Axum development server + uses. +- **EZI-2b DynamoDB and celld providers on Axum.** Both meet the EZI-2a + contract on Axum. DynamoDB uses each key's partition as its partition key, so + writes spread across partitions and no item is updated by every insert + (AX-4). Accept: the contract suite passes against both on Axum, listing + within a partition is strong, and keys in different partitions land in + different DynamoDB partitions. +- **EZI-2p Portable DynamoDB and celld providers.** The same providers run on + every adapter over EdgeZero's outbound HTTP, while the native stores (Fastly + KV, Cloudflare KV, Spin KV) stay on their own platform (DEC-3). Each states + the credential source it uses on WASM targets and a latency budget for a + conditional write. Accept: the contract suite passes against both on Fastly; + EZI-2c and EZI-2s run it on Cloudflare and Spin. +- **EZI-2c and EZI-2s Cloudflare and Spin stores.** On each platform the + portable DynamoDB and celld providers (EZI-2p) pass the EZI-2a contract, and + the native store meets it or reports `KvError::Unsupported`; DEC-8 picks the + default. Accept: the contract suite passes for every store offered there, and + a native store that cannot meet it reports `Unsupported`, so identity on it is + rejected at startup. +- **EZI-3 Trusted client metadata.** One accessor returns the trusted client IP, + and one the JA4 fingerprint where a trusted provider supplies it + (IABTechLab/edgezero#283), on every adapter, Fastly and Axum first. JA4 is an + EdgeZero abstraction, `ctx.ja4()`, that each adapter fills from its own + source: Fastly from runtime TLS, Cloudflare from Bot Management, Axum from a + trusted proxy header such as CloudFront's `CloudFront-Viewer-JA4-Fingerprint` + set by a hop EdgeZero's checked ingress trusts, and Spin never (`NotExposed`). + Trusted Server reads JA4 only through it. Accept: the same accessors work on + each adapter as its part lands, and a JA4 header from an untrusted peer reads + as absent. +- **EZI-4 Geo with region on every adapter.** `ctx.geo()` gives country and + region. Fastly keeps its existing geo. Axum uses CloudFront geo from + IABTechLab/edgezero#439, bound from EdgeZero's manifest so an application + reads `ctx.geo()` without its own boot profile (EZI-4a). Cloudflare reads its + request metadata (EZI-4c). Spin, whose requests carry no geo, reads a trusted + ingress header the platform sets or a KV- or API-backed provider (EZI-4s, as + #1518 describes for WASM targets). Accept: a US request on Cloudflare and on + Spin reports country and region, and Axum's geo loads with no Trusted Server + geo setting. +- **EZI-5 Rate limiter (after the MVP, DEC-15).** A limiter with Fastly Edge + Rate Limiting and + an in-memory backend for Axum, then Cloudflare and Spin backends per + DEC-15. Accept: a contract test passes per backend. +- **EZI-6 Integration points.** EdgeZero keeps a protected integration branch + for this work (DEC-5), and each integration point is a tag on it that Trusted + Server main pins until EdgeZero releases the work. R1 creates the branch and + tags IABTechLab/edgezero#275 through IABTechLab/edgezero#439, so Axum + production serving does not wait for the record contract. R1b adds EZI-2a and + EZI-2b. R2 adds the Fastly and Axum parts of EZI-1. R2b adds EZI-3, + IABTechLab/edgezero#90, IABTechLab/edgezero#92, the JA4 accessor + (IABTechLab/edgezero#283) and Axum's geo binding (EZI-4a), so providers never + wait on JA4. Together R1, R1b, R2 and R2b cover Fastly and Axum. R3 adds the + Cloudflare parts of EZI-1 to EZI-4, and R4 the Spin parts. EZI-2p and EZI-5 + follow the MVP (DEC-15). Accept: each tag exists on the protected integration + branch and Trusted Server main pins it; R1, R1b, R2 and R2b contain no + Cloudflare or Spin part. + +## Configuration + +- The SharedID cookie name comes from `managed_user_ids[].storage.name`; no + new setting (SID-1). +- The existing EC settings enable identity; startup rejects them where + identity is unsupported (ID-2). +- Provider `rate_limit_per_hour` and `mirror_enabled` from #1487 and #1489 + follow DEC-15 and DEC-10; on Axum no limit applies by default (DEC-11). +- The reference deployment turns geo on (AX-3). +- Environment overrides use the `TRUSTED_SERVER__` prefix only (AX-2). +- Record stores are EdgeZero logical stores in `edgezero.toml`, with no + adapter-local store setting (ID-2). +- The EC route path is configurable; the default is `/_ts/api/v1/ec` (ID-9). + +## Consent and privacy + +- Creating an EC needs TCF Purpose 1 (`consent_allows_ec`). EIDs, SharedID + included, need Purposes 1 and 4 (SID-6). +- An unknown jurisdiction blocks new ECs (fail closed) and keeps an existing + one. +- The mirror cookie is readable by page scripts, so it is opt-in per partner + and filtered per vendor (WB-4, DEC-10). +- The `_pubcid_optout` cookie is honored; a localStorage opt-out is invisible + to the server (SID-2). +- The EC route reads consent from the request's cookies and `Sec-GPC`, as a + navigation does, or from the TCF and GPP strings the injected script reads + from the CMP and sends with the call, which replace the consent cookies for + that request. Both pass through the same decoding and the same Purpose 1 rule + (ID-9). + +## Failure handling + +- A store that fails or keeps failing a precondition after bounded retries + means no new EC and no `ts-ec` on that response; an existing `ts-ec` stays + (ID-1). +- A provider that times out, errors or is rate limited is skipped and never + delays the response past the deadline (ID-4). +- A failed after-response task is logged and retried on the next navigation, + and never holds shutdown past the grace (AX-1, ID-5). +- An unsupported capability fails startup with an error that names the + capability and the adapter (ID-2). + +## Compatibility and rollout + +- Fastly records holding a SharedID under `sharedid.org` are re-filed under + `pubcid.org`, atype 1, on their next write (T3). There is no bulk migration. +- `user.ext.eids` stays only for the downstreams T2 names. +- `ts-ec` format and attributes do not change. +- Adapters gain identity in the order Fastly, Axum, Cloudflare, Spin. The MVP + is Fastly and Axum; Cloudflare and Spin follow it. Until an adapter's + tickets land, its startup rejects identity configuration. +- The RC tag keeps its current use until main reaches parity; RC units close + as their ports land. + +## Out of scope + +- A narrow install with one path rule and no page proxy. The EC route (ID-9) + serves pages that Trusted Server proxies; that install would also need the + script served without the page proxy. +- Conformance with the SharedID Edge specification. +- Binary attestation and package publishing (#1449, #1463, #1543). They + continue on their own track at lower priority (DEC-19). +- An Akamai adapter. + +## Decisions + +Decided: + +- **DEC-1 Cutover** (user, 2026-11-11). #1506 is the EdgeZero cutover. #1527 + closes once #1506 covers its tests. +- **DEC-2 MVP** (user, 2026-11-12). Fastly and Axum are the MVP: plan waves 0 to 9. + The one open decision the MVP waits on is DEC-9, through T22. Cloudflare, Spin + and the decision-gated work follow it. +- **DEC-3 Shared record store** (user, 2026-11-11). The EC record runs on one + `EcKvStore` over EdgeZero's `KvStore`, with no per-adapter backends. The user + reaffirmed it on 2026-11-12: Axum's record uses EdgeZero's pluggable `KvStore` + adapters (DynamoDB and celld through EZI-2b in the MVP), never an Axum-only + store such as #1536's. The DynamoDB and celld adapters are available to every + adapter (EZI-2p, after the MVP); the native stores (Fastly KV, Cloudflare KV, + Spin KV) only to their own platform. +- **DEC-4 Platform order** (user, 2026-11-12). Fastly, then Axum, then + Cloudflare, then Spin. Fastly and Axum (the MVP, DEC-2) must ship without any + Cloudflare or Spin work: no Fastly or Axum item is blocked by a Cloudflare or + Spin item. Shared work may touch every adapter (the shared app and the + rejection, 501 and absence paths in the plan's Global Constraints). Cloudflare + and Spin follow the MVP. +- **DEC-5 EdgeZero integration branch** (user, 2026-11-12). EdgeZero keeps an + integration branch that carries the unreleased work headed for EdgeZero main: + IABTechLab/edgezero#275, the IABTechLab/edgezero#406 to + IABTechLab/edgezero#439 chain and the EZI tickets. Trusted Server main pins + its tags (R1, R1b, R2 and R2b in the MVP) instead of waiting for an EdgeZero + release, and repins to a released tag once EdgeZero publishes one. +- **DEC-6 Base for the Axum line** (user, 2026-11-12). Forward-port the RC line + to main rather than back-port the provider stack to it, which conflicted in 13 + to 23 files at the heads first measured and 21 to 24 at the round-4 heads. + #1531 restacks on #1506 for A1 and #1532 on #1531 for #1529. The image and + Terraform units (#1539, #1538, #1540) restack on #1531: #1539 and #1538 apply + cleanly, and #1540 at `db1ba337a` conflicts in one file, + `docs/guide/native-ec-persistence.md`. #1536 and #1537 are not restacked: + their Axum-only stores, store selection and wiring give way to EZI-2b, T6 and + T21, and their tests move into A2, A8 and T5. #1535 goes into the EC route + (T19, T20), and #1533 and #1534 close as copies of #1525 and #1526. +- **DEC-7 SharedID creation and retention.** Creation (user, 2026-11-12): when + neither the record nor the request has a SharedID, the server creates one, + because a browser may block Prebid.js from loading at all (WB-3). Retention + (proposed, assumed by the plan): the stored value wins and lives as long as + the record (SID-5). +- **DEC-10 Mirror partners** (user, 2026-11-12). ID5 and LiveRamp, the + important identity providers, get `mirror_enabled`, and the MVP's provider + and mirror checks (T11, A10) use them. +- **DEC-11 Axum rate limiting** (user, 2026-11-12). Every limit is off on Axum + by default, inbound and outbound, because deployers rate-limit at their own + load balancer; the reference stack shows one rule (AX-6). Once rate limiting + returns (DEC-15), a limit a deployer turns on keeps its own in-memory counter + in each task and enforces the limit divided by the configured task count. No + shared counter is planned. +- **DEC-16 One implementation for every adapter** (user, 2026-11-12). Every + feature is built once and works on every adapter, delivered in DEC-4's + order. Shared logic lives in Trusted Server core; a platform capability an + adapter lacks is an EdgeZero ask, never code in one adapter crate (SH-5). + SH-5 records the existing platform bridges that stay until their asks land. +- **DEC-17 celld in the MVP** (user, 2026-11-12). EZI-2b delivers both the + DynamoDB and the celld provider before R1b. +- **DEC-18 JA4 when available** (user, 2026-11-12). ID-7 relies on a trusted + JA4 where the adapter supplies one; without one, record creation is not + blocked. JA4 is EdgeZero's abstraction (EZI-3), never read by Trusted + Server from a header or a platform SDK. +- **DEC-19 Attestation and packaging excluded** (user, 2026-11-12). Binary + attestation and package publishing (#1449, #1463, #1543) are not part of + this work and carry lower priority. The MVP builds the production image + from source (AX-5) without publishing or attesting it. + +Open: + +- **DEC-8 Cloudflare and Spin store.** Which store Cloudflare and Spin use by + default for the record: the DynamoDB or celld adapter, or a native store that + meets the contract, per [Platform support](#platform-support). Blocks EZI-2c, + EZI-2s, R3, T8 and T9. +- **DEC-9 LiveRamp Custom ID.** The current value is the IP-derived EC prefix, + shared by everyone behind one IP. Blocks T22, which sets the Custom ID, + enables LiveRamp and checks it before launch because LiveRamp is in the MVP + (DEC-10), and #1245. The user is getting a LiveRamp test setup to settle it. +- **DEC-12 EC EID source.** Which source name the EC uses, and whether it keeps + going out as `user.id`. Blocks #1549; no other ticket until decided. +- **DEC-13 `ts-ec` refresh** for returning readers. Blocks T16. +- **DEC-14 Spin after-response work.** The work runs before the response, + because DEC-16 rules out skipping it. Open: the latency it may add and its + bound. Blocks T9. +- **DEC-15 Rate limiting after the MVP** (user, 2026-11-12, deferred). Rate + limiting is punted past the MVP: the keys (batch sync stays per partner), + EdgeZero's limiter with its Cloudflare and Spin backends, and new limits on + record creation and on Axum. Blocks EZI-5, EZI-5c, EZI-5s and T10. + +## How the claims were checked + +Every state and "not implemented" claim comes from reading code at named +revisions, not from PR descriptions: + +- Code: `git grep` and `git show` across Trusted Server main, every open PR + head in both repositories, the RC tags, branches with no open PR, and PRs + merged or closed since 2026-10-01. +- Overlaps and conflicts: `git diff --name-only` between heads and + `git merge-tree --write-tree` trial merges. +- PR and issue state: `gh pr view` and `gh api graphql`, read on the dates in + the status line. +- The full CI gate list ran at this PR's first head, and the docs gates at + each later head; results are in the review threads and the PR's head-move + comments. + +## Review log + +### Round 1, 2026-11-12 + +Reviews 5871533635 and 5871619954 at `00df85414`, from fresh agent sessions +covering factual claims, consistency and coverage, merge-order feasibility, and +repository policy. 34 findings: 8 blocking, 17 should-fix, 9 nits. This +revision addresses them; each thread holds its disposition. + +### Round 2, 2026-11-12 + +Review 5875159165 at `7cb8933f7`. It accepted and resolved 28 round-1 findings +and left six open: the fallback clock (thread 1), the review log and draft gate +(7), SH-1's acceptance against the Axum HTTP client exception (14), the RC-unit +baseline (15), ID-5's acceptance on Spin (18) and decision gates missing from +plan cells (23). It added two should-fix findings: Task 1 must re-parent the +EdgeZero issues it moves (35), and WB-3's no-write case must require a +consistent `_cst` (36). No blocking finding. The revision after it +addresses all eight. The PR stays in draft while known changes remain and is +marked ready for each review round, as CONTRIBUTING.md asks. + +### Round 3, 2026-11-12 + +Review 5877498110 at `fbdfbedef` accepted and resolved threads 1, 7, 14, 15, +18, 23, 35 and 36, found nothing blocking and opened one should-fix, thread 37: +the plan's `gh issue view` and `gh pr view` calls did not name the repository. +The decision commits that followed (`ba106e554` to `c4417b7f8`) are recorded +below. + +### Ticket audit, 2026-11-12 + +At the user's request, an audit of every plan ticket's order, priority, size +and MVP need, with each blocker checked against code at the PR heads. In the +spec, EZI-3 adds JA4, because ID-7 cannot pass on Axum without it, and ID-7's +state and the platform table say so; EZI-6 moves the record contract into R1b, +so A1 no longer waits for it; DEC-2's MVP is plan waves 0 to 9; and DEC-8, +DEC-9, DEC-11 and DEC-12 list every ticket they block. The plan's changes are +listed in the PR conversation. + +Later that day the user decided the Axum half of DEC-11: each task keeps its +own counter with every limit divided by the task count, and the MVP has no +shared counter. EZI-5 no longer waits on a decision, and the rest of the old +DEC-11 (keys, task count, Cloudflare and Spin backends) is now DEC-15. + +The user then brought the EC route into the MVP (ID-9): the script Trusted +Server injects into proxied pages calls a same-origin route that mints the EC +and writes the cookies on its own response, and the route answers both +Prebid's `pixelUrl` and the script's JSON call. ID-1 and WB-1 now include that +route, and the Axum mirror cookie (T14) is P1. + +The user then required every feature to be built once and work on every +adapter, with platform gaps sent to EdgeZero (DEC-16). SH-5 adds one identity +app in core that every adapter mounts (T21). The per-adapter identity tickets +shrink to turning that app on with each adapter's EdgeZero capabilities and +passing the conformance suite, and T7, T14 and A11 fold into A2 and A10. +EZI-2a adds EdgeZero's local store for Axum development, and EZI-4 binds +Axum's geo provider from EdgeZero's manifest. + +The user then turned every Axum limit off by default, inbound and outbound, +because deployers rate-limit at their own load balancer (DEC-11), and asked +for an example rate rule in the reference stack (AX-6). A7 no longer waits on +DEC-15. + +The user then punted rate limiting past the MVP (DEC-15): EZI-5 and its +Cloudflare and Spin parts and T10 move to a later row, and Fastly keeps main's +limits. The user also decided DEC-7: the server creates a SharedID when the +reader has none, because a browser may block Prebid.js from loading (WB-3, +T13). + +The user then decided DEC-5: EdgeZero keeps an integration branch for the +unreleased work, and Trusted Server main pins its revisions instead of waiting +for a release. AX-7 allows those revisions, EZI-6 calls R1 to R4 integration +points, and A1 no longer waits on a decision for its pin. + +The user then kept celld in the MVP (DEC-17), named ID5 and LiveRamp as the +mirrored providers (DEC-10), which makes DEC-9 a launch need, and required +#1535's Sourcepoint consent sync. ID-9's route now also takes later consent +changes, so #1535's separate per-adapter `/_ts/consent` route folds into it +(T19, T20). + +The user then reaffirmed DEC-3 for Axum: the record uses EdgeZero's pluggable +`KvStore` adapters. DEC-6 now covers only the base, restacking the RC units on +#1506, and DEC-9 waits on a LiveRamp test setup. + +The user then made the DynamoDB and celld adapters available to every adapter, +with native stores only on their own platform: EZI-2b builds them over +EdgeZero's outbound HTTP, EZI-2c and EZI-2s run them on Cloudflare and Spin, +and DEC-8 now picks each of those platforms' default store. + +The user then decided DEC-6. Rechecking the restack against DEC-3 and DEC-16 +showed that #1536 and #1537 are mostly Axum-only stores and wiring, so A2 is a +new pull request on T21 and T6 that takes their tests, and that the image unit +belongs on #1543, with which it shares `.dockerignore`. + +The user then decided DEC-18: ID-7 relies on a trusted JA4 where one exists and +otherwise does not block, and JA4 is an EdgeZero abstraction. Axum's JA4 +therefore needs the trusted proxy header that IABTechLab/edgezero#469's draft +defers to its section 13.1, which the plan asks to ship in R2. + +The user then excluded attestation and packaging (DEC-19): AX-5 keeps only the +source-built production image, #1449 and #1543 leave the plan, and the image +and Terraform units restack on #1531 instead of #1543. + +### Round 4, 2026-11-12 + +Reviews 5881124556 and 5881450878 at `c4417b7f8` reviewed those decisions and +opened 66 threads; four of the second duplicate the first (the LiveRamp gate, +Fastly's limiter under T21, withdrawal ordering and the EC route's JSON). Three +were blocking: LiveRamp had no MVP ticket (T22 now checks it, gated by DEC-9), +the record contract asked DynamoDB for strong listing across partitions (EZI-2a +now lists strongly within a partition, and AX-4 partitions by listing prefix), +and the EC route's JSON exposed every EID (it now carries only the mirror's +view). EZI-2b now covers Axum only, with the portable providers in EZI-2p after +the MVP, and R2 splits so providers no longer wait on JA4 (R2b). The revision +after it addresses every open thread, including thread 37; each thread holds its +disposition. + +### Round 5, 2026-11-13 + +Review 5884927046 at `8e564f24c` accepted and resolved 65 of the 67 round-4 +threads, 101 in all, and opened nothing new. Threads 40 and 58 stayed open on +one issue: sending only the latest waiting consent could drop a withdrawal made +between a grant in flight and a later regrant. ID-9 and T20 now keep a waiting +withdrawal as a barrier that later changes wait behind, and ID-9's Accept adds +the grant, withdrawal and regrant case. After it, #1516 merged on main and #1540 +and #1541 moved, so Part A, Part C, AX-6 and DEC-6 now record their new state. + +### Round 6, 2026-11-13 + +Review 5885768720 at `de7b2268c`, a merge of main onto `3bbd297ed`, accepted +and resolved threads 40 and 58, so all 103 were resolved, found nothing new +and gave an APPROVE verdict. `403a62a5f` then recorded #1516's merge and the +moved #1540 and #1541, which moved the head past that verdict. + +### Round 7, 2026-11-13 + +Review 5885982220 at `403a62a5f` kept all 103 resolutions and opened one +should-fix, thread 104: at its new head #1540's example inputs already select +CloudFront geo, so AX-3's State and Part C's "turn geo on" were stale. Both +now describe that head, and Part C asks #1540 to bind geo through EdgeZero's +manifest (EZI-4a) instead of `TS_NATIVE_GEO_PROFILE_TOML`.