diff --git a/crates/trusted-server-adapter-fastly/src/error.rs b/crates/trusted-server-adapter-fastly/src/error.rs index 560a2e18..12215f71 100644 --- a/crates/trusted-server-adapter-fastly/src/error.rs +++ b/crates/trusted-server-adapter-fastly/src/error.rs @@ -17,3 +17,22 @@ pub fn to_error_response(report: &Report) -> Response { Response::from_status(root_error.status_code()) .with_body_text_plain(&format!("{}\n", root_error.user_message())) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_store_unavailable_renders_503() { + let report = Report::new(TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "not seeded".to_string(), + }); + let resp = to_error_response(&report); + assert_eq!( + resp.get_status(), + fastly::http::StatusCode::SERVICE_UNAVAILABLE, + "should render 503 for ConfigStoreUnavailable" + ); + } +} diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 1b697f68..4981129f 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -26,6 +26,11 @@ pub enum TrustedServerError { #[display("Configuration error: {message}")] Configuration { message: String }, + /// Config store could not be read (unseeded, transient backend, or a listed + /// key missing) — Settings cannot be loaded. Retryable / fix by seeding. + #[display("Config store unavailable: {store_name} - {message}")] + ConfigStoreUnavailable { store_name: String, message: String }, + /// Auction orchestration error. #[display("Auction error: {message}")] Auction { message: String }, @@ -123,6 +128,7 @@ impl IntoHttpResponse for TrustedServerError { Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, @@ -242,6 +248,15 @@ mod tests { assert_eq!(error.user_message(), "Invalid header value"); } + #[test] + fn config_store_unavailable_maps_to_503() { + let err = TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "not seeded".to_string(), + }; + assert_eq!(err.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + #[test] fn status_code_maps_each_error_variant_to_expected_http_response() { // Compile-time guard: adding a TrustedServerError variant without @@ -264,6 +279,7 @@ mod tests { | TrustedServerError::EdgeCookie { .. } | TrustedServerError::PartnerNotFound { .. } | TrustedServerError::RequestTooLarge { .. } + | TrustedServerError::ConfigStoreUnavailable { .. } | TrustedServerError::InsecureDefault { .. } => (), }; @@ -341,6 +357,13 @@ mod tests { }, StatusCode::SERVICE_UNAVAILABLE, ), + ( + TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "config store unavailable".to_string(), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), ( TrustedServerError::Auction { message: "auction failed".to_string(), diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index efcfbcd4..92d6bdfb 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -17,9 +17,12 @@ const DEFAULT_CONFIG_STORE_ID: &str = "app_config"; /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened -/// config entry is missing, cannot be read, fails hash verification, or fails -/// Trusted Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when a +/// config-store entry cannot be read (store unseeded, transient backend, or a +/// listed key missing), and [`TrustedServerError::Configuration`] / +/// [`TrustedServerError::Settings`] (HTTP 500) when the read succeeds but +/// reconstruction fails (metadata unparseable, hash verification, or settings +/// validation). pub fn get_settings_from_services( services: &RuntimeServices, ) -> Result> { @@ -32,9 +35,12 @@ pub fn get_settings_from_services( /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened -/// config entry is missing, cannot be read, fails hash verification, or fails -/// Trusted Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when a +/// config-store entry cannot be read (store unseeded, transient backend, or a +/// listed key missing), and [`TrustedServerError::Configuration`] / +/// [`TrustedServerError::Settings`] (HTTP 500) when the read succeeds but +/// reconstruction fails (metadata unparseable, hash verification, or settings +/// validation). pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, store_name: &StoreName, @@ -66,10 +72,11 @@ fn read_config_entry( ) -> Result> { config_store .get(store_name, key) - .change_context(TrustedServerError::Configuration { + .change_context(TrustedServerError::ConfigStoreUnavailable { + store_name: store_name.to_string(), message: format!( - "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" - ), + "unavailable or not seeded (failed to read `{key}`) — run `ts config push`" + ), }) } @@ -77,6 +84,7 @@ fn read_config_entry( mod tests { use super::*; use crate::config_payload::build_config_payload; + use crate::error::IntoHttpResponse; use crate::platform::PlatformError; use crate::settings::Settings; use crate::test_support::tests::crate_test_settings_str; @@ -142,4 +150,68 @@ mod tests { "error should mention missing keys metadata" ); } + + #[test] + fn unseeded_store_is_config_store_unavailable_503() { + let store = MemoryConfigStore { + entries: BTreeMap::new(), + }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("unseeded store must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "unseeded store read failure should map to 503" + ); + // The actionable hint must ride the error chain so it reaches the + // server log (the operator's channel); the public 503 body stays + // generic by design. + assert!( + format!("{err:?}").contains("ts config push"), + "error chain should carry the actionable `ts config push` hint for logs" + ); + } + + #[test] + fn malformed_hash_stays_500() { + let mut entries = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + entries.insert(CONFIG_HASH_KEY.to_string(), "sha256:deadbeef".to_string()); + let store = MemoryConfigStore { entries }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("hash mismatch must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "reconstruct/verify failure should stay 500" + ); + } + + #[test] + fn missing_listed_key_is_503() { + // Metadata (`ts-config-keys` / `ts-config-hash`) reads succeed, but a key + // the metadata lists is absent — still a read failure → 503. + let mut entries = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + let victim = entries + .keys() + .find(|key| !key.starts_with("ts-config-")) + .cloned() + .expect("payload should have at least one settings key"); + entries.remove(&victim); + let store = MemoryConfigStore { entries }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("missing listed key must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "a listed key missing is a config-store read failure → 503" + ); + } } diff --git a/docs/superpowers/plans/2026-06-17-edgezero-269-repin.md b/docs/superpowers/plans/2026-06-17-edgezero-269-repin.md new file mode 100644 index 00000000..bfdff332 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-edgezero-269-repin.md @@ -0,0 +1,267 @@ +# EdgeZero #269 HTTP-Layer 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:** Land the HTTP-layer (runtime) half of adopting edgezero `stackpop/edgezero#269` in trusted-server — by **converging onto Christian's `feature/ts-cli-next`** (which already carries the repin, the `Body` fixes, and runtime Settings-from-config-store for Fastly), then closing the runtime gaps it leaves: seed-before-serve safety, secrets/KV runtime wiring, non-Fastly adapters, and the missing runtime-config-store spec. + +**Architecture:** trusted-server keeps its bespoke `platform/` layer (`RuntimeServices` + `PlatformConfigStore`/`SecretStore`/`KvStore`). #269's only forced code break is `Body::into_bytes() → Option` (18 sinks — Appendix A). Christian's branch already fixes those and wires `get_settings_from_services()` to rebuild `Settings` from the `app_config` config store via the shared `config_payload` flatten/hash contract. Our work is the **runtime-side hardening + spec**, not a parallel repin. + +**Tech Stack:** Rust 2024, cargo, `wasm32-wasip1` (Fastly via Viceroy), edgezero git dep (`2eeccc9`, #269 HEAD), `error-stack`. + +**Source spec:** [2026-06-16-edgezero-269-repin-breaking-api-finding.md](../specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md) — esp. **§12** (convergence), §2 (sinks), §9 (decisions). + +--- + +## Strategy change (why this plan was rewritten) + +The prior version of this plan was a standalone minimal-repin off PR14. Investigation of `feature/ts-cli-next` (2026-06-18, spec §12) showed that branch is **not just CLI** — it already implements the end-to-end Fastly config-store migration: same #269 pin, the `Body` fixes, store ids, the `config_payload` contract, **and** runtime `Settings`-from-store load. So a separate Fastly repin is **redundant**. This plan now **builds on his branch** and focuses on the runtime gaps. The verified `Body`-sink enumeration is preserved as Appendix A (still the authoritative sink reference when his ad-hoc fixes merge up the stack). + +--- + +## Open decisions — resolve at Phase 0 before coding + +| # | Decision | Recommendation | +| --- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| D1 | Build on `feature/ts-cli-next` vs keep the PR14-stack minimal-repin | **Build on his** (his is end-to-end for Fastly; ours duplicates it) | +| D2 | Whole-`Settings` → store (his) vs two-tier small `AppConfig` (our spec §6) | **Adopt his whole-`Settings`** (one source of truth; already implemented) | +| D3 | `Body` fix style | **His `ok_or_else` (graceful)** over `.expect()` (spec §2) | +| D4 | Empty/unseeded store behavior | **Decide explicitly** (Phase 2) — today it's a hard fail / outage | +| D5 | CLI-driven secret push (he punts) vs runtime secret writes (already exist via `management_api.rs`) | Keep runtime rotation; treat CLI secret-push as a later follow-up | +| D6 | Branch/merge topology — his branch is off `main`, the stack is PR14→PR20 | Phase 5 — confirm with team | + +Do **not** start Phase 1 until **D1–D3** are confirmed (they set the base branch +and code style). **D4–D6 are sequenced, not skipped:** D4 (empty-store response) +is resolved in Phase 2 Step 5, D5 (secret-write boundary) in Phase 3 Step 2, D6 +(branch topology) in Phase 5 Step 1. + +--- + +## Scope & non-goals + +**In scope:** converge onto his branch; verify the repin + `Body` fixes are complete against Appendix A; run the full gate (host, **wasm32-wasip1**, **`--all-targets`**, clippy, test) + integration-tests lockfile; harden runtime config-store loading (empty/malformed-store, seed-before-serve); confirm secrets/`ec_identity_store` KV runtime wiring; write the runtime-config-store spec; merge up the stack. + +**Out of scope (separate plans):** the CLI crate itself (`ts config`/`audit` — Christian); CLI-driven secret push; full edgezero `run_app`/`app!`/extractor adoption (he kept the bespoke layer, so do we); non-Fastly adapter _feature_ parity beyond making them build. + +--- + +## File structure (what we touch / extend, on his branch) + +| File | Role | Our action | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------- | +| `Cargo.toml` / `Cargo.lock` | edgezero pinned `2eeccc9` | verify; re-pin to `main` post-merge (Phase 5) | +| `crates/trusted-server-core/src/config_payload.rs` | flatten/hash contract (shared seam) | **read-only reference** — do not fork | +| `crates/trusted-server-core/src/settings_data.rs` | `get_settings_from_services` runtime load | **harden** empty/malformed behavior (Phase 2) | +| `crates/trusted-server-adapter-fastly/src/main.rs` | entry point: build services → load settings | **harden** the settings-error path (Phase 2) | +| `edgezero.toml` | store ids: `app_config` / `secrets` / `ec_identity_store` | verify; reference in the spec | +| `crates/trusted-server-core/src/{proxy,publisher,auction/endpoints,auction/formats,request_signing/endpoints}.rs`, `integrations/{prebid,testlight}.rs` | `Body` sinks | **verify** all 18 covered (Appendix A) | +| `crates/trusted-server-adapter-{cloudflare,spin}` | stubs, untouched by him | **make build** under #269 (Phase 3) | +| `docs/superpowers/specs/-runtime-config-store.md` | the missing spec | **create** (Phase 4) | + +--- + +## Phase 0: Convergence decision + adopt the base + +- [ ] **Step 1: Confirm D1–D3** with the team (record in the spec §9). If D1 = "build on his," proceed; if "keep PR14-stack," fall back to Appendix B (the minimal-repin tasks). + +- [ ] **Step 2: Create the HTTP-layer branch off his branch** + +```bash +git fetch origin +# Record the exact SHA — his branch is an unmerged WIP and may force-push/rebase. +git rev-parse origin/feature/ts-cli-next # note this; if he rebases, re-base from the new SHA + coordinate +git checkout -b feature/edgezero-269-http origin/feature/ts-cli-next +``` + +- [ ] **Step 3: Baseline build (inherit his state)** + +Run: `cargo build --workspace --all-targets 2>/tmp/ez_base.log; echo "exit=$?"` +Expected: **green** (his branch should already compile). If red, capture and triage before any new work. + +--- + +## Phase 1: Verify the inherited repin + `Body` fixes + +His `Body` fixes were ad-hoc (driven by his build), not enumerated. Verify completeness against Appendix A, and run the **full** gate (he is unlikely to have run wasm + `--all-targets` + clippy on every leg). + +- [ ] **Step 1: Enumerate the sinks (locate, don't "prove")** + +Run: `git grep -nE 'into_bytes\(\)' crates/trusted-server-core/src -- 'proxy.rs' 'publisher.rs' 'auction/endpoints.rs' 'auction/formats.rs' 'request_signing/endpoints.rs' 'integrations/prebid.rs' 'integrations/testlight.rs'` +Expect **18 sites** (8 prod + 10 test). Eyeball each has an `Option` handler +(`.ok_or_else`/`.expect`/`.unwrap_or_default`). **Note: grep cannot prove +correctness** — a fixed Shape-C `let b = …into_bytes().ok_or_else(…)?;` and a +broken bare `.into_bytes()` both contain `.into_bytes()`. This step is enumeration +only; the **authoritative completeness proof is Step 2's green `--all-targets` + +`cargo test`.** Appendix A line numbers are **PR14-base and do NOT apply** to this +`main`-based branch — trust the grep _count_ (18), not the numbers. + +- [ ] **Step 2: Full gate (the legs he likely skipped)** + +```bash +cargo build --workspace --all-targets +cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +``` + +Expected: all green. Any failure here is the real signal — fix before Phase 2. + +- [ ] **Step 3: integration-tests lockfile** + +`crates/integration-tests` is a separate workspace that path-deps `trusted-server-core`. +Run: `( cd crates/integration-tests && cargo build --workspace )` first (don't +`generate-lockfile` — that can re-resolve and _cause_ drift). Only if it fails on +shared-dep mismatch: `cargo update -p --precise ` (never +blanket). Repeat for `crates/openrtb-codegen` if it drifts. + +- [ ] **Step 4: Commit any gate fixups** + +```bash +git add crates Cargo.toml Cargo.lock && git commit -m "Complete Body sink coverage and pass full gate on #269" || echo "nothing to commit" +``` + +--- + +## Phase 2: Runtime config-store hardening (the core HTTP-layer deliverable) + +**Problem (verified against his `main.rs`):** `get_settings_from_services` → +`get_settings_from_config_store` reads `ts-config-keys` first; on an +**empty/unseeded store** `read_config_entry`'s `?` propagates a `Configuration` +error. His settings-error arm **does serve a response** — +`to_error_response(&e).send_to_client(); return;` (not a bare return, not an +opaque default; `fn main()` returns `()` and serves explicitly). So the issue is +**not** "no response" — it is that **every route returns a generic error** until +the store is seeded, and the error is **indistinguishable from a real config +bug**. Fresh deploy before `ts config push` = **total outage with an opaque 500**. +The gap our layer owns: make the unseeded case **actionable** (clear message) and +**correctly classified** (retryable 503, not 500). + +> **Call chain (read first):** `get_settings_from_services(&runtime_services)` → +> `get_settings_from_config_store(&dyn PlatformConfigStore, &StoreName)` → +> `read_config_entry` (per key) → `settings_from_config_entries` (hash verify). +> The in-memory `PlatformConfigStore` fake **already exists** as +> `MemoryConfigStore` in `settings_data.rs` tests (around line 84) — reuse it; do +> not write a new one. Confirm the exact constructor (`MemoryConfigStore { entries }` +> vs `::new(...)`) before writing the test below. + +- [ ] **Step 1: Write a failing test — empty store yields an actionable, typed error (not a generic read failure)** + +In `settings_data.rs` tests (reuse `MemoryConfigStore`): + +```rust +#[test] +fn empty_config_store_reports_unseeded_not_generic_failure() { + let store = MemoryConfigStore::new(BTreeMap::new()); // no ts-config-keys + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("empty store should error"); + // assert it carries an actionable "config store not seeded — run `ts config push`" context + assert!(format!("{err:?}").contains("not seeded") || format!("{err:?}").contains("ts config push")); +} +``` + +- [ ] **Step 2: Run it — verify it fails** (`cargo test -p trusted-server-core empty_config_store -- --nocapture`). Expected: FAIL (current error message is generic "failed to read … key `ts-config-keys`"). + +- [ ] **Step 3: Implement — distinguish "unseeded" from "read error"** + +In `read_config_entry` / `get_settings_from_config_store`, when the **metadata** key (`ts-config-keys`) is absent, attach an actionable context (e.g. `TrustedServerError::Configuration` with `"config store `{store}`is not seeded — run`ts config push --adapter fastly`"`). Keep transport/read failures distinct. + +- [ ] **Step 4: Run the test — verify it passes.** + +- [ ] **Step 5: Decide + implement the adapter response (D4)** + +His settings-error arm already serves via `to_error_response(&e).send_to_client(); return;`. +Two options — **decide D4 here:** +(a) keep the arm, but have `to_error_response` map the new "unseeded" error context +to **503** (retryable) instead of 500; or +(b) special-case the unseeded error in `main.rs` before `to_error_response`: +`FastlyResponse::from_status(503).with_body_text_plain("config not provisioned — run `ts config push`").send_to_client(); return;` +(matches the existing `from_status(...).with_body_text_plain(...).send_to_client()` +idiom at `main.rs:119–121`). Add an adapter test asserting **503 + body** for the +unseeded case. This turns an opaque 500 into an observable, actionable signal — +and keeps real config bugs as 500. + +- [ ] **Step 6: Malformed-store test** — seed a `ts-config-hash` that doesn't match the entries; assert `settings_from_config_entries` errors on hash mismatch (his code already verifies; add the test if absent so the contract is locked). + +- [ ] **Step 7: Confirm secrets + KV runtime wiring** + - `secrets` store: request-signing reads signing keys via `PlatformSecretStore` (pre-existing `management_api.rs` provides write CRUD). Add/confirm a test that a missing signing secret degrades to a clear error, not a panic. + - `ec_identity_store` KV: `main.rs` starts `UnavailableKvStore` and EC routes lazily bind the configured store. Confirm a non-EC route still serves when EC KV is unavailable (existing behavior — add a regression test if missing). + +- [ ] **Step 8: Commit** + +```bash +git add crates && git commit -m "Harden runtime config-store load: actionable unseeded error and 503 response" +``` + +--- + +## Phase 3: Adapter + build-surface gaps + +- [ ] **Step 1: Make non-Fastly adapters build under #269** + +First confirm what "builds" means for these stubs — spec §1 notes +cloudflare/axum are **absent from the dependency graph** (not currently compiled). +If the crate has no real wasm entry, "builds" = `cargo check -p trusted-server-adapter-cloudflare` +on host; only use `--target wasm32-unknown-unknown` (install the target first) if +it has a genuine worker entry point. Same judgment for spin. +If they break on `Body`/edgezero churn, apply the Appendix A fix shapes. They are stubs — goal is **compiles**, not feature parity (out of scope). + +- [ ] **Step 2: Document the secret-write boundary (D5)** + +Confirm: runtime key-rotation secret writes work via `management_api.rs` (pre-existing); CLI-driven secret _push_ is deferred (Christian punts it). Capture this split in the spec so it is a recorded decision, not an accident. + +- [ ] **Step 3: Commit any adapter fixups.** + +--- + +## Phase 4: Runtime-config-store spec (the doc his CLI design references but never wrote) + +- [ ] **Step 1: Write `docs/superpowers/specs/-runtime-config-store.md`** covering: + - the load sequence (`build_runtime_services` → `get_settings_from_services` → `settings_from_config_entries`); + - the **shared `config_payload` contract** (escaping, sorted-key canonicalization, `sha256` over settings-only entries, `ts-config-*` reserved keys) — reference, do not duplicate; + - the **seed-before-serve** operational contract + the 503 unseeded behavior (Phase 2); + - empty / missing-key / malformed-hash / transport-error matrix; + - store-name resolution + `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` override; + - secrets/KV runtime read paths + the secret-write boundary (D5); + - non-Fastly adapter status. + +- [ ] **Step 2: Docs gate** — `cd docs && npm run format` (prettier-clean), then commit. + +--- + +## Phase 5: Stack propagation + re-pin + +- [ ] **Step 1: Reconcile topology (D6).** His branch is off `main`; the migration stack is PR14→PR20. Confirm with the team whether the HTTP-layer branch merges via `main` (with his) or threads the stack. Do not push/merge without approval. + +- [ ] **Step 2: Re-pin to edgezero `main` after #269 merges** — one-line dep change in `Cargo.toml`, regenerate lock, re-run the Phase 1 gate. + +- [ ] **Step 3: Open the PR (approval-gated).** Base = whatever Step 1 resolves. Assign `@me`. Summary: HTTP-layer convergence + runtime hardening + spec. + +--- + +## Risks & watch points + +| Risk | Mitigation | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Fresh deploy = outage** (unseeded store, no fallback) | Phase 2: actionable error + 503; document seed-before-serve; consider a provisioning gate in deploy | +| His `Body` fixes incomplete vs our 18 sinks | Phase 1 Step 1 cross-check against Appendix A | +| He likely didn't run wasm + `--all-targets` + clippy on every leg | Phase 1 Step 2 runs the full matrix | +| Pinned to an **open, force-pushable** #269 ref | Re-pin to `main` post-merge (Phase 5); rollback = revert the dep commit | +| **Building on a colleague's unmerged WIP branch** (`feature/ts-cli-next`) — it may rebase/force-push out from under us, vanishing our merge-base | Record its SHA at Phase 0 Step 2; if he rebases, re-base from the new SHA and coordinate before any merge; keep our additions as discrete commits so they re-cherry-pick cleanly | +| integration-tests lockfile drift | Phase 1 Step 3, targeted `--precise` only | +| Branch topology (his off `main`, stack off PR14) | Phase 5 Step 1, confirm with team | +| Whole-`Settings`-in-store enlarges blast radius of a bad push | hash verification (his) + malformed-store test (Phase 2 Step 6) | + +--- + +## Appendix A — verified `Body::into_bytes` sink reference (authoritative) + +From the compiler spike (spec §2/§10): **18 sink bindings, 8 production + 10 test-only**, all `into_bytes` (no `as_bytes` sink). The line numbers below are **PR14-base — they do NOT apply to the `main`-based `feature/ts-cli-next`**; use them only as a count/shape reference (8 prod + 10 test). On any branch, the compiler (`--all-targets`) is the source of truth. Use this to confirm Christian's ad-hoc fixes are complete and when merging up the stack. + +- **Production (8):** `proxy.rs:38`, `publisher.rs:46`, `auction/endpoints.rs:81`, `proxy.rs:1550`, `proxy.rs:1665`, `request_signing/endpoints.rs:103/246/365`. +- **Test-only (10):** `auction/formats.rs:444`, `prebid.rs:2067`, `testlight.rs:461`, `proxy.rs:2034/2795/2851`, `publisher.rs:748/1079/1562`, `request_signing/endpoints.rs:464`. +- **Not a sink:** `http_util.rs:456` (the `enforce_max_body_size(bytes: &[u8], …)` signature). +- **Fix style (D3):** production → `into_bytes().ok_or_else(|| )?`; compression/test → `unwrap_or_default()`; only `.expect("should …")` where a buffered body is truly invariant. + +## Appendix B — fallback: standalone minimal-repin (only if D1 = "keep PR14 stack") + +If the team rejects building on his branch, the original minimal-repin still applies: branch off PR14, repin to `2eeccc9`, fix the Appendix A sinks with the D3 style, reconcile the integration-tests lock, full gate, merge up PR14→PR20. (This duplicates his Fastly work and is **not** recommended — see spec §12.) diff --git a/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md b/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md new file mode 100644 index 00000000..a17abba1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md @@ -0,0 +1,312 @@ +# EdgeZero #269 HTTP-Layer Runtime 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:** Harden the runtime config-store load so an unseeded/unavailable `app_config` store returns an actionable **503** (not an opaque 500), confirm the non-Fastly adapters still build, and record the secret-write boundary — on top of the inherited #269 base. + +**Architecture:** trusted-server boots by rebuilding `Settings` from the `app_config` config store (`get_settings_from_services` → `get_settings_from_config_store` → `read_config_entry` per key → `settings_from_config_entries`). We classify failures by **call site**: a **config-store read failure** (unseeded, transient, or a missing listed key — all `PlatformConfigStore::get → Err`) maps to a new `TrustedServerError::ConfigStoreUnavailable` → **503**; a **reconstruct/verify failure** (`settings_from_config_entries`: hash mismatch, unparseable) stays `Configuration` → **500**. One new error variant, no platform-layer change (spec option Y). + +**Tech Stack:** Rust 2024, `error-stack` (`Report`), `derive_more::Display`, cargo, `wasm32-wasip1`. + +**Source spec:** [2026-06-18-edgezero-269-http-layer-runtime-design.md](../specs/2026-06-18-edgezero-269-http-layer-runtime-design.md) — §3.3 behavior matrix, §4.1 mechanism, §4.4 the `get→Option` follow-up (out of scope here). + +**Branch:** `feature/edgezero-269-http` (off `ts-cli-next` `14a91cc1`, edgezero `2eeccc9`), inherited base verified green. + +--- + +## Scope & non-goals + +**In scope:** the new `ConfigStoreUnavailable` 503 variant + read-failure classification (§4.1); core + adapter tests; malformed-hash stays-500 test; non-Fastly build check (§4.2); secret-write boundary note (§4.3). + +**Out of scope:** `PlatformConfigStore::get → Result>` convergence (spec §4.4 — pre-existing trait, store-convergence follow-up); the PR14→PR20 stack; CLI changes; edgezero extractor/`run_app` adoption. + +--- + +## File structure + +| File | Change | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/error.rs` | Add `ConfigStoreUnavailable { message: String }` variant + `status_code()` arm → `SERVICE_UNAVAILABLE` | +| `crates/trusted-server-core/src/settings_data.rs` | `read_config_entry` `change_context` → `ConfigStoreUnavailable` (actionable message); `get_settings_from_config_store` metadata reads inherit it; **reconstruct path unchanged**; add tests | +| `crates/trusted-server-adapter-fastly/src/` (test) | Adapter test: read-failure error → **503** to client via `to_error_response` | + +`settings_from_config_entries` and `config_payload.rs` are **not** touched (reconstruct/verify stays 500; shared seam read-only). + +--- + +## Task 1: Add the `ConfigStoreUnavailable` error variant (→ 503) + +**Files:** Modify `crates/trusted-server-core/src/error.rs` + +- [ ] **Step 1: Write the failing test** (in `error.rs` `#[cfg(test)]`) + +```rust +#[test] +fn config_store_unavailable_maps_to_503() { + let err = TrustedServerError::ConfigStoreUnavailable { + message: "config store unavailable or not seeded".to_string(), + }; + assert_eq!(err.status_code(), StatusCode::SERVICE_UNAVAILABLE); +} +``` + +- [ ] **Step 2: Run it — verify it fails** + +Run: `cargo test -p trusted-server-core config_store_unavailable_maps_to_503` +Expected: FAIL to compile — variant doesn't exist yet. + +- [ ] **Step 3: Add the variant + mapping** + +In the `TrustedServerError` enum, beside `Configuration { message: String }` (mirror its `#[display(...)]` style): + +```rust +/// Config store could not be read (unseeded, transient backend, or a +/// listed key missing) — the service cannot load Settings. Retryable / fix +/// by seeding the store. +#[display("Config store unavailable: {message}")] +ConfigStoreUnavailable { message: String }, +``` + +In `status_code()`, beside the `KvStore` 503 arm (`error.rs:125`): + +```rust +Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, +``` + +**Also (required — or Step 4 won't compile):** `error.rs` has an existing +exhaustiveness-guard test `status_code_maps_each_error_variant_to_expected_http_response` +(~lines 246-390) whose `let _guard: fn(&TrustedServerError) = |error| match error { … }` +lists **every** variant with no wildcard. Add the new variant to that guard arm +(and a case to its `cases` array) in this step, e.g.: + +```rust +TrustedServerError::ConfigStoreUnavailable { .. } => {} +``` + +Skipping this turns Step 4 into a non-exhaustive-match **compile error**, not a pass. + +- [ ] **Step 4: Run it — verify it passes** + +Run: `cargo test -p trusted-server-core config_store_unavailable_maps_to_503` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/error.rs +git commit -m "Add ConfigStoreUnavailable error variant mapping to 503" +``` + +--- + +## Task 2: Classify config-store read failures as `ConfigStoreUnavailable` + +**Files:** Modify `crates/trusted-server-core/src/settings_data.rs` + +Background (confirmed): `read_config_entry` wraps `config_store.get(...)` with +`change_context(TrustedServerError::Configuration { … })` today (→ 500). The +in-memory test fake is `MemoryConfigStore { entries: BTreeMap }` +(**struct literal, no `::new`**); its `get` returns `Err(PlatformError::ConfigStore)` +for a missing key — so an empty map models the unseeded store. + +- [ ] **Step 1: Write the failing tests** (in `settings_data.rs` `#[cfg(test)]`, reuse `MemoryConfigStore`) + +**Imports (required — `status_code()` is a trait method):** the test module is +`use super::*`, which does **not** bring in the status trait. Add to the test +module: `use crate::error::IntoHttpResponse;` (precedent: `proxy.rs` and +`request_signing/endpoints.rs` test modules do the same). `http::StatusCode` works +as a bare path (`http` is a direct dep) — no `use` needed for it. + +```rust +#[test] +fn unseeded_store_is_config_store_unavailable_503() { + let store = MemoryConfigStore { entries: BTreeMap::new() }; // no ts-config-keys + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("unseeded store must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "unseeded/unavailable config store should be 503" + ); +} + +#[test] +fn malformed_hash_stays_500() { + // Build a valid payload, then corrupt the hash entry so reconstruct fails. + let mut payload = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + payload.insert(CONFIG_HASH_KEY.to_string(), "sha256:deadbeef".to_string()); + let store = MemoryConfigStore { entries: payload }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("hash mismatch must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "corrupt (loaded-but-invalid) config should stay 500" + ); +} +``` + +- [ ] **Step 2: Run — verify the unseeded test fails, malformed passes** + +Run: `cargo test -p trusted-server-core -- unseeded_store_is_config_store_unavailable malformed_hash_stays_500` +Expected: `unseeded_…` FAILS (currently 500); `malformed_hash_stays_500` PASSES already (reconstruct path unchanged). If `malformed` fails, the corruption isn't reaching `settings_from_config_entries` — re-check the fixture before touching code. + +- [ ] **Step 3: Implement — flip only the read path** + +In `read_config_entry`, change the `change_context` target from +`TrustedServerError::Configuration` to the new variant with an actionable message: + +```rust +fn read_config_entry( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, + key: &str, +) -> Result> { + config_store + .get(store_name, key) + .change_context(TrustedServerError::ConfigStoreUnavailable { + message: format!( + "config store `{store_name}` unavailable or not seeded \ + (failed to read `{key}`) — run `ts config push`" + ), + }) +} +``` + +Do **not** change the `serde_json::from_str(&keys_raw)` parse (`Configuration`/500 +— that's reconstruct of metadata, genuine corruption) or `settings_from_config_entries`. + +- [ ] **Step 4: Run — both tests pass + full core suite** + +Run: `cargo test -p trusted-server-core` +Expected: the two new tests PASS; the existing `settings_data` round-trip/load tests still PASS (seeded path unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/settings_data.rs +git commit -m "Classify config-store read failures as ConfigStoreUnavailable (503)" +``` + +--- + +## Task 3: Adapter end-to-end — read failure reaches the client as 503 + +**Files:** Add a test in `crates/trusted-server-adapter-fastly/src/` (follow the existing test module style — see `route_tests.rs` for how adapter tests assert status; `to_error_response` lives in `crate::error`). + +- [ ] **Step 1: Write the failing test** + +Assert that the adapter's error→response path maps a `ConfigStoreUnavailable` +error to a 503 response. Minimal, no live store: + +```rust +#[test] +fn config_store_unavailable_error_renders_503() { + use trusted_server_core::error::TrustedServerError; + let err = error_stack::Report::new(TrustedServerError::ConfigStoreUnavailable { + message: "unseeded".to_string(), + }); + let resp = crate::error::to_error_response(&err); + assert_eq!(resp.get_status(), fastly::http::StatusCode::SERVICE_UNAVAILABLE); +} +``` + +(Confirm `to_error_response`'s exact signature/return type — adjust the call and +the status accessor to match `route_tests.rs` conventions. If `to_error_response` +takes the error by value or a different ref, follow the existing call sites.) + +- [ ] **Step 2: Run — verify it fails** (compile or assertion), then make it pass + +Run: `cargo test -p trusted-server-adapter-fastly config_store_unavailable_error_renders_503` +If it fails only because `to_error_response` already maps via `status_code()` (Task 1), it should pass once the call signature is correct — this test **locks** that the variant→503 mapping isn't bypassed by the adapter. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src +git commit -m "Lock adapter 503 response for ConfigStoreUnavailable" +``` + +--- + +## Task 4: Confirm non-Fastly adapters still build + +**Files:** none (verification) + +- [ ] **Step 1: Determine what "builds" means per stub** + +`trusted-server-adapter-{cloudflare,spin}` are stubs (cloudflare/axum absent from +the dependency graph). Run host checks: +`cargo check -p trusted-server-adapter-cloudflare` and `-p trusted-server-adapter-spin`. +Only attempt `--target wasm32-unknown-unknown` if the crate has a real worker +entry (install the target first). + +- [ ] **Step 2: If anything breaks under #269**, apply the §4.1 / finding fix shapes (likely none — our change is additive to core). Expected: green with no edits. + +- [ ] **Step 3: Commit any fixups** (likely none). + +--- + +## Task 5: Record the secret-write boundary (§4.3) + +**Files:** Modify a doc comment near the signing/secret store wiring (no behavior change) + +- [ ] **Step 1:** Add a brief doc comment where signing secrets are read (or at + `management_api.rs`'s secret-write entry) noting: runtime key-rotation writes go + through `FastlyPlatformSecretStore` CRUD (pre-existing); CLI-driven secret push + is deferred (spec §4.3). One comment so the split is discoverable in code, not + only the spec. + +- [ ] **Step 2: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src +git commit -m "Document runtime-vs-CLI secret-write boundary" || echo "nothing to commit" +``` + +> Skip this task if the team prefers the boundary lives only in the spec. + +--- + +## Task 6: Full gate + +**Files:** none (verification) + +- [ ] **Step 1: Run the gate** + +```bash +cargo build --workspace --all-targets +cargo build -p trusted-server-adapter-fastly --release --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +``` + +Expected: all green (the §6 baseline + the new tests). + +- [ ] **Step 2: integration-tests lockfile** (separate workspace, path-deps core) + +Run: `( cd crates/integration-tests && cargo build --workspace )`. Only on +shared-dep mismatch: `cargo update -p --precise ` (never +blanket). + +- [ ] **Step 3: Commit any fmt fixups** + +```bash +git add crates Cargo.toml Cargo.lock && git commit -m "Gate fixups" || echo "nothing to commit" +``` + +--- + +## Risks & notes + +| Risk / note | Handling | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `to_error_response` signature differs from the Task 3 sketch | Confirm against `route_tests.rs` call sites; the test is illustrative | +| The unseeded message also fires on transient backend errors | By design (spec §3.3/§3.4 option Y) — 503 covers both; message names both paths | +| `get→Option` would let us split unseeded vs transient precisely | **Out of scope** (spec §4.4) — tracked store-convergence follow-up; this plan does not block on it | +| `MemoryConfigStore` constructor | Struct literal `MemoryConfigStore { entries }` — **no `::new`** (confirmed) | diff --git a/docs/superpowers/specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md b/docs/superpowers/specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md new file mode 100644 index 00000000..c9c55412 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-edgezero-269-repin-breaking-api-finding.md @@ -0,0 +1,681 @@ +# Finding: edgezero #269 repin — breaking-API surface for trusted-server + +- **Date:** 2026-06-16 (build-verified 2026-06-17, §10) +- **Author:** Prakash (HTTP port); Christian owns the CLI port (see §7) +- **Upstream:** [stackpop/edgezero#269](https://github.com/stackpop/edgezero/pull/269) + "EdgeZero CLI Extensions" — head `feature/extensible-cli`, base `main`, + **OPEN / unmerged** as of 2026-06-16. Sibling adaptation precedent: + [stackpop/mocktioneer#110](https://github.com/stackpop/mocktioneer/pull/110) + (design + plan only, docs-only). +- **Companion doc:** extends + [2026-03-19-edgezero-migration-design.md](./2026-03-19-edgezero-migration-design.md) + (the original Fastly→EdgeZero migration, PRs 1–17; PR13 merged, branch now on + PR19 canary cutover). +- **Method:** diffed the exact pin `170b74b` (current) against + `feature/extensible-cli` HEAD (`git diff 170b74b..HEAD`, 257 commits, + +28,969 / −5,754 across 143 files), then cross-referenced every changed + public symbol against trusted-server's actual consumption sites. All + signatures below are quoted verbatim from the two refs; call sites are + `file:line` in this repo. + +--- + +## 0. TL;DR + +1. **trusted-server currently pins edgezero at `170b74b`** (March 2026, "unified + key-value store abstraction #165"). `feature/extensible-cli` is **257 commits + ahead** of that exact commit — and `170b74b` is a clean ancestor, so a repin + to post-#269 swallows the **entire** delta, not just #269's own commits. + +2. **Almost none of #269's headline breaks reach trusted-server.** The original + migration deliberately wrapped edgezero behind trusted-server's own + `platform/` trait layer (`RuntimeServices`, `PlatformConfigStore`, + `PlatformSecretStore`, `PlatformKvStore`, `PlatformHttpClient`). As a result + trusted-server uses **none** of: `run_app`, the `app!` macro, + `RequestContext`, edgezero extractors, typed `AppConfig`, or `[stores.*]` + manifest tables. Every one of those is where #269's breakage lives. + +3. **The actual code-level break is a single method:** + `edgezero_core::body::Body::into_bytes()` now returns `Option` instead + of panicking. (`as_bytes()` changed the same way but has **no** trusted-server + call site — §2.) **Compiler-enumerated** (not rg-guessed): **18 sink bindings + — 8 production + 10 test-only** (§2/§10). Mechanical fix. + +4. **`KvError` going `#[non_exhaustive]` + two new variants does _not_ break us** + — trusted-server only _constructs_ `KvError::Unavailable` and never + exhaustively matches the enum. + +5. **Strategic question for the HTTP port:** #269 matures edgezero's _own_ + first-class multi-store registry, async `ConfigStore`/`SecretStore`, + `Config`/`Secrets`/`Kv` extractors, and typed `AppConfig`. These now overlap + heavily with trusted-server's bespoke `platform/` layer. The HTTP port can be + a **minimal repin** (keep the bespoke layer) or a **convergence** onto + edgezero's surface. See §6. + +6. **The stack already walks edgezero forward** — pins are _not_ frozen at + `170b74b`: PR1–13 = `170b74b`, PR14–18 = `38198f9`, PR19–20 = `ce6bcf7`. The + #269 repin is the next step of a bump the team already does. PR14 is where + trusted-server _starts_ consuming edgezero's high-level surface + (`RequestContext`/`EdgeError`/middleware/router) — yet a real build (§10) + proves even that base breaks on **nothing but `Body`**. See §11. + +7. **Plan (agreed):** do the upgrade on a **dedicated branch off PR14** (not on + main, not in-place on any reviewed PR), then **merge up** the stack; **re-pin + to edgezero `main` after #269 merges**. Full-adaptation roadmap (store + convergence + typed config + entry-point) is a _separate, optional_ track — + **not** forced by the repin (§11). + +8. **Superseded for Fastly by `feature/ts-cli-next` (§12).** Christian's "CLI" + branch already implements the end-to-end Fastly config-store migration — same + #269 pin, the `Body` fixes (graceful `ok_or_else`, not `.expect()`), the store + ids, the `config_payload` flatten/hash contract, **and runtime + Settings-from-store load** (`get_settings_from_services`). So our minimal-repin + (#771) is largely redundant for Fastly. **Revised: build on his branch**; our + real HTTP-layer deliverable is the **runtime-config-store spec** his CLI doc + references but never wrote. See §12. + +--- + +## 1. What trusted-server actually consumes from edgezero + +Verified by `rg` across `crates/`. The dependency is a **thin, low-level slice** +of `edgezero-core` plus one type from `edgezero-adapter-fastly`. + +| edgezero symbol | trusted-server usage | Reaches #269 break? | +| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `edgezero_core::body::Body` (alias `EdgeBody`) | pervasive — bodies on every request/response, integrations, publisher, auction | **YES** (`into_bytes` → `Option`; `as_bytes` changed too but has **no** TS sink) | +| `edgezero_core::http::{Request, Response, request_builder, response_builder, HeaderValue, …}` | request/response construction in `platform.rs`, `proxy.rs`, `http_util.rs`, tests | No (stable; alias names unchanged) | +| `edgezero_core::key_value_store::{KvStore (as `PlatformKvStore`), KvError, KvHandle, KvPage, NoopKvStore}` | KV trait impls, EC identity graph, `UnavailableKvStore` stub | No (trait sigs unchanged; `KvError` change is inert for us — §3.2) | +| `edgezero_adapter_fastly::key_value_store::FastlyKvStore` | `platform.rs:13` — only symbol used from the fastly adapter | No (`open()` sig unchanged) | +| `edgezero-adapter-axum`, `edgezero-adapter-cloudflare` (workspace deps) | **declared in root `[workspace.dependencies]` only; no member crate references them — `cargo tree -i edgezero-adapter-axum` / `-cloudflare` return "did not match any packages"** | No (absent from the dependency graph — not compiled at all; see §9 Q4 → drop) | +| `edgezero-adapter-spin` | **not a dependency** — `trusted-server-adapter-spin` is an in-repo stub | No (edgezero's Spin SDK6/wasip2 churn never reaches us) | + +**Not used at all** (and therefore immune to #269): `run_app`, `app!`, +`RequestContext`, `FromRequest`/extractors, `EdgeError`, `IntoResponse`, +`ProxyClient`/edgezero `proxy`, typed `AppConfig`, manifest `[stores.*]` / +`[adapters.*]` tables. trusted-server's manifest is `trusted-server.toml` (a +bespoke `Settings` struct in `settings.rs`), **not** an edgezero `edgezero.toml`. +(Baseline as of the current pin / pre-`ts-cli-next`. Christian's branch adds an +`edgezero.toml` and deletes `trusted-server.toml` — but still reads config through +the **bespoke `PlatformConfigStore`**, not edgezero's first-class store/extractor, +so this "uses none of …" list stays true even there. See §12.) + +--- + +## 2. The one break that reaches trusted-server: `Body` → `Option` + +`crates/edgezero-core/src/body.rs`. The `Body` enum shape is **unchanged** — +`Body::Once(Bytes)` / `Body::Stream(LocalBoxStream<…>)` — so trusted-server's +pattern matches in `platform.rs` survive. Two accessor return types changed: + +| Item | BASE (`170b74b`) | HEAD (`feature/extensible-cli`) | Break | +| ------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------- | +| `Body::as_bytes` | `pub fn as_bytes(&self) -> &[u8]` (panics on `Stream`) `body.rs:48` | `pub fn as_bytes(&self) -> Option<&[u8]>` (`None` on `Stream`) `body.rs:24` | return type | +| `Body::into_bytes` | `pub fn into_bytes(self) -> Bytes` (panics on `Stream`) `body.rs:55` | `pub fn into_bytes(self) -> Option` (`None` on `Stream`) `body.rs:62` | return type | + +Everything else on `Body` is intact: `empty()`, `from_bytes()`, `from_stream()`, +`into_stream()`, `is_stream()`, `text()`, `json()`, `to_json()`, and `stream()` +(the earlier subagent claim that `stream()` was removed is **wrong** — it moved +in source order only). `From` impls unchanged. + +**Behavior** (not just signatures) verified for the accessor neighbours: `Body::to_json` +is byte-identical BASE↔HEAD and matches on the `Once`/`Stream` enum directly — +it never calls `as_bytes`, so the `Option`-return change cannot leak into JSON +deserialization. `text()`/`json()`/`to_json()` are unused in trusted-server +regardless. + +### Affected call sites — compiler-enumerated (authoritative) + +> **Source of truth = the compiler, not `rg`.** An earlier hand-built `rg` list +> was wrong (missed production sinks `proxy.rs:38` and `auction/endpoints.rs:81`; +> mis-tagged tests as production). The list below is the exhaustive set from +> `cargo build --workspace --all-targets` on the repinned spike (§10): **27 +> compiler errors collapsing to 18 distinct `into_bytes()` sink bindings.** All +> are `Body` (`EdgeBody`); **no `as_bytes` site exists** in trusted-server. + +Line numbers are **PR14-base**; they shift per branch as the stack rewrites these +files — re-derive from the compiler on whatever branch you repin (the §8 gate does +this). One binding often produces several errors (`.len()`, `.to_vec()`, +`from_slice(&…)`, `from_utf8(&…)` on the now-`Option`). + +**Production (8) — fail plain `cargo build` (lib + bin):** + +| Binding site (PR14) | Shape | +| ------------------------------------ | ------------------------------------------------------------------- | +| `proxy.rs:38` (`body_as_reader`) | `Cursor::new(body.into_bytes())` | +| `publisher.rs:46` (`body_as_reader`) | `Cursor::new(body.into_bytes())` | +| `auction/endpoints.rs:81` | `let b = body.into_bytes(); b.len(); from_slice(&b)` | +| `proxy.rs:1550` | `let b = req.into_body().into_bytes(); enforce(&b); from_utf8(&b)` | +| `proxy.rs:1665` | same shape (rebuild path) | +| `request_signing/endpoints.rs:103` | `let b = req.into_body().into_bytes(); enforce(&b); from_slice(&b)` | +| `request_signing/endpoints.rs:246` | same (rotate; also `b.is_empty()`) | +| `request_signing/endpoints.rs:365` | same (deactivate) | + +**Test-only (10) — fail only `cargo test` / `--all-targets`, invisible to plain +`cargo build`:** + +`auction/formats.rs:444`, `integrations/prebid.rs:2067`, +`integrations/testlight.rs:461`, `proxy.rs:2034`, `proxy.rs:2795`, +`proxy.rs:2851`, `publisher.rs:748`, `publisher.rs:1079`, `publisher.rs:1562`, +`request_signing/endpoints.rs:464`. + +**NOT a sink:** `http_util.rs:456` appears in errors as the _expected_ side — it's +the `enforce_max_body_size(bytes: &[u8], …)` signature. No edit; `&Bytes` derefs +to `&[u8]` once the caller unwraps. + +**False positives — leave untouched** (receiver is `str`/`String`/`FromUtf8Error`, +not `Body`; confirmed by source): +`http_util.rs:286,320` (`str::as_bytes`), `request_signing/endpoints.rs:23` +(`String::into_bytes`), `request_signing/endpoints.rs:452` (test, `str::as_bytes`), +`sourcepoint.rs:571` / `datadome.rs:323` (`rewrite_script_content() -> String`), +`sourcepoint.rs:822` (`FromUtf8Error::into_bytes`). + +### Fix — style (updated per `feature/ts-cli-next`) + +> **Revised guidance.** Christian's branch already fixed these sinks and chose +> **`into_bytes().ok_or_else(|| )?`** (graceful error, no panic) for +> production request/response handlers, `unwrap_or_default()` for +> compression/test paths, and reserved `.expect()` for genuinely-unreachable +> spots. That is **better than a blanket `.expect()`** — a streaming/empty body +> must not panic the worker. **Adopt his approach:** propagate an error at +> production handler sinks; only `.expect("should …")` where a buffered body is +> truly invariant (and never `unwrap_or_default()` where an empty body would +> silently corrupt behavior). Align with his exact per-sink choices when we +> converge (§12). + +For a production handler sink, prefer (error variant illustrative — match the +existing one at each call site, not necessarily `BadRequest`): + +```rust +let bytes = req.into_body().into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::BadRequest { message: "request body should be buffered".into() }) +})?; +``` + +The three mechanical shapes below still apply (substitute `ok_or_else(…)?` for +`.expect(…)` at production sinks): + +```rust +// Shape A — value consumed directly (e.g. proxy.rs:38, publisher.rs:46) +let body = resp.into_body().into_bytes() + .expect("should have a buffered body"); + +// Shape B — chained .to_vec() (e.g. prebid.rs:2067, proxy.rs:2034) +String::from_utf8( + resp.into_body().into_bytes() + .expect("should have a buffered body") + .to_vec(), +) + +// Shape C — bound, then borrowed into &[u8] / &Bytes (e.g. proxy.rs:1550, +// auction/endpoints.rs:81, request_signing/endpoints.rs:*) +let b = req.into_body().into_bytes() + .expect("should have a buffered request body"); +enforce_max_body_size(&b, …)?; // &Bytes → &[u8] +serde_json::from_slice(&b)?; // borrow the unwrapped Bytes +``` + +No signature changes propagate to callers (the locals are consumed in place). + +--- + +## 3. Low-level surface that changed upstream but is INERT for trusted-server + +These changed across the 257-commit delta but do **not** break our build, +verified against actual usage. Documented so the repin reviewer doesn't chase +ghosts. + +### 3.1 `KvStore` trait — reordered, not re-signed + +`key_value_store.rs`. All methods keep identical signatures +(`get_bytes`/`put_bytes`/`put_bytes_with_ttl`/`delete`/`list_keys_page`/`exists`, +all `async`, same params/returns). Only source ordering changed (clippy +`arbitrary_source_item_ordering`). trusted-server's `UnavailableKvStore` / +`NoopKvStore` impls and `KvIdentityGraph` calls are unaffected. + +### 3.2 `KvError` — `#[non_exhaustive]` + new variants (inert here) + +| | BASE | HEAD | +| -------- | ------------------------------------ | --------------------------------------------------------------------------- | +| attr | (none) | `#[non_exhaustive]` `key_value_store.rs:302` | +| variants | `NotFound { key }`, `Unavailable`, … | adds `LimitExceeded { message }` `:311`, `Unsupported { operation }` `:328` | + +Inert for us: trusted-server only **constructs** `KvError::Unavailable` (a unit +variant — still constructible downstream under enum-level `#[non_exhaustive]`) in +`platform/kv.rs`, and never writes an exhaustive `match` on `KvError`. No catch-arm +needed. + +### 3.3 `KvPage`, `KvHandle`, `NoopKvStore`, `FastlyKvStore::open`, http builders + +All present and signature-stable: + +- `KvPage` — same fields (`keys`, `cursor`/etc.), alphabetized only. +- `KvHandle` `key_value_store.rs:354`, `NoopKvStore` `:818` — exist. +- `edgezero_core::http::request_builder()` / `response_builder()` — same + signatures; `RequestBuilder`/`ResponseBuilder` are still exported alias names + (now aliasing `HttpRequestBuilder`/`HttpResponseBuilder` internally, transparent + to us); `Request`/`Response`/`Method`/`StatusCode`/`HeaderValue`/… aliases + unchanged. +- `edgezero_adapter_fastly::key_value_store::FastlyKvStore::open(name: &str) -> +Result` — unchanged. + +--- + +## 4. Full #269 breaking-API catalog (does NOT reach trusted-server today) + +Recorded for completeness — these are the framework-level breaks that bite +_consumers who use edgezero's high-level surface_ (e.g. mocktioneer). They matter +to us only **if** the HTTP port chooses convergence (§6) or when Christian's CLI +port (§7) adopts typed config. Grouped by subsystem. + +### 4.1 Adapter entrypoints — `run_app` dropped the manifest arg + +| Adapter | BASE | HEAD | +| ---------- | -------------------------------------------------------------------- | -------------------------------------------------- | +| axum | `pub fn run_app(manifest_src: &str) -> anyhow::Result<()>` | `pub fn run_app() -> anyhow::Result<()>` | +| cloudflare | `run_app(manifest_src: &str, req, env, ctx)` | `run_app(req, env, ctx)` | +| fastly | `run_app(manifest_src: &str, req)` | `run_app(req)` | + +Manifest/store config now flows from `A::stores()` (macro-baked) + `EDGEZERO__*` +env vars instead of an `include_str!` manifest string. **Not used by us** (we have +a manual `fn main()` event loop, not `run_app`). + +### 4.2 `Hooks::stores()` + `StoresMetadata` + +New `fn stores() -> StoresMetadata` on the `Hooks` trait (default impl returns +empty). The `app!` macro auto-emits it from `[stores.*]`. **Not used by us** (no +`app!`, no `Hooks` impl). + +### 4.3 Manifest `[stores.*]` hard rewrite + +Old per-adapter shape is now a **hard load error**: + +```toml +# BASE (now rejected) +[stores.kv] +name = "MY_KV" +[stores.kv.adapters.cloudflare] +name = "CF_BINDING" + +# HEAD (portable; names move to env) +[stores.kv] +ids = ["default"] +default = "default" +``` + +`[adapters..stores.*]` and unknown `[adapters..*]` subtables now +fail `manifest.validate()`. `Manifest::kv_store_name()` removed → runtime +`EnvConfig::store_name(kind, id)` (`EDGEZERO__STORES______NAME`). +**Not used by us** (no edgezero manifest). + +### 4.4 Multi-store registry + async `ConfigStore`/`SecretStore` + +New `store_registry.rs`: `StoreRegistry` with `default()`/`named(id)`, aliases +`KvRegistry`/`ConfigRegistry`/`SecretRegistry`, and `BoundSecretStore` (binds +platform store name per logical id). New async read traits +`ConfigStore::get(&self, key) -> Result, ConfigStoreError>` and +`SecretStore::get_bytes(&self, store_name, key) -> Result, …>`. +**Overlaps directly with our `PlatformConfigStore`/`PlatformSecretStore`** — see +§6. Not consumed today. + +### 4.5 `RequestContext` store accessors + +`kv_handle()` removed; replaced by `kv_store(id)`/`kv_store_default()` (+ config +& secret variants) returning `Option`. **Not used by us** (no +`RequestContext`). + +### 4.6 Extractor overhaul + +`Kv(KvHandle)` → `Kv(KvRegistry)` with `.default()`/`.named(id)`; new +`Config`/`Secrets` extractors. **Not used by us.** + +### 4.7 `#[derive(AppConfig)]` + `#[secret]` (entirely new) + +New derive macro (`edgezero-macros/src/app_config.rs`) emitting `AppConfigMeta` +with `SECRET_FIELDS`. `#[secret]` / `#[secret(store_ref)]` only on scalar +`String` fields; rejects `Option`, `Cow`, non-scalars, `serde(rename)`, +container `rename_all`, duplicate/`=`/unknown-arg forms (compile-fail UI tests). +Pairs with CLI `config validate`/`config push`. **This is Christian's CLI-port +territory** (§7); net-new adoption, not a break. + +### 4.8 `EdgeError` / `IntoResponse` / `ProxyResponse` + +`EdgeError` now `#[non_exhaustive]`, gains `NotImplemented`, `source()`→`inner()`. +`IntoResponse::into_response` now returns `Result`. +`ProxyResponse::into_response` returns `Result`. **None used by us** (we use +`error_stack::Report` and never touch `EdgeError`/edgezero +`IntoResponse`/edgezero `proxy`). + +### 4.9 CLI surface (new) — inventory only + +New `edgezero-cli` commands: `auth` (login/logout/status), `provision`, +`config validate`, `config push`, `demo` (replaces `dev`). Generated `-cli` +crate per app. Typed entrypoints a consumer crate calls: +`run_config_validate_typed::()`, `run_config_push_typed::()`, plus +`run_{auth,build,deploy,provision,serve}`. **Christian's port** (§7). + +### 4.10 Spin adapter — SDK 6.0 / wasip2 + +edgezero's Spin adapter moved to `spin-sdk ~6.0`, `wasm32-wasip1`→`wasip2`, +`#[http_component]`→`#[http_service]`, `IncomingRequest`→`Request`, async stores. +**Does not reach us** — trusted-server does not depend on `edgezero-adapter-spin`; +our `trusted-server-adapter-spin` is an in-repo stub. + +--- + +## 5. Net repin work for the HTTP port (minimal path) + +If the HTTP port is a **straight repin** (keep the bespoke `platform/` layer): + +1. Bump the four `edgezero-*` git deps in root `Cargo.toml` from `rev = "170b74b"` + to the #269 branch (then to `main` post-merge), regenerate root `Cargo.lock`. +2. **Reconcile `crates/integration-tests/Cargo.lock`** (it has its own lock; so + does `crates/openrtb-codegen/`). CI enforces that shared direct deps match + between the root and integration-tests lockfiles, and the 257-commit edgezero + delta will drag shared transitive deps (bytes/http/serde/…). Fix with targeted + `cargo update -p --precise ` in the integration-tests workspace — + **never** a blanket `cargo update`. +3. Fix the **18 `Body::into_bytes()` sink bindings** (§2) — 8 production + 10 + test — with explicit `.expect("should …")` / `None` handling. +4. Run the full gate (§8): host + Fastly `wasm32-wasip1`, **`--all-targets`**, + clippy `-D warnings`, `cargo test --workspace`, `cargo fmt --check`. + +**Status:** compilation is now **verified** (host, lib + tests — §10): the forced +code delta is the `Body` sinks and nothing else. **Still unverified:** +`wasm32-wasip1`, clippy, full test pass, and lockfile reconciliation (step 2). +Source-API diffing cannot see transitive breaks (dep bumps, MSRV, feature +unification, `spin-sdk ~6.0` lock entries); the §8 matrix is the proof, not this +document. "Does not reach us" (§4.10) is true at _source_ level but does not by +itself prove the lock graph resolves or that wasm builds. + +### 5.1 Risks & assumptions + +| Risk / assumption | Mitigation | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pinning to an **OPEN, unmerged, force-pushable** ref (`feature/extensible-cli`) | Pin to the branch only if we must move pre-merge; re-pin to edgezero `main` after #269 merges (mirrors mocktioneer #110). | +| **Transitive build breakage** unseen by source diffing (lock re-resolution, MSRV, features, spin-sdk 6) | Verification gate above — actually build on a scratch branch before sign-off. | +| Branch rebased / dep destabilizes after we pin | **Rollback = single-commit revert.** `170b74b` stays recoverable; the repin is one `Cargo.toml`/`Cargo.lock` commit — `git revert` it to return to the known-good pin. | +| Assumption: all 18 `Body` sinks are buffered (`Once`) bodies | True today (compiler-confirmed receivers); if a future sink is genuinely streaming, use `into_stream()` / branch on `None` instead of `.expect()`. | +| **integration-tests lockfile drift** — CI fails if shared direct deps diverge between root and `crates/integration-tests/Cargo.lock` | Reconcile with targeted `cargo update -p --precise` (§5 step 2); never blanket-update. | +| **Test-only sinks slip through** a `cargo build`-only check | Gate runs `--all-targets` + `cargo test` (§8) — 10 of 18 sinks are test-only (§10). | + +--- + +## 6. Strategic divergence (decision for the HTTP port) + +The original migration built a trusted-server-owned abstraction: +`RuntimeServices { config_store, secret_store, kv_store, backend, http_client, +geo, client_info }` with `PlatformConfigStore`/`PlatformSecretStore`(full CRUD)/ +`PlatformKvStore`/`PlatformHttpClient` traits. #269 ships edgezero's _own_ +first-class equivalents: async `ConfigStore`/`SecretStore` read traits, the +multi-store `StoreRegistry`, `Config`/`Secrets`/`Kv` extractors, env-var store +binding, and typed `AppConfig`. + +So two parallel abstractions now exist for the same job. The original design doc +already flagged this risk (PR2: "these must not coexist as parallel abstractions +… file an EdgeZero issue to generalize `ProxyClient` into `HttpClient`"). #269 is +edgezero answering that — on the store/config axis. + +| Option | What it means | Cost | Pull | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A. Minimal repin** | Keep `platform/` layer; only fix `Body`. | 18 sinks | Fastest; preserves CRUD writes (edgezero stores are read-only) and the `select()` fan-out we depend on. | +| **B. Converge stores** | Map `PlatformConfigStore`/`SecretStore` reads onto edgezero's `ConfigStore`/`SecretStore` + `StoreRegistry`; keep our write-CRUD as an extension. | medium | Aligns with framework direction; less bespoke code. But edgezero read traits don't cover our management writes (key rotation), so the layer can't fully dissolve. | +| **C. Full adoption** | Also take `run_app`/`app!`/`RequestContext`/extractors/typed `AppConfig`. | large | Matches mocktioneer; big rewrite of our manual dispatch + `Settings`. | + +**Recommendation:** ship **A** as the repin (unblocks everything, tiny diff), +then evaluate **B** as a separate follow-up once #269 lands on `main`. **C** is a +roadmap question, not a repin question — and it's where the HTTP/CLI split +actually pays off: typed `AppConfig` (C/§4.7) is Christian's CLI surface, and our +read-store convergence (B) is the HTTP surface. The shared contract between the +two of you is **the typed config struct + its `[stores.config]` declaration** — +agree its shape and store id before either port starts. + +--- + +## 7. HTTP-port vs CLI-port split + +| | HTTP port (Prakash) | CLI port (Christian) | +| ----------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| #269 axis | runtime / stores / adapters (§4.1–4.6, 4.8, 4.10) | CLI + typed config (§4.7, 4.9) | +| repin-forced work | `Body` fix (§2) | none (trusted-server has no edgezero CLI today) | +| net-new adoption | optional store convergence (§6 B) | `-cli` crate, `auth`/`provision`/`config validate`/`config push`, `#[derive(AppConfig)]`, CI `config validate --strict` gate | +| shared seam | **reads** the typed config from the bound store | **defines/validates/pushes** the typed config | + +Note: because trusted-server uses a bespoke `Settings` + `trusted-server.toml` +(not edgezero config), Christian's CLI port is largely a **net-new adoption** +(mirroring mocktioneer #110 §3.5–3.9), not a break-fix. Sequence the typed-config +struct contract first; both ports depend on it. + +--- + +## 8. Verification commands + +```bash +# reproduce the upstream diff base (full clone; 170b74b is not in a shallow fetch) +cd /tmp && rm -rf ez && git clone https://github.com/stackpop/edgezero ez && cd ez +git fetch origin feature/extensible-cli +git diff 170b74b..origin/feature/extensible-cli -- crates/edgezero-core/src/body.rs + +# after repin, in this repo — the full gate: +cargo build --workspace --all-targets # CRITICAL: --all-targets, else 10 test-only sinks hide +cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check + +# integration-tests lockfile gate (separate workspace lock): +( cd crates/integration-tests && cargo build --workspace ) # must resolve against root deps +``` + +Expected pre-fix failures: type errors at the 18 `Body` sinks (§2) — `Option` +has no `.len()`/`.to_vec()`, can't pass where `Bytes`/`&[u8]` expected. **8 surface +under plain `cargo build`; the other 10 only under `--all-targets`/`cargo test`.** + +Compilation green is **verified** (§10). The remaining legs — wasm, clippy, full +test, lockfile reconciliation — are the proof of "done"; transitive lock/MSRV/ +feature breaks surface only here, not in source-API diffing. **Re-run this gate on +every branch as the pin advances** (§11), since the sink set and line numbers +shift per layer. + +--- + +## 9. Decisions & open questions + +**Decided (this review cycle):** + +- **Repin target:** pin the upgrade branch to `feature/extensible-cli` (or its + HEAD sha) to start now; **re-pin to edgezero `main` after #269 merges.** +- **Where:** dedicated branch **off PR14** — _not_ main, _not_ in-place on any + reviewed PR — then **merge up** the stack (merge, not rebase). See §11. +- **Scope:** the _forced_ repin work is minimal (the 18 `Body` sinks, §2/§10). The + full A+B+C adaptation (store convergence onto edgezero's `ConfigStore`/ + `SecretStore`/`StoreRegistry`; two-tier typed `AppConfig`; entry-point + convergence) is a **separate, optional roadmap** — see §6 and the companion + full-adaptation design — and is **not** required by the repin. +- **Platform layer:** _hybrid_ — converge stores onto edgezero (+ a thin + write-CRUD extension for rotation), keep `PlatformHttpClient`/`PlatformBackend`/ + `Geo`/`ClientInfo` (edgezero gaps). Roadmap, not repin. +- **`edgezero-adapter-axum`/`cloudflare`:** drop — absent from the dependency + graph (`cargo tree -i` matches no package, §1); not compiled. + +**Still open:** + +1. **Convergence with `feature/ts-cli-next` (§12).** Christian's branch already + implements the end-to-end Fastly config-store migration (repin + Body fix + + runtime Settings-from-store), so our minimal-repin (#771) is largely subsumed + for Fastly. Decide: rebase our HTTP work onto his config system vs keep the + PR14-stack repin. **Recommend: build on his.** +2. **Body-fix style conflict** — his `ok_or_else` (graceful) vs our former + `.expect()`. Resolved in §2 (adopt his); confirm per-sink alignment on merge. +3. **Secret-write conflict** — he punts secret-store writes (key rotation) until + edgezero exposes write primitives; our original migration design kept + write-CRUD in TS. Decide which holds. +4. **Shared config contract — now CONCRETE, not "to agree":** store ids + `app_config` / `secrets` / `ec_identity_store` (his `edgezero.toml`) and the + `config_payload` flatten/hash rules (his core module). The remaining gap is + the **runtime-config-store spec** his doc references but never wrote — that is + our HTTP-layer deliverable (§12). +5. wasm32-wasip1 + clippy + test legs of the verification gate (§10 covered host + build only). + +--- + +## 10. Verified build result (spike) + +The §5 "prediction, not a result" hedge is **discharged for compilation** (host). +A throwaway branch `spike/edgezero-269-upgrade` was cut **off PR14** (base pin +`38198f9`), repinned to #269 HEAD (`2eeccc9`), and built twice: + +``` +cargo build --workspace → exit 101, 15 errors (lib + bin only) +cargo build --workspace --all-targets → exit 101, 27 errors (adds tests) +``` + +**Every error is downstream of `Body::into_bytes` → `Option`, all in +`trusted-server-core`. Zero errors from `RequestContext`, `EdgeError`, middleware, +router, or any other #269 churn — even though PR14 imports all of those.** This +empirically confirms the central thesis: the repin's forced code change is the +`Body` break and nothing else. + +The two runs differ by design and this gap is the lesson: + +- **Plain `cargo build`** compiles lib + bin → the **8 production** sinks (§2). +- **`--all-targets`** also compiles tests → **+10 test-only** sinks. These are + **invisible to plain `cargo build`** and only fail under `cargo test` / + `--all-targets`. A repin that greens `cargo build` but skips `--all-targets` + would ship a red test suite. **The gate (§8) must include `--all-targets`.** + +27 raw errors collapse to **18 distinct `into_bytes()` bindings** (one binding → +several errors via `.len()`/`.to_vec()`/`from_slice(&…)`/`from_utf8(&…)`). Full +enumeration with the production/test split is §2 — that list is now the +compiler's, superseding the earlier rg attempt (which missed `proxy.rs:38` and +`auction/endpoints.rs:81`). + +**Not yet run** (remaining gate legs): `wasm32-wasip1` build, `cargo clippy -D +warnings`, `cargo test --workspace`, and the integration-tests lockfile +reconciliation (§5.1). Compilation-green ≠ gate-green. + +> Evidence branch kept (repin only; trial code edits reverted) so the failing +> build is reproducible. No real branch was modified. + +--- + +## 11. Impact on the in-flight stacked migration branches + +The stack is `PR1 → … → PR20`, partially linear / partially diverged +(PR15/PR16/PR19 are not clean descendants of their predecessor — merges +happened). Pins climb in steps: + +| Branches | edgezero pin | date | +| ----------- | ------------ | ------ | +| PR1–13 | `170b74b` | Mar 18 | +| PR14–18 | `38198f9` | Apr 9 | +| PR19–20 | `ce6bcf7` | May 21 | +| (#269 HEAD) | `2eeccc9` | Jun 12 | + +**Key facts:** + +- `Body::into_bytes`→`Option` landed in `7ec2ad1` ("strict clippy #257", **Jun + 12**) — _after every current stack pin_ (latest is May 21). So **no existing + branch has absorbed the `Body` break**; whichever branch first bumps to a + rev ≥ Jun-12 eats all 18 sinks (§2). +- **PR14 is the inflection**: it introduces `run_app`/`RequestContext`/ + `EdgeError`/`middleware`/`router` consumption (PR13 has none). That is the + high-level surface #269 churned — _but the §10 build proves it does not break_. +- main's "only `Body`" story is therefore true **for the whole stack**, not just + main. The earlier worry that PR14+ would drag in context/error/router breakage + is **disproven by build**. + +**Why not repin at the bottom (main / `upgrade-edgezero-http-layer`):** that base +predates the consuming code, so it can't exercise PR14+ at all; the real build +signal only exists from PR14 up. + +**Why a dedicated branch off PR14, not in-place on PR14:** PR14 is still under +review; folding a version bump into it conflates two review concerns and forces +rework when review feedback rewrites the migrated code. A branch _on top of_ PR14 +gets the same build signal without disturbing any open review. + +**Propagation:** land the repin + `Body` fix once on the dedicated branch, then +**merge up** PR14→15→16→17→18→19→20 (merge, not rebase, per team preference). +Conflicts will cluster in the files every layer rewrites — `publisher.rs`, +`proxy.rs`, `request_signing/endpoints.rs`, `auction/endpoints.rs`. Run the §10 +gate (host + wasm + clippy + test) **per branch as the pin advances**, not once. + +--- + +## 12. Convergence with `feature/ts-cli-next` (Christian's branch) + +Inspected 2026-06-18. The "CLI" branch is **not just CLI** — it is an +**end-to-end config-store migration for the Fastly adapter**, off `main`, pinned +to the **same #269 HEAD** (`2eeccc9`) we used. It already carries the repin, the +`Body` fixes, the CLI crate, _and_ runtime Settings-loading from the config store. +This materially changes our plan. + +### 12.1 What it already implements (overlaps our work) + +| Area | His branch | Effect on us | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| #269 repin | deps pinned `2eeccc9` | our repin (#771) duplicated for Fastly | +| `Body` sink fixes | `into_bytes().ok_or_else(…)?` (prod), `unwrap_or_default()` (compress/test) | supersedes our `.expect()` style (§2) | +| Store ids | `edgezero.toml`: config `app_config`, secrets `secrets`, kv `ec_identity_store` | the shared seam, concrete | +| Config contract | `trusted-server-core/src/config_payload.rs` — flatten all of `Settings` → entries + `sha256` (`ts-config-hash`/`ts-config-keys`), reversible | shared core module; CLI pushes, runtime reads | +| **Runtime load** | adapter `main.rs`: `build_runtime_services()` → `get_settings_from_services()` reads `app_config`, rebuilds `Settings` | **this is the HTTP-layer pattern, already wired for Fastly** | +| `trusted-server.toml` | **deleted**; replaced by `trusted-server.example.toml` | config now lives in the store, seeded via `ts config push` | + +### 12.2 The HTTP-layer pattern, demonstrated + +`crates/trusted-server-adapter-fastly/src/main.rs` (his branch): + +```rust +let runtime_services = build_runtime_services(&req, kv_store); // config store available first +let settings = match get_settings_from_services(&runtime_services) { … }; // load Settings FROM store +``` + +`get_settings_from_services` → resolves the store name via +`env_config.store_name("config", DEFAULT_CONFIG_STORE_ID)` (`= "app_config"`) → +reads `ts-config-keys` / `ts-config-hash` / each entry → +`settings_from_config_entries` (verifies hash) → `Settings`. **That entry-point +sequence is exactly what "our HTTP layer" was meant to build.** + +**Crucial: he reads through the bespoke `PlatformConfigStore`, not edgezero's +store surface.** `services.config_store()` returns `&dyn PlatformConfigStore` +(impl `FastlyPlatformConfigStore`); he uses **none** of edgezero's #269 +`ConfigStore`/`StoreRegistry`/`Config` extractor/`RequestContext` (grep-confirmed: +zero in his `main.rs`/`settings_data.rs`). So he converged the config **source** +(toml → store) while **keeping `RuntimeServices` + the `platform/` layer** — i.e. +he took our §6 **hybrid** path, not full edgezero adoption. This also keeps §1's +"uses none of …" list accurate on his branch, and validates that our HTTP layer +should bind the store via `PlatformConfigStore`, not edgezero's extractor. + +### 12.3 Runtime contract (new, important) + +A **missing key is a hard error** — there is **no `trusted-server.toml` +fallback** anymore. The settings-error arm in `main.rs` **does serve a response** +(`to_error_response(&e).send_to_client(); return;`) — so an unseeded store yields +a **generic 500 on every route** (not a silent no-response), and that 500 is +**indistinguishable from a real config bug**. Net: the worker **cannot serve real +routes until the store is seeded** (`ts config push`). This is a new +deploy-ordering requirement (seed-before-serve) and an operational risk — the HTTP +layer should make the unseeded case **actionable** (clear message) and **correctly +classified** (retryable 503, not 500). See the plan's Phase 2. + +### 12.4 Conflicts to resolve (see §9) + +1. **Body-fix style** — adopt his `ok_or_else` (done in §2); align per-sink. +2. **Secret writes** — he punts key-rotation secret writes until edgezero adds + write primitives; our original migration design kept write-CRUD in TS. Decide. +3. **Base branch** — his off `main`; ours off the PR14 stack. His already carries + repin+Body, so for Fastly our minimal-repin is redundant. +4. **Whole-`Settings` vs two-tier** — he flattens _all_ of `Settings` into the + store (not our two-tier small `AppConfig`). One source of truth; bigger blast. + +### 12.5 Revised recommendation + +- **Build on his branch, don't run a parallel repin.** Our #771 minimal-repin is + superseded for Fastly; keep it only as the verified breaking-API reference. +- **Our HTTP-layer deliverable = the "runtime-config-store spec" his CLI doc + references but never wrote** — document `get_settings_from_services`, the + flatten/reconstruct rules (shared `config_payload`), the seed-before-serve + contract, empty/malformed-store behavior, and non-Fastly adapter wiring. +- **Keep our compiler-verified `Body` enumeration (§2/§10)** as the authoritative + sink reference when merging his ad-hoc fixes up the stack. diff --git a/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md b/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md new file mode 100644 index 00000000..eca7b329 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md @@ -0,0 +1,307 @@ +# Design: EdgeZero #269 HTTP-Layer Runtime + +- **Date:** 2026-06-18 +- **Author:** Prakash (HTTP-layer / runtime). CLI side: Christian (`feature/ts-cli-next`). +- **Status:** design — base build verified green (see §6). +- **Base:** edgezero `stackpop/edgezero#269` (`feature/extensible-cli`, HEAD `2eeccc9`), + adopted on `main` via `feature/ts-cli-next`. Our work branches off it + (`feature/edgezero-269-http`). +- **Companion docs:** references + [2026-06-16-edgezero-269-repin-breaking-api-finding.md](./2026-06-16-edgezero-269-repin-breaking-api-finding.md) + for the breaking-API / `Body`-sink detail (not duplicated here). Subsumes the + plan's Phase-4 "runtime-config-store spec" — this is that, widened to all + runtime surfaces. + +--- + +## 1. Scope & base assumptions + +This spec governs the **runtime (HTTP-layer) half** of running trusted-server on +edgezero #269. It is the source of truth for how the Fastly adapter boots, loads +configuration, and serves requests under #269. + +**Convergence model (decided):** `main` is the #269 convergence point. +`ts-cli-next` (off `main`) lands first, establishing #269 on `main`; our +HTTP-layer work branches off it and also targets `main`. **The PR14→PR20 +migration stack is explicitly out of scope** — it reconciles to #269 on its own +schedule and is not a dependency of, or dependent on, this work. + +**Inherited from `ts-cli-next`** (already done, verified green — §6): + +- the #269 dependency repin (`2eeccc9`); +- the `Body::into_bytes() → Option` fixes across `trusted-server-core`; +- the **Fastly adapter migration** to the #269 API (the dual-path entry point was + removed — §2.2); +- runtime `Settings`-from-config-store load (`get_settings_from_services`). + +**What we own (add on top):** runtime hardening of the config-store load path +(§4), the non-Fastly adapter build state (§4.2), the secret-write boundary +decision (§4.3), and this spec. + +**Architecture invariant:** trusted-server keeps its **bespoke `platform/` +layer** (`RuntimeServices` + `PlatformConfigStore`/`PlatformSecretStore`/ +`PlatformKvStore`). We do **not** adopt edgezero's first-class +`ConfigStore`/`StoreRegistry`/extractor/`RequestContext`. The #269 convergence is +of the config **source** (TOML → config store), not the abstraction. + +--- + +## 2. Surface inventory + +Every surface #269 touches, with current state and owner. "Inherited" = done on +`ts-cli-next`; "ours" = HTTP-layer work in this spec. + +| Surface | State under #269 | Owner | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `Body::into_bytes()` → `Option` (`trusted-server-core`) | Fixed: `request_body_bytes` helper + graceful `ok_or_else` (prod) / `unwrap_or_default` (test); `body_as_reader` returns `Result`. 18 sinks (finding §2). | inherited | +| **Adapter entry flow** | Dual-path (`into_core_request` + router `oneshot` + middleware) **removed**; converts via `compat::from_fastly_request` → `route_request` (§2.2). | inherited | +| **Config-store load** | `Settings` rebuilt at boot from the `app_config` config store via the `config_payload` flatten/hash contract (§3). `trusted-server.toml` deleted. | inherited; **hardening = ours** (§4.1) | +| `secrets` store | Read via `PlatformSecretStore`; write CRUD (key rotation) via pre-existing `adapter-fastly/src/management_api.rs`. | inherited; **boundary doc = ours** (§4.3) | +| `ec_identity_store` KV | Adapter boots `UnavailableKvStore`; EC routes lazily bind the configured store at dispatch. | inherited; **regression test = ours** | +| **`fastly` 0.11 / 0.12 coexistence** | edgezero #269 pulls `fastly 0.12.1`; trusted-server core+adapter stay on `fastly 0.11.13`. Bridged at `compat::from_fastly_request` (core, 0.11). Both versions resolve in the tree (§2.3). | inherited (constraint) | +| integration-tests lockfile | Separate workspace, path-deps core; shared deps must match root. | verify (§4) | +| CI gates | fmt / clippy `-D warnings` / `cargo test --workspace` / wasm32-wasip1 — all green on base (§6). | verify per change | +| JS (`crates/js`) | Untouched by #269. | n/a | + +### 2.1 Store ids (`edgezero.toml`) + +| Kind | id | Runtime use | +| ------- | ------------------- | ----------------------------- | +| config | `app_config` | `Settings` source (§3) | +| secrets | `secrets` | signing keys; rotation writes | +| kv | `ec_identity_store` | EC identity graph | + +### 2.2 Adapter entry flow (why the dual-path is gone) + +PR14 introduced a dual-path entry: `edgezero_adapter_fastly::into_core_request` +plus an edgezero router `oneshot` and a middleware chain keyed on +`FastlyRequestContext`. Under #269 those symbols are **fastly-0.12-bound**, but +the adapter builds the request with **fastly-0.11** — an unbridgeable version +mismatch. The migrated adapter (inherited) abandons that path: it converts the +Fastly request via `trusted_server_core::compat::from_fastly_request` +(fastly-0.11, in core) and routes through `route_request`, deleting `app.rs` and +`middleware.rs`. **This spec does not revive the dual-path.** + +### 2.3 The `fastly` version split (load-bearing constraint) + +The 0.11/0.12 coexistence is **deliberate and required**, not a smell: +edgezero #269 internally uses `fastly 0.12`; trusted-server stays on `fastly +0.11`. The only safe bridge is `compat::from_fastly_request` (a core, 0.11 +function that produces the platform-neutral `http` request). **Do not** call +edgezero APIs that take/return a fastly-0.12 `Request`/`Response` directly from +adapter code built on 0.11 — that reintroduces the PR14 dead end. All +adapter↔core hand-off goes through `compat` and the bespoke `platform/` types. + +--- + +## 3. Runtime config-store load (core design) + +### 3.1 Load sequence + +At adapter boot (`crates/trusted-server-adapter-fastly/src/main.rs`): + +``` +build_runtime_services(&req, kv_store) // config store available first + → get_settings_from_services(&services) // settings_data.rs + → get_settings_from_config_store(services.config_store(), &store_name) + → read_config_entry(CONFIG_KEYS_KEY) // "ts-config-keys" + → read_config_entry(CONFIG_HASH_KEY) // "ts-config-hash" + → read_config_entry() + → settings_from_config_entries(entries) // hash verify + reconstruct +``` + +`store_name` resolves via `EnvConfig::store_name("config", DEFAULT_CONFIG_STORE_ID)` +where `DEFAULT_CONFIG_STORE_ID = "app_config"`, overridable by +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME`. + +**Store dependency ordering (resolves the apparent §2 tension):** the **config +store is required at boot** — if `app_config` is unseeded, settings load fails and +_no_ route serves (§3.3). The **`ec_identity_store` KV is optional/lazy** — the +adapter boots `UnavailableKvStore` and EC routes bind it at dispatch, so EC-KV +being unavailable degrades only EC routes while everything else serves. The §2 +"non-EC routes still serve" resilience therefore holds **only after** config load +has succeeded. + +### 3.2 The `config_payload` contract (shared seam — reference only) + +`trusted-server-core/src/config_payload.rs` is the **single bidirectional +contract**: the CLI flattens `Settings` → config-store entries; the runtime +reconstructs from the same module. Do **not** fork it. Properties (per the CLI +design): escaped dotted keys (`\` → `\\`, `.` → `\.`); leaf values as canonical +JSON; `ts-config-keys` (sorted key array) + `ts-config-hash` (`sha256` over +settings-only entries) metadata; `ts-config-*` reserved. + +### 3.3 Behavior matrix (the contract this spec locks) + +**The boundary is "couldn't load the config" vs "loaded it but it's corrupt"** — +because `PlatformConfigStore::get` collapses key-absent and transport failure into +the same `PlatformError::ConfigStore` (`platform.rs:50-66`), the runtime cannot +cheaply tell "unseeded" from "transient backend" today (see §4.4 for the long-term +fix). So we classify by **where** the failure occurs, not by trying to subdivide a +read error: + +| Situation | Current (inherited) | Target (ours) | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Config-store read failure** — `read_config_entry` returns `Err` for any reason: store unseeded (`ts-config-keys` absent), transient backend hiccup, or a listed key missing | every case → generic **500**; indistinguishable from a real bug | **503** (`SERVICE_UNAVAILABLE`) via one new `TrustedServerError` variant, actionable message `"config store unavailable or not seeded — run \`ts config push\`"`. 503 is correct: unseeded → seed it; transient → retry. | +| **Reconstruct / verify failure** — config read OK but `settings_from_config_entries` fails (hash mismatch, unparseable value) | `Configuration`/`Settings` → 500 | **500** (genuine corruption / bug) — unchanged | +| Seeded + valid | `Settings` loads | unchanged | + +The 503/500 split is exactly the read-vs-reconstruct boundary in +`get_settings_from_config_store` → `read_config_entry` (read; → 503) vs +`settings_from_config_entries` (reconstruct; → 500). No `PlatformError` change +needed. + +### 3.4 Seed-before-serve (operational contract) + +Because `Settings` lives only in the store (no `trusted-server.toml` fallback), +**the service cannot serve real routes until `ts config push` seeds +`app_config`.** The runtime makes the unseeded state **observable** (503 status + +an actionable message in the **server logs** — §3.3), but observability is not +availability — the store **must** be seeded for the service to function. + +**This bites existing production, not just fresh installs.** `trusted-server.toml` +is deleted, so the moment the #269 wasm goes live on a service whose `app_config` +store is empty, **every route 503s** — an instant outage, not an edge case. + +**Cutover migration (one-time, ordered):** + +1. Export the currently-live `Settings` to a config payload (the CLI's + `config push` flattens `Settings` via `config_payload` — §3.2). +2. **Seed `app_config` first** (`ts config push --adapter fastly`), verify the + store holds `ts-config-keys`/`ts-config-hash` + entries. +3. **Then** deploy the #269 wasm. + +Never deploy the wasm before the store is seeded. This ordering is the contract; +§5 carries it as the top risk + the rollback. + +**503 covers two cases; the actionable text goes to LOGS, not the client body.** +Under option Y (§3.3) a 503 means "couldn't load config": either **unseeded** +(operator must `ts config push`) or a **transient backend** error (genuinely +retryable). The actionable message — `config store \`{store}\` unavailable or not +seeded (failed to read \`{key}\`) — run \`ts config push\`` — is carried on the +error (`ConfigStoreUnavailable`'s `Display`) and surfaced in the **server log** +(`main.rs` logs the full error chain). The **client 503 body stays generic** +(`user_message()`'s catch-all, "An internal error occurred") **by design** — per +the security rule, don't leak internal tooling/paths to public clients; detail +lives server-side. So: operator-actionable in logs, safe-generic to the client. + +--- + +## 4. HTTP-layer work we add + +### 4.1 Config-store load hardening (the core deliverable) + +Implement §3.3's target column, TDD-first, in `settings_data.rs` + `error.rs`. +Reuse the existing `MemoryConfigStore` test fake. Test at **both layers** — +neither alone proves the contract: + +- **core** (`settings_data.rs` / `error.rs`): a config-store **read failure** → + the new variant, and the variant's `status_code()` == **503**; a + malformed-hash (reconstruct failure) → stays **500**. +- **adapter** (`trusted-server-adapter-fastly`): the read-failure error actually + reaches the client as **503** via `to_error_response` (end-to-end check that + the variant→status mapping isn't bypassed). + +**Mechanism — one new variant, no platform-layer change (option Y).** A +`change_context`/`attach` does **not** alter `status_code()`, and +`PlatformConfigStore::get` cannot distinguish key-absent from transport error +(both `PlatformError::ConfigStore` — §4.4). So classify by **call site**: + +1. Add `TrustedServerError::ConfigStoreUnavailable` (or similar), mapped to + `StatusCode::SERVICE_UNAVAILABLE` in `error.rs` — precedent: the `KvStore` arm + already maps to 503 (`error.rs:125`). +2. In `get_settings_from_config_store` / `read_config_entry`, `change_context` + **read failures** (the `config_store.get(...)` path — `ts-config-keys`, + `ts-config-hash`, each entry) to `ConfigStoreUnavailable` (→ 503) with the + actionable message. +3. Leave `settings_from_config_entries` failures (hash mismatch, unparseable) + as `Configuration`/`Settings` (→ 500). That is the only change — the + read-vs-reconstruct boundary does the classification. + +No new `PlatformError` variant, no change to `PlatformConfigStore` or any of its +impls. (Detailed steps live in the implementation plan, not here.) + +**Do NOT add a `user_message()` arm for this variant.** The actionable text must +reach **logs only**; the public client body stays generic via `user_message()`'s +catch-all (§3.4, security). The `ConfigStoreUnavailable` `Display` (carrying the +"run `ts config push`" message) flows to the log via `main.rs`'s error-chain dump +— that is the operator's channel. Adding a `user_message()` arm would leak +internal tooling into public 503 bodies. + +### 4.4 Long-term: `PlatformConfigStore::get → Result>` (tracked follow-up — NOT this work) + +The reason §4.1 can't cleanly separate "unseeded" from "transient backend" is a +**pre-existing trait-shape smell**: `PlatformConfigStore::get` (defined in PR2 / +#545, on `main` — not `ts-cli-next`) returns `Result` and folds +key-absent into `Err(PlatformError::ConfigStore)`, same as a transport failure. + +The durable fix is to make absence a **value**, not an error — +`get(...) -> Result, Report>` (`Ok(None)` = absent, +`Err` = real failure). This is **exactly edgezero #269's own `ConfigStore::get` +shape** (`Result, ConfigStoreError>`), so it is also the +**store-convergence** direction (finding §6 B). With it, unseeded (`Ok(None)` on +`ts-config-keys`) becomes distinguishable from transient (`Err`) for free, and +§4.1's option Y sharpens into the precise option X **without rework**. + +**Out of scope here** (it's a pre-existing trait `ts-cli-next` only consumes, and +touches every `PlatformConfigStore` impl). Track it on the store-convergence +follow-up; optionally surface it as a non-blocking comment on the `ts-cli-next` +PR (its config-load path is the consumer that exposes the limitation). + +### 4.2 Non-Fastly adapters + +`trusted-server-adapter-{cloudflare,spin}` are stubs (cloudflare/axum are absent +from the dependency graph). Goal: they **compile** under #269 — not feature +parity. Confirm what "builds" means per stub (`cargo check -p …` on host vs a +real wasm target) before acting. + +### 4.3 Secret-write boundary (decision to record) + +Runtime key-rotation secret **writes** already work via the pre-existing +`crates/trusted-server-adapter-fastly/src/management_api.rs` +(`FastlyPlatformSecretStore` CRUD). The CLI's `config push` +**does not** push secrets (deferred by the CLI design until edgezero exposes +secret-store write primitives). Net: **runtime rotation stays; CLI-driven secret +push is out of scope.** Recorded so the split is intentional. + +--- + +## 5. Risks + +| Risk | Mitigation | +| ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cutover outage on existing prod** — `trusted-server.toml` deleted; deploying the #269 wasm before `app_config` is seeded 503s every route instantly | §3.4 cutover migration: **seed the store first, then deploy the wasm** (ordered); §3.3 makes the unseeded state observable | +| **No rollback path once `trusted-server.toml` is gone** | Rollback = **redeploy the pre-#269 wasm** (still reads `trusted-server.toml`); keep that build artifact available through cutover; the repin is also a single-commit revert | +| Unseeded / fresh install can't serve | §3.3 actionable 503 + §3.4 seed-before-serve | +| **Two `fastly` versions in the wasm binary** (0.11 + 0.12 coexist, §2.3) | Builds green today (§6); watch binary size / duplicate-symbol bloat; the 0.12 bump is deferred (§7), not a fix | +| **`fastly` 0.11/0.12 coexistence** misused — calling 0.12 edgezero APIs from 0.11 adapter code | §2.3: all hand-off via `compat` + `platform/`; never revive the dual-path | +| **`ts-cli-next` is unmerged WIP** (force-pushable, off `main`) | record its SHA when branching; re-base from new SHA + coordinate if it moves; keep our additions as discrete commits | +| Whole-`Settings`-in-store enlarges blast radius of a bad push | `ts-config-hash` verification + malformed-store test (§3.3) | +| `config_payload` forked / drifts | treat as read-only shared seam (§3.2); both sides import one module | +| integration-tests lockfile drift after repin reaches main | targeted `cargo update -p --precise`; never blanket | + +--- + +## 6. Verification (base, this branch) + +`feature/edgezero-269-http` off `ts-cli-next` (`14a91cc1`), pinned `2eeccc9`, +verified **green** on 2026-06-18: + +- `cargo build --workspace --all-targets` — 0 errors +- `cargo build -p trusted-server-adapter-fastly --release --target wasm32-wasip1` — ok +- `cargo test --workspace` — 1372 + 38 + 21 + 2 pass, 0 fail +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — exit 0 +- `cargo fmt --all -- --check` — clean + +The hardening (§4.1) must keep all of the above green and add its own tests. + +--- + +## 7. Out of scope + +- The PR14→PR20 migration stack and its reconciliation to #269 (separate effort). +- edgezero `run_app`/`app!`/extractor/`RequestContext` adoption (we keep the + bespoke `platform/` layer). +- CLI-driven secret push; the CLI crate itself (`ts config`/`audit`). +- Bumping trusted-server to `fastly 0.12` (the 0.11/0.12 bridge via `compat` is + the chosen design).