diff --git a/Cargo.lock b/Cargo.lock index c7716783..6b68ad21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -766,7 +766,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1397,7 +1397,7 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "toml", ] @@ -1405,7 +1405,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "anyhow", "async-trait", @@ -1433,7 +1433,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "anyhow", "async-trait", @@ -1456,7 +1456,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "anyhow", "async-stream", @@ -1485,7 +1485,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "anyhow", "async-trait", @@ -1512,7 +1512,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "chrono", "clap", @@ -1537,7 +1537,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "anyhow", "async-compression", @@ -1568,12 +1568,13 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/stackpop/edgezero?branch=main#42843b1c3934fab32f99cc76ddd5881f421cccc7" +source = "git+https://github.com/stackpop/edgezero?branch=worktree-state-nested-secrets-spec-review#6ebc29a58df179e1c61e15cc2a54edae927a3f2f" dependencies = [ "log", "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.118", "toml", "validator", @@ -1675,7 +1676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2551,7 +2552,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3564,7 +3565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.118", @@ -3648,7 +3649,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4010,7 +4011,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4078,7 +4079,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4726,7 +4727,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5801,7 +5802,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -5907,15 +5908,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5949,30 +5941,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5985,12 +5960,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6003,12 +5972,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6021,24 +5984,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6051,12 +6002,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6069,12 +6014,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6087,12 +6026,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6105,12 +6038,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 3d139333..2faeee12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,12 +43,12 @@ criterion = { version = "0.5", default-features = false, features = ["cargo_benc derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } -edgezero-cli = { git = "https://github.com/stackpop/edgezero", branch = "main" } -edgezero-core = { git = "https://github.com/stackpop/edgezero", branch = "main", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", branch = "worktree-state-nested-secrets-spec-review", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", branch = "worktree-state-nested-secrets-spec-review", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", branch = "worktree-state-nested-secrets-spec-review", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", branch = "worktree-state-nested-secrets-spec-review", default-features = false } +edgezero-cli = { git = "https://github.com/stackpop/edgezero", branch = "worktree-state-nested-secrets-spec-review" } +edgezero-core = { git = "https://github.com/stackpop/edgezero", branch = "worktree-state-nested-secrets-spec-review", default-features = false } env_logger = "0.11" error-stack = "0.6" fastly = "0.12" diff --git a/docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md b/docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md new file mode 100644 index 00000000..50d5fbc5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md @@ -0,0 +1,562 @@ +# EdgeZero Store-Registry Migration (Phase 1, D6-a) 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:** Route trusted-server's runtime **and boot-time** config/secret **reads** through EdgeZero stores/registries, add **named/non-default KV** selection (default KV is already EdgeZero; named KV / `consent_store` selection is **not** — see Step 2c), reconcile every logical store id (kv/config/secrets) with `edgezero.toml`, and delete the duplicated Fastly chunk resolver — while **keeping** the runtime **write** path (key rotation) intact via a composite store (decision **D6-a**). + +**Architecture:** trusted-server core reads/writes stores through the bespoke `PlatformConfigStore`/`PlatformSecretStore` traits (each mixes read `get`/`get_string` + write `put`/`create`/`delete`), surfaced via `RuntimeServices` (one trait object per kind). EdgeZero's `ConfigStore`/`SecretStore` are **read-only**; per-request `ConfigRegistry`/`SecretRegistry` live in request extensions. This phase introduces a **composite store** whose *reads* resolve from EdgeZero and whose *writes* delegate to the existing management-API-backed impl, migrates the Fastly/Axum **boot** config read to EdgeZero, and adds **local** registry builders for Fastly's custom `oneshot` dispatch (EdgeZero's builders are `pub(crate)`). + +**Tech Stack:** Rust 2024, toolchain 1.95.0, `error-stack` `Report`, EdgeZero (`edgezero-core`/`edgezero-adapter-fastly` git dep), Viceroy, `cargo test-{fastly,axum,cloudflare,spin}`. + +**Spec:** `docs/superpowers/specs/2026-07-02-edgezero-full-migration-design.md` §5 Phase 1, D5, D6, §4a. + +## Pinned dependency (verified) + +This plan targets the EdgeZero commit pinned in `Cargo.lock`: **`branch = worktree-state-nested-secrets-spec-review` @ `6ebc29a5`** (PR [stackpop/edgezero#306](https://github.com/stackpop/edgezero/pull/306)). That commit **has** every API this plan uses — verified by inspecting the pinned checkout: `store_registry.rs` (`StoreRegistry`/`ConfigRegistry`/`SecretRegistry`/`from_parts`), `StoresMetadata` + `Hooks::stores()` (`app.rs`), `dispatch_with_registries` + `build_*_registry` (adapter-fastly), `AxumDevServer::{with_config,with_kv,with_secret}_registry`, the `[stores.*]` `ids`/`default` manifest schema (`manifest.rs`, `StoreDeclaration`, `deny_unknown_fields`), CloudflareConfigStore backed by **KV namespaces**, and AxumConfigStore backed by **`.edgezero/local-config-.json`**. Older cargo-cache checkouts (e.g. `ce6bcf7`, "Add store support for Spin adapter #253") **predate** the registry refactor and lack these — do not review against them; confirm any API check against `6ebc29a5`. + +## Global Constraints + +- Rust **2024 edition**, toolchain **1.95.0**; WASM target `wasm32-wasip1`. +- Errors: `error-stack` `Report` only (no `anyhow` outside the Spin entry point); `derive_more::Display`; import `Error` from `core::error::`. +- No `unwrap()` in production (`expect("should …")`); no `println!`/`eprintln!` (use `log`). +- No wildcard imports (except `use super::*` in `#[cfg(test)]`); no imports inside functions. +- Commits: sentence case, imperative, no semantic prefixes, no `Co-Authored-By`/AI footers. +- CI gate before PR: `cargo fmt --all -- --check`; `cargo clippy-{fastly,axum,cloudflare,spin-native,spin-wasm}`; **`cargo check-cloudflare` + `cargo check-spin`** (wasm-target surfaces — `test-cloudflare`/`test-spin` are native and do **not** compile the wasm runtime paths); `cargo test-{fastly,axum,cloudflare,spin}`; `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity`. +- **Every task leaves all four adapters building and green.** +- **EdgeZero `ConfigStore`/`SecretStore` are read-only.** Runtime writes stay on the management path (D6-a). +- **Registry lookup is strict:** an unknown logical id yields `None`. Every id any config field names — in **any** kind (kv/config/secrets) — must be declared in `edgezero.toml`. +- **This plan is D6-a-locked.** If Task 1 selects D6-b (move key rotation to ops/CLI) or D6-c (upstream write API), **stop after Task 1** and write a separate plan — those change the admin API surface and are out of Phase 1 scope. + +--- + +## Task 1: Kind-aware store inventory + confirm D6-a (decision gate, no deletions) + +Deliverable: a **decision record** appended to "Task 1 Output" that Tasks 2+ consume. No code is deleted here. + +**Files:** +- Modify (append record): this plan file. +- Read-only inventory: `crates/trusted-server-core/src/**`, `crates/trusted-server-adapter-*/src/**`, `edgezero.toml`, `trusted-server.example.toml`, `crates/trusted-server-integration-tests/fixtures/**`. + +**Interfaces:** +- Produces: the **kind-partitioned store-id map** (`{kv, config, secrets}` → each logical id → platform name → declared?) and the **write-site list**, consumed by Tasks 2, 3, 8. + +- [ ] **Step 1: Enumerate store ids by kind** + +Run: +```bash +cd /Users/ag/projects/iab/trusted-server/.claude/worktrees/edgezero-migration-spec +# KV ids (logical ids referenced by Settings — NOT Fastly-only platform stores) +rg -n 'ec_store|consent_store|creative_store' crates/trusted-server-core/src/settings.rs crates/trusted-server-core/src/consent_config.rs crates/trusted-server-core/src/auction_config_types.rs trusted-server.example.toml +# config ids (incl. DataDome IP-CIDR config store) +rg -n 'config_store_id|jwks|JWKS_CONFIG_STORE_NAME|"app_config"|config_store\s*=|datadome-ip-bypass|default_ip_cidr_source_store' crates/trusted-server-core trusted-server.example.toml +# secret ids +rg -n 'secret_store_id|secret_store\s*=|"secrets"|ts_secrets|signing_keys|SIGNING_SECRET_STORE_NAME' crates/trusted-server-core trusted-server.example.toml +rg -n '\[stores\.' edgezero.toml +``` +Expected (verified): **KV** ids = `ec.ec_store` (`ec_identity_store`, `settings.rs:452`), `consent.consent_store` (`consent_config.rs:80`), and `auction.creative_store` (`auction_config_types.rs:28`, default `"creative_store"`, **deprecated** — creatives are delivered inline); **config** ids = the app-config blob store (**store id `trusted_server_config`**, see D5 rule below), `request_signing.config_store_id`, the JWKS store (`JWKS_CONFIG_STORE_NAME`), and **DataDome's IP-CIDR config store** (`ProtectionIpCidrSourceConfig.config_store`, default `datadome-ip-bypass`, `protection_scope.rs:165`); **secret** ids = `secrets` (`request_signing.secret_store_id`), DataDome `ts_secrets`, the S3 secret store, `signing_keys` (`SIGNING_SECRET_STORE_NAME`) — versus `edgezero.toml` declaring only one id per kind. NOTE: `counter_store` (`RATE_COUNTER_NAME` in the Fastly `rate_limiter.rs`) and `opid_store` are **Fastly-only** platform stores, not `Settings` logical ids — out of scope for D5. `creative_store` **is** a `Settings` id: declare it in `[stores.kv]` (deprecated) so strict lookup can't fail, and flag it for removal in a later phase. + + **D5 app-config store-id/key decision (record in Task 1 Output):** the app-config blob → config **store id `trusted_server_config`**, blob **key `trusted_server_config`** (key == store id, D7-consistent — see below). This changes `settings_data.rs::DEFAULT_CONFIG_STORE_ID` from `"app_config"` to `"trusted_server_config"` (`settings_data.rs:11`) **and** `config_payload.rs::CONFIG_BLOB_KEY` from `"app_config"` to `"trusted_server_config"`. Rationale: `default_config_key()` falls back to the id when no `…__KEY` env is set, and D7 forbids that runtime env — so the key must equal the id, or `ts config push` would need `--key`. The `app_config` name is fully retired. + + **Request-signing store ids (do NOT point at app-config):** request signing reads use hard-coded `JWKS_CONFIG_STORE_NAME = "jwks_store"` (config) + `SIGNING_SECRET_STORE_NAME = "signing_keys"` (secret); writes use `request_signing.config_store_id`/`secret_store_id`. Today the example sets these to `"app_config"`/`"secrets"` — which sends **writes to a different store than reads**. Fix: set `request_signing.config_store_id = "jwks_store"` and `secret_store_id = "signing_keys"` in `trusted-server.example.toml` + fixtures, and declare `jwks_store` (config) + `signing_keys` (secret) as logical ids in `edgezero.toml`. (Under the composite, reads resolve `registry.named("jwks_store")`; writes go to the same store via the writer/management id.) + +- [ ] **Step 2: Enumerate runtime WRITE sites** + +Run: +```bash +rg -n '\.config_store\(\)\.(put|delete)|\.secret_store\(\)\.(create|delete)' crates/trusted-server-core +``` +Expected: only `KeyRotationManager` in `crates/trusted-server-core/src/request_signing/rotation.rs` (`store_private_key`, `store_public_jwk`, `deactivate_key`, `delete_key`). Confirm no other runtime writers. + +- [ ] **Step 3: Record the kind-partitioned D5 map** + +Append a table to "Task 1 Output": for each `{kv|config|secrets}` id → resolution (declare in `edgezero.toml`, or collapse onto the kind's default) → the concrete platform resource per adapter. **Under D7 there is no `EDGEZERO__STORES__*__NAME` mapping** — the logical id opens the same-named platform store, so the table records the *platform resource per adapter* (Fastly local/prod store, CF KV namespace / flat secret keys, Spin KV label / variable), **not** an env var. Spec default: app-config blob → config id `trusted_server_config` key `trusted_server_config` (key == id); JWKS → its own config id; `ec_identity_store` → kv id; collapse `secrets`→`trusted_server_secrets` where identical; declare DataDome/S3/signing as distinct secret ids. + +- [ ] **Step 4: Confirm D6-a (or STOP)** + +Confirm this phase keeps the write-capable composite (D6-a). Record it. **If the team instead chooses D6-b/c, stop here** and open a separate plan (`…-key-rotation-ops-migration.md`); do not proceed to Task 2. + +- [ ] **Step 5: Commit the record** + +```bash +git add docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md +git commit -m "Record Phase 1 kind-aware store-id map and confirm D6-a" +``` + +--- + +## Task 2: Declare all store ids (kv/config/secrets) in `edgezero.toml` + reconcile fields/fixtures + +**Files (exact):** +- Modify: `edgezero.toml` (`[stores.kv]`/`[stores.config]`/`[stores.secrets]` `ids`) +- Modify: `crates/trusted-server-core/src/settings_data.rs` (`DEFAULT_CONFIG_STORE_ID`) +- Modify: `crates/trusted-server-core/src/config_payload.rs` (`CONFIG_BLOB_KEY` → `"trusted_server_config"`) +- Modify: `trusted-server.example.toml`, `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml` +- Create: `crates/trusted-server-core/src/testdata/all-store-refs.toml` +- **Modify (integration test surfaces that hard-code `app_config` — break under the rename):** `crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs` (`[local_server.config_stores.app_config]` + `app_config = '''…'''` + the generator test asserting them → `trusted_server_config`), `crates/trusted-server-integration-tests/tests/common/config.rs` (`{"app_config": envelope}` → `{"trusted_server_config": …}`), `crates/trusted-server-integration-tests/tests/environments/axum.rs` (env `TRUSTED_SERVER_CONFIG_APP_CONFIG_APP_CONFIG` → the trusted_server_config-keyed name) +- Modify (platform manifests): `fastly.toml`, `crates/trusted-server-adapter-cloudflare/wrangler.toml`, `crates/trusted-server-adapter-spin/spin.toml` (Axum uses `.edgezero/local-config-*.json` local dev files — document but do not commit machine-local state) +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs` (`settings_from_cloudflare_config_json` side-channel key `app_config` → `CONFIG_BLOB_KEY`) +- Create: `crates/trusted-server-core/src/stores.rs` (shared `stores_metadata()`); Modify: each `crates/trusted-server-adapter-{fastly,axum,cloudflare,spin}/src/app.rs` (`impl Hooks` → add `fn stores()`) +- Test: `crates/trusted-server-core/src/settings.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Consumes: Task 1 map. +- Produces: `Settings::referenced_store_ids_by_kind() -> ReferencedStoreIds { kv: BTreeSet, config: BTreeSet, secrets: BTreeSet }`; an `edgezero.toml` whose per-kind `ids` are supersets. + +- [ ] **Step 1: Write the failing test (parameterized over multiple configs)** + +Cover the example config, the integration fixture, AND a purpose-built config that exercises every store-backed field (DataDome IP-CIDR sources, S3 auth, request-signing) so optional/targeted settings can't escape coverage. Add to `settings.rs` under `#[cfg(test)]`: +```rust +fn assert_all_ids_declared(config_toml: &str, label: &str) { + let settings = Settings::from_toml(config_toml).unwrap_or_else(|e| panic!("{label} should parse: {e}")); + let referenced = settings.referenced_store_ids_by_kind(); + let declared = declared_store_ids_by_kind_from_manifest(); // reads edgezero.toml + for (kind, ids) in [("kv", &referenced.kv), ("config", &referenced.config), ("secrets", &referenced.secrets)] { + for id in ids { + assert!( + declared.for_kind(kind).contains(id), + "[{label}] {kind} store id `{id}` referenced by Settings is not declared in edgezero.toml", + ); + } + } +} + +#[test] +fn every_referenced_store_id_is_declared_by_kind() { + assert_all_ids_declared(include_str!("../../../trusted-server.example.toml"), "example"); + assert_all_ids_declared( + include_str!("../../trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml"), + "integration-fixture", + ); + // Purpose-built config exercising DataDome IP-CIDR, S3, and request-signing store refs. + assert_all_ids_declared(include_str!("testdata/all-store-refs.toml"), "all-store-refs"); +} +``` +(Create `crates/trusted-server-core/src/testdata/all-store-refs.toml` populating every store-id field with a declared id. `settings.rs` lives in `src/`, so the `include_str!` path relative to it is `testdata/all-store-refs.toml` — **not** `../testdata/…`.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test-fastly every_referenced_store_id_is_declared_by_kind` +Expected: FAIL — `ec_identity_store`/`consent_store`/`creative_store` (kv), `jwks_store`/`datadome-ip-bypass`/`trusted_server_config` (config), `signing_keys`/`ts_secrets` (secrets) referenced but not declared. + +- [ ] **Step 3: Implement `referenced_store_ids_by_kind()` + manifest helper** + +Add the `ReferencedStoreIds` struct + method returning **KV** ids (`ec.ec_store`, `consent.consent_store`, `auction.creative_store`), **config** ids (`request_signing.config_store_id`, the app-config store id, and **every `ProtectionIpCidrSourceConfig.config_store`** from DataDome scopes — default `datadome-ip-bypass`), **secret** ids (`request_signing.secret_store_id`, DataDome `ts_secrets`, S3). Do **not** include `counter_store`/`opid_store`. Add test-only `declared_store_ids_by_kind_from_manifest()` parsing `edgezero.toml`. + +Apply the **D5 renames**: set `settings_data.rs::DEFAULT_CONFIG_STORE_ID = "trusted_server_config"`; set `request_signing.config_store_id = "jwks_store"` and `secret_store_id = "signing_keys"` in `trusted-server.example.toml` + fixtures (they must match the read constants — **not** `app_config`/`secrets`). + +- [ ] **Step 4: Declare every id in `edgezero.toml`** — `[stores.kv]` = `trusted_server_kv`, `ec_identity_store`, `consent_store`, `creative_store`; `[stores.config]` = `trusted_server_config`, `jwks_store`, `datadome-ip-bypass`; `[stores.secrets]` = `trusted_server_secrets`, `signing_keys`, `ts_secrets`, `s3-auth`. (Names double as the platform store names under D7.) Also set `config_payload.rs::CONFIG_BLOB_KEY = "trusted_server_config"` (blob key == store id, per the D5 rule) so `ts config push`'s default key and the boot read agree with no env/`--key`. + +- [ ] **Step 5: Wire `Hooks::stores()` on all four adapters (Blocker — metadata is not wired today).** Each `impl Hooks for TrustedServerApp` currently overrides only `routes()`; the default `stores()` returns **empty** `StoresMetadata`, so no registries can be built from it. Add `fn stores() -> StoresMetadata` returning the `[stores.*]` metadata, generated once from `edgezero.toml`. Prefer a single shared fn in `trusted-server-core` (`pub fn stores_metadata() -> StoresMetadata`) that all four adapters return, so the ids live in one place. Verify against `edgezero_core::app::StoresMetadata`/`StoreMetadata` shape. + +- [ ] **Step 5b: Anti-drift test — `stores_metadata()` and every adapter's `Hooks::stores()` must equal `edgezero.toml`.** Registries are built from `TrustedServerApp::stores()`, **not** from the `edgezero.toml` that Step 1's test validates — so a stale/incomplete `stores_metadata()` would pass Step 1 while runtime registries silently miss ids. Add a test that parses `edgezero.toml`'s `[stores.*]` ids/default and asserts they equal `trusted_server_core::stores_metadata()` **and** each `::TrustedServerApp::stores()` (per kind, ids as sets + default). Put the core half in `trusted-server-core` and one assertion in each adapter's test module (so a future adapter that forgets to return `stores_metadata()` fails). + +- [ ] **Step 6: Declare the stores in every PLATFORM manifest (Blocker — local resources missing), per each adapter's real mapping.** D7 requires each logical id to be openable as a real platform store. The adapters map kinds to concrete resources differently — declare exactly: + - **Fastly** (`fastly.toml`): KV ids → `[[local_server.kv_stores.]]`; config ids → `[local_server.config_stores.]`; secret ids → `[local_server.secret_stores.]`. Also add the production-service store bindings for each id. + - **Cloudflare manifest `cloudflare.toml` — reconcile the STALE schema (Medium blocker).** `crates/trusted-server-adapter-cloudflare/cloudflare.toml` still uses the pre-rewrite manifest schema (`[stores.kv].name = …`, `[stores.kv.adapters.cloudflare].name = …`), which the pinned EdgeZero manifest parser (`manifest.rs`, `deny_unknown_fields`) **rejects** in favor of `[stores.*]` `ids`/`default`. Either migrate it to the `ids`/`default` schema (matching `edgezero.toml`) or **delete it if `edgezero.toml` is the single source** and nothing loads `cloudflare.toml`. Do not leave a stale-schema manifest that a tool/test could load. + - **Cloudflare** (`wrangler.toml`): EdgeZero backs **config stores by a KV namespace binding** (`config_store.rs`) — so each **config** id (`trusted_server_config`, `jwks_store`, `datadome-ip-bypass`) gets a `[[kv_namespaces]]` binding (as does each KV id). **Secrets use a FLAT namespace — `CloudflareSecretStore::get_bytes` ignores `store_name` and reads `env.secret(key)`.** So do **not** `wrangler secret put signing_keys` (that provisions the wrong name). Provision the concrete secret **keys the code reads**: the signing KIDs written by `KeyRotationManager`, the DataDome `server_side_key_secret_name`, and the S3 `access_key_id` / `secret_access_key` / optional session-token keys. Document the exact `wrangler secret put ` commands in the operator runbook; `store_name`/store-id is irrelevant on Cloudflare. + - **Spin** (`spin.toml`): config **and** KV ids open **KV-store labels** (`request.rs:282`) — declare each under the component's `key_value_stores = [...]`. Secrets are likewise a **flat** namespace (`SpinSecretStore` ignores `store_name`) mapped to Spin variables — provision the concrete secret **keys** (as for Cloudflare), lowercased per Spin's variable rules, not the store id. + +- [ ] **Step 6b: Update every surface that hard-codes the old `app_config` store/key** (they run in the adapter suites / boot paths, so the rename breaks them): + - **`generate-viceroy-config.rs` — MERGE, don't duplicate.** The generator already emits `[local_server.config_stores.trusted_server_config]` holding the **rollout flags** (`edgezero_enabled`, `edgezero_rollout_pct`), separate from the `app_config` store holding the envelope blob. After the rename both live in ONE store `trusted_server_config`, so **merge the envelope entry into the existing `trusted_server_config.contents` table under key `trusted_server_config`** (alongside the flag keys) — do **not** emit a second `[local_server.config_stores.trusted_server_config]` block (duplicate table). Update the generator's assertion test accordingly. + - **`tests/common/config.rs`:** `{"app_config": envelope}` → `{"trusted_server_config": envelope}`. + - **`tests/environments/axum.rs`:** rename the `TRUSTED_SERVER_CONFIG_APP_CONFIG_APP_CONFIG` env var to the `trusted_server_config`-keyed name the Axum config store expects. + - **`crates/trusted-server-adapter-cloudflare/src/app.rs` (`settings_from_cloudflare_config_json`):** it reads the literal `value.get("app_config")` from the `TRUSTED_SERVER_CONFIG` side-channel (Cloudflare stays on the side-channel until Phase 2). Change this literal to `CONFIG_BLOB_KEY` (now `"trusted_server_config"`) so Cloudflare boot doesn't break under the rename. (Key-string update only; the Phase 2 store migration is separate.) + - **`crates/trusted-server-integration-tests/tests/environments/cloudflare.rs`:** the `inject_cloudflare_config` tests assert against `{"app_config":"blob"}` / `TRUSTED_SERVER_CONFIG = '''{"app_config":…}'''` literals (lines ~206/210/221/232) — update to the `trusted_server_config` key. + - **`crates/trusted-server-adapter-cloudflare/wrangler.toml`:** the placeholder `TRUSTED_SERVER_CONFIG = '{"app_config":""}'` (line ~23) + its `app_config` comment (line ~21) — update to `trusted_server_config`. + - **Axum**: dev-only local files `.edgezero/local-config-.json` (config) and the redb KV default; document how to seed them, do not commit machine-local state. + Cross-check each existing manifest — some ids (`jwks_store`, `signing_keys`) are already partially declared (`request_signing/mod.rs` doc references `fastly.toml`); add only the missing ones. + +- [ ] **Step 7: Run to verify pass + full adapter suites + wasm checks** + +Run: `cargo test-fastly every_referenced_store_id_is_declared_by_kind` +Then: `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin && cargo check-cloudflare && cargo check-spin` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add edgezero.toml fastly.toml trusted-server.example.toml \ + crates/trusted-server-adapter-cloudflare/wrangler.toml \ + crates/trusted-server-adapter-cloudflare/cloudflare.toml \ + crates/trusted-server-adapter-spin/spin.toml \ + crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml \ + crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs \ + crates/trusted-server-integration-tests/tests/common/config.rs \ + crates/trusted-server-integration-tests/tests/environments/axum.rs \ + crates/trusted-server-integration-tests/tests/environments/cloudflare.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ + crates/trusted-server-core/src/settings.rs \ + crates/trusted-server-core/src/settings_data.rs \ + crates/trusted-server-core/src/config_payload.rs \ + crates/trusted-server-core/src/stores.rs \ + crates/trusted-server-core/src/lib.rs \ + crates/trusted-server-core/src/testdata/all-store-refs.toml \ + crates/trusted-server-adapter-fastly/src/app.rs \ + crates/trusted-server-adapter-axum/src/app.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ + crates/trusted-server-adapter-spin/src/app.rs +git commit -m "Declare store ids in edgezero.toml, manifests, Hooks::stores(); rename app-config store/key to trusted_server_config" +``` + +--- + +## Task 3: Registry-backed composite store (reads → EdgeZero registry by store_name, writes → management path) + +Concrete D6-a mechanism. The bespoke traits read **by `StoreName`** and callers use **multiple** store ids (`trusted_server_config`, `jwks_store`, `datadome-ip-bypass`, `s3-auth`, `ts_secrets`, `ec_identity_store` for KV). So the composite must hold the **whole `ConfigRegistry`/`SecretRegistry`** (not a single handle) and resolve `named(store_name)` on each read; writes (`put`/`create`/`delete`) delegate to the existing management-API-backed writer. Preserves `KeyRotationManager` writes with zero call-site changes. + +**Files:** +- Modify: `crates/trusted-server-core/src/platform/traits.rs` (split write-only traits) +- Create: `crates/trusted-server-core/src/platform/composite.rs` (`CompositeConfigStore`, `CompositeSecretStore`) +- Modify: `crates/trusted-server-core/src/platform/mod.rs` (export composite + writer traits) +- Test: `crates/trusted-server-core/src/platform/composite.rs` (`#[cfg(test)]`) + +**Interfaces:** +- Consumes: `edgezero_core::store_registry::{ConfigRegistry, SecretRegistry, ConfigStoreBinding, BoundSecretStore}`, write-only `Arc`/`Arc`. +- Produces: + - New **write-only** traits `PlatformConfigWriter { put; delete }` and `PlatformSecretWriter { create; delete }` (extracted from the read+write `PlatformConfigStore`/`PlatformSecretStore`). This is what lets Task 8 delete the per-adapter **read** impls while keeping the writer object — the writer no longer needs `get`/`get_bytes`. + - `CompositeConfigStore::new(reader: Option, writer: Arc) -> Self` implementing the full read+write `PlatformConfigStore`. The reader is `Option` because **an empty `StoreRegistry` cannot be constructed** (fields are private; `from_parts` returns `None` on empty `by_id`) — so the "absent registry" case is `None`, not an empty registry. `get(store_name, key)` = `let reg = self.reader.as_ref().ok_or(PlatformError::ConfigStore)?;` then (since **`ConfigRegistry::named(id)` returns `Option`, not a handle**) `let binding = reg.named(store_name.as_str()).ok_or(PlatformError::ConfigStore)?;` then `block_on(binding.handle.get(key))`. EdgeZero `ConfigStore::get` returns `Result, ConfigStoreError>`; the bespoke `get` returns `Result`, so map `Ok(None)`/`Err(ConfigStoreError::*)` → `PlatformError::ConfigStore`. `put`/`delete` → `writer`. + - `CompositeSecretStore::new(reader: Option, writer: Arc) -> Self` implementing `PlatformSecretStore`: `get_bytes(store_name, key)` = `self.reader.as_ref().ok_or(PlatformError::SecretStore)?.named(store_name.as_str()).ok_or(PlatformError::SecretStore)?` → `block_on(bound.get_bytes(key))`; map `Ok(None)`/`Err` → `PlatformError::SecretStore`. `create`/`delete` → `writer`. Both an **absent registry** (`None`) and a store_name not in the registry are hard errors (strict), never a silent fallback. + +- [ ] **Step 0: Split write-only traits.** In `traits.rs`, define `pub trait PlatformConfigWriter: Send + Sync { put; delete }` and `pub trait PlatformSecretWriter: Send + Sync { create; delete }` (matching the parent traits' `Send + Sync` bounds, since they're held as `Arc`). Keep `PlatformConfigStore`/`PlatformSecretStore` as the read+write surface `RuntimeServices` exposes. This split is the prerequisite that makes Task 8's "delete reads, keep writes" compile. Run `cargo check-axum` to confirm the split compiles before proceeding. + +- [ ] **Step 1: Write the failing test — reads resolve the NAMED store; unknown store errors; writes delegate** + +```rust +#[test] +fn composite_config_reads_named_store_and_writes_delegate() { + // Arrange: a ConfigRegistry with TWO ids (default `trusted_server_config`, non-default `jwks_store`). + let reader = config_registry(&[ + ("trusted_server_config", "current-kid", "kid-1"), + ("jwks_store", "kid-1", "{\"kty\":\"OKP\"}"), + ], "trusted_server_config"); + let writer = Arc::new(RecordingConfigWriter::default()); + let composite = CompositeConfigStore::new(Some(reader), writer.clone()); + // Act + Assert: non-default store resolves. + let jwk = composite + .get(&StoreName::from("jwks_store"), "kid-1") + .expect("should read from the non-default jwks_store"); + assert_eq!(jwk, "{\"kty\":\"OKP\"}"); + // Unknown store id is a strict error, not a fallback to default. + let err = composite.get(&StoreName::from("nope"), "kid-1").expect_err("should error on unknown store id"); + assert!(matches!(err.current_context(), PlatformError::ConfigStore), "unknown id -> ConfigStore error"); + // Write delegates to the management-path writer, PRESERVING the target StoreId (core D6-a risk). + composite + .put(&StoreId::from("jwks_store"), "current-kid", "kid-2") + .expect("should delegate write"); + assert_eq!( + writer.puts.lock().expect("should acquire writer lock").as_slice(), + &[("jwks_store".to_owned(), "current-kid".to_owned(), "kid-2".to_owned())], + "write must delegate to the writer with the SAME StoreId, key, and value", + ); +} +``` + +- [ ] **Step 1b: Write the failing SECRET-composite test too** (not just config — Cloudflare/Spin secrets are a **flat** namespace, so route tests won't prove store-id binding; this unit test does). Mirror the config test for `CompositeSecretStore`: +```rust +#[test] +fn composite_secret_reads_named_store_and_writes_delegate() { + // SecretRegistry with default + a non-default `ts_secrets` id. + let reader = secret_registry(&[ + ("trusted_server_secrets", "API_KEY", b"default-key"), + ("ts_secrets", "server-side-key", b"dd-secret"), + ], "trusted_server_secrets"); + let writer = Arc::new(RecordingSecretWriter::default()); + let composite = CompositeSecretStore::new(Some(reader), writer.clone()); + // Non-default store resolves. + let v = composite.get_bytes(&StoreName::from("ts_secrets"), "server-side-key").expect("read"); + assert_eq!(v, b"dd-secret"); + // Unknown store id is a strict error. + assert!(matches!( + composite.get_bytes(&StoreName::from("nope"), "x").expect_err("should error on unknown secret store").current_context(), + PlatformError::SecretStore, + )); + // create/delete delegate with the target StoreId preserved. + composite.create(&StoreId::from("ts_secrets"), "new", "val").expect("create delegates"); + assert_eq!( + writer.creates.lock().expect("should acquire writer lock").as_slice(), + &[("ts_secrets".to_owned(), "new".to_owned(), "val".to_owned())], + "create must delegate with the SAME StoreId", + ); +} +``` + +- [ ] **Step 2: Run to verify both fail** + +Run: `cargo test-fastly composite_` (one shared-prefix filter runs both `composite_config_…` and `composite_secret_…`; `cargo test` takes a single filter). +Expected: FAIL (module does not exist). + +- [ ] **Step 3: Implement `composite.rs`** + +`get` resolves `reader.named(store_name)` → `ConfigStoreBinding`, then `block_on(binding.handle.get(key))`; `get_bytes` resolves `reader.named(store_name)` → `BoundSecretStore`, then `block_on(bound.get_bytes(key))` (mirror `storage/kv_store.rs`). Strict: `named` returning `None` → `PlatformError`; EdgeZero `Ok(None)`/`Err` → `PlatformError`. `put`/`create`/`delete` forward to `writer`. Add `config_registry(entries, default)` / `secret_registry(...)` test helpers that build a real `StoreRegistry` from in-memory EdgeZero stores (config entries wrapped as `ConfigStoreBinding { handle, default_key }`), and a `RecordingConfigWriter` (impl `PlatformConfigWriter`) that records **`(StoreId, key, value)`** tuples so tests assert the target StoreId is preserved on delegation. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cargo test-fastly composite_` (runs both config + secret composite tests) +Expected: PASS. + +- [ ] **Step 5: Reconcile `StoreName` semantics (D7).** `platform/types.rs::StoreName` is documented as an "edge-visible **platform** name". The composite now resolves `registry.named(store_name.as_str())` by **logical id**, so `StoreName` for reads must carry the **logical store id**. Update the `StoreName` doc comment to say "logical runtime store id" for reads, and audit read call sites (`request_signing/{signing,rotation}.rs`, `proxy.rs`, `integrations/datadome/{protection,protection_scope}.rs`) to confirm they pass **logical ids** (`trusted_server_config`, `jwks_store`, `ts_secrets`, `datadome-ip-bypass`, …), not physical platform names. No functional change if ids already equal names (D7 convention), but the doc + audit prevent implementers from passing physical names into logical registries. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/platform/ +git commit -m "Add registry-backed composite store; document StoreName as logical read id" +``` + +--- + +## Task 4: Migrate Fastly + Axum BOOT config read to EdgeZero (before deleting bespoke impls) + +`build_state()` loads `Settings` at boot via `get_settings_from_config_store(&FastlyPlatformConfigStore, …)` / `&AxumPlatformConfigStore` — **before** any request context. Migrate the boot read to an EdgeZero-backed boot reader so the bespoke impls can be deleted later (Task 8) without breaking boot. (P-BOOT option a for Fastly/Axum: `ConfigStore` opens at boot.) + +**Files:** +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs:161` (`load_settings_from_config_store`) +- Modify: `crates/trusted-server-adapter-axum/src/app.rs:54` (`build_state`) +- Modify: `crates/trusted-server-core/src/settings_data.rs` (accept an EdgeZero `ConfigStoreHandle` reader) +- Test: `crates/trusted-server-adapter-fastly/src/app.rs` (`#[cfg(test)]`), Axum equivalent + +**Interfaces:** +- Consumes: `edgezero_core` Fastly/Axum `ConfigStore` open primitives; Task 3 nothing (boot read is direct). +- Produces: `get_settings_from_config_store` taking `&ConfigStoreHandle` (EdgeZero) instead of `&dyn PlatformConfigStore`. + +- [ ] **Step 1: Write a failing CORE-level test** for the re-typed loader (deterministic, no adapter/Viceroy). In `crates/trusted-server-core/src/settings_data.rs` `#[cfg(test)]`, build an in-memory EdgeZero store and assert the loader parses the blob: + +```rust +#[test] +fn get_settings_reads_blob_via_edgezero_handle() { + // Arrange: an EdgeZero ConfigStoreHandle over an in-memory store holding the blob envelope. + let blob = blob_envelope_json(include_str!("../../../trusted-server.example.toml")); + let handle = ConfigStoreHandle::new(Arc::new(InMemoryConfigStore::with(&[("trusted_server_config", &blob)]))); + // Act + let settings = get_settings_from_config_store(&handle, "trusted_server_config") + .expect("should parse settings from the EdgeZero-read blob"); + // Assert + assert!(settings.ec.ec_store.is_some(), "should deserialize the example config"); +} +``` +(`InMemoryConfigStore` is a local test double implementing `edgezero_core::config_store::ConfigStore`; `blob_envelope_json` wraps the TOML→JSON in a `BlobEnvelope`. Add both to the test module.) + +Run: `cargo test-fastly get_settings_reads_blob_via_edgezero_handle` → Expected: FAIL. + +- [ ] **Step 2: Re-type `get_settings_from_config_store`** to `(&ConfigStoreHandle, key: &str)`, called with **store id `trusted_server_config`, key `trusted_server_config`** (D5 — key == store id; `CONFIG_BLOB_KEY` is set to `trusted_server_config` in Task 2). In Fastly `load_settings_from_config_store()` open the EdgeZero `FastlyConfigStore` for `trusted_server_config` at boot and wrap in a `ConfigStoreHandle`. In Axum `build_state()` open the EdgeZero Axum config store, which reads **`.edgezero/local-config-trusted_server_config.json`** (`edgezero-adapter-axum/src/config_store.rs` — id-scoped local file); do **not** apply any env-key override (D7). The adapter-level boot wiring is exercised by each adapter's existing `build_state` test path (no new Viceroy test needed — the core test above covers the parse logic). + +- [ ] **Step 3: Run to verify pass** (core test + adapter boot suites) + +Run: `cargo test-fastly get_settings_reads_blob_via_edgezero_handle` +Expected: PASS. +Then confirm the adapter boot paths still build/pass via their existing `build_state` coverage: +Run: `cargo test-fastly && cargo test-axum` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-axum/src/app.rs crates/trusted-server-core/src/settings_data.rs +git commit -m "Load boot config via EdgeZero config store on Fastly and Axum" +``` + +--- + +## Task 5: Preserve `AxumDevServer::with_config` + add registries; RuntimeServices via composite (Axum/Cloudflare/Spin) + +**Blocker addressed:** Axum today calls `TrustedServerApp::routes()` + `AxumDevServer::with_config(...)` (`adapter-axum/src/main.rs:23`) — which never builds registries. We **keep** `AxumDevServer::with_config` (to preserve the custom `PORT`/`axum.toml` behavior) and add registries via its `.with_config_registry()/.with_kv_registry()/.with_secret_registry()` builder methods (Step 0). Cloudflare and Spin already dispatch via EdgeZero `run_app`, which builds registries from `Hooks::stores()` (wired in Task 2 Step 5) — confirm they do once `stores()` exists. Then build `RuntimeServices` config/secret from `CompositeConfigStore`/`CompositeSecretStore` (reader = the whole request registry from extensions; writer = the per-adapter write-only impl). Store-name binding uses EdgeZero's `EnvConfig` fallback-to-logical-id (D7 — we set no `EDGEZERO__STORES__*__NAME`). + +**Files:** +- Modify: `crates/trusted-server-adapter-axum/src/main.rs` (keep `AxumDevServer::with_config`, chain registry setters) +- Create: `crates/trusted-server-adapter-axum/src/registries.rs` (`build_{config,kv,secret}_registry_axum(&StoresMetadata)`) +- **Modify (core KV surface, Step 2c):** `crates/trusted-server-core/src/platform/types.rs` (`RuntimeServices::kv_handle_named` + `kv_registry` field/builder), `crates/trusted-server-core/src/consent/mod.rs` + `storage/kv_store.rs` (consent KV surface → `KvHandle`), `crates/trusted-server-core/src/publisher.rs` (interim call site keeps `kv_handle()`; the named `consent_store` flip is **Task 6**) +- Modify: `crates/trusted-server-adapter-{axum,cloudflare,spin}/src/platform.rs` (`build_runtime_services` → composite + `kv_registry` from extensions) +- **Fastly named-KV + composite + `runtime_services_for_consent_route` removal → Task 6** (Fastly injects registries only in Task 6). Task 5 leaves Fastly compiling: the core `kv_handle_named` is additive and returns `None` on Fastly until Task 6 wires `kv_registry` (consent falls back safely). +- Test-support (Step 2d): a shared `test_context_with_registries(...)` helper; migrate existing direct-context tests (`adapter-{axum,cloudflare,spin,fastly}` test modules) +- Test: `crates/trusted-server-adapter-axum/src/app.rs` route tests (+ cloudflare/spin equivalents; non-default KV test per adapter) + +- [ ] **Step 0: Wire registries into Axum while keeping the custom PORT behavior.** Do **not** call `dev_server::run_app` — it reads bind config only from `EDGEZERO__ADAPTER__HOST/PORT` and would drop trusted-server's `PORT`/`axum.toml` handling (`main.rs:11`, `port_from_env`). Instead keep the current `AxumDevServer::with_config(router, config)` and chain the builder's registry setters (verified present: `AxumDevServer::{with_config_registry, with_kv_registry, with_secret_registry}`): +```rust +// adapter-axum/src/main.rs +let router = TrustedServerApp::routes(); +let stores = trusted_server_core::stores_metadata(); // Task 2 Step 5 +let mut server = AxumDevServer::with_config(router, config); +if let Some(reg) = build_config_registry_axum(&stores) { server = server.with_config_registry(reg); } +if let Some(reg) = build_kv_registry_axum(&stores) { server = server.with_kv_registry(reg); } +if let Some(reg) = build_secret_registry_axum(&stores) { server = server.with_secret_registry(reg); } +server.run()?; +``` +Add `build_*_registry_axum(&StoresMetadata)` in `adapter-axum/src/registries.rs` mirroring Task 6's Fastly by-id builders but opening the EdgeZero **Axum** store primitives (`.edgezero/*` local backends). This preserves PORT/axum.toml exactly and wires registries. Run `cargo run -p trusted-server-adapter-axum` to confirm it boots on the configured port. + +**Interfaces:** +- Consumes: Task 3 composite; `ConfigRegistry`/`SecretRegistry` from request extensions. +- Produces: `RuntimeServices` whose reads flow through EdgeZero, writes through the composite writer. + +- [ ] **Step 1: Write failing Axum tests covering the default AND a non-default config id AND a non-default secret id** (in the Axum app test module): + - `discovery_reads_jwks_from_nondefault_config_store` — `GET /.well-known/trusted-server.json`: seed the Axum `ConfigRegistry` with two ids (`trusted_server_config` default + the JWKS store id); assert `200` + the JWKS `kid` in the body (proves non-default **config** id resolution). + - `datadome_reads_secret_from_nondefault_secret_store` — a request to a **protected non-integration** route (the DataDome server-side key is read during protected-request *filtering* in `protection.rs`, which **skips** the `/integrations/datadome/*` routes — see `protection.rs:110`), so drive a publisher/first-party route in DataDome's protection scope. Seed the `SecretRegistry` with two ids (default + `ts_secrets`) and the server-side key under `ts_secrets`; assert the filter reads it (proves non-default **secret** id resolution). + - `first_party_proxy_reads_s3_secret` — `GET /first-party/proxy` for an S3-auth asset route: seed the S3 secret id; assert the SigV4 path obtains the secret (proves the S3 secret read). + +Run each (one filter per `cargo test` invocation): +```bash +cargo test-axum discovery_reads_jwks_from_nondefault_config_store +cargo test-axum datadome_reads_secret_from_nondefault_secret_store +cargo test-axum first_party_proxy_reads_s3_secret +``` +Expected: FAIL (all three). + +- [ ] **Step 2: Build `RuntimeServices` via the composite** in each adapter's `build_runtime_services(ctx: &RequestContext)`. **Extract the whole registry from request extensions** — `ctx.request().extensions().get::().cloned()` / `get::()` — the same way EdgeZero's `Config`/`Secrets` extractors do. Do **not** use `ctx.config_store_default()`/`config_store(id)` (those return a single bound handle and would wire only the default store). Pass the cloned registry **`Option`** as the composite reader (Task 3) and the per-adapter **write-only** impl (`PlatformConfigWriter`/`PlatformSecretWriter`) as the writer. **Absent-registry policy (concrete):** `build_runtime_services` returns `RuntimeServices` (not `Result`) on all adapters, so don't add a fallible signature. The composite reader is `Option` / `Option` (an empty `StoreRegistry` is unconstructable — private fields; `from_parts` → `None` on empty). So pass `ctx.request().extensions().get::().cloned()` (already an `Option`) straight into `CompositeConfigStore::new(...)`; when it's `None`, the composite's `get`/`get_bytes` **error** (`PlatformError`) on first read rather than silently reading a default store. A missing registry surfaces as a read error at the call site — no builder signature change, no empty-registry construction. + +- [ ] **Step 2b: Non-default coverage on Cloudflare AND Spin (not just Axum).** Cloudflare/Spin platform mappings differ (config = KV-namespace/label backed; secrets = flat namespace), so default-only assertions are insufficient. Add, in each of the Cloudflare and Spin test modules, tests proving **non-default** resolution: a `jwks_store` **config** read and a `ts_secrets` / S3 **secret**-key read resolve through the composite (route tests if cheap, else small `build_runtime_services` + composite-read tests seeding a 2-id registry). + +- [ ] **Step 2c: Named-KV resolution — a CORE surface change, not adapter-only (DECIDED — registry-backed KV now).** The core `RuntimeServices` exposes only `kv_store(&self) -> &dyn PlatformKvStore` — a **single** handle — and consumers that have a store id today **drop it**: `publisher.rs:626` does `settings.consent.consent_store.as_deref().map(|_| services.kv_store())`. So adapter-only changes cannot make `consent_store` resolve. This step is cross-cutting: + - **Type decision — resolve named KV as `KvHandle`, and migrate consent onto `KvHandle`.** `KvRegistry::named(id)` yields a `KvHandle` (a wrapper `{ store: Arc }`), **not** a `&dyn PlatformKvStore` — and `ConsentPipelineInput.kv_store` is currently `Option<&dyn PlatformKvStore>` (`consent/mod.rs:89`), so a `KvHandle` does not fit directly. Do **not** wrap; **migrate the consent KV surface to `KvHandle`** (the idiomatic edgezero handle — `RuntimeServices` already exposes `kv_handle()` for the default). Specifically: + - **Core (`platform/types.rs`):** add `RuntimeServices::kv_handle_named(&self, id: &str) -> Option` (mirroring the existing `kv_handle()`), resolving from a `KvRegistry` carried on `RuntimeServices` (add a `kv_registry` field + builder setter, populated by adapters from `ctx.request().extensions().get::()`). + - **Consent type migration (Task 5) vs behavioral flip (Task 6) — avoid an interim Fastly regression.** Change `ConsentPipelineInput.kv_store` from `Option<&dyn PlatformKvStore>` to `Option` and update the persistence fns (`load_consent_from_kv`/`save_consent_to_kv`/`delete_consent_from_kv`) to take `&KvHandle` (they already `block_on`) — **in Task 5**. But **do NOT flip the `publisher.rs:626` call site to `kv_handle_named` in Task 5**: Fastly has no `KvRegistry` until Task 6, so `kv_handle_named("consent_store")` would return `None` there and consent persistence would **silently skip on Fastly** between Task 5 and Task 6. Today Fastly consent works because `runtime_services_for_consent_route` (`app.rs:205`) reopens the consent store and **swaps the default** kv via `with_kv_store`. So in **Task 5**, the call site keeps today's behavior: pass `services.kv_handle()` (the default handle — which on Fastly is the swapped consent store; on the others matches current behavior). The **named-lookup flip** — `settings.consent.consent_store.as_deref().and_then(|id| services.kv_handle_named(id))` — and removal of the Fastly swap happen **atomically in Task 6**, once all four adapters (Fastly included) inject a `KvRegistry`. The behavioral test (below) therefore lands in **Task 6**. + - Audit other KV consumers (`ec/*`) for the same "id dropped" pattern; they already use `kv_handle()` so are lower-risk. + - **Adapters — Axum/Cloudflare/Spin here; Fastly in Task 6 (sequencing).** Populate `RuntimeServices.kv_registry` from extensions in `build_runtime_services` for **Axum/Cloudflare/Spin** (they inject registries via EdgeZero `run_app`/`dispatch_with_registries`). **Fastly's** named-KV wiring belongs in **Task 6**, because Fastly only injects registries into extensions in Task 6 (its custom `oneshot`); its active per-request services are built in `app.rs:238` (`build_per_request_services`), not `platform.rs`, and its consent special-casing (`app.rs:205`, `runtime_services_for_consent_route`, used at `app.rs:588/735`) is removed **there** once the `kv_registry` is present. Doing Fastly named-KV in Task 5 would populate from a registry not yet in extensions. + - **Files (Task 5):** `crates/trusted-server-core/src/platform/types.rs`, `crates/trusted-server-core/src/consent/mod.rs` (`ConsentPipelineInput.kv_store` type), `crates/trusted-server-core/src/storage/kv_store.rs` (consent persistence fns → `&KvHandle`), `crates/trusted-server-core/src/publisher.rs` (interim call site, kept on `kv_handle()`), `crates/trusted-server-adapter-{axum,cloudflare,spin}/src/platform.rs`, adapter test helpers (Step 2d). **Fastly `platform.rs`/`app.rs` (populate `kv_registry`, remove consent special-case) + the `publisher.rs` named-lookup flip are Task 6**, not here. + - **Test (Task 5):** the `kv_handle_named` surface resolves — per adapter, `kv_handle_named("consent_store")` returns a handle distinct from the default; unknown id → `None`. (The **behavioral** consent test — that `consent_store` is actually selected and the default is left untouched — lands in **Task 6** with the call-site flip; see Task 6.) + +- [ ] **Step 2d: Test-support — registry-populated `RequestContext` helper + migrate existing direct-context tests.** Strict registries make a missing registry a wiring bug, but existing adapter tests call `build_runtime_services(&ctx)` / `build_per_request_services(&ctx)` on **hand-built** `RequestContext`s with no registries inserted (e.g. `adapter-axum/src/app.rs:130`, `adapter-cloudflare/src/app.rs:151,314`, `adapter-spin/src/app.rs:440`, `adapter-cloudflare/src/platform.rs:729`). Those will now fail (composite → `registry.named()` → `None`). Add a shared test helper (e.g. `test_context_with_registries(config: &[…], kv: &[…], secrets: &[…]) -> RequestContext`) that inserts `ConfigRegistry`/`KvRegistry`/`SecretRegistry` into the context, and **migrate every existing direct-context test** to use it. Enumerate them during the run (`rg 'build_(runtime|per_request)_services'` in adapter test modules). + +Also convert the **existing core consent tests** that construct `ConsentPipelineInput` with a `&dyn PlatformKvStore` store — `crates/trusted-server-core/src/consent/mod.rs` has `kv_store: Some(&store)` call sites at ~lines 1450 / 1481 / 1492 — to the new `Option` shape (build a `KvHandle` over an in-memory `KvStore` test double). These fail to compile the moment `ConsentPipelineInput.kv_store` changes type, so migrate them in the same step (`rg 'kv_store: Some\(' crates/trusted-server-core/src/consent` to find them all). + +- [ ] **Step 3: Run to verify pass** — this task changes **core** (`platform/types.rs`, consent type, `publisher.rs` interim call site) and **Axum/CF/Spin** platform wiring. **Fastly is NOT modified here** (its named-KV wiring + consent flip are Task 6) — but run **all four** anyway to confirm Fastly still compiles/passes with the core changes (the `kv_handle()` interim call site preserves Fastly's swap behavior), per the "all four green" rule. + +Run: `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin && cargo check-cloudflare && cargo check-spin` +Expected: PASS. + +- [ ] **Step 4: Commit** (core + Axum/CF/Spin — **not** Fastly; Fastly is committed in Task 6) + +```bash +git add crates/trusted-server-core/src/platform/types.rs \ + crates/trusted-server-core/src/consent/mod.rs \ + crates/trusted-server-core/src/storage/kv_store.rs \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-adapter-axum \ + crates/trusted-server-adapter-cloudflare \ + crates/trusted-server-adapter-spin +git commit -m "Add named-KV surface + composite reads on Axum/Cloudflare/Spin; migrate consent to KvHandle" +``` + +--- + +## Task 6: Local Fastly registry builders + injection into the custom `oneshot` path + +EdgeZero's Fastly `dispatch_with_registries` and its registry builders are `pub(crate)` (verified in the pinned checkout), so trusted-server must build the registries **locally** and insert them into the request extensions before `app.router().oneshot()`. (Alternative: an upstream EdgeZero public builder — tracked as **R11**; not assumed here.) + +**Files:** +- Create: `crates/trusted-server-adapter-fastly/src/registries.rs` (`build_config_registry`, `build_secret_registry`, `build_kv_registry`) +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs:477` (the `oneshot` dispatch block) +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs:238` (`build_per_request_services` → build from composite, Step 4b) +- Test: `crates/trusted-server-adapter-fastly/src/registries.rs` (`#[cfg(test)]`) + a route test in `route_tests.rs` + +**Interfaces:** +- Consumes: `StoresMetadata` (from `Hooks::stores()`), EdgeZero `FastlyConfigStore`/`FastlyKvStore`/`FastlySecretStore` open primitives, `StoreRegistry::from_parts` (which returns **`Option`** — `None` when the default id is absent from `by_id`). +- Produces (signatures mirror EdgeZero's own private Fastly builders): + - `build_kv_registry(&StoresMetadata) -> Result, FastlyError>` — KV store `open` can fail (→ `Err`); a metadata with no KV stores or a missing default → `Ok(None)`. + - `build_config_registry(&StoresMetadata) -> Option` and `build_secret_registry(&StoresMetadata) -> Option` — `None` when the kind is undeclared or the default id can't be assembled. + - **Failure policy:** each opens every declared id **by logical id** (D7). If a *declared* store fails to open (KV `Err`) propagate it to the request as an error; if the *default* id is missing, `from_parts` yields `None` → the registry is not inserted → the strict extractor later returns `None` → the handler surfaces a 500 (no silent fallback). This matches EdgeZero's `dispatch_with_registries` behavior for the other adapters. + +- [ ] **Step 1: (D7) No runtime env reader.** Per D7 the runtime does **not** read `EDGEZERO__STORES__*__NAME` — stores are opened by **logical id**. This deletes the need for a Fastly runtime-dictionary `EnvConfig` reader (and sidesteps that `fastly::ConfigStore` has no `iter()` and EdgeZero's reader is private). If a deployment ever needs to remap a physical store name, that is handled at provisioning time, not here. No code in this step; it records the design constraint the builders follow. + +- [ ] **Step 2: Write a failing builder test** — `build_config_registry` opens each declared id by name and yields a registry whose `default()` resolves and whose declared non-default id (`jwks_store`) resolves; an id **not** in `StoresMetadata` is absent (`named("nope").is_none()`). Name: `build_config_registry_resolves_declared_ids`. + +Run: `cargo test-fastly build_config_registry_resolves_declared_ids` → Expected: FAIL. + +- [ ] **Step 3: Implement the three builders** in `registries.rs` with the signatures above — the three kinds construct **differently** (mirror EdgeZero's own private Fastly builders, `request.rs`): + - **KV** (`build_kv_registry -> Result, FastlyError>`): `FastlyKvStore::open(id)` returns `Result` (not a `KvHandle`), so for each id `let store = FastlyKvStore::open(id)?;` then wrap `KvHandle::new(Arc::new(store))` (map `KvError` → `FastlyError`); collect into `BTreeMap`; `StoreRegistry::from_parts(by_id, default_id)`. + - **Config** (`-> Option`): for each id, build a `ConfigStoreHandle` over `FastlyConfigStore` for that id (+ `ConfigStoreBinding { handle, default_key }`); `from_parts`. + - **Secret** (`-> Option`): **do NOT open per id.** Create **one** `SecretHandle::new(Arc::new(FastlySecretStore))` (the provider is stateless — `FastlySecretStore::get_bytes(store_name, key)` opens the named store per call), then bind each id via `BoundSecretStore::new(handle.clone(), store_name)` where `store_name` = the logical id (D7); `from_parts`. + - All open **by logical id** (D7 — no `EnvConfig`/runtime dictionary). `from_parts` yields `None` if a kind is undeclared or the default id is absent. + +- [ ] **Step 4: Insert registries in the oneshot block** — replace the lone `core_req.extensions_mut().insert(config_store)` at `main.rs:477`: build the three registries via Step 3 (propagate `build_kv_registry`'s `FastlyError` into the dispatch's `Result`), and `if let Some(reg) = ...` insert each into `core_req.extensions_mut()`, preserving the existing `client_info`/`device_signals` inserts. + +- [ ] **Step 4b: Build Fastly `RuntimeServices` from the composite + wire named KV + remove consent special-casing (Fastly half of Step 2c, sequenced here).** Fastly's `build_per_request_services` (`adapter-fastly/src/app.rs:238`) currently does `RuntimeServices::builder().config_store(Arc::new(FastlyPlatformConfigStore))…` — reading directly, ignoring the registries. Now that Step 4 injects `Config`/`Secret`/`Kv` registries into extensions, change it to: + - extract the registries from extensions (`ctx.request().extensions().get::().cloned()` / `SecretRegistry` / `KvRegistry`) and build `CompositeConfigStore`/`CompositeSecretStore` (reader = registry; writer = the Fastly **read+write** impl kept for legacy, per Task 8) — as Task 5 does for the other adapters; + - populate `RuntimeServices.kv_registry` from the `KvRegistry` (Step 2c core surface), so `kv_handle_named("consent_store")` works on Fastly; + - **remove `runtime_services_for_consent_route` (`app.rs:205`) and its call sites (`app.rs:588/735`)** — consent now selects its store via `kv_handle_named`, so the special reopening is redundant. + Without this, Step 4 wires registries nothing reads, and named consent KV stays Fastly-special. + +- [ ] **Step 5: Write a failing Fastly route test** — `GET /.well-known/trusted-server.json` via the EdgeZero `oneshot` path returns the JWKS doc read through the injected `ConfigRegistry` (built with default + `jwks_store` ids). Name: `oneshot_discovery_reads_jwks_via_registry` (mirror the `StubJwksConfigStore`/`JWKS_CONFIG_STORE_NAME` pattern in `route_tests.rs`, but drive the EdgeZero path, not `route_request`). + +Run: `cargo test-fastly oneshot_discovery_reads_jwks_via_registry` → Expected: FAIL, then PASS only after Steps 3, 4, **and 4b** (the test reads through `RuntimeServices`, which is composite-backed only after 4b — without 4b the injected registries are unused and the read still hits the old direct store). + +- [ ] **Step 5b: Flip the consent call site to named KV + behavioral test (the atomic cutover — all four adapters now inject a `KvRegistry`).** Now that Fastly (Step 4b) and Axum/CF/Spin (Task 5) all inject a `KvRegistry`, change `publisher.rs:626` from `services.kv_handle()` to `settings.consent.consent_store.as_deref().and_then(|id| services.kv_handle_named(id))`. This is the point where the configured id actually selects the store on **every** adapter (and `runtime_services_for_consent_route`'s swap, removed in 4b, is no longer needed). Add the **behavioral** core test (write it failing first): with `consent_store = "consent_store"` and a registry holding a **default** KV + a distinct `consent_store` KV, a consent round-trip (load/save/delete) reads/writes the **`consent_store`** handle and leaves the **default** store **untouched** (assert against both). Files: `crates/trusted-server-core/src/publisher.rs` (+ core consent test). + +- [ ] **Step 5c: Fastly named-KV / consent route test.** With `runtime_services_for_consent_route` removed (4b), add a Fastly test proving `consent_store` resolves via the **injected `KvRegistry`** — a consent-persisting route (or a `build_per_request_services`-level test) writes/reads through the `consent_store` handle, not the default. This guards the special-case removal. + +- [ ] **Step 6: Fastly suite + parity + commit** (core consent flip is committed here with the Fastly work, since the flip is only safe once Fastly injects registries) + +Run: `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin && cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` +```bash +git add crates/trusted-server-adapter-fastly \ + crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/consent/mod.rs +git commit -m "Inject Fastly registries; flip consent to named KV; remove consent special-casing" +``` + +--- + +## Task 7: Delete the duplicated Fastly chunk resolver + +With reads via EdgeZero (`FastlyConfigStore` reassembles chunks transparently), collapse `get_settings_from_config_store` and drop the local resolver. + +**Files:** +- Modify: `crates/trusted-server-core/src/settings_data.rs` +- Test: `crates/trusted-server-core/src/settings_data.rs` (`#[cfg(test)]`) + +- [ ] **Step 1: Rewrite/keep the settings_data test** to assert the blob is read + parsed (not locally chunk-reassembled) — EdgeZero owns reassembly now. + +- [ ] **Step 2: Delete `FastlyChunkPointer`, `FastlyChunkRef`, `resolve_fastly_chunk_pointer`, `sha256_hex`, and the chunk constants;** collapse `get_settings_from_config_store` to `ConfigStore::get` + `settings_from_config_blob`. + +- [ ] **Step 3: Run tests + wasm checks** — `cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin && cargo check-cloudflare && cargo check-spin` → PASS. + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-core/src/settings_data.rs +git commit -m "Delete duplicated Fastly config-chunk resolver; rely on EdgeZero FastlyConfigStore" +``` + +--- + +## Task 8: Retire per-adapter config/secret READ impls — EXCEPT Fastly's, which the live legacy path still needs (D6-a) + +Now that all reads (boot + request) flow through EdgeZero **on the edgezero path**, convert the per-adapter management impls to **write-only** (`PlatformConfigWriter`/`PlatformSecretWriter` from Task 3 Step 0) + `management_api.rs` (D6-a). + +**⚠️ Phase 1 / Phase 5 boundary (Blocker fix):** Fastly's `legacy_main` (`adapter-fastly/src/main.rs:726`) is **still live** until Phase 5 (gated on 100% rollout, issue #495). It builds `RuntimeServices` via `build_runtime_services` (`adapter-fastly/src/platform.rs:578`), which wires `FastlyPlatformConfigStore` / `FastlyPlatformSecretStore` **for reads**. So **Fastly's read impls must NOT become write-only in Phase 1** — doing so breaks (or fails to compile) the legacy path before it is deleted. Therefore: +- **Axum / Cloudflare / Spin** read impls → **write-only** now (they have no legacy path). +- **Fastly** `FastlyPlatformConfigStore`/`FastlyPlatformSecretStore` stay **read+write** (full `PlatformConfigStore`/`PlatformSecretStore`) until Phase 5. The edgezero path on Fastly reads via the composite (Task 3–6); `legacy_main` reads via the direct impl. Both coexist. Fastly's read impls are deleted / narrowed to write-only in **Phase 5**, together with `legacy_main`. + +**Files:** +- Modify: `crates/trusted-server-adapter-{axum,cloudflare,spin}/src/platform.rs` (→ write-only) +- Leave: `crates/trusted-server-adapter-fastly/src/platform.rs` config/secret **read** impls in place (write-only conversion deferred to Phase 5) +- Modify: `crates/trusted-server-adapter-fastly/src/route_tests.rs` (update stubs to the composite/registry shape) + +- [ ] **Step 1: Convert the NON-Fastly config/secret impls to write-only.** For **Axum / Cloudflare / Spin**, re-implement the old read+write impls (`AxumPlatformConfigStore`, `NoopConfigStore`, Cloudflare/Spin equivalents, secret impls) as `PlatformConfigWriter`/`PlatformSecretWriter` (drop `get`/`get_bytes`; keep `put`/`create`/`delete`). This compiles only because Task 3 Step 0 split the traits. **Leave `FastlyPlatformConfigStore`/`FastlyPlatformSecretStore` as full read+write** — `legacy_main` still reads through them until Phase 5 (see the boundary note above). Keep `management_api.rs`. + +- [ ] **Step 2: Update `route_tests.rs`** — the stub stores (`StubJwksConfigStore`, etc.) and `RuntimeServices` construction move to the composite/registry shape: build the composite reader from a real `ConfigRegistry`/`SecretRegistry` with **at least two ids** (default + a non-default such as `jwks_store`/`ts_secrets`), and assert an **unknown store id resolves strictly to an error** (not a silent fallback to default). Writer = a recording stub; keep coverage of the write path (`put`/`create`/`delete`) so key-rotation delegation stays tested. + +- [ ] **Step 3: Full CI gate** + +Run: `cargo fmt --all -- --check && cargo clippy-fastly && cargo clippy-axum && cargo clippy-cloudflare && cargo clippy-spin-native && cargo clippy-spin-wasm && cargo check-cloudflare && cargo check-spin && cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin && cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity` +Expected: PASS. **Key rotation/delete still works** (composite writer path). + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-adapter-* +git commit -m "Retire per-adapter config/secret read impls; reads via EdgeZero, writes via composite" +``` + +--- + +## Task 1 Output (filled in during execution) + +_Kind-partitioned D5 map and the confirmed D6-a decision are recorded here by Task 1 before Tasks 2+ run._ + +--- + +## Scope, gating, and follow-ups + +- **D6-a locked.** Runtime key-rotation writes stay on the management path via the composite. If Task 1 selects D6-b/c, this plan **stops after Task 1**; a separate `key-rotation-ops-migration` plan handles the admin-surface change. +- **R11 (open):** whether EdgeZero should expose a **public** registry-builder helper (so Fastly need not maintain local builders, Task 6). Decide with the edgezero maintainer; not assumed here. +- **Not in this phase:** `RuntimeServices` removal (Phase 4); Cloudflare/Spin `include_str!`/side-channel config removal (Phase 2); `from_toml_and_env` + `config` dep (Phase 2); `Redacted` / secret externalization (Phase 3); `management_api.rs` deletion (only under a future D6-b). +- **No dependency on edgezero #305** — Phase 1 uses shipped EdgeZero store APIs only. diff --git a/docs/superpowers/specs/2026-07-02-edgezero-full-migration-design.md b/docs/superpowers/specs/2026-07-02-edgezero-full-migration-design.md new file mode 100644 index 00000000..c0d8d52a --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-edgezero-full-migration-design.md @@ -0,0 +1,296 @@ +# Trusted Server → EdgeZero — Full Migration (umbrella design) + +- **Status:** Draft for review +- **Date:** 2026-07-02 +- **Scope:** Move trusted-server **completely** onto EdgeZero primitives: config push, KV, secret store, config injection without an embedded `trusted-server.toml`, extractor-based handlers, and deletion of every pre-EdgeZero workaround. +- **Shape:** Umbrella roadmap. Defines the end-state, the current-state gap, and an ordered set of phases with dependencies. **Each phase gets its own implementation plan** (`writing-plans`) before code is written. +- **Companion spec:** Phase 0 (`State` extractor + nested `#[secret]`) is an **edgezero-repo** change, specified separately (`…-state-and-nested-secrets-design.md`) and tracked via edgezero PR [stackpop/edgezero#306](https://github.com/stackpop/edgezero/pull/306). This umbrella depends on it but does not re-specify it. + +--- + +## 1. End-state + +trusted-server is a fully EdgeZero-native app **converged on the canonical `app-demo` wiring** (decision: full convergence): every adapter binary is the one-line `run_app::` entry; `App` is generated by the `edgezero_core::app!("edgezero.toml")` macro (routes from `[[triggers.http]]`, `stores()` from `[stores.*]`); core is platform-neutral; config, KV, and secrets flow exclusively through EdgeZero's `StoreRegistry`; app config is a signed blob published by `ts config push`; handlers are `#[action]` functions taking `FromRequest` extractors, with shared app state reaching them via `State>`; and no *pre-EdgeZero* shim **and no trusted-server-local registry/dispatch wiring** remains. + +**Reaching this requires two upstream EdgeZero capabilities** (Fastly today calls `app.router().oneshot()` directly — verified `adapter-fastly/src/main.rs` — because the standard helpers drop duplicate `Set-Cookie` values via `set_header`, trigger a logger reinit, and can't capture JA4/H2 from the raw `FastlyRequest`; and macro/`run_app` apps have no way to inject `State` since `with_state` is builder-only): +- **P0-C** — a header-preserving Fastly `run_app` dispatch + a pre-dispatch extension hook (§4a). +- **P0-D** — app-state injection for macro/`run_app` apps: a `Hooks` state hook `run_app` inserts per request (§4a). +Both are **required prerequisites** under full convergence — Fastly is `run_app::`, not a permanent exception. + +Concretely, at the end of this migration: + +- **No adapter/runtime app-config baking** — no `include_str!` of `*.toml` **app config** in any adapter runtime path; all four adapters load app config from the EdgeZero config store. (The `ts config init` CLI command still embeds `trusted-server.example.toml` as a scaffolding template — that is not runtime config baking and is out of scope.) +- **No app-level secrets embedded in the pushed config blob.** Secrets live in the EdgeZero secret store; the blob carries only key names, resolved at request time. +- **No bespoke `PlatformConfigStore` / `PlatformSecretStore` / `RuntimeServices`.** Core and adapters use EdgeZero `ConfigStore` / `SecretStore` / `KvStore` via `StoreRegistry`. +- **No `FastlyManagementApiClient`, no `settings_data.rs` chunk resolver, no `config`-crate env overlay, no `Redacted`.** +- **Core handlers are extractor-based**; the per-adapter handler shims are gone. +- **The legacy Fastly `route_request` path, `compat.rs`, and the `edgezero_enabled` / `edgezero_rollout_pct` flags are deleted** (final phase, gated on 100% rollout). + +--- + +## 2. Current-state gap analysis + +Verified across `trusted-server-core`, the four adapters, `trusted-server-cli`, and the pinned `edgezero` dependency. + +| Concern | Today | Gap to close | +|---|---|---| +| **KV** | ⚠️ **Default** KV is on EdgeZero (`KvStore`/`KvHandle`, re-exported as `PlatformKvStore`), but **named/non-default KV is not selected**: `RuntimeServices` exposes only a single default handle and consent drops the configured `consent_store` id (`publisher.rs:626`) | Phase 1 (named-KV surface + consent migration — see D5/Step 2c) | +| **Routing** | ✅ All 4 adapters route through EdgeZero `RouterService` + `Hooks` | None structurally; handler authoring changes in Phase 4 | +| **Core off `fastly::` types** | ✅ Enforced by `migration_guards.rs` | Keep the guard; extend coverage as adapters shrink | +| **Config load** | ⚠️ Fastly + Axum load the blob from the config store; **Cloudflare** reads a `TRUSTED_SERVER_CONFIG` env side-channel (native fallback `include_str!`); **Spin `include_str!` `trusted-server.example.toml`** — none of these is a boot-time config-store read | Phase 2 (P-BOOT) | +| **Config injection** | ⚠️ `TrustedServerAppConfig` wraps `Settings`, `SECRET_FIELDS = &[]` → secrets inline in blob; `#[derive(AppConfig)]`/`#[secret]` unused | Phases 2–3 | +| **Config / Secret stores** | ❌ Core uses bespoke `PlatformConfigStore`/`PlatformSecretStore` + `RuntimeServices`; 4× per-adapter `platform.rs` impls; `FastlyManagementApiClient` for writes | Phase 1 | +| **Fastly config chunking** | ❌ `settings_data.rs` re-implements EdgeZero's `chunked_config.rs` verbatim in core | Phase 1 | +| **Env overlay** | ❌ `from_toml_and_env` + `TRUSTED_SERVER__*` via the `config` crate (test-only, but keeps the dep) | Phase 2 | +| **Handlers → extractors** | ❌ Hand-written `Fn(RequestContext)` shims calling `(&Settings, &RuntimeServices, Request)`; `#[action]`/`FromRequest` unused; **no `State` extractor exists upstream** | Phase 0 (upstream) → Phase 4 | +| **Legacy Fastly path** | ❌ `legacy_main`/`route_request` + `compat.rs` + rollout flags live (marked "TODO delete after Phase 5 cutover — #495") | Phase 5 | + +**Key architectural constraints discovered:** + +1. **No `State`/`Extension` extractor in EdgeZero.** trusted-server threads `Arc` (`Settings`, `AuctionOrchestrator`, `IntegrationRegistry`) via closures. Extractor migration needs an upstream `State` → **Phase 0**. +2. **`AppConfig` re-parses + verifies + secret-walks the whole blob every request** — too costly for `Settings`. Decision: keep loading `Settings` once at startup into `Arc`, exposed via `State`, rather than the per-request `AppConfig` extractor (see §4, Decision D1). +3. **Full secret externalization needs nested/array `#[secret]`** because `Settings` is deeply nested → **Phase 0** (edgezero derive change). +4. **Integration proxies are a second, nested `matchit` router** with their own `IntegrationProxy::handle(&Settings, &RuntimeServices, req)` convention — orthogonal to the core route handlers (see §4, Decision D2). +5. **Fastly cannot use `run_app::` today** — it bypasses standard dispatch for multi-value `Set-Cookie` preservation, to skip a logger reinit, and to capture TLS JA4 / H2 fingerprints from the raw `FastlyRequest`. Needs an EdgeZero-adapter capability (**P0-C**, §4a) or a permanent documented exception → **Phase 0 / §4a**. +6. **Config is loaded at boot, before any request context exists** (`build_state()` → `load_startup_settings()`), but EdgeZero's config-store handle is only wired *per request* (`ConfigRegistry` in request extensions). On Cloudflare, config arrives via a `TRUSTED_SERVER_CONFIG` env side-channel injected at the worker entry; on Spin it's baked example TOML. So "load config from the store at startup" needs a **boot-time store-access mechanism**, not the per-request registry → **§4a + Phase 2**. +7. **The logical app-config store id is inconsistent** — `settings_data.rs` defaults to `app_config`, `edgezero.toml` declares `trusted_server_config`, and Fastly splits rollout flags (`trusted_server_config`) from the app-config blob (`app_config`). Must be unified → **Decision D5**, before Phase 1/2 planning. + +--- + +## 3. Phase map (ordered, foundation-first) + +Each phase leaves **all four adapters building and green**. Dependencies are explicit; within a phase, work can parallelize. + +``` +Phase 0 (edgezero, external) ── State extractor ─────────────┐ + └─ nested/array #[secret] ───┐ │ + v v +Phase 1 (stores) ──> Phase 2 (config) ──> Phase 3 (secrets) Phase 4 (extractors) + │ + Phase 5 (legacy removal, gated 100% rollout) +``` + +- **Phase 1** depends on nothing upstream (EdgeZero store APIs already exist). +- **Phase 2** depends on Phase 1 (config store must be EdgeZero-native first). +- **Phase 3** depends on Phase 2 **and** Phase 0's nested `#[secret]`. +- **Phase 4** (full convergence) depends on Phase 0's `State` **plus P0-C (Fastly `run_app` dispatch) and P0-D (macro app-state injection)** — both new required edgezero prerequisites (§4a). It is cleaner after Phases 1–3. +- **Phase 5** is last and **gated on the edgezero rollout reaching 100%** (issue #495). + +**Full-convergence sequencing note.** Because Phase 4 moves all adapters to `run_app` (which builds registries internally from `stores()`), the Phase 1 **adapter-registry wiring** (Fastly local builders + custom-`oneshot` injection, Task 6; Axum `with_*_registry`, Task 5 Step 0) is **interim scaffolding thrown away in Phase 4**. Two ways to sequence: +- **(Recommended) Land P0-C + P0-D early** (alongside Phase 0 A/B), so Phase 1's adapter wiring can target `run_app` from the start and skip the throwaway local builders — Phase 1 then only does the *core* store work (composite, store-id reconciliation, boot read), and the adapters get registries free from `run_app`. +- **(Fallback) Proceed with the interim builders** if P0-C/P0-D slip; accept that Task 5 Step 0 / Task 6 are deleted in Phase 4. The core Phase 1 work (composite, D5/D6, chunk-resolver deletion) is unaffected either way. + +--- + +## 4. Cross-cutting design decisions + +**D1 — Config caching, not per-request extraction (retained under full convergence).** The user's full-convergence choice explicitly "reconsiders D1", but the rationale stands: `AppConfig`'s per-request re-parse/verify/secret-walk is prohibitive for a struct this large, so keep the **load-once** model. Under full convergence the *mechanism* changes: instead of a hand-built router with `with_state`, `Settings` is loaded once (lazily on first use, cached in a `OnceCell` inside `AppState`) and reaches `#[action]` handlers via `State>` injected through the **P0-D** state hook. Handlers that genuinely want the stock per-request typed extractor can still use `AppConfig` for small sub-configs, but the hot `Settings` path stays cached. Trade-off unchanged: config changes need a new deploy/boot (already true today). + +**D2 — Integration proxy router stays put (for now).** Phase 4 migrates the **named/core routes** to extractors. The integration registry's nested `matchit` dispatch and `IntegrationProxy::handle` signature are internal, working, and orthogonal; migrating them is a **follow-up** (Phase 4b, optional), not silently dropped. Called out so the extractor migration isn't mistaken for "all handlers." + +**D3 — Secret resolution happens at startup, not per request.** With D1, the startup config load resolves `#[secret]` fields against the secret store once (Phase 3). Adapters must therefore have a secret-store handle available **at boot**, not only per request — the same boot-time-store-access problem as config (constraint 6). Resolution is shared with §4a. On Cloudflare `env` and on Spin the host component are both available at the `run_app` entry, so a boot-time handle is constructible; the gap is that EdgeZero currently only exposes stores via the per-request registry. + +**D4 — One typed `Settings` as the AppConfig root.** Replace `TrustedServerAppConfig` (wrapper with empty `SECRET_FIELDS`) by deriving `AppConfig` directly on `Settings`, with `#[secret]` on the real secret fields (Phase 3). Removes a transitional indirection. + +**D5 — Reconcile ALL runtime store ids with `edgezero.toml`, kind by kind.** Not just the app-config blob, and **not just config/secrets**: EdgeZero's registry lookup is **strict** (unknown id → `None`), so every logical store id any config field or call site names at runtime must appear in `edgezero.toml` under the **correct kind** — `[stores.kv]`, `[stores.config]`, or `[stores.secrets]`. Reconciliation is kind-aware (`Settings::referenced_store_ids_by_kind()`): **KV** ids = `ec.ec_store` (`ec_identity_store`), `consent.consent_store`, `auction.creative_store` (deprecated); **config** ids = the app-config store `trusted_server_config` (blob key == id: `trusted_server_config`), `jwks_store`, `datadome-ip-bypass`; **secret** ids = `signing_keys`, DataDome `ts_secrets`, the S3 secret store `s3-auth` (`default_s3_secret_store()`). (`counter_store`/`opid_store` are Fastly-adapter constants, not `Settings` ids.) Every declared id must map to a real platform store, opened **by logical id** (D7 — no `EDGEZERO__STORES__*__NAME`). The app-config-store rename and the request-signing store-id fix are detailed just below; the full inventory is the Phase 1 plan's Task 1. + +**D6 — Runtime write path for request-signing key rotation.** EdgeZero `ConfigStore`/`SecretStore` are **read-only** at runtime; writes go through provisioning (author/ops time). But `KeyRotationManager` writes+deletes config (JWKS) and secrets (private keys) **at request time** via `/_ts/admin/keys/{rotate,deactivate,delete}`, backed by `management_api.rs`. Three resolutions: +- **(a) Keep a write-capable admin abstraction** — retain the trusted-server `put`/`create`/`delete` traits + `management_api.rs` for the admin write path only; EdgeZero read-only stores serve the read path. Least disruptive; leaves a non-EdgeZero write path (so "completely on EdgeZero" is not literally met for admin writes). +- **(b) Move key rotation out of runtime** — make rotate/deactivate/delete an **ops/CLI** operation (`ts keys …`) that writes via EdgeZero provisioning; the runtime only reads current keys. Most EdgeZero-native; changes the admin surface from an HTTP endpoint to an operator command — a **product/ops decision**. +- **(c) Add an EdgeZero runtime store-write/provision API** — upstream change; broadens the platform. +**Decision needed before Phase 1 deletions.** Until then `management_api.rs` stays. This is R10; recommend (a) as the interim (unblocks Phase 1 read migration) with (b) as the target end-state if ops agrees. + +--- + +**D7 — Runtime app-config is config-store-only; no runtime environment variables (hard cutoff).** At runtime, every adapter builds `Settings` **solely** from the config-store blob: open store → verify envelope → deserialize (+ secret walk) → validate. There is **no runtime environment-variable overlay** for app config on any adapter. All env-var influence on config content happens at **push time**: `ts config push` (which runs on a host that has the env vars) reads them, applies the AppConfig env overlay, and bakes the resolved values into the signed blob. Consequences: +- Delete `Settings::from_toml_and_env` and the `TRUSTED_SERVER__*` overlay entirely (Phase 2) — not merely unused, **forbidden** at runtime. +- The AppConfig loader's `env_overlay` is a **push-time-only** option; runtime never applies it. +- **Store-name binding follows the same spirit:** a logical store id resolves to the **platform store of the same name by default** — via EdgeZero's existing `store_name` **fallback to the logical id when the env var is unset**. So we **provision no `EDGEZERO__STORES__*__NAME` values**, and every adapter opens stores by logical id. Note the mechanism, not the absolute: EdgeZero's Axum/Cloudflare/Spin builders still *call* `EnvConfig` internally — but with nothing set they fall back to the logical id, which is D7-compliant with **no EdgeZero change**. Only Fastly's *custom* dispatch path (which bypasses EdgeZero's builders) needs trusted-server-local by-id builders (Phase 1 Task 6). If a deployment ever needs a different physical name, that is a **provisioning/manifest** concern at deploy time, never a runtime env read. + +--- + +## 4a. Prerequisites (must resolve before or during Phase 1/2) + +These are not trusted-server refactors; they are EdgeZero-adapter capability gaps or up-front decisions that gate the phases. + +**P0-C — Header-preserving Fastly `run_app` dispatch + pre-dispatch extension hook (REQUIRED under full convergence).** For Fastly to be the one-line `run_app::`, EdgeZero's Fastly `run_app`/dispatch must: (1) preserve duplicate response headers (esp. `Set-Cookie`) instead of collapsing via `set_header`; (2) allow opting out of the per-call logger reinit; and (3) expose a **pre-dispatch hook** to inject request-scoped extensions (`client_info`, `device_signals` from JA4/H2) derived from the raw `FastlyRequest` before conversion. **This must be upstreamed** — the "permanent Fastly exception" fallback is off the table now that the decision is full convergence. New edgezero PR (same track as #306). Until it lands, Fastly keeps its interim custom `oneshot` + local registry builders (Phase 1 Task 6) as **throwaway scaffolding**, replaced by `run_app` once P0-C merges. + +**P0-D — App-state injection for macro/`run_app` apps (REQUIRED, new).** `State` (from #306) is read from request extensions, and `RouterBuilder::with_state` inserts it — but the `app!` macro generates the router and `run_app` dispatches it, so a macro app has **no builder to call `with_state` on**. EdgeZero must add a way for a `Hooks` app to register app-level state that `run_app` inserts into every request — e.g. a `Hooks::app_state() -> Extensions` (or `configure`-time state layer / `app!` `[app] state` support) that the per-adapter `run_app` copies into each request's extensions alongside the store registries. Without this, `State>` cannot reach `#[action]` handlers in a macro app. New edgezero PR. + +**P-BOOT — Boot-time store access for startup config + secret load.** Define, per adapter, how `build_state()` obtains a config-store (and secret-store) handle at boot, before request context. Options: +- **(a) Boot-time handle from the adapter environment** — Cloudflare builds a config-store handle from the `env` binding passed to `run_app`; Spin from the host component config; Fastly/Axum open the store eagerly (already do). Requires EdgeZero to expose a boot-time store constructor (or trusted-server constructs it from the adapter's env directly, mirroring today's `TRUSTED_SERVER_CONFIG` side-channel but reading the store instead). +- **(b) Lazy first-request load + cache** — defer the config load to the first request (where the registry exists), cache `Arc` in a `OnceCell`. Keeps D1's load-once semantics but moves the load off the boot path. Trade-off: first request pays the cost and must handle a config-load error as a request error. +Recommendation: **(a)** where the adapter env is available at boot (Cloudflare/Spin both pass it to `run_app`), falling back to **(b)** only if an adapter genuinely cannot construct a boot-time handle. Settle in the Phase 2 plan. + +*(P-BOOT's `build_state()` gets subsumed under full convergence: once `run_app` owns startup, boot config load is `run_app` reading the config store via the registry it builds; the boot reader collapses into option (a)/(b) inside `run_app`, and D1's load-once `Settings` is populated from the P0-D state hook at first use.)* + +--- + +## 5. Phases + +### Phase 0 — EdgeZero prerequisites (external, edgezero repo) + +**Owner:** edgezero. **Tracked by:** its own spec + PR [stackpop/edgezero#306](https://github.com/stackpop/edgezero/pull/306) — "State extractor + nested/array #[secret] support". +**Delivers:** (A) `State` extractor + `RouterBuilder::with_state`; (B) nested/array `#[secret]` in `#[derive(AppConfig)]` + path-aware `secret_walk`; **(C, if resolved upstream) P0-C** header-preserving Fastly dispatch + pre-dispatch extension hook (§4a). +**Blocks:** Phase 3 (B), Phase 4 (A), Phase 5/Fastly end-state (C). **This umbrella consumes it as a versioned dependency** — bump the pinned `edgezero` rev once merged. +**Note for #306:** the trusted-server secret audit (Phase 3 / §5) confirms **array secrets exist** (`ec.partners[].api_token`, `handlers[].password`) and **optional-string secrets exist** (`ts_pull_token`). So edgezero #306's `ArrayEach` and `Option` support are **required**, not deferrable — this settles that PR's open question B-1. + +--- + +### Phase 1 — Stores onto EdgeZero `StoreRegistry` + +**Goal:** route all **read** store access through EdgeZero `ConfigStore` / `SecretStore` / `StoreRegistry` (KV is already there), and delete the bespoke read layer + duplicated chunk resolver. **Runtime writes and store-id reconciliation must be resolved first** (see D5, D6 below and the plan's task 1). + +> **Plan ordering (per review):** the Phase 1 plan does NOT start with deletions. Task 1 is a **store-capability inventory** — enumerate every runtime store id and every read vs write call site — and a **decision on D6** (runtime writes). Deletions come only after the write path is settled. + +**Changes:** +- **Reads (runtime + boot):** route `PlatformConfigStore`/`PlatformSecretStore` **reads** and the `RuntimeServices` config/secret fields through EdgeZero handles resolved from the per-request registries, matching KV — via a **composite store** (reads → EdgeZero, writes → existing management path; see D6-a). Also migrate the **boot-time** config load: Fastly `load_settings_from_config_store()` and Axum `build_state()` read `Settings` at boot through `&FastlyPlatformConfigStore` / `&AxumPlatformConfigStore` **before** request context exists, so those must move to a boot-time EdgeZero config read *before* the bespoke read impls are deleted. Migrate read consumers: `proxy.rs` (S3), `request_signing/{signing,rotation}.rs` (reads), `integrations/datadome/{protection,protection_scope}.rs`. +- **Writes (D6):** `KeyRotationManager` writes+deletes **config and secrets at request time** (`store_private_key`/`store_public_jwk`/`delete_key` for `/_ts/admin/keys/rotate` + deactivate/delete). EdgeZero `ConfigStore`/`SecretStore` are **read-only by design**. So `management_api.rs` **cannot be deleted in Phase 1 as originally written**. Resolve per D6 before touching it. +- **Store-id reconciliation (D5, expanded, kind-aware):** every runtime store id referenced by config must be declared in `edgezero.toml` under the **correct kind** — `[stores.kv]`, `[stores.config]`, or `[stores.secrets]` — or strict registry lookup returns `None`. Reconcile at least: **KV** — `ec.ec_store` (`ec_identity_store`), `consent.consent_store`, `auction.creative_store` (deprecated — creatives are inline — but still a `Settings` field, so declare it to keep strict lookup safe); **config** — the **app-config blob store** (see the store-id/key rule below), `request_signing.config_store_id` (`app_config` today), the JWKS/config-list store, and **DataDome's IP-CIDR config store** (`ProtectionIpCidrSourceConfig.config_store`, default `datadome-ip-bypass`, `protection_scope.rs`); **secrets** — `request_signing.secret_store_id` (`secrets` today), DataDome secret store (`ts_secrets`), the S3 secret store — plus all `trusted-server.example.toml` + integration/test fixtures. (`counter_store`/`opid_store` are **Fastly-adapter** constants (rate limiter / opid), **not** `Settings` logical ids, and are out of scope.) + + **App-config store-id/key rule (explicit, D7-consistent):** the app-config blob lives in the config **store id `trusted_server_config`** (the `edgezero.toml` default) under **blob key `trusted_server_config`** — the key equals the store id. Why: `default_config_key()` = `EnvConfig::store_key("config", id)`, which **falls back to the id** when no `EDGEZERO__STORES__CONFIG__…__KEY` env is set — and D7 forbids that runtime env. So a separate `app_config` key would require a runtime env override (violating D7) or `ts config push --key app_config`. Instead Phase 1 sets `settings_data.rs::DEFAULT_CONFIG_STORE_ID = "trusted_server_config"` **and** changes `config_payload.rs::CONFIG_BLOB_KEY` from `"app_config"` to `"trusted_server_config"`, so `ts config push` (default key = store id) and the boot read agree with **zero** env/`--key`. + + **Do NOT repoint request-signing at the app-config store.** Request signing uses its **own** stores: reads use the hard-coded runtime names `JWKS_CONFIG_STORE_NAME = "jwks_store"` (config) and `SIGNING_SECRET_STORE_NAME = "signing_keys"` (secret); writes use `request_signing.config_store_id`/`secret_store_id`, which are the **management-API identifiers of those same jwks/signing stores** (Fastly separates runtime name from management id). Today `trusted-server.example.toml` sets `config_store_id = "app_config"` / `secret_store_id = "secrets"`, which points **writes at the wrong store** (writes JWKS state into app-config while reads look in `jwks_store`). Phase 1 fixes this: set `request_signing.config_store_id` to the **jwks store**'s id and `secret_store_id` to the **signing_keys** store's id, so reads and writes target the same store. Declare `jwks_store` (config) and `signing_keys` (secret) as their own logical ids in `edgezero.toml`. + + **Requirement (state explicitly):** because writes use `config_store_id`/`secret_store_id` as **Fastly management-API identifiers** while reads use the runtime **logical id**, Phase 1 requires — for the jwks/signing stores — that the **Fastly management resource id equals the runtime logical id** (`jwks_store`, `signing_keys`). Provision the Fastly stores with those exact names/ids. If a deployment cannot make them equal, it must supply an explicit management-id→logical-id mapping (out of Phase 1 scope); the plan assumes equality and the operator runbook must call it out. + + **`StoreName` semantics (D7):** `platform/types.rs::StoreName` is documented as an "edge-visible platform name". Under D7 runtime reads resolve through the registry by **logical id** (`registry.named(id)`), so for reads `StoreName` now carries the **logical store id** (== platform name by default). Phase 1 updates the `StoreName` doc and audits read call sites to pass logical ids, not physical platform names. +- **Fastly registry injection (ties to P0-C):** Fastly's custom `oneshot` path (§1) currently inserts only a `ConfigStoreHandle`, not registries via `dispatch_with_registries`. EdgeZero's `dispatch_with_registries` and its registry builders are **`pub(crate)`** (verified in the pinned checkout), so trusted-server must build the registries **locally** (from `StoresMetadata` + `EnvConfig` + the EdgeZero Fastly store open primitives) and insert them into extensions before `oneshot` — or an EdgeZero public builder must be added upstream (**R11**). +- Convert the **Axum/Cloudflare/Spin** `platform.rs` config/secret **read** impls to write-only (Fastly's stay read+write until Phase 5 — see the deletions note below); adapters build registries from `[stores.*]` metadata (via `dispatch_with_registries` on Axum/Cloudflare/Spin, via the Fastly-specific injection above). +- Delete `settings_data.rs`'s `FastlyChunkPointer` resolver — EdgeZero's `FastlyConfigStore` resolves chunks transparently. `get_settings_from_config_store` collapses to `ConfigStore::get` + `settings_from_config_blob`. + +**Deletions (after D6/D5 resolved):** `settings_data.rs` chunk resolver, `platform/traits.rs` config/secret **read** traits, and the **Axum/Cloudflare/Spin** `platform.rs` config/secret read impls (→ write-only). **Fastly's read impls stay** until Phase 5 — `legacy_main` reads through `FastlyPlatformConfigStore`/`FastlyPlatformSecretStore` via `build_runtime_services` and is live until the 100%-rollout cutover, so converting them to write-only in Phase 1 would break the legacy path (Phase 1/Phase 5 boundary). **`management_api.rs` deletion is conditional on D6.** +**Keeps:** `RuntimeServices` as a shrinking bundle (removed in Phase 4); the runtime write path until D6 resolves it; `StoreName`/`StoreId` where writes/provisioning need the management-id split. +**Acceptance:** all adapters build; `cargo test-fastly/-axum/-cloudflare/-spin` + parity green; secret/config **reads** go through EdgeZero registries; **named KV ids resolve as registry handles on all four adapters** via `RuntimeServices::kv_handle_named(id) -> Option` (not default-only — e.g. `consent_store` selects its store on Cloudflare/Spin; the consent KV surface migrates from `&dyn PlatformKvStore` to `KvHandle`, and Fastly's consent-store special-casing is removed); **key rotation/delete still works** (per the D6 resolution); every declared store id (kv/config/secret) is **declared and openable** (a `KvHandle` resolves by id — no strict-lookup `None`); Fastly `legacy_main` still reads (its read impls untouched until Phase 5). + +> **Scope clarification (ec_store):** Phase 1 only validates that `ec.ec_store` (`ec_identity_store`) is **declared and resolves to a `KvHandle`** through the registry. It does **not** wire the EC identity graph (`KvIdentityGraph`/`EcKvStore`) on non-Fastly adapters — those deliberately omit the EC API routes today (`adapter-spin/src/app.rs:115`), and portable EC support is a separate, larger effort out of Phase 1 scope. Registry-level handle resolution ≠ EC graph wiring. + +--- + +### Phase 2 — Finish config injection (no embedded `trusted-server.toml`) + +**Goal:** every adapter loads app config from the EdgeZero config store; kill compile-time config baking and the legacy env overlay. + +**Changes:** +- Derive `AppConfig` on the config root (interim: still `TrustedServerAppConfig` until Phase 3 collapses it onto `Settings`) so all adapters use the same store-load path. +- **Cloudflare** (`adapter-cloudflare/src/app.rs`) and **Spin** (`adapter-spin/src/app.rs`): replace startup config sourcing (Cloudflare's `TRUSTED_SERVER_CONFIG` env side-channel + the native `include_str!` fallback; Spin's baked example TOML) with a **boot-time config-store read** per **P-BOOT (§4a)**. `build_state()` obtains a config-store handle from the adapter env passed to `run_app` (option a) or defers to a lazy first-request cached load (option b). This is the load-bearing detail — settle the mechanism in the Phase 2 plan. Seed each platform's config store (`wrangler.toml` / `runtime-config.toml` / `fastly.toml` local blocks) with the pushed blob under the D5 store id/key. +- Delete `Settings::from_toml_and_env`, `ENVIRONMENT_VARIABLE_PREFIX/SEPARATOR`, and the `config` **dev-dependency**. Per **D7 (hard cutoff)** there is **no runtime env overlay** — runtime reads the blob only. Env-var influence on config happens exclusively at **push time** via `ts config push` (AppConfig `env_overlay` applied there, then baked into the blob). + +**Deletions:** both `include_str!` config paths, `from_toml_and_env`, `config` crate dep. +**Acceptance:** Cloudflare + Spin serve with store-loaded config (no baked TOML); `ts config push` blob is the single source on all four adapters; tests green. + +--- + +### Phase 3 — Secret externalization (full) + +**Goal:** no app-level secret is stored inside the config blob; secrets live in the EdgeZero secret store and resolve at startup (D3). + +**Depends on:** Phase 0 (B) nested `#[secret]`, Phase 2. + +**Changes:** +- Collapse `TrustedServerAppConfig` onto `Settings` (D4): `#[derive(AppConfig)]` on `Settings`, `#[secret]` / `#[secret(store_ref)]` on the real secret fields (S3 keys, request-signing key refs, DataDome server-side key, integration API keys, etc.), including the **nested** ones enabled by Phase 0. +- Delete `Redacted` and its manual redaction handling; `#[secret]` + the secret store replace it. +- Operator migration: `ts` provisions secrets into the secret store (via EdgeZero provision), and a migration guide moves existing inline secrets out of `trusted-server.toml`. `reject_placeholder_secrets` becomes a check on the resolved values at boot. +- Startup load resolves `#[secret]` fields against the secret store (D1/D3), then validates. + +**Secret inventory (spec artifact — verify + extend during the Phase 3 plan).** Preliminary audit of `Settings`; shapes drive the edgezero #306 requirements: + +| Secret | Path | Shape | Notes | +|---|---|---|---| +| Partner API tokens | `ec.partners[].api_token` | **array element** | needs `ArrayEach` (edgezero #306) | +| Handler passwords | `handlers[].password` | **array element** | needs `ArrayEach` | +| EC passphrase | `ec.passphrase` | scalar `String` | nested | +| Pull token | `ts_pull_token` | **`Option`** | needs optional-secret support (edgezero #306) | +| Publisher proxy secret | `publisher.proxy_secret` | scalar `String` | nested | +| DataDome server-side key | `integrations.datadome.*` (store-ref name+key) | store-ref | already resolves via secret-store name+key | +| S3 / proxy secret access key | `proxy.secret_access_key` (+ `proxy.secret_store`) | store-ref | already store-backed | +| Request-signing keys | `request_signing.*` (`secret_store_id`) | store-ref | already store-backed | + +Two consequences: (1) edgezero #306 **must** ship `ArrayEach` + `Option` (see Phase 0 note); (2) the already-store-backed secrets (DataDome, S3, request-signing) need only re-expression as `#[secret(store_ref)]`, not relocation. + +**Deletions:** inline secrets in the blob, `Redacted`, `SECRET_FIELDS = &[]` wrapper. +**Acceptance:** pushed blob contains only secret **key names**; boot resolves them; a config with nested **and array** secrets validates and serves; operator migration guide published; tests green. + +--- + +### Phase 4 — Full convergence: `app!` macro + `run_app` + `#[action]` extractors + +**Goal:** trusted-server becomes the canonical `app-demo` shape — every adapter binary is `run_app::`, `App` is `app!`-macro-generated from `edgezero.toml`, handlers are `#[action]` fns with `FromRequest` extractors, and **all** trusted-server-local registry/dispatch wiring (the Phase 1 interim Fastly/Axum builders, the custom `oneshot`, the per-adapter handler shims) is deleted. + +**Depends on:** Phase 0 (A) `State`, **P0-C** (Fastly `run_app` dispatch), **P0-D** (macro app-state injection). Cleaner after Phases 1–3. + +**Changes:** +- **Entry points → `run_app::`** on all four adapters. Fastly uses the P0-C header-preserving dispatch (deleting the custom `oneshot` + local registry builders from Phase 1 Task 6). Axum bridges `PORT`/`axum.toml` → `EDGEZERO__ADAPTER__PORT/HOST` and calls `dev_server::run_app` (deleting the `AxumDevServer::with_config` + `with_*_registry` wiring from Phase 1 Task 5). Cloudflare/Spin already `run_app`. +- **Routing → `app!` macro.** Declare trusted-server's routes in `edgezero.toml` `[[triggers.http]]` (named routes + a `/{*rest}` catch-all trigger for the publisher/integration fallback), and let the macro generate `Hooks::routes()`/`stores()`. This replaces the hand-written `routes_for_state`/`NAMED_ROUTES` tables. +- **App state via P0-D.** `Arc` (Settings/orchestrator/integration registry) is registered through the P0-D `Hooks` state hook and reaches `#[action]` handlers as `State>`. *Granularity (one `Arc` vs per-component states) is a Phase 4 plan decision.* +- **Handlers → `#[action]`.** Rewrite core `handle_*` (`proxy.rs`, `publisher.rs`, `auction/endpoints.rs`, `request_signing/endpoints.rs`, `ec/*.rs`) from `(&Settings, &RuntimeServices, Request)` to `#[action]` signatures using `State<…>`, `Json`/`Query`/`Path`/`Headers`/`Host`, and the store extractors. Delete the per-adapter handler shims + `NamedRouteHandler` enums; retire `RuntimeServices`. +- **EC lifecycle & pre-route filters** stay as **middleware** (declared via `[app].middleware` in `edgezero.toml`), plus the P0-C pre-dispatch hook for Fastly JA4/client_info. +- **Integration proxy router** (`IntegrationProxy::handle`) is dispatched from the catch-all fallback `#[action]` handler — it keeps its internal `matchit` routing but is reached as an `#[action]`, so no separate dispatch system survives. + +**Deletions:** custom Fastly `oneshot` + Phase 1 Fastly/Axum local registry builders (throwaway scaffolding), `AxumDevServer::with_config` wiring, per-adapter handler shims, `NamedRouteHandler` enums, hand-written `routes_for_state`/`NAMED_ROUTES`, `RuntimeServices` (final form). +**Acceptance:** all four binaries are one-line `run_app::`; routes come from `edgezero.toml`; every named route a given adapter supports is served via an `#[action]` handler (route sets are not uniform — Fastly-only EC routes preserved); Fastly EC after-send / finalize ordering preserved; parity test green. + +--- + +### Phase 5 — Delete the legacy Fastly path (gated on 100% rollout) + +**Goal:** remove the pre-EdgeZero Fastly entry path once the EdgeZero rollout is complete. + +**Gate:** edgezero rollout at 100% (issue #495). Do not start until confirmed. + +**Changes:** +- Delete `legacy_main` / `route_request` (`adapter-fastly/src/main.rs`), `compat.rs` (fastly↔http shim), and the flag machinery (`edgezero_enabled`, `edgezero_rollout_pct`, `select_edgezero_entrypoint`, `should_route_to_edgezero`, IP-bucket hashing). +- `main()` calls the EdgeZero path unconditionally — the P0-C dispatch variant, or the documented Fastly dispatch shim (§4a), depending on how P0-C resolves. +- Retire the `trusted_server_config` rollout-flag reads (the flags, not the config store — after D5 the store may still hold app config). +- **Now convert Fastly's `FastlyPlatformConfigStore`/`FastlyPlatformSecretStore` read impls to write-only** (the conversion deferred from Phase 1 Task 8) — safe here because `legacy_main`/`build_runtime_services`'s direct reads are deleted with the legacy path. +- **Ancillary cleanup (easy to miss):** Fastly route tests importing legacy stores + `route_request` (`adapter-fastly/src/route_tests.rs`); generated Viceroy config rollout flags (`integration-tests/src/bin/generate-viceroy-config.rs`); `fastly.toml` local `edgezero_enabled`/`edgezero_rollout_pct` config; and the rollout runbook `docs/internal/EDGEZERO_MIGRATION.md`. + +**Deletions:** `legacy_main`, `route_request`, `compat.rs`, rollout flags, `route_tests.rs` legacy imports, viceroy-config flags, `fastly.toml` flag config, `EDGEZERO_MIGRATION.md` runbook. +**Acceptance:** Fastly adapter has a single EdgeZero entry path; no rollout flags anywhere (adapter, tests, generated config, `fastly.toml`, docs); full CI gate green; production traffic unaffected (already 100% on EdgeZero by gate definition). + +--- + +## 6. Cruft deletion ledger (rolled into phases) + +| Item | File(s) | Phase | Replaced by | +|---|---|---|---| +| Fastly chunk-pointer resolver | `core/src/settings_data.rs` | 1 | EdgeZero `FastlyConfigStore` + `chunked_config.rs` | +| Bespoke config/secret store traits | `core/src/platform/traits.rs` (config+secret trait defs); `mod.rs`/`types.rs` edited, not deleted (KV re-export + shrinking `RuntimeServices` stay) | 1 | EdgeZero `ConfigStore`/`SecretStore`/`StoreRegistry` | +| Per-adapter config/secret read impls → write-only | `adapter-{axum,cloudflare,spin}/src/platform.rs` **Phase 1**; `adapter-fastly/src/platform.rs` **Phase 5** (Fastly reads kept for `legacy_main` until cutover) | 1 / 5 | EdgeZero registries + composite for reads | +| Fastly management REST client (**runtime writes**) | `adapter-fastly/src/management_api.rs` | **conditional (D6)** — 1 only if key rotation moves to ops/CLI; otherwise retained as the admin write path | EdgeZero provisioning (if writes leave runtime) — else no replacement | +| Adapter/runtime app-config baking | `adapter-{cloudflare,spin}/src/app.rs` (`include_str!` + Cloudflare `TRUSTED_SERVER_CONFIG` side-channel) | 2 | boot-time store-loaded config (P-BOOT). *`ts config init` template embed is out of scope.* | +| Legacy env overlay + `config` dep | `core/src/settings.rs` (`from_toml_and_env`, `ENVIRONMENT_VARIABLE_*`) | 2 | `EDGEZERO__*` / AppConfig env layers | +| AppConfig wrapper w/ empty `SECRET_FIELDS` | `core/src/config.rs` | 3 | `#[derive(AppConfig)]` on `Settings` | +| `Redacted` | `core/src/redacted.rs` | 3 | `#[secret]` + secret store | +| Per-adapter handler shims | `adapter-*/src/app.rs` | 4 | `#[action]` + extractors | +| Legacy Fastly path + flags + compat | `adapter-fastly/src/{main.rs,compat.rs}` | 5 | single EdgeZero entry path | +| Rollout-flag ancillaries | `adapter-fastly/src/route_tests.rs` (legacy imports), `integration-tests/src/bin/generate-viceroy-config.rs` (flags), `fastly.toml` (local flag config), `docs/internal/EDGEZERO_MIGRATION.md` (runbook) | 5 | — (deleted with the rollout mechanism) | + +**Explicitly NOT cruft (do not remove):** `migration_guards.rs` (intentional `fastly::` ban test), `s3_sigv4.rs` (AWS-domain canonical/hashing), `platform/image_optimizer.rs` (no EdgeZero equivalent yet), EC KV CAS wrapper (`ec/kv*.rs` — needs EdgeZero generation-CAS parity first; revisit, don't delete). + +--- + +## 7. Risks & open questions + +| ID | Question | Owner / resolution | +|----|----------|--------------------| +| R1 | Do any `Settings` secrets live inside **arrays**? | **Resolved: yes** (`ec.partners[].api_token`, `handlers[].password`) + optional (`ts_pull_token`). edgezero #306 must ship `ArrayEach` + `Option` (see §5 Phase 3 inventory + Phase 0 note). | +| R7 | P0-C: header-preserving Fastly `run_app` dispatch + pre-dispatch hook — **REQUIRED** under full convergence (no permanent-exception fallback). | New edgezero PR; gates Phase 4 Fastly convergence. | +| R13 | P0-D: macro/`run_app` app-state injection (`with_state` is builder-only). | New edgezero PR; gates `State` in `#[action]` handlers. | +| R14 | Sequencing: land P0-C/P0-D early (skip Phase 1 throwaway adapter builders) vs proceed with interim builders? | Decide now (§3 sequencing note) — affects Phase 1 Tasks 5–6 scope. | +| R8 | P-BOOT: boot-time store handle (a) vs lazy cached first-request load (b), per adapter? | Phase 2 plan (§4a). | +| R9 | D5: reconcile **all** runtime store ids **by kind** with `edgezero.toml` (strict lookup fails otherwise) — KV: `ec_identity_store`, `consent_store`, `creative_store`; config: `trusted_server_config` (app-config blob, key == id), `jwks_store`, `datadome-ip-bypass`; secrets: `signing_keys`, `ts_secrets`, `s3-auth`. Request signing uses `jwks_store`/`signing_keys` (not the app-config store). | Phase 1 plan task 1/2. | +| R12 | Fastly `EnvConfig` reader is private / `fastly::ConfigStore` has no `iter()`. | **Resolved by D7** — runtime opens stores by logical id; no store-name env/dictionary read; no local `EnvConfig` reader needed. | +| R10 | D6: runtime write path for key rotation — keep write-capable admin abstraction (a), move to ops/CLI (b), or upstream an EdgeZero write API (c)? | **Blocks Phase 1 deletions.** Phase 1 plan locks to **(a)**; (b)/(c) → separate plan. | +| R11 | Should EdgeZero expose a **public** Fastly registry-builder helper? | Lower priority under D7 — local builders open by logical id and need only public store constructors. Decide with edgezero maintainer if convenient. | +| R2 | `StoreName` vs `StoreId` split — still needed after `management_api.rs` deletion? | Phase 1; drop if only the CLI provision path used it. | +| R3 | EC identity API + Fastly rate limiter are Fastly-only today | Out of scope here; note as a portability follow-up (not blocking). | +| R4 | Cloudflare/Spin boot-time secret-store access for D3 | Confirm in Phase 3 scoping. | +| R5 | Config-change-requires-redeploy (D1) acceptable to operators? | Already true today; confirm no regression expectation. | +| R6 | Phase 4 handler rewrite is large — split by route group? | Yes; per-implementation-plan, group by file (`proxy`, `auction`, `ec`, `request_signing`, `publisher`). | + +--- + +## 8. Next step + +Per phase, run `writing-plans` to produce an implementation plan **at phase start** (not upfront for all five) — the plan for Phase N should reflect the state left by Phase N-1. Begin with **Phase 1** once this umbrella is approved and the Phase 0 edgezero PR is merged (or Phase 1 can start immediately since it has no upstream dependency).