Outbound HTTP design spec - #275
Draft
aram356 wants to merge 259 commits into
Draft
Conversation
…quest path
Real fixes (not just docs) for every production-code .expect() that could
fire under upstream contract change or misconfigured input:
- `IntoResponse::into_response` now returns `Result<Response, EdgeError>`
workspace-wide (breaking change). Cascades through `Responder`,
`EdgeError::into_response`, `RouterService::oneshot`, the handler future
in `core/handler.rs`, and the route-listing builder.
- `ProxyResponse::into_response` and `core::response::response_with_body`
now return `Result<Response, EdgeError>` and propagate `http::Builder`
failures via `map_err(EdgeError::internal)?` instead of `.expect()`.
- `core::body::Body::into_bytes_bounded` rewritten as a `match self {
Once | Stream }` so the unreachable `is_stream()`-guarded `.expect()`
pair is gone — the compiler proves exhaustiveness.
- `core/compression.rs` decoder slice access now propagates as
`io::Error::other(...)` instead of `.expect("AsyncRead contract")`,
so a malicious or buggy upstream stream fails the request rather than
crashing the worker.
- `axum/response.rs::into_axum_response` error path no longer uses
`Response::builder().expect(...)`; constructs the 500 response
directly via `Response::new` + `status_mut` + `headers_mut().insert`,
every step infallible by `http`-crate contract.
- `axum/proxy.rs` replaced `Default` (which panicked on TLS init) with
fallible `AxumProxyClient::try_new() -> Result<_, reqwest::Error>`.
Production caller in `request.rs::into_core_request` propagates as a
`String` error (matches the fn's existing return type).
- `fastly/logger.rs::init_logger` now returns
`Result<(), InitLoggerError>` (a typed enum wrapping the underlying
build error and `log::SetLoggerError`) instead of `.expect("non-empty
Fastly logger endpoint")`. `lib.rs::init_logger` re-exports the wider
return type.
- `cli/generator.rs::render_templates` propagates the previously-
`.expect("adapter context dir has a file name")` invariant as
`io::Error::other` since the surrounding fn already returns
`io::Result<()>`.
`axum/service.rs::call` (the tower `Service` impl) bridges the new
`Result<Response, EdgeError>` from `RouterService::oneshot` into a
`Response<AxumBody>` by mapping the error to a hard-coded 500 with a
plain-text body — `Service::call` returns `Result<Response, Infallible>`
so we cannot propagate further up the stack here.
`adapter-fastly` adds `thiserror` as a direct dependency for
`InitLoggerError`. All 557 workspace tests still pass.
Replaces the previous \`std::io::Result<()>\` / \`io::Error::other(format!(...))\`
shape across the \`edgezero new\` code path with two domain-specific error
types:
- \`crate::scaffold::ScaffoldError\` (variants \`Io { path, source }\` and
\`Render { name, message }\`) wraps every Handlebars failure and every
filesystem op inside template rendering with the offending path/template
name attached.
- \`crate::generator::GeneratorError\` (variants \`OutputDirExists\`,
\`AdapterDirMissingFileName\`, \`Io { path, source }\`, and
\`Scaffold(#[from] ScaffoldError)\`) replaces the workspace-construction
io::Error stringification.
\`generate_new\`, \`ProjectLayout::new\`, \`collect_adapter_data\`, and
\`render_templates\` all return \`Result<_, GeneratorError>\`.
\`adapter-cli\` and \`scaffold\` now depend on \`thiserror\` directly. All
557 workspace tests still pass.
The `IntoResponse::into_response` change in 1506738 turned the trait into `-> Result<Response, EdgeError>` workspace-wide. The demo app (`examples/app-demo/`) is excluded from the main `Cargo.toml` workspace, so it didn't get rebuilt by the workspace clippy/test gate and silently broke. This propagates the same fix to the demo: - Every `block_on(handler(ctx)).expect("handler ok").into_response()` in `crates/app-demo-core/src/handlers.rs` test code now appends `.expect("response")` to unwrap the response result. - Every `into_body().into_bytes()` test path now appends `.expect("buffered")` since `Body::into_bytes()` returns `Option<Bytes>` (changed in the defensive-coding pass). `cd examples/app-demo && cargo test --workspace --all-targets` passes all 21 demo handler tests; `cargo clippy --workspace -- -D warnings` also clean.
Inherit pedantic+restriction lints in the demo workspace and each demo crate. Fix the lints that flagged real issues in the demo handlers (`as _` trait imports, inlined format args, fast-path `to_string`, renamed shadowed bindings, separated literal suffix). The demo's allow-list is intentionally narrower than the library's — only entries the demo actually trips. New allows can be added lazily as future failures surface.
Add a clippy.toml mirroring the parent (allow expect/unwrap/panic/ indexing-slicing in tests). Then refactor away the workspace allows that were genuine wins: - shadow_reuse: rename `chunk` and `cursor` shadows - absolute_paths: import std::env, std::time::Duration, std::process, and use already-imported Arc instead of std::sync::Arc - default_numeric_fallback: add type suffixes (1_u64, 0_i32..3_i32, 1_i64) - pattern_type_mismatch: implicitly fixed by str_to_owned changes - missing_trait_methods: implement KvStore::exists on the test MockKv - expect_used in production code: stream() now propagates the response builder error via EdgeError::internal The remaining allow-list keeps only entries the demo actually trips that match main's philosophical stance — std (not core/alloc) for binaries, idiomatic `?` over match, terse closure idents, and the single exhaustive_structs site that comes from the `app!` macro.
- str_to_string (21 sites): `.to_string()` → `.to_owned()` on `&str` - arithmetic_side_effects: counter `n + 1` → `n.wrapping_add(1)` - min_ident_chars + pattern_type_mismatch: rename closure destructures `|(k, v)|` → `|&(name, value)|`/`|&(key, value)|` - pub_with_shorthand + field_scoped_visibility_modifiers: drop `pub(crate)` shorthand on the demo's DTOs and handlers — the `mod handlers;` declaration is already private, so plain `pub` is crate-private at the boundary - print_stderr: axum main returns `anyhow::Result<()>` and lets the Termination impl render errors; fastly/cloudflare host stubs keep `eprintln!` behind a localized `#[expect]` with reason since they only run on the wrong target Workspace allow-list now keeps only the entries that match main's philosophical stance (idiomatic `?`, `pub` shorthand handled per-call site, etc.) plus the single `exhaustive_structs` site from the `app!` macro.
Drop the `arbitrary_source_item_ordering` allow in favor of the canonical clippy-restriction layout: - Top of `handlers.rs`: consts (alphabetical), then structs (alphabetical: ConfigParams, EchoBody, EchoParams, NoteIdPath, ProxyPath), then handler fns - Test mod: uses, then structs (alphabetical), then impls grouped with their self-types, then helper + test fns interleaved in alphabetical order - `impl KvStore for MockKv` methods alphabetical (delete, exists, get_bytes, list_keys_page, put_bytes, put_bytes_with_ttl) - Hoisted the late `use edgezero_core::secret_store::...` up to the test mod's use block No behavior changes — pure reordering. Demo workspace allow-list drops to 8 entries.
The `edgezero new` generator now scaffolds the same lint policy EdgeZero itself uses: - Root `Cargo.toml` carries `[workspace.lints.clippy]` (pedantic warn + restriction deny) with the same demo-tested allow-list - Root `clippy.toml` exempts tests from `unwrap`/`expect`/`panic`/ indexing-slicing restriction lints - Each generated crate's Cargo.toml inherits via `[lints] workspace = true` Generated projects are clippy-clean against the strict gate out of the box.
Both adapters were calling `from_core_response` directly on the router's return value, but `oneshot` now yields `Result<Response, EdgeError>` since the response builder errors propagate through the router. Extract the response with `?` first so the wasm32 builds (`--target wasm32-unknown-unknown` for cloudflare, `--target wasm32-wasip1` for spin) compile again.
… per-site Real fixes (allows now justified by audit, not laziness): - build.rs returns `Result<(), Box<dyn Error>>` instead of expect-panicking - adapter registry / blueprint registry recover from poisoned RwLocks via `unwrap_or_else(PoisonError::into_inner)` rather than expect-panicking - ManifestLoader gains `try_load_from_str` returning `io::Result`; adapter `run_app` paths propagate via `?`. The non-fallible `load_from_str` keeps its panic-on-bad-input contract for compile-time-embedded manifests, with a documented per-fn `#[expect(clippy::panic, reason = ...)]` - `expand_app` macro emits `compile_error!()` instead of panicking on bad `edgezero.toml` (rustc surfaces a clean build error) - `parse_handler_path` keeps a panic with a clear reason — proc-macro expansion errors *are* build failures - `partial_pub_fields` on `Manifest`: privatized `root` and `logging_resolved`, kept the deserialized fields `pub` for the public API. Localized `#[expect]` documents the deliberate split - `must_use_candidate` fixed on cli_support helpers via `#[must_use]` - `missing_inline` fixed on adapter/scaffold registry functions - `pub_use`, `format_push_string`, `arithmetic_side_effects`, `default_numeric_fallback`, `pattern_type_mismatch`, `min_ident_chars`, `str_to_string`, `absolute_paths`, `module_name_repetitions`, `shadow_reuse`: all kept as workspace allows but with concise rationales replacing the prior verbose audit notes Each remaining workspace allow now has a one-line reason. The list is shorter than before but explicitly accepts the lints whose "fix" would universally make the code worse (match-ergonomics destructures, std-only binary entrypoints, idiomatic `?`/return).
…space-wide 54 sites across 23 files. Fixed places where my bulk replace had wrongly converted Display::to_string() calls (anyhow::Error, io::Error, i32 etc.) back to .to_string(). The lint allow is dropped from the workspace.
23 sites across extractor.rs, key_value_store.rs, middleware.rs, proxy.rs, adapter-axum dev_server/key_value_store, adapter-spin decompress. Validator length(min=N) gets _u64; range(min=N, max=N) gets matching type suffix; loop-bound and assertion literals get explicit i32.
Core crate: replaced 60+ `std::collections::HashMap`, `std::sync::Arc`, `std::ops::Deref/DerefMut`, `crate::error::EdgeError`, `futures::executor::block_on`, `std::task::*`, `std::string::String::*` absolute paths with explicit `use` statements. Axum proxy.rs: imported the various `axum::http::*` and `axum::routing::*` types used in test functions. The lint stays allowed at the workspace level for adapter test modules where one-shot uses of framework types like `axum::http::HeaderMap` and `fastly::kv_store::KVStore` are clearer inline.
Real fixes (workspace allows dropped, code refactored): - AdapterAction marked #[non_exhaustive] with wildcard arms in adapter cli match sites — drops a workspace exhaustive_enums concession - Adapter crate exposes `pub mod registry` instead of pub-using items at the crate root — drops the workspace pub_use concession - expand_action_impl made private (no longer pub(crate)) — drops the workspace pub_with_shorthand concession on this site - ManifestLoader, Manifest, ManifestApp/HttpTrigger/Environment/Binding/ ResolvedEnvironment*, ManifestAdapterBuild/Commands, ManifestConfigStoreConfig, ManifestLoggingConfig, ResolvedLoggingConfig, ManifestKvConfig, ManifestSecretsConfig, HttpMethod, LogLevel — all reordered to match canonical clippy item ordering (consts first, then structs, impls, fns; alphabetical within each group) - Manifest impl methods sorted alphabetically; Manifest fields sorted - match-ergonomics destructures rewritten as let-else for clarity - HttpMethod gained Copy; LogLevel/HttpMethod take `self` (drops trivially_copy_pass_by_ref) - partial_pub_fields fixed via consistent pub on Stores in fastly request - needless_pass_by_value: run_app_with_config / run_app_with_logging take `&FastlyLogging`; map_edge_error / map_lookup_error take by ref; build_fastly_request takes `&HeaderMap`; generate_new takes `&NewArgs` - expect_used localized on register_templates with rationale - ManifestLoader::load_from_str / parse_handler_path keep panic-on-bad- build-input contract documented per-fn - Router: route-listing duplicate-path panic + add_route panic both documented per-fn (build-time programmer error) - spin contract test uses #[allow] for expect/tests-outside per file - separate manifest_definitions.rs in macros crate (drops mod-after-use) Workspace allows that survived (most match audited rationales): implicit_return, question_mark_used, single_call_fn, separated_literal_suffix, pub_with_shorthand (rustfmt-enforced), pub_use, min_ident_chars, single_char_lifetime_names, shadow_reuse, module_name_repetitions, format_push_string, pattern_type_mismatch, arithmetic_side_effects, float_arithmetic, as_conversions, exhaustive_structs, exhaustive_enums, missing_trait_methods, absolute_paths, std_instead_of_alloc/core, missing_inline_in_public_items, tests_outside_test_module, arbitrary_source_item_ordering (core-crate files outside manifest.rs). Tests pass, strict clippy clean across workspace + demo.
Override KvStore::exists in 4 production impls (axum/fastly/cloudflare + NoopKvStore) and the in-test MockStore. Override configure/name/ config_store/build_app in the two Hooks test impls. Update the #[app] macro to emit configure, build_app, and a None-returning config_store when [stores.config] is absent so generated user apps still pass clippy. Add explicit clone_from to RouteEntry's Clone impl.
Delete config_store, key_value_store, and secret_store crate-root
re-exports — items remain reachable via the `pub mod` paths. Update the
two short-path callers (axum service.rs / secret_store.rs) to use full
module paths. Keep `pub use edgezero_macros::{action, app}` and the
`http` facade re-exports — these are the only surviving sites and the
lint is module-scoped so it cannot be silenced per-item. Workspace
allow rationale updated to point to those two patterns.
The previous comment framed `push_str(&format!(...))` as a stylistic preference. It is actually the only call-site form that satisfies the full restriction-deny gate: `write!(s, ...)` returns a `Result` which trips `let_underscore_must_use` under `let _ =`, `unwrap_used` under `.unwrap()`, and `expect_used` under `.expect()`.
Switch generator.rs from `push_str(&format!(...))` to `writeln!(...)?` which writes directly into the buffer (no temp String allocation) and propagates `std::fmt::Error` rather than silencing it. Add `GeneratorError::Format(#[from] std::fmt::Error)` and bubble the result through `render_manifest_section` and `append_readme_entries`. Drop the workspace allow.
Rename 'a → 'mw on Next, 'a → 'route on RouteMatch, 'a → 'manifest on manifest_command, and 'a → 'blueprint on AdapterContext. Drop the workspace allow.
Eliminate let-rebinding shadows across core, fastly, axum, and cli
crates. The recurring patterns:
- `while let Some(chunk) = stream.next().await { let chunk = chunk?; }`
→ rename outer to `result`, keep inner `chunk`
- `if let Some(cursor) = cursor.filter(...)` → rename outer/inner to
distinct names
- `let path = path.into()` (Into-paramter idiom) → rename to
destination-specific name
- closure params shadowing outer captures → rename closure param
All renames preserve semantics; tests + workspace clippy + wasm
target checks all pass.
Split `#[cfg(all(test, feature = "..."))]` on test modules into two separate cfg attributes (`#[cfg(test)] #[cfg(feature = "...")]`) which the lint recognizes correctly. Affects edgezero-adapter-fastly lib.rs and edgezero-cli main.rs.
Convert the http builder re-exports to `pub type` aliases (real fix — no `pub use` required) and wrap the `header` re-export in a child module with a scoped `#![expect]`. Add file-level `#![expect(clippy::pub_use)]` to each adapter lib.rs (axum, fastly, spin, cloudflare) and to edgezero-core/lib.rs for the proc-macro re-export. Cloudflare uses `cfg_attr(target_arch = "wasm32", expect)` because its re-exports are wasm-gated and would leave the expect unfulfilled on the host build.
For each adapter (axum/fastly/spin/cloudflare): make the previously
private internal modules `pub mod` and drop every `pub use` re-export.
Callers now reach types via the full path, which is what the lint
suggests as the proper fix. Update internal cross-module refs and
external callers (edgezero-cli, demo crates, axum/spin scaffold
templates, fastly/spin/cloudflare contract tests).
Remaining `pub_use` expects:
- `edgezero-core/src/lib.rs` — single-line proc-macro re-export
(`pub use edgezero_macros::{action, app}`); the canonical proc-macro
distribution pattern requires this and the lint is module-scoped, so
a tightly-scoped file-level expect is the only available form
- `edgezero-core/src/http.rs::header` — wrapped in a child module with
the expect scoped to that one line; required by the CLAUDE.md HTTP
facade rule
The two `#[allow(deprecated)]` annotations on `AppExt::dispatch` implementations (cloudflare/fastly) were unnecessary — implementing a deprecated trait method does not trigger the `deprecated` lint, only calling the deprecated declaration does. Drop them. Also fix the fastly contract integration test (wasm32-only) which was still importing names from the previous crate-root re-exports — switch to the new `request::`/`response::`/`context::` module paths.
Per-package `.cargo/config.toml` is only honored when cwd is inside the package directory, so `cargo test -p edgezero-adapter-fastly --target wasm32-wasip1 --test contract` from the workspace root fails to resolve the Viceroy runner. Mirror the runner at the workspace level. Cargo invokes test runners with cwd set to the package manifest directory, so `../../examples/...` resolves correctly for any adapter package targeting wasm32-wasip1.
Three previously-duplicated wasm test jobs (cloudflare, fastly, and a new spin entry) collapse into one `adapter-wasm-tests` matrix that varies on adapter, target, and runner. Spin uses Wasmtime; fastly keeps Viceroy; cloudflare keeps wasm-bindgen-test-runner. Per-adapter toolchain installs are gated with `if: matrix.adapter == ...` so each job only pulls what it needs. Also fix a pre-existing compile error in `crates/edgezero-adapter-spin/ tests/contract.rs:171` (`name == "x-edgezero-res"` needed a deref) — silently broken because there was no CI job exercising it.
Remove the `extra_check` matrix flag and gating `if:` — every adapter in the wasm matrix now runs the same test+check pair, and the duplicate "Check Spin wasm32 compilation" step in the top-level `test` job (now redundant with the matrix's spin cell) goes away. axum is the host-target adapter — its 102 tests already run as part of `cargo test --workspace --all-targets` in the `test` job. It has no `--test contract` integration target, so adding it to the wasm matrix would either need a special-case command or duplicate the workspace-test work. Keeping it in the `test` job is the simpler call.
The cargo cache restores `~/.cargo/bin/{viceroy,wasm-bindgen-test-runner}`
from prior runs; a bare `cargo install` then fails with `binary already
exists in destination`. Match the same `command -v` guard the spin step
already uses, and for wasm-bindgen also re-check the version (the cache
key is per-Cargo.lock so a wasm-bindgen bump in lockfile needs a refresh).
Replace the conditional `command -v` guards with unconditional `cargo install --force` for both viceroy and wasm-bindgen-cli. The cargo cache restores prior binaries into `~/.cargo/bin/` and `cargo install` rejects by default; the previous version-grep guard was fragile and the simpler `--force` is always safe with `--locked`.
Five `pub(crate)` items in fastly/spin are file-local, not actually cross-module: drop them to private (Stores, dispatch_with_handles, resolve_kv_handle, resolve_secret_handle, MAX_DECOMPRESSED_SIZE). Also drop `validate_name` to private in edgezero-core/secret_store (only used inside the same file). The remaining five `pub(crate)` items (dispatch_raw, dispatch_with_ store_names, parse_uri, parse_client_addr, decompress_body) are genuine cross-file crate-internal API and must stay at crate visibility. `pub_with_shorthand` wants `pub(in crate)` but rustfmt unconditionally rewrites that back to `pub(crate)` — there is no spelling that satisfies both the lint and rustfmt, so the workspace allow stays with a tighter rationale.
aram356
force-pushed
the
feature/extensible-cli
branch
from
June 22, 2026 19:24
161a244 to
93f72fe
Compare
# Conflicts: # .github/workflows/test.yml # .tool-versions # Cargo.lock # Cargo.toml # crates/edgezero-adapter-axum/src/cli.rs # crates/edgezero-adapter-axum/src/config_store.rs # crates/edgezero-adapter-axum/src/dev_server.rs # crates/edgezero-adapter-axum/src/service.rs # crates/edgezero-adapter-cloudflare/src/cli.rs # crates/edgezero-adapter-cloudflare/src/lib.rs # crates/edgezero-adapter-cloudflare/src/request.rs # crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs # crates/edgezero-adapter-fastly/Cargo.toml # crates/edgezero-adapter-fastly/src/cli.rs # crates/edgezero-adapter-fastly/src/config_store.rs # crates/edgezero-adapter-fastly/src/lib.rs # crates/edgezero-adapter-fastly/src/request.rs # crates/edgezero-adapter-spin/src/cli.rs # crates/edgezero-adapter-spin/src/cli/push_cloud.rs # crates/edgezero-adapter-spin/src/request.rs # crates/edgezero-adapter-spin/src/templates/runtime-config.toml.hbs # crates/edgezero-adapter-spin/src/templates/spin.toml.hbs # crates/edgezero-adapter-spin/tests/contract.rs # crates/edgezero-adapter/src/registry.rs # crates/edgezero-cli/Cargo.toml # crates/edgezero-cli/src/adapter.rs # crates/edgezero-cli/src/args.rs # crates/edgezero-cli/src/config.rs # crates/edgezero-cli/src/generator.rs # crates/edgezero-cli/src/lib.rs # crates/edgezero-cli/src/main.rs # crates/edgezero-cli/src/provision.rs # crates/edgezero-cli/src/scaffold.rs # crates/edgezero-cli/src/templates/app/name.toml.hbs # crates/edgezero-cli/src/templates/cli/src/main.rs.hbs # crates/edgezero-cli/src/templates/core/Cargo.toml.hbs # crates/edgezero-cli/src/templates/core/src/config.rs.hbs # crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs # crates/edgezero-cli/src/templates/root/Cargo.toml.hbs # crates/edgezero-cli/src/templates/root/README.md.hbs # crates/edgezero-cli/src/templates/root/edgezero.toml.hbs # crates/edgezero-cli/src/templates/root/gitignore.hbs # crates/edgezero-core/src/app.rs # crates/edgezero-core/src/app_config.rs # crates/edgezero-core/src/context.rs # crates/edgezero-core/src/env_config.rs # crates/edgezero-core/src/error.rs # crates/edgezero-core/src/extractor.rs # crates/edgezero-core/src/key_value_store.rs # crates/edgezero-core/src/lib.rs # crates/edgezero-core/src/manifest.rs # crates/edgezero-core/src/store_registry.rs # crates/edgezero-macros/src/app_config.rs # crates/edgezero-macros/tests/app_config_derive.rs # crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr # crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr # crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr # docs/.vitepress/config.mts # docs/guide/kv.md # docs/superpowers/plans/2026-05-20-cli-extensions.md # docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md # docs/superpowers/plans/2026-06-04-spin-per-backend-push.md # docs/superpowers/specs/2026-06-01-spin-kv-config.md # examples/app-demo/Cargo.lock # examples/app-demo/app-demo.toml # examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml # examples/app-demo/crates/app-demo-adapter-spin/spin.toml # examples/app-demo/crates/app-demo-cli/Cargo.toml # examples/app-demo/crates/app-demo-cli/src/main.rs # examples/app-demo/crates/app-demo-cli/tests/config_flow.rs # examples/app-demo/crates/app-demo-core/src/config.rs # examples/app-demo/crates/app-demo-core/src/handlers.rs # examples/app-demo/edgezero.toml # scripts/smoke_test_config.sh
- Define config-store / kv-store / secret-store capability semantics (bare enum variants had no doc comment, unlike the six outbound caps). - Add explicit transport-error mapping bullets to the Cloudflare (§4.2) and Spin (§4.4) adapters, matching Axum/Fastly (DNS/TLS/connect -> bad_gateway). - Collapse the now-obsolete dual-baseline enforcement-gate hedging: PR #269 has merged to main, so the #269 sibling-gate topology is the active shape and the pre-#269 wording is marked historical.
- §4.3: document Fastly send_all cancellation/drop semantics (no async cancellation; every PendingRequest is harvested; sibling deadline never aborts other slots). - §3.3.1 / §3.1.3: spell out Deadline::after overflow fallback and into_response()'s only Err (adapter-invariant internal) condition. - §5.4: add test rows for Json/ValidatedJson cap migration, the *Within explicit-cap extractors, the per-adapter capability() matrix, and back-compat manifest parse. - §5.5: state where Tier 2 runs per adapter (host gate for Axum; per-adapter wasm-target matrix for the three WASM adapters). - §7: add dispositions for adapter request.rs, the Tier 3 MockServer, the run_* gate-site files + ensure_capabilities home, and CLAUDE.md/workflow wasip2 refresh; note sha2 is already a workspace dep. - Collapse the now-obsolete dual-baseline (#269 pre/post) framing in §3.5.2, §5.5, and §6 to the post-#269 shape, keeping the pre-#269 note as history.
- into_response(): drop the unreachable 'body handle surrendered by a prior terminal call' Err example — all terminal methods take self by move, so double-consumption is a compile error; reword as infallible-in-safe-use with a reserved internal path. - §5.4 capability() matrix row: include the omitted BoundedCooperative variant (CapabilitySupport has four variants: Native / BoundedCooperative / BestEffort / Unsupported).
…er (WIP) Amends the spec from three source-verified investigations (spin-sdk 6/wasip3, worker 0.8.3/workerd, Spin host-sync lifecycle) plus review findings: - Spin: replace the unimplementable WASI-0.2 upload loop (wasi:io is deleted in WASI 0.3) with a hand-built wasi:http request; the SDK's high-level send spawns an uncancellable body pump. Lazy response passthrough = BestEffort (EdgeZero's own FullBody alias, not a platform limit). - Cloudflare: set-cookie IS preservable (compat_date already enables it); the collapse is HeaderMap::insert. Second collapse bug on the client-facing path (Headers::set) and a to_str().unwrap() panic hazard. Comma-joining of repeated non-set-cookie headers is the real, narrow loss. - Spin host sync: provision writes, build/serve/deploy validate at adapter.rs::execute (a hook in SpinCliAdapter::execute would be dead code). - Fastly SendErrorCause -> 504/502; RequestContext::new preserved; test seams behind test-utils (not cfg(test)); tiers redefined by owning crate. Known-open: a follow-up review found compile-level errors in the normative pseudocode (pub(crate) validator across crates, builder/getter name collisions, Spin pump result handling, StoredError reconstruction, Hooks defaults vs denied missing_trait_methods). Tracked in the decisions register; NOT ready for task-level planning.
…cs/outbound-http-spec
…ted)
Built a 2-crate compile-check skeleton (core + separate adapter crate) and let
the compiler/clippy adjudicate the review's compile-class findings:
- validate_for_dispatch: pub (pub(crate) is unreachable from adapter crates) [E0603]
- deadline accessors: single budget_inputs() struct accessor (getter names
collided with the public builder setters -> E0592 duplicate definitions)
- Spin pump: unwrap Result<Bytes,EdgeError>, keep+prioritize pump result
(join!(...).1 silently discarded source errors and cap overflow)
- StoredError: variant-specific snapshot enum ({kind,message} doubled Internal's
prefix and couldn't hold ConfigOutOfDate/MethodNotAllowed/NotFound payloads)
- Capability schema: inline in manifest.rs + derive Serialize (reconciled active
section with §7; separate capability.rs won't compile in the macro include)
- Baked manifest: app! must emit BOTH Hooks methods (clippy::missing_trait_methods
is denied -> proven error); manifest() uses a per-impl function-local OnceLock
calling a PUBLIC Manifest::from_baked_json (reparse must call pub(crate) finalize,
unreachable from the downstream generated impl)
- Spin host default: Option<Vec<String>> (absent=None https-only vs explicit
["*"]=http+https; bare Vec can't carry the distinction -- serde test passes)
- Fastly cache: MUST be session-scoped thread_local!, not a per-request client
field (client is constructed per inbound request; names are session-global ->
request #2 fails closed on NameInUse)
- provision: corrected -- receives NO host data; needs manifest re-read or a
signature change (my earlier 'no trait change' was wrong)
- config validate: run_config_validate_typed is a separate public entry generated
CLIs call directly; gate must be a shared inner op behind both entries
- Fastly test seam: recording BackendBuilder, not a cache inspector (Backend is
opaque -- getters don't round-trip SSL/SNI/cert/override_host)
Skeleton at scratchpad/skel compiles green in corrected form. Still open: policy
calls (loopback-vs-no-network, command-class gating, compression, migration
completeness incl PROXY_HEADER + error-JSON status field).
Skeleton (scratchpad/skel, compiles green) confirmed the CLI-gate wiring shape:
a gate on only one of run_config_validate / run_config_validate_typed leaves the
typed path silently ungated -> must be a shared inner gated op both entries call.
Policy decisions (all user-locked):
- Command-class gating: gate runtime-producing/mutating (build/serve/deploy/
provision/config push) + validation (config validate); EXEMPT read-only
diagnostic (config diff, auth status) and credential (auth login/logout). This
REVERSES the earlier 'gate config diff' lock. Gate count 6 -> 5; execute(..)
branches to skip auth. Swept §3.5.3 table, §5.4 enumerations (+ exemption
regression-guard row), §6/§7 counts, §1 header.
- Loopback tests: amend CLAUDE.md so 'network' = public internet; a loopback
mock origin is permitted (in-process fake rejected -- can't prove real socket
concurrency).
- Compression: decode single gzip/br; identity/unknown/stacked/repeated ->
passthrough untouched (bytes + content-encoding intact), never a hard fail.
Case-insensitive token match. Normative table added to §3.4.1.
- PROXY_HEADER (x-edgezero-proxy): PRESERVED through the rename.
Migration honesty (finding 13): replaced the false 'no public capability lost'
claim with an enumerated removal table -- ProxyHandle::client(), body_mut(),
extensions()/extensions_mut(), request_mut() are DROPPED (breaking); PROXY_HEADER
preserved. EdgeError JSON shape corrected to the REAL converter's
{status, kind, message, field_path?}, not {kind, message}.
…e appendices) Independent source-grounded verification review: compile-class CLEAN (no blockers), current-tree claims ALL accurate. Two active defects were my own incomplete command-class-gating sweep from the prior round, plus three stale appendix entries: - §7 CLI gate enumerations (both) still gated the EXEMPT config diff and said execute(..) covers auth. Corrected: execute gates build/serve/deploy only (branches to skip Auth*); four siblings run_provision/run_config_push_typed/ run_config_validate/run_demo; run_config_diff_typed is NOT a gate site. - §5.4 row asserted the execute gate fires on auth -> corrected to build/serve/ deploy only, + asserts config diff / auth do NOT hard-fail. - Appendix entries: added [SUPERSEDED] markers (not rewriting frozen history, matching the spec's existing 'superseded by AY' convention) to: lazy=Native- on-three-WASM-adapters (only CF Native now), the WASI-0.2 subscribe/check_write protocol (doesn't exist in SDK 6), and the #[cfg(test)] seam (must be test-utils). Also compile-verified the Fastly session-cache thread_local! pattern in the skeleton (request #2 reuses across per-request clients; mismatch fails closed). Gate count now consistently FIVE across §3.5.3, §5.4, §6, §7; auth + config diff exempt everywhere in the authoritative body.
…tives)
First executable TDD slice of the outbound-http implementation. Scoped to the two
purely-additive, skeleton-verified core primitives that keep cargo test --workspace
green at every step:
- EdgeError::BadGateway (502) / GatewayTimeout (504): variants, constructors, all
five exhaustive-match updates (kind_str/message/status/inner/into_response
field_path), + tests for status/kind/message and the {error:{status,kind,message}}
JSON shape (no field_path). Test code verified against the real error.rs APIs
(sync into_bytes(), nested error-wrapping).
- time.rs: Deadline (after with DEADLINE_FAR_FUTURE clamp + saturating checked_add,
at_instant/instant/remaining/is_expired), DispatchBudget struct, and the three
budget constants with exact verbatim values.
dispatch_budget() itself is explicitly deferred to Phase 1b (needs OutboundRequest +
the budget_inputs() accessor), which is the breaking proxy->outbound rename slice.
PLAN (all findings verified against the real tree before fixing): - Task 1 would NOT have compiled: enumerated only 5 exhaustive EdgeError matches, but there are EIGHT -- the 3 test-module matches (error.rs 281/335/369) have explicit panic-arms listing every other variant with NO '_' wildcard, so adding two variants breaks them. #[non_exhaustive] does not relax exhaustiveness inside the defining crate. Listed all 8 + added a compiler-driven catch step (E0004). - is_expired() was WRONG at exact equality: checked_duration_since returns Some(ZERO) at t==deadline, so it reported not-expired, contradicting the spec's 'deadline <= now => expired'. Now compares instants directly; remaining() returns None at equality. Added an equality test. - 'cargo test -p X a b' is invalid (verified: unexpected argument 'b'). Single filter. - GatewayTimeout's JSON contract was untested -> both variants table-driven, and the existing kind_strings_per_variant matrix (error.rs:502) gains rows (with the suffixed 502_u16/504_u16 literals the macro actually uses). - Flaky timing tests replaced with instant-bounded ones (no now()-1s underflow, no 50-60s tolerance); the Duration::MAX test now actually proves the 7-day clamp. - DispatchBudget moved to Phase 1b to ship with its producer (spec treats §3.3.2 carrier+producer as one contract). - fmt added to Task 1; Task 3 now runs all five CI gates incl. fmt --check and the wasm32-wasip2 target. - Phase 1b noted as multiple slices, not one atomic step. SPEC (stale pseudocode/footnotes the prose sweeps missed): - Fastly cache ownership normalized to ONE owner: session-scoped thread_local! + RefCell (single-threaded guest, no Mutex). Removed the contradictory per-client 'Mutex<HashMap> on FastlyOutboundClient, one per request context' claims, which fail on the 2nd inbound request (names are session-global, client is per-request). Test seam targets the thread-local, not a client field. - Footnote 2 claimed between_bytes_timeout bounds streamed-upload inter-chunk gaps; it is receive-side only and bounds nothing on the guest->origin write path. Now agrees with §4.3 and §8 risk 7: BOTH source-pull and host-write are BestEffort. - CLI gate pseudocode rewritten: execute(..) branches to skip Auth*, gates the TYPED push path (bundled run_config_push is an erroring stub), validate is adapter-less and loops configured adapters behind ONE shared gated_validate that both the bundled and typed entries call, demo reads the baked Hooks::manifest(), and run_config_diff_typed is explicitly NOT a gate site. - Spin sample applied '?' to Option<Duration> in a Result fn -> explicit let-else to gateway_timeout.
…r stages
Verified every finding against the tree/compiler before fixing.
PROVEN BUGS (skeleton-adjudicated):
- Trait-default static is NOT per-impl. Proof test: two Hooks impls relying on a
caching default BOTH returned the first impl's value (AppA=1, AppB=1) -- items
in generic fns are not monomorphized, so ONE static is shared by all
implementors. As specced, app A's baked manifest would serve app B's capability
checks. Fix: default manifest() returns None; the OnceLock lives in each
macro-generated impl (distinct static per impl). Test kept as a regression guard.
- The repo DENIES clippy::restriction wholesale (root Cargo.toml) and does NOT
allow-list missing_inline_in_public_items / min_ident_chars /
arithmetic_side_effects / expect_used / as_conversions. My Phase 1a Deadline
code and the spec's pseudocode both violated it:
* plan: added #[inline] to every public fn, d -> duration, replaced
with checked_duration_since + explicit zero filter via pure remaining_at(now)/
is_expired_at(now) helpers (which also make the tests exact/deterministic --
no tolerance windows, no now()-1s underflow; the clamp test now bounds
d.instant() to prove the 7-day clamp).
* clippy.toml allows expect/unwrap/panic IN TESTS but NOT arithmetic_side_effects
-- documented, hence checked_add(..).expect(..) in test code.
* spec: dispatch_budget .expect(..) -> explicit invariant EdgeError::internal;
fastly_timeout_ms casts + unchecked arith -> as_millis + saturating +
u64::try_from (compile-verified AND behaviour-tested: sub-ms->1, 1.5ms->2,
Duration::MAX clamps without panic).
FASTLY CONTRADICTIONS:
- Step 6 listed DNS/TLS as Backend::builder().finish() errors; they only arrive from
send. Split the two stages (BackendCreationError vs SendErrorCause), which also
makes the §5.4 rows writable (a fake builder cannot produce a DNS branch).
- Decided the ambiguous cause: DnsTimeout -> 504 (classify by 'did a timer fire?',
not by subsystem); DNS resolution failure (NXDOMAIN) -> 502.
- Canonical-accessor test row pointed at map inspection; Backend is opaque -> now
targets the recording BackendBuilder (map inspection reserved for identity/reuse).
- Purged mutex-era reasoning from the thread_local algorithm ('drop the lock' ->
'release the borrow'); removed the impossible 'prior session / another deployment'
NameInUse cause (names are session-scoped and may overlap across sessions) and
replaced it with the three real causes.
- Upload summary still claimed a BoundedCooperative write-side bound and that only
source-yield was the 'worst phase' -- both phases are unbounded BestEffort.
- [SUPERSEDED] markers on the two appendix Mutex-era entries.
STALE FOLLOW-UPS (verified already done -- would have scheduled redundant work):
- Risk 10 (Spin wasip2 refresh) CLOSED: CLAUDE.md already quotes wasip2 for gate 5
and lists Spin as wasm32-wasip2; surviving wasip1 refs are Fastly's (correct).
- §1 header no longer claims the CLI gates are unreconciled -- §3.5.3 reconciles them.
- §7 CLAUDE.md entry now carries the real deliverable: amend the unqualified
no-network rule to permit a loopback mock origin.
…rror policy Reviewer compiled the Phase 1a code under the repo's lint policy and found duration_suboptimal_units. I reproduced the repo's EXACT lint table in an isolated crate and found MORE than reported -- then iterated to green. PHASE 1a PLAN (now verified GREEN under the repo's real gate): - duration_suboptimal_units (pedantic; CI runs -D warnings so it FAILS the build): from_secs(7*24*60*60) -> from_hours(168); from_secs(60) -> from_mins(1). - arbitrary_source_item_ordering (denied restriction) -- NOT in the review, found by compiling: items must be ALPHABETICAL, and it policies ENUM VARIANTS too (proven: appending BadGateway after Validation errors). The plan said "add after Validation", which would have failed on first build. Corrected: BadGateway before BadRequest, GatewayTimeout between ConfigOutOfDate and Internal; constructors likewise. Deadline's methods reordered alphabetically. - Confirmed std_instead_of_core/std_instead_of_alloc ARE allow-listed, so `use std::time::Duration` is fine (checked the full allow-list rather than guessing). - Fixed the two "compares instants directly" descriptions -- impl uses checked_duration_since(..).filter(..). SPEC (High): - ManifestCapabilities/ManifestOutboundCapability derived only Deserialize while Manifest derives Serialize -> would break Manifest's derive and the macro crate. Both now derive Serialize. - SECURITY: hosts was Vec<String> defaulting to ["*"], but the renderer defines "*" as http+https while an ABSENT field must stay https-only. Every existing manifest that never declared hosts would silently gain cleartext outbound on next build. Now Option<Vec<String>>: None -> ["https://*:*"]; Some(["*"]) -> http+https (opt-in); Some([]) -> rejected. Validator signature updated to the inner slice. - Fastly error policy: replaced the two catch-alls with an EXHAUSTIVE per-variant table built from the real fastly-0.12.1 enums. Key correction: ConnectTimeoutTooLarge /FirstByteTimeoutTooLarge/BetweenBytesTimeoutTooLarge/NameTooLong/EncodingError and HttpRequestUriInvalid/InternalError mean EDGEZERO violated its own clamp/name/URI invariants -> `internal` (500), NOT bad_gateway. Mapping them to 502 disguises an adapter bug as an upstream failure. Widened the §5.4 "internal only on three paths" assertion to five, with a regression guard against the 502 mapping returning.
…t sweep)
Addresses ALL remaining feedback. Verified each against the real SDK/tree.
SECURITY -- baked-manifest gate failed OPEN:
- parse-failure and "no manifest" both collapsed to None, and ensure_capabilities
treats None as permission to proceed => a MALFORMED baked contract silently
disabled required-capability enforcement (the gate reports success exactly when
it can no longer verify anything). Replaced Option with a three-state
BakedManifest { Absent, Present(&'static Manifest), Malformed(&'static str) }:
Absent proceeds (no contract), Malformed HARD-FAILS with an actionable message.
- Macro output now uses fully-qualified paths (::edgezero_core::manifest::Manifest,
::std::sync::OnceLock) -- it expands in a downstream crate that may not have them
in scope.
FASTLY -- test seam was impossible as specced (verified against fastly-0.12.1):
- SendError has private fields + no public ctor; SendErrorCause is #[non_exhaustive].
NEITHER is constructible in a test, so "one test per SendErrorCause" was unwritable.
Also retracted my own earlier instruction to "match SendErrorCause exhaustively so a
future variant is a compile error" -- #[non_exhaustive] makes that impossible; a `_`
arm is mandatory. BackendCreationError is the opposite (not non_exhaustive =>
constructible AND exhaustively matchable). Documented the asymmetry and split
classification: a pure classify(SendFailure) over a locally-defined constructible
enum (unit-testable), plus a thin cause_to_failure boundary map covered by Tier 3.
- Purged the last mutex-era reasoning: "prior session" NameInUse cause (names are
session-scoped; a fresh session starts clean), "outer lock held continuously",
"no other thread", finer-grained locking. Now stated in single-threaded
session-map-borrow terms. [SUPERSEDED] marker added to the last appendix entry.
CLI:
- Manifest::configured_adapter_names() does not exist -> iterate
manifest.adapters.keys() (BTreeMap<String, ManifestAdapter>, manifest.rs:95;
ordered => deterministic failure reporting).
- The three config commands are NOT symmetric; replaced the over-general "same
applies to push/diff" with a per-command table: push = gate the TYPED path only
(bundled is an erroring stub, nothing to gate); validate = shared inner op both
entries call (the only one needing it); diff = never gated.
LINT SWEEP (spec pseudocode, same gate as Phase 1a):
- sent += chunk.len() -> saturating_add (arithmetic_side_effects).
- production cert_host().unwrap() -> explicit if-let (unwrap_used).
- dispatch_budget: added #[inline] + the lint contract; |dur| -> |duration|
(min_ident_chars), including the closure body.
PHASE 1a PLAN:
- The new variants must also join the retry_after_* and field_path_* matrices
(502/504 carry neither), not just kind_strings_per_variant -- three matrices.
…+ strip meta-refs from code comments
Verified each finding against the tree/compiler before fixing.
HIGH (self-inflicted lifetime bug): ensure_capabilities took BakedManifest, whose
Present holds &'static Manifest, but the four file-backed gate sites pass LOCAL
&Manifest borrows (not 'static) -> cannot compile. Introduced a lifetime-bearing
ManifestContract<'a> { None, Malformed(&'static str), Present(&'a Manifest) };
BakedManifest converts in via as_contract(); file-backed callers build it from
Option via from_opt. Fail-closed on Malformed preserved.
HIGH (StoredError): the "total" capture omitted NotImplemented + ServiceUnavailable
(EdgeError has 10 variants); MethodNotAllowed stored String not Method (forcing a
fallible reconstruction); the cancellation example used the REJECTED flat
{kind,message} shape; and three call sites used clone_as_edge_error() while the
method is to_edge_error(). All fixed.
HIGH (Fastly error classification, mutually-exclusive rules): creation step-6 prose
+ the §5.4 stage-1 row said "every non-NameInUse -> 502", contradicting the
per-variant table (clamp/name/encoding -> internal 500). And a "internal is never
correct during send" sentence contradicted SendFailure::LocalInvariant. The
per-variant tables are now authoritative everywhere; the blanket-502 prose is gone.
HIGH (Fastly cache unbounded): backend identity used EXACT ceiled budget_ms, which
drifts by the ms across requests (97/98/99 for a nominal 100ms deadline) while the
thread_local map is session-wide -> one leaked backend per distinct ms, unbounded.
Now identity uses budget_bucket_ms (ceil to a 1-2-5 grid); host timers armed with
the bucket (>= real budget, a looser backstop) while the exact deadline stays
enforced by the cooperative is_expired() check. Bounds distinct backends per host to
~a few dozen, independent of request count. Added a bound-assertion §5.4 row.
HIGH (Spin provisioning wrong-file): recommended re-reading from manifest_root, but
manifest_root = args.manifest.parent() DROPS the filename while --manifest accepts an
arbitrary name -> would read the wrong file for --manifest custom.toml. Now: thread
the hosts (or full path) into provision; a bare re-read does not work.
HIGH (outbound-host default): the initial manifest EXAMPLE still advertised the
cleartext-enabling ["*"] default the corrected schema rejects. Example + appendix now
state absent -> https-only ["https://*:*"], explicit ["*"] -> http+https opt-in.
MEDIUM: Manifest::configured_adapter_names() doesn't exist -> iterate
manifest.adapters.keys() (real BTreeMap field). The three config commands aren't
symmetric (per-command table). Response migration summary said buffering is reserved
for Body::Once on all three WASM adapters -- only Cloudflare streams lazily; Axum,
Fastly, Spin all buffer Body::Stream (fixed + appendix superseded marker).
MASTER-SPEC LINT: DEADLINE_FAR_FUTURE from_secs(7*24*60*60) -> from_hours(168) at both
live sites; Capability enum carries a documented #[expect(arbitrary_source_item_ordering)]
(semantic grouping tied to the matrix); added a prominent §3 directive that all code
blocks are illustrative and must be brought to the strict lint gate before use.
PHASE 1a PLAN: `let v` -> `body_json` (min_ident_chars, reviewer-verified); the three
error.rs tests are not all "exhaustive matrices" (only kind_strings_per_variant is;
the other two are subset checks) -- wording corrected. Deadline block replaced with
the version compile-verified GREEN under the repo's exact lint table.
COMMENTS: stripped section-number / round-N / phase meta-references from all code-block
comments (plan + 894 spec lines) -- comments describe the code, not the planning process.
…consistency Independent review verified the Phase 1a plan end-to-end against the tree and found one real defect, now confirmed here: - The Task 1 / Task 2 test snippets did NOT pass cargo fmt --check as written. Verified with rustfmt at the REAL test-module nesting (inside ): the one-line array-of-tuples rows exceed max_width=100 (~106 cols) and rustfmt rewraps every tuple element onto its own line, plus the message-bearing assert!/assert_eq! and checked_add(..).expect(..) chains. The plan presented these as exact landed code and its Self-Review claimed 'no diff' -- wrong. Replaced both snippets with their rustfmt-canonical multi-line form (re-derived from rustfmt, not guessed); rustfmt --check now exits 0 at real nesting for both blocks. - Spec appendix nit: the resolved-findings table still wrote DEADLINE_FAR_FUTURE = Duration::from_secs(7 * 24 * 60 * 60), contradicting the canonical §3.3.1 def (from_hours(168), which the live sites already use). Made consistent.
The prior meta-ref stripping regex over-reached on comment lines: its empty-paren tidy step ate () off method calls (.is_expired/.enable_ssl/.disable_ssl), bare-§ removal left dangling 'per;'/'per)'/', )' fragments, and one /// collapsed to //////. Repaired 23 comment lines back to grammatical rustdoc (no § back-refs, per the earlier directive -- just correct text). Verified no residual artifacts.
…tent SDK variant
H2 (fail-open, security): from_baked_json did parse + finalize but SKIPPED validate(),
while the real try_load_from_str does parse -> validate() -> finalize(). A crafted `{}`
is valid JSON and parses to a default Manifest with EMPTY capabilities, so an
invalid-but-parseable contract became Present and the gate proceeded against no required
capabilities. Now runs validate(); failure -> Malformed (hard-fail). Added a §5.4
regression-guard row. (finalize only rebuilds derived logging state -- verified in tree.)
H1 (my own regression): last round's cache-bounding used a 1-2-5 grid AND armed the host
timers with the rounded-up bucket, which breaks the headers-phase deadline (that phase
has no cooperative check -- the guest is blocked in the SDK awaiting headers). A 201 ms
budget -> 500 ms bucket = ~299 ms of unpreemptable overshoot. Replaced with a TIGHT
geometric ladder (ratio 1.1): compile-verified (strict clippy + tests) that the result
is always >= real budget (never premature), overshoot < 10%, 97..100 ms collapse to
~1 bucket, and the full 1ms-7day range yields < 300 buckets. 201 ms -> 215 ms now.
Stopped claiming "wall-clock contract unchanged" -- honestly documented the <=10%
headers-phase overshoot as part of Fastly's BoundedCooperative outbound-deadlines
posture, and reconciled the proof, the phase-split prose, and the §5.4 rows.
M6: the "real fastly 0.12.1" table named HttpCacheKeyInvalid, which does NOT exist.
The pinned SDK has HttpRequestCacheKeyInvalid, HttpCacheLimitExceeded,
HttpCacheApiUnsupported. Corrected.
…tract location, test matrix
H3 (Spin can discard a valid early final response): the upload used join!(pump, send)
with pump-failure precedence. If an origin returns 413/401/redirect BEFORE consuming the
full body, it stops reading -> the guest write sees reader-gone -> the pump returned
bad_gateway and DISCARDED the valid response; and a stalled source delayed an
already-available response to the deadline (504). Both violate "completed non-2xx is Ok".
Fixed two ways:
1. Reader-gone in the pump is NOT an error -- end the pump cleanly (Ok) so `send`
surfaces the response.
2. Replaced join! with an ORDERED select: a completed `send` (Ok or Err) IS the
exchange result; a pump SOURCE-error/cap-overflow only wins if it lands before any
response. Compile-verified (pin/select/Either/borrow) in an isolated crate; used the
select-returned future to avoid a double mutable borrow, and no `loop` (never_loop).
Added 3 §5.4 rows: early-final-response not discarded, stalled-source not delayed,
pre-response pump error still surfaces.
M4 (run_demo has no injection seam): run_demo() hardcodes run_app::<App>(), so the
required crafted-JSON gate test is unwritable. Added a generic run_demo_for<A: Hooks>();
run_demo() delegates with the concrete demo App; the test calls run_demo_for::<TestApp>().
M5 (ManifestContract location + stale inventory): §7 still said ensure_capabilities takes
BakedManifest and located the helper in the CLI crate. Corrected: it takes
ManifestContract<'_> (file-backed sites hold non-'static borrows), and both
ManifestContract and BakedManifest::as_contract() live in core beside BakedManifest.
M-test-matrix: empty send_all was Tier 1 only, but the mandatory per-adapter send_all
conformance suite must re-run it (Tier 2) since each adapter implements send_all
independently. Marked Tier 1 + Tier 2.
M7: verified already resolved -- the "prior session" lines are the corrections
("NOT a prior session"), and config push has no residual shared-gate claim.
…st) + plan literal suffix The reviewer verified Phase 1a passes everything in an isolated copy EXCEPT the literal suffix, and caught that TWO rounds of my Fastly fixes rested on a false premise. ROOT CAUSE (verified against fastly-0.12.1 BackendBuilder docs): dynamic-backend names are SESSION-scoped, not global-across-requests. Connections pool across sessions, but each request is a new session with a FRESH namespace. So: - The "request #2 fails on NameInUse" concern that drove the thread_local cache was WRONG -- request #2 is a new session, re-registration is fine. - The "unbounded cross-request cache" concern that drove geometric bucketing was WRONG -- a per-session cache is bounded by fan-out size and discarded each request. Reverted BOTH: the cache is a per-session RefCell FIELD on the per-request client (fresh each session, correct by construction; a cross-request thread_local would carry STALE entries into a reused instance). Identity + host timers use the EXACT ceil-to-ms again (no bucket), which restores the exact headers-phase deadline bound. This resolves: - F2: "Net guarantee" (25ms + rounding) is correct again (no bucket overshoot). - F9: cache lifetime is per-session; fixed "prior session" NameInUse wording. - the H1 overshoot I introduced last round (removed, not just documented). fastly_timeout_ms reverted to plain ceil; §5.4 bucket rows replaced with a per-session-bound row; phase-split prose back to budget (not budget_bucket). F1 (Phase 1a blocker): 502u16/504u16 violate unseparated_literal_suffix (denied; verified 502u16 errors, 502_u16 passes). Fixed to 502_u16/504_u16, added the lint note, and added --all-targets to the task clippy command so tests are linted pre-commit. F6 (my regression): run_demo_for<A: Hooks + App> can't compile (App is a struct). And overriding only manifest_json() is a no-op (default manifest() returns Absent). Replaced with a PURE demo_capability_gate<A: Hooks>() (no blocking server on success); TestApp overrides manifest(). F7 (my regression): {} is a VALID empty manifest (accepted with defaults), so it yields Present, not Malformed. Fixture changed to genuinely-invalid JSON (bad outbound host / duplicate required+optional capability) that parses but fails validate(). F11: config-push gate row said "shared inner op, both entries" -- corrected to typed entry only (the bundled stub errors; gating it enforces nothing).
…ladder, smoke tests F3 (Cloudflare timeout did not cancel the fetch): dropping the raced future does NOT abort the subrequest -- the Workers runtime keeps the POST going after EdgeZero returns 504. Verified worker 0.8.3 exposes AbortController + Fetch::send_with_signal(&signal). §4.2 now issues via send_with_signal and calls controller.abort() on expiry; §8 risk 3 marked resolved. F8 (Spin map_spin_send_err was referenced but unspecified): added the WASI ErrorCode classification mirroring Fastly's SendFailure -- DnsTimeout/ConnectionTimeout/ ConnectionReadTimeout -> 504 (must NOT fall through to 502), transport -> 502, local-invalid -> internal, `_` -> 502. F4 (only Axum had a 502/504 response-synthesis mapping): specified that Fastly and Spin buffered-drain converters also synthesize the platform response from err.status() + into_response(), not a stringly platform error; §5.4 tests 502/504/over-cap/500 per adapter. F5 (Fastly Tier 2 had no transport seam): the recording builder + clock can't script PendingRequest::poll/wait or connect/header hangs. Added a `test-utils` FastlyDispatch trait so tests script the exchange; rows that need real TCP/TLS timing are Tier 3 only. F10 (capability model couldn't express "exact deadlines required"): defined the support-level ladder -- `required` is satisfied by Native OR BoundedCooperative, hard- fails on BestEffort/Unsupported; exact-only need is a deferred outbound-deadlines-exact capability (§8 risk 14), not an ad-hoc "declare exactness". Low: required/optional capabilities validated unique + disjoint (a from_baked_json -> Malformed / config-validate failure); added public smoke tests for Deadline::remaining() /is_expired() using after(ZERO) (no checked_sub underflow on a fresh WASM Instant).
…nown_fields, Spin timeouts H2 (my regression, compile error): OutboundHttpClient: Send + Sync (the handle stores Arc<dyn ..> in http::Extensions, which require Sync), but last round I "corrected" the Fastly cache to a RefCell field -- RefCell is !Sync, so it cannot compile. The ORIGINAL Mutex<HashMap> field was correct. Reverted to a per-session Mutex<HashMap> field on the per-request client: uncontended on the single-threaded WASM guest (satisfies Sync, does not serialize), fresh each request. This is now the third and final correction of a cache model I kept getting wrong from one false premise (names persist across requests) -- the verified truth is session-scoped names + a per-session Mutex field. H3: deleted the stale cross-request/process-scoped cache block (3896) that still contradicted the authoritative per-session section, plus the RefCell heading and the "no Mutex needed" prose. H7 (Spin timeout misclassification): verified wasip3-0.4.0 has FIVE http timeout ErrorCode variants -- DnsTimeout, ConnectionTimeout, ConnectionReadTimeout, ConnectionWriteTimeout, HttpResponseTimeout. map_spin_send_err mapped only three, leaking ConnectionWriteTimeout + HttpResponseTimeout to 502. All five -> 504, with an exhaustive classifier test. H1 (capability declarations fail open): ManifestCapabilities/ManifestOutboundCapability lacked #[serde(deny_unknown_fields)], so a typo (require/host) is silently ignored -> enforcement disabled or broad default invoked. Added deny_unknown_fields to both, and attached the validate_capabilities_disjoint schema validator (rejects duplicates and required∩optional overlap). §5.4 tests typo/duplicate/overlap. Low: purged the last stale "bucketed" wording (identity uses exact ceil-to-ms; the snapshot-vs-arm slack is the pre-existing guard, unrelated to bucketing).
…bility non_exhaustive) H4 (Axum decompression): reqwest's auto-decode matches exact-lowercase content-encoding from one map entry -- it cannot honour the portable contract (case-insensitive GZIP, identity/unknown/stacked/repeated passthrough). Axum now does NOT enable reqwest gzip/brotli; it routes through the SAME shared §3.4.1 decoder as the other adapters, so all four share one decompression contract. H5 (Cloudflare streamed-response expiry did not cancel the fetch): the buffered path aborts via AbortController, but the streamed wrapper only emitted an error chunk. The AbortController now moves INTO the body wrapper and aborts on deadline, chunk-read failure, or early consumer drop -- so a timed-out stream cancels the subrequest, not just stops reading. H6 (Spin buffered uploads were not cancellable): spin_sdk::http::send spawns a detached uncancellable pump even for a buffered Body::Once (which can block on host backpressure after the send future is dropped). ALL Spin uploads -- buffered and streamed -- now use the hand-built wasi:http request + owned in-race pump, so both body kinds are genuinely cancellable and streamed-upload-deadlines stays Native. Mediums: Capability/CapabilitySupport enums -> #[non_exhaustive] and Adapter::capability gets a default returning Unsupported (out-of-tree adapters compiled against older core still build; unknown capability fails closed). Optional capabilities logged at BestEffort AND Unsupported (not just Unsupported). Axum response converter drains async on the Tokio reactor (NOT block_on inside a worker), with a timer-backed-stream test. Tier 2 seam- dependent rows are required (the seam is a deliverable), not waived. CF redirect uses RequestRedirect::Manual (enum, not "manual" string). Spin wasip3 setters use .map_err (they return Result<(),()>, bare ? won't compile). Provision host-data: locked option (a) (add hosts to the provision context). Plan Task 3 renamed/scoped to the five core gates (Phase 1a is additive core-only).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the EdgeZero outbound HTTP design spec at
docs/superpowers/specs/2026-05-21-outbound-http-design.md. Targets the PR #269 (feature/extensible-cli) baseline — the spec assumes the multi-store manifest, theedgezero_cli::adapter::execute(..)dispatcher, the expandedAdapterActionset,Adapter::provision/ config-validation hooks, Spin SDK 6 / wasip2, and thedemocommand. Earlier appendices that quote the pre-#269 surface are explicitly flagged as historical.Driving pattern
The spec is written against fan-out HTTP workloads — N concurrent outbound requests under a shared wall-clock deadline, results harvested in input order. The driving pattern is treated as a portable substrate; the spec deliberately does not name a single consumer.
Scope
Six driver requirements:
OutboundHttpClienttrait with singlesendand concurrentsend_allon every adapter (Axum, Cloudflare, Fastly, Spin). One handler source compiles unchanged across all four.dispatch_budget(req, now)with explicitnowsnapshot,DEFAULT_NO_DEADLINE_BUDGET = 30s,DEADLINE_FAR_FUTURE = 7 days,BATCH_DISPATCH_SLACK_MAX = 25ms. Fastly's bounded-cooperative semantics are documented with precise overshoot bounds.max + sizeof(current_chunk)worst case, in-flight chunk size source-controlled), pre-append cap checks across inbound + outbound bounded drains. Batch model isΣᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ.outbound-http,outbound-deadlines,outbound-flexible-phase-budget,send-all-slot-isolation,streamed-upload-deadlines,lazy-streamed-response-passthrough,config-store,kv-store,secret-store). Enforcement runs as five pre-dispatch gates (one insideexecute(..), siblings onrun_provision/run_config_push/run_config_validate/run_demo).MockOutboundClient), Tier 2 (per-adapter translation), Tier 3 (runtime against a local mock origin). Adapter-specific mechanics (Fastly host timers, harvest behaviour, dynamic backend identity) are restricted to Tier 2/3 since Tier 1's mock has no analogue.backend_target() / host_authority() / sni_hostname() / cert_host()are the single source of truth for the host/port/SNI/cert split. Adapters MUST consume these, not re-derive fromreq.uri(). IP-literal HTTPS (RFC 6066 §3) is handled bysni_hostname() == None && cert_host() == Some(ip).Out of scope (explicit non-goals)
tokio,reqwest,fastly,worker, orspin-sdkin core or app/library crates.Process
The spec was revised through 49 review rounds. The non-normative resolution journal lives in Appendices A through AX. Appendix AR is the round-44 rebase snapshot, superseded by Appendices AS / AT / AU / AV / AW / AX (rounds 44–49).
Test plan
This is a docs-only PR.
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-targetsdocs/)