Skip to content

Outbound HTTP design spec#275

Draft
aram356 wants to merge 232 commits into
mainfrom
docs/outbound-http-spec
Draft

Outbound HTTP design spec#275
aram356 wants to merge 232 commits into
mainfrom
docs/outbound-http-spec

Conversation

@aram356

@aram356 aram356 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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, the edgezero_cli::adapter::execute(..) dispatcher, the expanded AdapterAction set, Adapter::provision / config-validation hooks, Spin SDK 6 / wasip2, and the demo command. 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:

  • Portable outbound clientOutboundHttpClient trait with single send and concurrent send_all on every adapter (Axum, Cloudflare, Fastly, Spin). One handler source compiles unchanged across all four.
  • Deadline / timeout primitivesdispatch_budget(req, now) with explicit now snapshot, 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.
  • Bounded buffering — persistent vs transient memory accounting (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ᵢ.
  • Capability declaration — nine capabilities (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 inside execute(..), siblings on run_provision / run_config_push / run_config_validate / run_demo).
  • Adapter contract tests — three tiers: Tier 1 (core + 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.
  • Canonical URI accessorsbackend_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 from req.uri(). IP-literal HTTPS (RFC 6066 §3) is handled by sni_hostname() == None && cert_host() == Some(ip).

Out of scope (explicit non-goals)

  • No consumer-specific target logic in EdgeZero.
  • No new direct dependency on tokio, reqwest, fastly, worker, or spin-sdk in core or app/library crates.
  • No back-compat shims; renames are mechanical and downstream consumers migrate.

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 -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-targets
  • Docs CI (ESLint + Prettier on docs/)
  • Spec renders correctly in VitePress (no broken cross-refs to §1–§8 or Appendix anchors)

aram356 added 30 commits April 25, 2026 14:13
Turns on `pedantic` (warn) and `restriction` (deny) workspace-wide and adds
`[lints] workspace = true` to every crate so the policy actually applies.

Captures a baseline allow-list in `Cargo.toml`, organized by category
(Documentation, Style/formatting, Defensive coding, API design, Imports/paths,
Output/diagnostics, Tests, Attributes) with per-lint counts and rationales —
each entry is a TODO unless explicitly marked intentional.

Defensive-coding pass:
- New `clippy.toml` with `allow-{unwrap,expect,panic,indexing-slicing}-in-tests`
  so test code keeps its conventional idioms; production code is denied.
- Production unwraps factored out: `current_dir()`/`init_logger()` now
  propagate via `?`; `writeln!` to a `String` rewritten as `push_str(&format!)`
  so there's no `Result` to discard; bundled-template registration and other
  genuine compile-time invariants use `.expect("...")` as documented assertions.
- Other small wins: `inefficient_to_string` fixed, `match_same_arms` collapsed,
  `manual_assert` swapped, `cast_lossless`+truncation replaced with bound-checked
  `u16::try_from` in adapter-axum CLI, `unreachable!()` in `#[action]` macro
  replaced with a proper `syn::Error::compile_error`.

Lints kept allowed in the workspace are annotated with `(intentional)` where
they conflict with idiomatic Rust (`implicit_return`, `question_mark_used`,
`pattern_type_mismatch`, `default_numeric_fallback`, `arithmetic_side_effects`,
`as_conversions`, `string_slice`) or have no per-test config option
(`assertions_on_result_states`).

`cargo clippy --workspace --all-targets --all-features -- -D warnings`,
`cargo fmt`, and `cargo test --workspace --all-targets` all pass.
Drives the API-design lint group from 18 allows down to 8 (kept as intentional
with rationale comments in `Cargo.toml`).

Factored out:
- `return_self_not_must_use` (18): added `#[must_use]` to all `RouterBuilder`
  builder methods. Catches "I forgot to call `.build()`" bugs.
- `impl_trait_in_params` (26): converted `fn f(x: impl Into<String>)` →
  explicit generics on `EdgeError::*`, `ConfigStoreError::*`, `RouteInfo::new`,
  `InMemorySecretStore::new`, `AxumConfigStore::{new,from_env,from_lookup}`.
  Makes turbofish callable.
- `rc_buffer` (4): `Arc<Vec<RouteInfo>>` → `Arc<[RouteInfo]>` in `RouterInner`
  and the builder. Saves an indirection.
- `unnecessary_wraps` (4): `build_fastly_request` and `convert_response` no
  longer wrap an always-Ok value in `Result`. Cleaner call sites.
- `mutex_atomic` (1): `Arc<Mutex<bool>>` → `Arc<AtomicBool>` in the
  `middleware_fn` test.
- `ref_patterns` (11): `if let Some(ref x) = ...` → `if let Some(x) = &...`
  across env-override `Drop` impls, router builder, response builder, body
  matchers.
- `wildcard_enum_match_arm` (7): `args.rs` tests now use `let-else` instead of
  catch-all wildcard match arms; `EdgeError::source` now lists each non-Internal
  variant explicitly; `cli/build.rs` switched to `if let Value::Table(_) = ...`;
  the one site that genuinely matches an external enum (`fastly::config_store::
  LookupError`) keeps a localized `#[allow(..., reason = "external enum")]`.
- `clone_on_ref_ptr` (1): `store.clone()` → `Arc::clone(&store)` in the axum
  service test (with explicit `Arc<dyn KvStore>` annotation so `Arc::clone`
  picks the right type).
- `renamed_function_params` (4): renamed `request: Request` → `req: Request`
  in `Service::call` impls to match the trait signature.
- `same_name_method` (2): `EdgeError::source` deliberately shadows
  `std::error::Error::source` (typed `&AnyError` vs trait-object `&dyn Error`).
  Documented at the call site with a `#[allow(..., reason = "...")]`.

Kept allowed (with `(intentional: ...)` comments in `Cargo.toml`):
- `exhaustive_structs` (108) and `exhaustive_enums` (18): blanket
  `#[non_exhaustive]` would break user pattern matching and field-syntax
  construction. Apply per-type only when genuinely planned.
- `must_use_candidate` (117): most flagged sites are getters returning
  `&str`/`&Path` — ignoring is impossible, the lint adds noise.
- `missing_trait_methods` (20): relying on default trait methods is fine.
- `needless_pass_by_value` (16): most flagged sites are deliberate ownership
  transfers — error transformers, proc-macro signatures, builders.
- `field_scoped_visibility_modifiers`, `partial_pub_fields`,
  `trivially_copy_pass_by_ref`: deliberate API design choices.

Final clippy + workspace tests pass.
Following pushback that the prior passes were papering over lints rather
than addressing them, this commit revisits each lint that was previously
allowed with hand-wavy reasoning and either (a) factors it out for real,
(b) applies it selectively where the fix matters, or (c) replaces the
rationale with a per-site audit finding.

Real fixes:

- `Body::as_bytes` and `Body::into_bytes` no longer panic on streaming
  bodies — they return `Option`. This eliminates two production panic
  sites the previous pass left as `panic = "allow"`. The internal
  `into_bytes_bounded` site is correctly gated by `is_stream()`; all
  other callers are tests that *intentionally* assert the body is
  buffered, now with `.expect("buffered")`.

- `assertions_on_result_states` is no longer allowed. All 13 sites
  converted from `assert!(r.is_ok())` / `assert!(r.is_err())` to
  `r.expect("...")` / `r.expect_err("...")` — these print the value or
  error on failure instead of just `assertion failed: false`.

- `#[non_exhaustive]` applied to all 4 error enums (`EdgeError`,
  `KvError`, `SecretError`, `ConfigStoreError`) and the 3 manifest
  enums (`HttpMethod`, `BodyMode`, `LogLevel`) — this is the idiomatic
  Rust pattern for error/config enums (see `std::io::ErrorKind`,
  `serde::de::Error`). Also applied to 19 deserialize-only manifest
  structs (`Manifest*`, `ResolvedEnvironment*`-where-not-constructed-
  externally).

- `needless_pass_by_value` real fix in `run_app_with_stores`:
  `FastlyLogging` and `StoreRequirements` are now passed by reference
  since the function only reads from them.

Lints kept allowed but with audited per-site rationales (replacing the
previous one-line hand-waves):

- `pattern_type_mismatch`: every flagged site uses Rust 2018
  match-ergonomics. The "fix" reverts to manual `ref` patterns or
  explicit `&Variant(...)` arms, both worse.
- `arithmetic_side_effects`: every site is bounded by domain invariants
  (TTL+now, path component counts, byte offsets after `len()` checks).
- `as_conversions`: dominated by trait-object coercions (`Arc::new(x)
  as BoxMiddleware`) which cannot be expressed as `From`/`Into` in
  stable Rust.
- `string_slice`: every flagged site indexes ASCII-only data (env var
  names, header names, `matchit` path components).
- `expect_used`: 62 production sites audited — bundled-template
  registration, AsyncRead-contract slice access, lock-poisoning
  unrecoverable, build-script panics. None benefit from `?`
  propagation.
- `panic`: route-registration `unwrap_or_else(|err| panic!(...))` and
  proc-macro expansion failures. Both build/setup-time programmer
  errors, not runtime conditions.
- `cast_possible_truncation` / `cast_sign_loss`: narrowing/sign casts
  always preceded by range checks.
- `exhaustive_structs` / `exhaustive_enums`: applied selectively above;
  remaining sites are tuple-struct extractors users *destructure*,
  unit structs, externally-constructed scaffold blueprints, request-
  context types used in integration tests, and small enums (`Body`,
  `AdapterAction`) where adding `#[non_exhaustive]` would force 12+
  adapter sites to add never-firing wildcard arms.

Workspace clippy + tests still pass with `-D warnings`.
Removes 22 mechanical-fix allow entries from `Cargo.toml` after fixing the
underlying call sites:

Auto-fixed (`cargo clippy --fix` + manual cleanup):
- `uninlined_format_args` (180), `redundant_closure_for_method_calls` (25),
  `map_unwrap_or` (29), `explicit_iter_loop` (14),
  `unseparated_literal_suffix` (24, separated form chosen),
  `implicit_clone` (2), `pathbuf_init_then_push` (3), `string_add` (3),
  `unreadable_literal` (4), `manual_let_else` (2), `else_if_without_else`
  (2 — the Fastly-vs-other-adapter logging branch refactored to a
  pre-computed `Option<endpoint>`), `return_and_then` (2), `ip_constant`
  (2), `manual_string_new` (1), `redundant_type_annotations` (1),
  `needless_raw_strings` (1), `needless_raw_string_hashes` (1),
  `elidable_lifetime_names` (2), `redundant_test_prefix` (1),
  `if_then_some_else_none` (6), `deref_by_slicing` (5), `shadow_same` (4),
  `match_wildcard_for_single_variants` (5), `pub_with_shorthand` (30),
  `decimal_literal_representation` (1).

Real fixes (manual):
- `key_value_store.rs`: replaced bare scoping blocks `{ ...?; }` with
  explicit `drop(table)` so neither `semicolon_inside_block` nor
  `semicolon_outside_block` fires (the lint pair is mutually exclusive
  and one always fires). Same treatment for `decompress.rs` and
  `proxy.rs` brotli-test compressor scopes.
- `middleware.rs`: collapsed the `Mutex` lock+await pattern into a
  single `self.log.lock().unwrap().push(...)` statement so the lock
  guard drops immediately (was previously triggering
  `await_holding_lock` after I removed the scoping block).
- `dev_server.rs`: `let service = service` (shadow_same) refactored
  into a `let service = { mut service = ...; ...; service }` block
  expression that yields the configured value.
- `response.rs`: dropped redundant `let stream = stream` shadow.
- `request.rs`: renamed `test_is_json_content_type` →
  `json_content_type_detection` (the redundant `test_` prefix).
- `proxy.rs` test panics: `_ => panic!(...)` → `Body::Stream(_) =>
  panic!(...)` so the match stays exhaustive when `Body` grows.
- `cli.rs`: `0xFFFF` instead of `65535` for the u16-MAX boundary.
- `dev_server.rs::stable_store_name_hash`: split FNV-1a magic numbers
  with `_` separators.

The Style section in `Cargo.toml` is rewritten as a tight allow-list
(no narrative, no historical commit log inside the manifest). Each
remaining entry has a one-line rationale grouped by category:
- Idiomatic Rust (8 lints): `implicit_return`, `min_ident_chars`,
  `single_call_fn`, `single_char_lifetime_names`, `pub_use`,
  `str_to_string`, `question_mark_used` (was duplicated; consolidated
  in Defensive section).
- Mutually-exclusive pairs we picked one side of: `separated_literal_suffix`,
  `pub_with_shorthand`.
- Held-by-choice (5 lints): `format_push_string`, `shadow_reuse`,
  `shadow_unrelated`, `similar_names`, `non_ascii_literal`,
  `too_many_lines`, `arbitrary_source_item_ordering`,
  `module_name_repetitions`.

Allow-list went from ~80 entries to 57 across all categories.
`cargo clippy --workspace --all-targets --all-features -- -D warnings`
and `cargo test --workspace --all-targets` both pass.
`#[action]` requires the user-written fn to be `async fn` because the
generated outer fn `.await`s it. When a handler body has no awaits of
its own, `clippy::unused_async` fires on the user's source — but the
user has no choice; the macro forces `async`.

Inject the allow into the inner fn's attribute list inside the macro
expansion so handler authors don't have to know about the lint.
Imports/paths track:
- `non_std_lazy_statics` (6 sites): `once_cell::Lazy` → `std::sync::LazyLock`
  in `crates/edgezero-adapter/src/{registry,scaffold}.rs`. Drops `once_cell`
  from `crates/edgezero-adapter/Cargo.toml`. (Workspace dep stays — example
  app still uses it.)
- `unused_trait_names` (37 sites): `use Foo;` → `use Foo as _;` for traits
  imported only for their methods (`StreamExt`, `Write`, `Read`, `Hooks`,
  `IntoHandler`, `Spanned`, etc.) across both library and proc-macro crates.
- `iter_over_hash_type` (1 site): the only flagged production iteration is
  in `RouterInner::dispatch` (collecting allowed methods for a 405 response).
  Refactored from a `for ... { allowed.insert(...) }` loop into
  `.iter().filter().map().collect::<HashSet<_>>()`. The result is a `HashSet`
  whose order doesn't matter (`EdgeError::method_not_allowed` sorts on render).

Attributes track:
- `allow_attributes` (3 sites): `#[allow(...)]` → `#[expect(..., reason)]` on
  the genuine deliberate-shadowing/wildcard-match-arm sites in
  `error.rs::EdgeError::source` and `config_store.rs::map_lookup_error`. The
  CLI build script (`build.rs`) now emits `#[expect(unused_imports, reason)]`
  on every generated `pub(crate) use` re-export.
- `allow_attributes_without_reason` (5 sites): every existing `#[allow(...)]`
  now has a `, reason = "..."` and (where stable-`expect` applies) is migrated
  to `#[expect(...)]`. Sites: `cli_support.rs` and `decompress.rs` top-of-file
  `#![expect(dead_code, ...)]`; the four test-only `Deserialize` field structs
  in `context.rs` and `params.rs`; the macro's `manifest_definitions` shim;
  the two fastly `deprecated` re-exports.

Also kept allowed (real audits in `Cargo.toml` rationales):
- `absolute_paths` (200+ sites): one-shot `std::env::var()` / `std::fmt::Display`
  uses; adding `use` statements wouldn't improve readability for single-use.
- `std_instead_of_alloc` / `std_instead_of_core`: not targeting `no_std`.
- `tests_outside_test_module`: lint matches plain `#[cfg(test)] mod tests`
  only — doesn't recognize `#[cfg(all(test, feature = "..."))]` or
  integration-test files in `tests/`.
- `print_stderr` / `print_stdout`: kept in CLI top-level error reporters and
  status output (`[edgezero] creating project at ...`).

Allow-list now at 51 entries.
…c / doc_markdown / missing_fields_in_debug

Adds public-API docs across every flagged site:

- `missing_panics_doc` (28 sites): added `# Panics` sections describing
  each panic condition. Most are documented invariants (lock poisoning,
  AsyncRead-contract slice access, builder pre-validated headers); a few
  are caller-controlled (`enable_route_listing_at` asserts on path shape,
  `RouterBuilder::build` panics on duplicate route, `load_from_str` panics
  on invalid embedded TOML — the docs note safer alternatives).
- `missing_errors_doc` (62 unique pub fns, 124 lints with re-exports):
  added `# Errors` sections describing the concrete error variants
  returned. Dispatched via batch script with per-fn descriptions covering
  every site (KV / secret / config-store / manifest / proxy / extractor /
  body / responder / middleware / adapter dispatch APIs).
- `missing_fields_in_debug` (2 unique sites — 4 with re-exports):
  `ProxyRequest`/`ProxyResponse` `Debug` impls now use `finish_non_exhaustive()`
  to acknowledge the deliberately-skipped `body` and `extensions` fields.
- `doc_markdown` (17 sites): backticked `EdgeZero`, `SystemTime`, `Axum`,
  `SecretStore`, etc. in doc comments.

Lints kept allowed (with rationale comments in `Cargo.toml`):
- `missing_docs_in_private_items` (275 sites): private docs aren't
  load-bearing for users — industry-standard "kept allowed".
- `missing_inline_in_public_items`: `#[inline]` is a perf hint; rustc/LLVM
  make better decisions than blanket-marking every cross-crate public item.

Allow-list: 51 → 47 entries.
…t_stdout allows

The CLI binary now initializes a `simple_logger` with no timestamps and no
level prefixes (so the user-facing UX is unchanged: `[edgezero] creating
project at ...` still prints exactly that), and all `println!` /
`eprintln!` sites are converted to `log::info!` / `log::error!` /
`log::warn!`.

Sites converted (24 total):
- `crates/edgezero-cli/src/main.rs`: top-level error reporters (`new`,
  `build`, `deploy`, `serve`, `dev`) + status output for store-binding
  warnings.
- `crates/edgezero-cli/src/generator.rs`: 9 status messages and 2 git
  warnings now go through the logger.
- `crates/edgezero-cli/src/dev_server.rs`, `adapter.rs`: dev manifest /
  command-failure reporting.
- `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/cli.rs`:
  one build-artifact-path message each.

Allow-list: 47 → 45 entries (`print_stderr` + `print_stdout` removed).
Real renames + restructuring (no inline allow attrs):

- `non_ascii_literal` (3 sites): replaced the Japanese KV-key test literal
  with `\u{...}` escapes (same runtime bytes, ASCII source) instead of
  `#[expect]`-ing the lint. Replaced `→` arrow in a CLI test message with
  `->`.
- `similar_names` (2 sites): renamed `decoded` → `output` in
  `crates/edgezero-adapter-spin/src/decompress.rs` to break the
  `decoded`/`decoder` prefix-share that the lint flags.
- `too_many_lines` (1 site): split `collect_adapter_data` in
  `crates/edgezero-cli/src/generator.rs` into three helpers
  (`blueprint_data_entries`, `render_manifest_section`,
  `append_readme_entries`).
- `shadow_unrelated` (~14 sites): renamed every flagged inner binding
  to be specific to its purpose:
  - `serve_with_stores`: `let router = Router::new()...` →
    `axum_router`; `let server = server.with_graceful_shutdown(...)` →
    `graceful_server`; `let shutdown = ...` → `shutdown_signal`.
  - `store_name_slug`: `Some(ch)` → `Some(lower_ch)` (was shadowing
    outer `ch`).
  - dev_server tests: `let url = ...` reused per-step → `write_url`,
    `read_url`, `check_url`, `delete_url`, `save_url`, `load_url`;
    `let resp = ...` → `write_response`/`read_response`/`save_resp`/
    `load_resp`/`exists_before`/`exists_after`.
  - `axum::key_value_store::get_bytes`: inner write-txn `table` →
    `write_table`, `entry` → `fresh_entry`.
  - `list_keys_page` cursor match: inner `Some(cursor)` → `Some(scan_from)`.
  - `data_persists_across_reopens` test: second `let store = ...` →
    `reopened`.
  - `axum::response::into_axum_response` error path: `body` →
    `error_body`, `response` → `error_response`. Test: `stream` →
    `body_stream`.
  - `fastly::key_value_store::list_keys_page`: inner `cursor` →
    `next_cursor`.
  - `fastly::proxy` test: collapsed two pairs of `body`/`collected` reuse
    into named bindings (`plain_body`, `gzip_body`).
  - `spin::decompress` test: `let result = ...` reused per-encoding →
    `none_encoding`, `identity_encoding`.
  - `core::body::from_stream_maps_errors` test: `stream` →
    `source`/`chunks`.
  - `core::key_value_store` tests: `let val = ...` reused → `after_first`/
    `after_second`/`int_val`/`str_val`/`single_dot_err`/`double_dot_err`.
  - `axum::cli::read_axum_project`: `Some(value)` → `Some(port_value)`
    (was shadowing outer `value` from `toml::from_str`).

Allow-list: 45 → 41 entries.
…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.
aram356 added 20 commits June 3, 2026 21:13
Stage 4 of docs/superpowers/specs/2026-06-01-spin-kv-config.md.
Rewrites the spin `config push` impl to HTTP-POST to the seed handler
(stage 3), threads a typed push context through the adapter trait, and
adds the CLI args + resolution chain.

Trait + context (T4.1):

- New `AdapterPushContext<'ctx>` in edgezero-adapter::registry with
  `#[non_exhaustive]` + builder API. Carries the already-resolved
  `seed_url` / `seed_token` (owned upstream, borrowed at the call
  boundary) and the `local` flag so adapters that have a separate
  local-emulator path can pick the right writeback target. Builder
  enforces the non_exhaustive construction constraint at the source
  level: the CLI builds via `AdapterPushContext::new().with_*()`,
  never a struct literal.
- `Adapter::push_config_entries` and `push_config_entries_local`
  gained the `push_ctx: &AdapterPushContext<'_>` parameter. 8-arg
  trait methods carry a documented `#[expect(too_many_arguments)]`.
  All four in-tree impls updated (axum/cloudflare/fastly/spin); the
  three non-spin adapters accept-and-ignore.
- 16 in-tree test call sites batch-updated.

CLI args + resolution (T4.2 + T4.3):

- `ConfigPushArgs` gained `--seed-url` and `--seed-token`.
  Documented prod chain (`--seed-url` -> env -> manifest) and local
  chain (`--seed-url` -> `EDGEZERO__ADAPTERS__<NAME>__LOCAL_SEED_URL`
  -> builtin `http://127.0.0.1:3000/__edgezero/config/seed`). Manifest
  is NEVER consulted on the local chain.
- New `ResolvedAdapterPushContext` field on the CLI's `PushContext`.
  `load_push_context` resolves URL + token + local up front and
  stashes owned strings; `dispatch_push` borrows from it to build
  the `AdapterPushContext<'_>` via the builder.
- `ManifestAdapterCommands` gained `seed_url: Option<String>`
  (additive under `#[non_exhaustive]`; serde `rename = "seed-url"`).
- Tokens are NEVER read from the manifest, even on the prod chain.

Spin push rewrite (T4.4 + T4.5 + T4.6):

- `push_config_entries` now POSTs JSON `{store, entries}` to
  `push_ctx.seed_url` via `reqwest::blocking::Client`. The `store`
  field is the env-resolved platform label, not the logical id, so
  an operator running with `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=…`
  has the matching label flow through to the seed handler's 404 check.
- `push_config_entries_local` delegates straight through to the
  prod impl. The CLI's URL chain (D3) already encoded the
  prod-vs-local distinction; the adapter doesn't need to switch on
  `push_ctx.local` again.
- Full D9 status table mapped to D12 error messages: 204 success,
  400 / 401 / 403 / 404 / 405 / 415 / 422, each with operator-actionable
  copy. The 401 message names all four fail-closed reasons
  (unset / blank / whitespace / <16 bytes). Connection-refused gets
  a specific "Is the Spin app running?" hint.
- `--dry-run` emits the planned URL + per-key would-set lines without
  posting; doesn't require a token.
- New `build_seed_payload` helper produces the D9 body shape.
- Old variables-helpers (`write_spin_variables`, `not_a_table_error`)
  marked `#[cfg(test)]` until Stage 5 deletes them with the
  provision-side variable writes.

Test surface (T4.7):

Replaced 4 variables-based push tests with 6 KV-push tests covering
the new contract:
- `push_with_no_entries_reports_no_op_without_posting` — empty
  shortcut still works.
- `push_dry_run_emits_url_and_entries_without_posting` — dry-run
  prints URL + dotted keys verbatim (no `.→__` translation).
- `push_errors_when_seed_url_unset_prod` — prod hint mentions env
  + manifest sources but NOT the local env var.
- `push_errors_when_seed_url_unset_local_names_local_env_var` —
  local hint mentions the `LOCAL_SEED_URL` env var.
- `push_errors_when_seed_token_unset_on_real_push` — token required
  on real push and documents the "NEVER read from edgezero.toml" rule.
- `build_seed_payload_emits_d9_body_shape` + `..._uses_platform_label_not_logical_id`
  — body shape + env-resolved store name.
- Updated `raw_push_spin_dry_run_dispatches_to_adapter` in the CLI
  test suite to provide a `seed_url` (now required for dry-run too,
  since the dry-run prints the planned URL).

Dep gating (D11 verified):

- `reqwest = "0.13"` gained `blocking` + `json` workspace features.
- `reqwest` listed under the `cli` feature on edgezero-adapter-spin
  (host-only). Confirmed via `cargo tree -i reqwest --target
  wasm32-wasip2` that reqwest does NOT leak into the wasm tree.
- `subtle` and `serde_json` remain non-optional and confirmed
  present in the wasm tree (the wasm seed handler core needs them).

Cargo.lock adds the existing `subtle 2.6.1` to the spin adapter's
manifest transitive set (a 5-line diff).

Verified: cargo fmt, host clippy workspace --all-features
-D warnings, cargo test workspace (95 spin tests; full workspace
green), per-target wasm-clippy on cloudflare / fastly / spin.
…tores

- `spin provision` now iterates `[stores.config]` ids alongside
  `[stores.kv]` and appends each platform-resolved label to
  `[component.X].key_value_stores` (idempotent), matching how KV
  stores are wired. Status lines report "added config label
  <label>" so operators see the parity.
- `single_store_kinds()` drops `"config"` and returns only
  `["secrets"]`: KV-backed config is multi-capable; secrets stay
  Single until native secret support lands.
- Scaffold `spin.toml.hbs` declares `key_value_stores =
  ["app_config"]` on the generated component so a fresh
  `edgezero new --adapter spin` is runnable without a manual
  provision pass.
- Delete the `[variables]`-backed code paths (`not_a_table_error`,
  `write_spin_variables`, 7 associated tests) and the
  `translate_key_for_spin` helper — provision no longer touches
  Spin variables for config. Add coverage for the KV-array write
  and the "multi-config now accepted, multi-secret still
  rejected" capability-gate split (`provision.rs` host tests).

Stage 5 gates: cargo fmt, host clippy
(--all-targets --all-features), cargo test --workspace
--all-targets, host check with `fastly cloudflare spin`, and
wasm clippy for fastly (wasm32-wasip1), cloudflare
(wasm32-unknown-unknown), and spin (wasm32-wasip2) all green.
Now that config lives in KV (arbitrary UTF-8 keys) and never
shares Spin's flat variable namespace, the validator pieces that
mirrored `[variables]` rules are obsolete:

- Delete Spin's `validate_app_config_keys` override + the
  `translate_key_for_spin` helper (and its 3 unit tests). The
  trait's `Ok(())` default applies; an `#[expect]` on the impl
  block documents why no impl is provided.
- Simplify Spin's `validate_typed_secrets`: drop the `.→__`
  config-key pre-population of the `seen` set. Secrets still
  resolve through Spin variables, so the within-`#[secret]`
  uniqueness + `is_valid_spin_key` canonicalisation checks stay.
- Update CLI tests: delete `spin_key_syntax_rejects_uppercase_*`,
  `spin_key_syntax_rejects_dash_in_key`,
  `spin_config_secret_collision_typed_only` (3 stale
  expectations). Rewrite `raw_push_runs_spin_key_validation_*`
  into `raw_push_runs_spin_adapter_manifest_check_*`: same
  regression intent (raw push must run `run_shared_checks` before
  dispatch), new probe (Spin's `[component.*]` discovery rather
  than the removed key rule).
- Spin adapter unit-test cleanup: remove
  `validate_app_config_keys_rejects_uppercase`,
  `validate_app_config_keys_rejects_dashes`,
  `validate_typed_secrets_detects_collision`,
  `validate_typed_secrets_detects_collision_after_lowercasing_secret_value`
  (all assert the deleted behavior); keep the secret-value
  canonicalisation + cross-secret collision tests intact.
- Update stale wording: the `Adapter::validate_app_config_keys`
  trait docstring no longer cites Spin's defunct rule; the
  `run_config_push_typed` and `ValidationContext` comments no
  longer cite "Spin key syntax" / `api-token` examples.

Stage 6 gates: cargo fmt, host clippy
(--all-targets --all-features), cargo test --workspace
--all-targets, host check with `fastly cloudflare spin`, and
wasm clippy on spin (wasm32-wasip2) all green.
User-facing docs and the reference app no longer describe Spin
config as `[variables]`-backed:

- `docs/guide/adapters/spin.md` — rewrite the Config Store
  section to describe the KV-backed flow (multi-store,
  `key_value_stores` declarations, seed-handler push). Trim the
  cross-namespace collision wording from the Secret Store
  section: KV config and Spin variables no longer share keys, so
  only the within-`#[secret]` canonicalisation check survives.
- `docs/guide/cli-reference.md` and `cli-walkthrough.md` — rewrite
  the spin row / bullet in the push table from "pure spin.toml
  editing + `.→__` translation" to "HTTP POST to
  `/__edgezero/config/seed`" with the full URL/token resolution
  chain. Drop the "Spin key syntax" bullet from the validate
  walkthrough.
- `docs/guide/configuration.md` — update the config-store
  adapter-behavior bullet for Spin: multi-store KV, labels
  declared in `key_value_stores`, seeded via `config push`.
- `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` —
  drop `greeting`, `feature__new_checkout`, `service__timeout_ms`
  from `[variables]` (now in the `app_config` KV store via the
  seed handler) and add `app_config` to the component's
  `key_value_stores`. `[variables]` is now SECRETS-ONLY
  (`api_token`, `vault`, `smoke_secret`).
- `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` —
  thread `args.seed_url` through `push_args` for spin (dry-run
  still requires the URL to be resolvable). Update the
  byte-identical test's comment to point at the new in-adapter
  preview test. Delete the trait-poking
  `spin_dry_run_preview_lists_app_demo_translated_keys_and_both_tables`
  test: it bypassed the public CLI to inspect log lines, hand-
  built `AdapterPushContext` + `ResolvedStoreId`, and duplicated
  `edgezero_adapter_spin::cli::tests::push_dry_run_emits_url_and_entries_without_posting`.
- `scripts/smoke_test_config.sh` — update the comment that
  cited Spin's `[variables]` defaults to describe the new
  `config push --adapter spin --local` flow.

Stage 7 gates: cargo fmt + host clippy
(--all-targets --all-features) in both root and
`examples/app-demo` workspaces, `cargo test --workspace
--all-targets` in both, app-demo `cargo build -p
app-demo-adapter-spin --target wasm32-wasip2 --release`, plus
wasm clippy on spin (wasm32-wasip2), fastly (wasm32-wasip1),
cloudflare (wasm32-unknown-unknown) all green.
…eout

The `spin up` smoke test surfaced a gap the plan didn't
anticipate: Spin rejects component-declared `key_value_stores`
labels with "unknown key_value_stores label <name>" unless each
custom label (anything but `default`) is declared in a
`runtime-config.toml` that the Spin runtime can resolve to a
backend. Fix this end-to-end so scaffolded and demo projects are
runnable out of the box:

- Add `runtime-config.toml.hbs` scaffold template that declares
  the default `[stores.config].ids = ["app_config"]` label
  against Spin's SQLite-backed KV backend (`type = "spin"`).
  Register it as the fourth Spin scaffold file alongside
  `Cargo.toml`, `src/lib.rs`, and `spin.toml`.
- Update the spin adapter's `serve` command template from
  `spin up --from {crate_dir}` to
  `spin up --from {crate_dir} --runtime-config-file
  {crate_dir}/runtime-config.toml` so `edgezero serve --adapter
  spin` resolves the KV labels in scaffolded projects.
- Add `examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml`
  declaring `app_config`, `sessions`, and `cache` against the
  SQLite backend (matching the labels app-demo's `spin.toml`
  references), and update `examples/app-demo/edgezero.toml`'s
  `adapters.spin.commands.serve` to pass
  `--runtime-config-file`.
- Document the runtime-config requirement in
  `docs/guide/adapters/spin.md`: show the file shape, explain
  why custom labels need an entry, link to Spin's
  runtime-config docs for production backends
  (azure/redis/etc.), and clarify that `provision` does NOT
  touch this file.

Final verification gate ran cargo fmt, host clippy
(--all-targets --all-features), and `cargo test --workspace
--all-targets` in both the root and `examples/app-demo`
workspaces. Wasm clippy passed for spin (wasm32-wasip2), fastly
(wasm32-wasip1), and cloudflare (wasm32-unknown-unknown);
app-demo wasm release builds succeeded for all three. End-to-
end `spin up` smoke confirmed Spin's runtime-config resolves
all three KV labels (`[key_value_store.<label>: spin]`); the
remaining wasi-http version skew (`wasi:http/types@0.3.0-rc-
2026-03-15` not in Spin 3.6.3's linker) is unrelated to the
KV-config migration and is gated on Spin catching up to the
WASI HTTP rc the wasm component imports.

Also commit the v12 plan + spec as a record of the eight-stage
design + execution path.
Lands the review-pass fixes from the PR thread (security hardening
of the seed handler, correctness, API cleanup, test coverage) plus
the design document for the per-backend-push pivot that
SUPERSEDES the seed handler in the follow-up commits.

Why "supersedes": the seed handler at /__edgezero/config/seed is
a permanent EdgeZero-owned attack surface that ships in every
deployed Spin app even with the hardening below. The PR-thread
review (separate reviewer) raised the bigger question: Spin can
seed KV stores via Spin's own mechanisms (`spin cloud key-value
set` for Fermyon Cloud, direct SQLite for local) — we shouldn't
need our own HTTP endpoint at all. This commit ships the
hardening as a transitional improvement; the next commit drops
the handler entirely and the one after adds per-backend writers.
See `docs/superpowers/plans/2026-06-04-spin-per-backend-push.md`.

## Security hardening of the seed handler (transitional)

- **Pre-auth body cap (256 KiB)**: bounds the read surface so an
  unauthenticated POST can't OOM the runtime with a multi-MB body.
  Returns 413 before `serde_json::from_slice` runs. (H1)
- **Per-entry caps**: `entries.len() <= 1000`, `value.len() <= 64
  KiB`, both return 413 with the offending index + key named in
  the body. (H2)
- **Fail-closed token gate FIRST**: server token validation moves
  before the method/content-type gates so an unauthenticated
  attacker can't fingerprint the route by observing 405/415
  behaviour. GET-with-no-token now returns 401 (matches the
  `run_app_with_seeder` docstring contract). (N-M3)
- **422 body names BOTH index and key** of the failing entry so
  operators can trim earlier entries and retry. (H4)
- **`SpinKvSeedWriter::write_batch`**: trait method rewritten to
  open the KV store ONCE per batch instead of N times per entry.
  Also gains the `NoSuchStore` variant distinct from
  `WriteFailed`, mapped to 404 (label not declared in
  runtime-config.toml) vs 422 (transient write failure). (M3 + M4)
- **Tighter content-type matching**: rejects `application/json-bad`
  which the previous `starts_with` check accepted. (N-L1)
- **Drop wire-token length from mismatch log**: was a partial
  oracle on server-token length. (M1)
- **16-byte-floor doc clarification**: explicit "16 random bytes
  (e.g. `openssl rand -base64 16`)" wording — operators using
  hex-encoded tokens get half the entropy. (M2)
- **Reserved-path collision detection**: `Adapter::reserved_paths()`
  trait method; CLI rejects any `[[triggers.http]].path` matching
  an adapter's reserved path so user-declared handlers can't be
  silently shadowed by `run_app_with_seeder`. (H3)

## Correctness fixes (survive the pivot)

- **`ConfigStoreError::internal`** for `SpinSdkKvStore::open`
  failures: structural / permanent (label not declared), distinct
  from transient `unavailable`. `build_config_registry` uses
  `anyhow::Context::with_context` to preserve the chain instead
  of stringifying. (M4)
- **`spin-sdk` pinned to `~6.0`** so a 6.1.x signature or KV
  schema change is a build failure rather than a runtime mismatch.
  (M6)
- **`Adapter::merged_id_kinds()`** trait method: Spin overrides
  to `&["kv", "config"]`. CLI rejects logical-id overlap across
  merged kinds — same id under `[stores.kv].ids` and
  `[stores.config].ids` would silently share writes via one
  underlying KV label. (M7)

## API cleanup (survive the pivot)

- **Drop dead `_config_keys` parameter** from
  `Adapter::validate_typed_secrets`. No implementer used it
  post-Stage-6; every caller still computed the flattened key
  set. (M8)

## Test bug + coverage

- **Fix duplicate `err.contains("api-token")` assertion** in
  `validate_typed_secrets_rejects_invalid_spin_variable_in_secret_value`:
  the field name `api_token` (underscore) was never actually
  checked. (H5)
- **11 new tests for `resolve_adapter_push_ctx`** URL/token
  resolution chains: flag > env > manifest precedence (prod),
  flag > env > builtin fallback (local), the security-load-
  bearing manifest-bypass guarantee under `--local`, distinct
  prod vs local env vars, token-from-flag-or-env (never
  manifest). (H6)
- **2 new manifest tests** pinning the `seed-url` TOML key's
  serde rename: the dashed key populates `commands.seed_url`,
  the underscored form is silently ignored — docs/errors must
  point at the dashed form. (N-M2)
- **2 new reserved-path tests** asserting the
  `/__edgezero/config/seed` collision is rejected at
  `config validate` time. (H3)
- **2 new merged-id collision tests** asserting kv+config
  overlap on the same logical id is rejected. (M7)
- **6 new seed handler tests**: 401 fail-closed on GET-no-token
  and wrong-CT-no-token, 413 on oversized body / too many
  entries / oversized value, 422 with index+key naming. (H1/H2/
  H4/N-M3)

## Other fixes

- **`raw_push_runs_spin_key_validation_before_push`** rewritten
  as `raw_push_runs_spin_adapter_manifest_check_before_push`:
  the original probed Spin's deleted `^[a-z][a-z0-9_]*$` rule;
  the new test uses `[component.*]` discovery in spin.toml as
  the regression probe. Same intent (raw push runs shared
  checks before dispatch), new probe.
- **`--local` help text** rewritten to document Spin-specific
  semantics: `--local` switches the seed-URL resolution chain
  away from the manifest's prod URL and toward the local env
  var / builtin fallback. The manifest prod URL is NEVER
  consulted under `--local` so a misconfigured prod entry can't
  bleed into a local push. (N-L2)
- **Shared-token model documented** in `args.rs`: `--seed-token`
  has no LOCAL variant, so operators with both contexts open
  should set the env var to a value safe for both or pass the
  flag explicitly per push. (M5)
- **App-demo wired through `run_app_with_seeder`**: the
  scaffold was already using it; app-demo was still on plain
  `run_app`, which made the seed endpoint unreachable on the
  demo. Will be reverted in the next commit when the seeder is
  deleted entirely. (N-M1)

## Gates

cargo fmt + host clippy (--all-targets --all-features) + cargo
test --workspace --all-targets across root and `examples/app-demo`.
Wasm clippy green for spin (wasm32-wasip2), fastly (wasm32-wasip1),
cloudflare (wasm32-unknown-unknown). 354 root core tests + 108 cli
tests + 88 spin tests + 71 macros tests + 12 adapter tests + the
rest of the workspace; 26 app-demo tests; all green.

## Next commits

1. `spin: drop /__edgezero/config/seed handler (architecture
   pivot)` — deletes seed.rs, run_app_with_seeder, --seed-url /
   --seed-token, the seed-url manifest field, the reserved_paths
   trait method, and the resolve_adapter_push_ctx URL/token
   logic. Reverts the app-demo seeder wiring above. The hardening
   in this commit becomes historical (the file is deleted) but
   the work was real for the time the handler was the design.

2. `spin: per-backend writers (SQLite-direct + Fermyon Cloud
   shellout)` — implements the replacement: parse Spin's own
   runtime-config.toml, dispatch to a SQLite writer (vendoring
   Spin's exact `spin_key_value` schema with a byte-compare
   contract test) or shell `spin cloud key-value set` based on
   the backend type + auto-detected Fermyon Cloud deploy.
7 of 8 reviewer items from the June-02 batch — the eighth ("preserve
the historical root-level adapter registry API") is intentionally left
unaddressed: pulling the registry items back to the crate root would
require either a workspace-wide pub_use exception or a backward-
compatibility shim, and we'd rather have downstream adapters migrate
to `edgezero_adapter::registry::*` (the path every in-tree adapter
already uses) than carry a permanent allow.

Fixes:
- examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs and
  crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs:
  spin_sdk::http_component expands to required WASI export glue
  (`__export_wasi_http_incoming_handler_0_2_0_cabi` produces an
  unsafe attribute + unsafe extern + unsafe block). Add a narrowly-
  scoped `#![allow(unsafe_code, reason = ...)]`. Verified the lint
  actually fires before adding the allow.
- crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs:
  pass `include_str!("../../../edgezero.toml")` as the new first
  argument to `run_app::<…>`; the previous `(req, env, ctx)` call
  no longer compiles against the current `run_app` signature.
- .github/workflows/test.yml: wasmtime install now enforces the pinned
  version on every run — `.tool-versions` joins the cache key so a
  bump invalidates the cached binary, and the install step compares
  `wasmtime --version` against the pin and reinstalls on mismatch.
  Defends against both stale cache hits and runner-provided wasmtime.
- crates/edgezero-adapter-cloudflare/src/lib.rs and
  crates/edgezero-adapter-spin/src/lib.rs: `init_logger` is a no-op
  that always returns `Ok(())`. Updated the `# Errors` doc to say
  so, with a note that the Result signature exists so the future
  "wire in a real logger" branch is drop-in compatible.
- crates/edgezero-adapter-fastly/src/request.rs: `dispatch_with_config`
  logs+skips missing config stores rather than returning an error.
  Updated the `# Errors` doc to describe the actual error surface
  (request conversion / KV resolution / dispatch / response conversion).
The seed handler at `POST /__edgezero/config/seed` shipped on every
deployed Spin app via `run_app_with_seeder` — a permanent
EdgeZero-owned attack surface that even hardened (16-byte
constant-time tokens, 256 KiB body cap, 1000-entry/64 KiB caps,
fail-closed token-first ordering, etc., per the prior commit) is
still a liability we own forever. The PR-thread review made the
case that Spin already exposes the right primitives for KV
seeding (`spin cloud key-value set` for Fermyon Cloud; direct
SQLite for local) and we shouldn't need our own HTTP endpoint at
all.

This commit removes the handler and the surrounding CLI surface.
The replacement (per-backend writers reading Spin's own
`runtime-config.toml` to dispatch to SQLite-direct or
`spin cloud key-value set`) lands in the next commit on this
branch — `docs/superpowers/plans/2026-06-04-spin-per-backend-push.md`
spells out the design. Until then, `config push --adapter spin`
returns a clear "under restructure" error pointing at the plan
document so anyone running it (e.g., the app-demo integration
test that's `#[ignore]`d here) gets actionable next steps.

## Runtime side

- Delete `crates/edgezero-adapter-spin/src/seed.rs` (917 lines:
  the handler itself plus the 23 D9-status-code unit tests added
  in the prior commit, the `SeedWriter` trait, the
  `SpinKvSeedWriter` impl, the `InMemorySeedWriter` test harness,
  and the `MAX_BODY_BYTES` / `MAX_ENTRIES` / `MAX_VALUE_BYTES`
  caps. All of this becomes moot once we don't own an HTTP
  endpoint).
- Delete `run_app_with_seeder` from
  `crates/edgezero-adapter-spin/src/lib.rs` and the `mod seed`
  gate. Plain `run_app::<A>(req)` is the only public entrypoint
  again.
- Revert
  `examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs`
  to call `run_app::<App>` (drops the wiring added in the prior
  commit).
- Revert
  `crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs`
  scaffold to call `run_app::<App>`. Generated projects no
  longer ship a seed endpoint.

## CLI side

- Delete `--seed-url`, `--seed-token` flags from
  `ConfigPushArgs`. Delete the related docstring paragraphs.
- Add `--runtime-config <PathBuf>` flag (default: adapter
  resolves a location, typically `runtime-config.toml` next to
  the adapter manifest). The per-backend writer will read this
  file to dispatch.
- Delete `seed_url`, `seed_token`, `with_seed_url`,
  `with_seed_token` from `AdapterPushContext`. Add
  `runtime_config_path: Option<&Path>` + builder.
- Delete `Adapter::reserved_paths` trait method (only user was
  Spin's seed route).
- Rewrite `resolve_adapter_push_ctx` to a 1-line function that
  passes through `--local` + `--runtime-config` without any
  URL/token resolution chain.
- Rewrite `dispatch_push` to use the new builder API. Drop
  `EDGEZERO__ADAPTERS__SPIN__SEED_*` env handling entirely
  (the env vars are gone).
- Delete the 11 `resolve_seed_*` unit tests added in the prior
  commit (Pass 6) — they tested URL/token resolution that no
  longer exists.
- Delete the 2 `spin_reserved_seed_*` and
  `spin_non_reserved_paths_*` tests + the
  `reject_reserved_path_collisions` helper.

## Manifest side

- Delete the `seed_url` field (and `rename = "seed-url"`) from
  `ManifestAdapterCommands`. Delete the 2 tests added in the
  prior commit (Pass 7) that pinned the rename.

## Spin adapter side

- Rewrite `Adapter::push_config_entries` /
  `push_config_entries_local` impls as stubs that return a
  pointed error ("under restructure; per-backend writers land in
  the next commit"). Both methods stay so the trait signatures
  remain satisfied; the real implementations land in Commit 3.
- Delete `Adapter::reserved_paths` impl.
- Delete `build_seed_payload` and the 4 push tests
  (`push_with_no_entries_*`, `push_dry_run_emits_url_*`,
  `push_errors_when_seed_url_unset_prod`,
  `push_errors_when_seed_url_unset_local_names_local_env_var`)
  + 2 `build_seed_payload_*` tests.
- Drop the `reqwest::blocking::Client as HttpClient` import (no
  more HTTP POST).
- Rewrite `raw_push_runs_spin_key_validation_before_push`'s
  docstring to drop the stale `api-token` reference (the test
  body already probes `[component.*]` discovery, not the deleted
  key rule).

## App-demo + scaffold

- App-demo's `config_push_spin_dry_run_dispatches_cleanly_and_preserves_manifest`
  integration test is `#[ignore]`-d with a pointer to
  `2026-06-04-spin-per-backend-push.md`. It gets rewritten in
  Commit 3 against a SQLite round-trip in a temp
  `.spin/sqlite_key_value.db`.
- `raw_push_spin_dry_run_dispatches_to_adapter` in CLI tests
  same treatment: body becomes a comment pointing at the plan,
  `#[ignore]` so the suite stays green.

## Docs + scripts

- `docs/guide/adapters/spin.md` Config Store section's push
  subsection rewritten to a `::: warning Push under restructure`
  callout pointing at the plan document. The rest of the section
  (KV-backed config model, runtime-config.toml requirement,
  multi-store via `[stores.config].ids`) stays — KV runtime
  reads are unchanged; only the writeback path is restructuring.
- `docs/guide/cli-reference.md` and `cli-walkthrough.md` spin
  rows / bullets rewritten with the same restructure callout.
- `scripts/smoke_test_config.sh` comment updated: the
  references to the seed handler become "SQLite write into
  `.spin/sqlite_key_value.db` once the per-backend writer
  lands".

## Net diff

14 files, 108 insertions, 1903 deletions. Most of the deletions
are seed.rs (917 lines) + the Pass 1-7 hardening + tests that
hardened the handler before we decided to retire it. Honest
history: that work was real for the time the handler was the
design; now the design changed.

## Gates

- `cargo fmt --all -- --check` — green.
- `cargo clippy --workspace --all-targets --all-features
  -- -D warnings` — green.
- `cargo test --workspace --all-targets` — 5 root crate test
  result blocks green (138 axum + 352 core + 41 cloudflare +
  43 fastly + 12 adapter + 94 cli + 58 spin + others), 1 test
  `#[ignore]`d (`raw_push_spin_dry_run_dispatches_to_adapter`),
  zero failures.
- App-demo workspace: 26 + 3 (1 ignored) + 1 + 0×6 — green.
- Wasm clippy: spin (`wasm32-wasip2`), fastly
  (`wasm32-wasip1`), cloudflare (`wasm32-unknown-unknown`) all
  green.

## Next commit

`spin: per-backend writers (SQLite-direct + Fermyon Cloud
shellout)` — implements `push_config_entries` for real:
parses `runtime-config.toml`, dispatches based on backend
`type`. SQLite path vendors Spin's exact `spin_key_value`
schema with a byte-compare contract test against the upstream
source. Fermyon Cloud path shells `spin cloud key-value set`
per entry, auto-detected from `[adapters.spin.commands].deploy
= "spin deploy"` (suppressed by `--local`).
Implements the per-backend `config push --adapter spin` dispatch
that the previous commit's deletion left as a stub. No HTTP
endpoint, no EdgeZero-owned attack surface in deployed apps —
just direct backend writes via the runtime-config-declared
adapter.

## Architecture

`crates/edgezero-adapter-spin/src/cli/` (new submodule):
- `runtime_config.rs` — parses `runtime-config.toml`'s
  `[key_value_store.<label>]` stanzas. Recognises `type = "spin"`
  (local SQLite), `type = "redis"`, `type = "azure_cosmos"`, and
  preserves anything else as `Unknown { type_name }` so the
  dispatcher can name the unrecognised type in its error.
- `push_sqlite.rs` — SQLite-direct writer using rusqlite. Vendors
  Spin's exact `spin_key_value(store, key, value)` schema and
  `INSERT … ON CONFLICT DO UPDATE` statement from
  spinframework/spin's `crates/key-value-spin/src/store.rs`. One
  transaction per batch; creates file + parent dir + schema if
  missing (matches Spin's runtime first-read behaviour).
- `push_cloud.rs` — Fermyon Cloud shellout. Shells `spin cloud
  key-value set --store <label> <key> <value>` per entry,
  capturing stderr. Detects "not logged in" specifically and
  points the operator at `spin cloud login`. Auto-detection from
  `[adapters.spin.commands].deploy` containing `spin deploy` /
  `spin cloud deploy`.

`dispatch_push` (in `cli.rs`) decides which writer to use:

1. `--local` set → SQLite-direct (forces local even if manifest
   deploy command would trip Fermyon Cloud auto-detect; lets the
   operator push without authenticating against Fermyon Cloud).
2. Else if `manifest_adapter_deploy_cmd` contains a Fermyon
   Cloud deploy hint → `spin cloud key-value set` shellout.
3. Else dispatch on `runtime-config.toml`'s
   `[key_value_store.<label>].type`:
   - `type = "spin"` (or no stanza) → SQLite-direct.
   - `type = "redis"` → error pointing at
     `redis-cli -u <url> SET <key> <value>`.
   - `type = "azure_cosmos"` → error pointing at the Azure CLI.
   - `Unknown { type_name }` → error naming the type so the
     operator can plan accordingly.

## Schema-drift protection

`push_sqlite::SPIN_KV_CREATE_TABLE` and `SPIN_KV_SET` are
vendored byte-for-byte from spinframework/spin's
`crates/key-value-spin/src/store.rs`. A unit test
(`vendored_schema_matches_upstream_byte_for_byte`) byte-equals
each constant against the upstream string; a second
(`vendored_schema_creates_table_with_expected_column_shape`)
runs `PRAGMA table_info` and asserts the resulting column
shape matches Spin's expected
`(store TEXT, key TEXT, value BLOB)` PRIMARY KEY `(store, key)`.

Combined with `Cargo.toml`'s `spin-sdk = "~6.0"` pin, this
catches drift two ways: our vendored copy diverging from
upstream (byte-compare test fails), or Spin shipping a 6.1.x
that touches the schema (semver pin blocks the build).

## API surface changes

- `AdapterPushContext` gains
  `manifest_adapter_deploy_cmd: Option<&'ctx str>` and a builder
  method (`with_manifest_adapter_deploy_cmd`). Threaded through
  from CLI's `dispatch_push` (which now reads
  `adapter_cfg.commands.deploy` and populates the field).

## Templates / scaffold

- `templates/spin.toml.hbs`: dropped the stale "seed handler"
  comment; replaced with "writes directly into the SQLite
  backend declared for it in runtime-config.toml, or shells
  `spin cloud key-value set` when deploying to Fermyon Cloud".
- `templates/runtime-config.toml.hbs`: corrected the example
  managed-backend type name (`azure_cosmos`, not `azure`) and
  added a paragraph stating that the current dispatcher
  supports `type = "spin"` and points other types at their
  native CLIs.
- `crates/edgezero-cli/src/templates/root/gitignore.hbs`: now
  also ignores `.wrangler/`, `.spin/`, and `.edgezero/` so
  scaffolded projects don't commit local emulator state. The
  EdgeZero repo's own `.gitignore` already did this; the
  scaffold was missing it.

## Tests

- 19 new spin-adapter unit tests across the three new files
  (runtime-config parser variants + missing file + malformed
  TOML, SQLite round-trip / overwrite / store-isolation /
  default-path / explicit-path resolution, vendored-schema
  byte-equal and PRAGMA shape, deploy-command Fermyon-Cloud
  detection, etc.). Spin adapter is now at 77 tests (up from
  58 after Commit 2's deletions).
- App-demo integration test
  `config_push_spin_writes_sqlite_round_tripped_via_rusqlite`
  (re-enabled, no longer `#[ignore]`-d): drives the full typed
  flow through `run_config_push_typed::<AppDemoConfig>` against
  a temp project with a `runtime-config.toml` selecting
  `type = "spin"`, then re-opens the resulting
  `.spin/sqlite_key_value.db` via rusqlite and asserts the
  flattened, secret-stripped entries landed. Pins:
  - `greeting` lands verbatim;
  - `service.timeout_ms` lands with the nested-table dotted key;
  - boolean `feature.new_checkout` flattens to "false";
  - `#[secret]` `api_token` is stripped from the payload;
  - `#[secret(store_ref)]` `vault` is stripped too.

## Docs

- `docs/guide/adapters/spin.md` Config Store → Seeding the
  store: replaced the Commit 2 "under restructure" placeholder
  with the real per-backend resolution order, the schema-
  coupling note (vendored CREATE TABLE + drift test +
  `spin-sdk` pin), and the local/cloud usage examples.
- `docs/guide/cli-reference.md` spin row in the push table:
  replaced placeholder with the actual dispatch rules.
- `docs/guide/cli-walkthrough.md` spin bullet: same.

## Gates

- `cargo fmt --all -- --check` — green.
- `cargo clippy --workspace --all-targets --all-features
  -- -D warnings` — green. Required two `#![expect]` opt-outs
  in `cli.rs`: `self_named_module_files` (workspace policy
  denies both `self_named_*` and `mod_module_*`, which
  contradict; repo convention is the self-named form) and
  `arbitrary_source_item_ordering` (the strict-ordering lint
  disagrees with the conventional placement of `mod`
  declarations after `use` blocks).
- `cargo test --workspace --all-targets` — green. 352 core +
  138 axum + 94 cli + 77 spin + 43 cloudflare + 41 fastly +
  others = ~1100 tests, zero failures.
- App-demo workspace tests — green, including the new
  SQLite round-trip integration test.
- Wasm clippy on spin (wasm32-wasip2), fastly (wasm32-wasip1),
  and cloudflare (wasm32-unknown-unknown) — all green.
  `rusqlite` is gated behind `[target.'cfg(not(target_arch =
  "wasm32"))'.dependencies]` so wasm builds never link it.
Five fixes against the per-backend writer redesign, surfaced by
the reviewer's deep-dive verification pass:

## F1 (blocker): `--local` now forces SQLite-direct unconditionally

Previously the dispatcher's `if !push_ctx.local && fermyon_cloud
_detect(...)` only short-circuited the Fermyon Cloud auto-detect.
After that early return, the runtime-config backend match still
ran -- so `--local` against `[key_value_store.app_config] type =
"redis"` printed the redis-cli error instead of writing local
SQLite, despite docs claiming `--local` "forces SQLite-direct".

The new dispatcher returns `write_sqlite(...)` immediately when
`push_ctx.local` is set. If the runtime-config DOES declare
`type = "spin"` with an explicit `path`, we honour that path; for
any other backend type we silently fall through to Spin's default
`.spin/sqlite_key_value.db`. Behaviour now matches the docs across
4 source files (adapters/spin.md, cli-reference.md, cli-walkthrough.md,
args.rs).

Three dispatch-matrix tests pin the override:
- `dispatch_push_local_forces_sqlite_even_when_runtime_config_declares_redis`
- `dispatch_push_local_forces_sqlite_even_when_runtime_config_declares_azure`
- `dispatch_push_local_forces_sqlite_even_when_deploy_targets_fermyon_cloud`

## F2: Platform-label collision check (not just logical ids)

`reject_merged_id_collisions` previously rejected only logical-id
overlap (`[stores.kv].ids = ["x"]` + `[stores.config].ids = ["x"]`).
But `EDGEZERO__STORES__KV__SESSIONS__NAME=shared` +
`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=shared` resolve two
distinct logical ids to the SAME Spin KV label at runtime --
re-creating the silent-shared-writes bug the check was meant to
prevent.

The check now resolves each (kind, logical_id) pair through
`EnvConfig::store_name(kind, id)` and tracks platform labels in
a separate map; second appearance of the same platform label
across merged kinds errors. The function gains an
`env_config: &EnvConfig` parameter and the caller reads
`EnvConfig::from_env()` once per validation.

Test: `spin_distinct_logical_ids_collide_when_env_overlay_resolves_to_same_platform_label`.

## F3: Spin runtime-version compatibility check

The byte-compare schema-drift test was self-referential (compared
to a literal in the same file). The `spin-sdk = "~6.0"` pin only
covers the build-time SDK, not the operator's installed Spin CLI
/ runtime. Spin's docs explicitly warn the SQLite file format is
"subject to change".

`push_sqlite::verify_spin_runtime_compat` shells `spin --version`
once per session before the first SQLite-direct write, parses the
major version, and `log::warn!`s if it's outside
`VERIFIED_SPIN_MAJOR_RANGE = &[2, 3]`. Never blocks (Spin is
optional from the writer's perspective; CI without Spin still
works). The module docstring now spells out all three layered
guards (vendored constants + build-time SDK pin + run-time CLI
check) and explicitly names what they CANNOT catch (a point-
release schema change within the same major + same CLI version).

Four tests pin the parser:
- handles the canonical `spin 3.6.3 (sha date)` format
- handles `spin-cloud 0.11.0` hyphenated prefix
- returns None for unrecognised stdout
- parses double-digit major versions defensively

## F4: Clean stale docs / comments

- `docs/guide/configuration.md:201-205`: still described Spin push
  as POSTing to `/__edgezero/config/seed`. Replaced with the
  per-backend dispatch summary and a link to the spin adapter
  guide's Seeding the store section.
- `crates/edgezero-adapter/src/registry.rs:253`: trait docstring
  on `push_config_entries` still mentioned Spin's
  `.` -> `__` key translation (removed in Stage 6 of the original
  KV migration). Rewritten to drop that example.
- `crates/edgezero-cli/src/config.rs:3`: module header still
  named "Spin key-syntax / component-discovery rules" and "Spin
  config/secret collision check" -- both removed (Stage 6).
  Replaced with the actual current shape ("per-adapter validators
  (`[component.*]` discovery for Spin, etc.)").

## F5: Real dispatch tests; un-stub the empty CLI test

The redesign deleted the seed-handler dispatch tests and left
`raw_push_spin_dry_run_dispatches_to_adapter` as an empty
`#[ignore]`d body -- effectively zero CLI-level coverage that
`config push --adapter spin` even reaches Spin's dispatcher.

11 new dispatch-matrix tests in `adapter-spin/src/cli.rs`'s test
mod pin every branch of `dispatch_push`:
- empty-entries no-op
- 3 `--local`-forces-SQLite cases (vs Redis / Azure / Fermyon
  Cloud auto-detect) -- F1 verification
- `--local` honours explicit Spin path
- Fermyon Cloud auto-detect from `spin deploy` deploy command
- Redis backend errors with redis-cli hint + url
- Azure backend errors with az-cosmosdb hint
- Unknown backend errors with the type name
- Default branch -> SQLite at `.spin/sqlite_key_value.db`
- `--runtime-config <path>` flag honoured (was test-coverage gap H2)
- Unrelated label in runtime-config doesn't block the matching
  label's dispatch

The empty CLI stub is re-implemented to drive a raw `--local`
dry-run through `run_config_push` end-to-end and assert spin.toml
stays byte-identical (the per-backend writer never edits it).

## Gates

cargo fmt + host clippy (--all-targets --all-features) + cargo
test --workspace --all-targets all green. Wasm clippy on spin
(wasm32-wasip2) green. App-demo workspace tests green.

Spin adapter test count: 93 (was 77, +16: 11 dispatch matrix +
4 version parser + 1 the test-mod helper shape). CLI test count:
96 (was 94, +2 for env-overlay platform-label collision +
un-stubbed CLI dispatch test).

Net diff: +674 / -45 lines across 5 files.
… provision collision check

Five fixes against the per-backend dispatcher, against the
reviewer's second-round findings on tip 0be7444.

## H1 (production blocker): Fermyon Cloud command shape was wrong

The `set` subcommand in fermyon/cloud-plugin's
`src/commands/key_value.rs` takes:

  spin cloud key-value set --store STORE KEY=VALUE [KEY=VALUE ...]

Key/value pairs are POSITIONAL arguments in `key=value` form
(parsed via `spin_common::arg_parser::parse_kv`) and the command
accepts MULTIPLE pairs per invocation. The previous shape (`--store
STORE KEY VALUE`, one shellout per entry) would fail upstream
arg-parsing and probably target the wrong concept.

Push_cloud now:
- Formats each entry as `key=value`, refusing `=` in keys
  (`parse_kv` splits on the FIRST `=` so a key containing `=`
  would silently truncate).
- Batches entries into chunks under a 96 KiB argv budget per
  invocation (well below typical `ARG_MAX`) — a 1000-entry push
  becomes 1 or 2 shellouts, not 1000.
- Updates the dry-run preview wording at `cli.rs:551`,
  `cli-reference.md:248`, and `runtime-config.toml.hbs:20` to
  reflect the corrected shape.

Seven new tests pin the shape:
- `format_pair_emits_key_equals_value`
- `format_pair_allows_equals_in_value`
- `format_pair_rejects_equals_in_key`
- `chunk_entries_packs_single_chunk_when_small`
- `chunk_entries_handles_empty_slice`
- `chunk_entries_splits_when_aggregate_exceeds_cap`
- `chunk_entries_rejects_oversized_single_pair`

## H2: Non-default labels now require a runtime-config stanza

Spin auto-provides ONLY the `default` KV label. Any other label
must have a `[key_value_store.<label>]` entry in
`runtime-config.toml` or `spin up` errors with "unknown
key_value_stores label X" and the SQLite file we just wrote is
unreadable from the running app.

The previous dispatcher fell through to SQLite-direct for any
absent label — so `app_config` could be pushed "successfully" to
.spin/sqlite_key_value.db while the running spin app fails at
`Store::open("app_config")`.

`verify_label_declared` now checks BOTH the `--local`-forced
branch AND the default branch:
- `default` label → fall through to SQLite (Spin auto-provides).
- Label with stanza → fall through to SQLite.
- Non-default label WITHOUT stanza → hard error naming the
  missing stanza, the runtime symptom Spin would emit, AND the
  exact line to add to runtime-config.toml.

Two new tests:
- `dispatch_push_default_label_with_no_runtime_config_dispatches_to_sqlite`
  (the `default` exception still works)
- `dispatch_push_non_default_label_without_runtime_config_stanza_errors`
  (the new error path)
- `dispatch_push_non_default_label_with_runtime_config_stanza_dispatches_to_sqlite`
  (counterpart to the error case)

Updated three pre-existing dispatch tests to declare the
runtime-config stanza they were silently relying on.

## M1: `provision --adapter spin` now runs the same env-overlay collision check

The env-resolved platform-label collision check landed in `config
validate` in commit 0be7444 but `provision` had its own pre-
dispatch flow that didn't call it. Result: an operator with
`EDGEZERO__STORES__KV__SESSIONS__NAME=shared` +
`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=shared` saw `config
validate` exit 1 but `provision --adapter spin --dry-run` exit 0
and emit two label writes that both land on the same store.

- `reject_merged_id_collisions` is now `pub(crate)` so
  `provision.rs` can call it.
- The call lives between the `validate_adapter_manifest` hook
  and the `resolve_kind` env-resolution block, so it fires
  unconditionally before any platform-label resolution.
- New test
  `run_provision_spin_rejects_env_overlay_platform_label_collision_across_kv_and_config`
  pins parity with the `config validate` regression added in
  0be7444.

## L1: Stale docs cleaned

- `docs/guide/cli-reference.md:281`: the spin provision row said
  "Config and secret ids are intentionally not handled here" --
  out of date since Stage 5 of the KV-config migration, when
  provision started writing config ids too. Rewritten.
- `docs/guide/cli-walkthrough.md:110`: same paragraph; same fix.
- `docs/guide/configuration.md:201` was already fixed in 0be7444;
  spot-check confirms.
- `runtime-config.toml.hbs:20`: scaffold comment said push "only
  dispatches to local SQLite" -- stale since the Fermyon Cloud
  writer landed. Rewritten to name all three dispatch branches
  (SQLite-direct, `spin cloud key-value set` for auto-detected
  cloud, native-CLI errors for redis / azure_cosmos / unknown).

## L2: `manifest_guard` added to the F2 env-overlay test

The F2 test sets process-global `EDGEZERO__STORES__*` env vars
without serialising against other env-mutating tests. Per
`test_support.rs:137` docs, every env-mutating test should hold
the manifest mutex before the `EnvOverride` guards drop. Added.

## Gates

- cargo fmt + host clippy (--all-targets --all-features) green.
- cargo test --workspace --all-targets green:
  - Spin adapter: 102 (was 93; +9 across H1 chunker + H2
    stanza-required branches).
  - CLI: 97 (was 96; +1 for the M1 provision regression).
- Wasm clippy on spin (wasm32-wasip2) green.
- App-demo workspace tests green.

The reviewer's earlier `cargo test -p edgezero-cli
raw_push_spin_dry_run_dispatches_to_adapter -- --ignored`
invocation now passes WITHOUT `--ignored` (the stub is real,
asserts spin.toml byte-identical post-dispatch, and was extended
to write the required runtime-config stanza).
…l, docs

Three fixes against the per-backend dispatcher, against the
reviewer's third-round findings on tip 7dd6ab6.

## Blocker: Parallel test env leakage

`cargo test -p edgezero-cli spin` failed under default
parallelism because the F2 collision test sets
`EDGEZERO__STORES__*__NAME=shared` (under manifest_guard) while
unrelated tests run `run_config_push` / `run_config_validate` in
parallel WITHOUT the guard. The unguarded tests read process env
mid-flight and see `shared`, then fail the new H2 "non-default
label without runtime-config stanza" check.

Audit + fix: 24 tests in config.rs that hit
`run_config_validate*` / `run_config_push*` now hold the
`manifest_guard` for the duration of the at-risk call. Existing
provision tests already held it (12 sites unchanged). Pattern
matches the documented invariant in `test_support.rs:137`:
"every test that touches process env must hold the guard".

Stress-tested 5 sequential `cargo test -p edgezero-cli` runs
under default parallelism: 5/5 pass at 97/97.

## H: Fermyon Cloud push now uses --app + --label, not --store

Per [fermyon/cloud-plugin's `key_value.rs`](https://github.com/fermyon/cloud-plugin/blob/main/src/commands/key_value.rs)
`SetCommand`, the `set` subcommand has two mutually-exclusive
addressing modes:
- `--store STORE` — target the cloud KV store by its actual
  resource name. Requires knowing the cloud-side resource.
- `--app APP --label LABEL` — target the cloud KV store via the
  app-scoped label that's mapped to it. Matches Fermyon's
  [label model](https://developer.fermyon.com/cloud/linking-applications-to-resources-using-labels).

EdgeZero's `store.platform` is the env-resolved Spin label
written into `spin.toml`'s `key_value_stores`, NOT the cloud-
side KV resource name. The previous `--store <platform>` shape
conflated the two: if an operator linked label `app_config` →
store `prod-config` in Fermyon Cloud, our writer was seeding
`prod-config` only when the operator had literally named the
label and the store the same.

The corrected shape: `--app APP --label LABEL KEY=VALUE …`,
where `APP` comes from `spin.toml`'s `[application].name` and
`LABEL` is the env-resolved platform label. Operators pre-link
the label to a cloud KV store via `spin cloud link key-value`
(or the dashboard); the dispatcher detects the
"label not linked" stderr and suggests the link command.

Implementation:
- `push_cloud::write_batch(app_name, label, entries)` takes the
  app name as a new first parameter.
- `dispatch_push` reads `[application].name` from spin.toml
  before calling the cloud writer. Missing `[application].name`
  produces an actionable error (rather than silently shelling
  `set --app  --label …` which would fail upstream with an
  unhelpful clap error).
- New helper `read_spin_application_name` mirrors the existing
  `resolve_spin_component` pattern.

Three new tests pin the corrected shape + actionability:
- `dispatch_push_fermyon_cloud_auto_detects_from_spin_deploy_and_uses_app_label_shape`
  asserts the dry-run preview includes `--app x --label app_config`
  and does NOT include `--store`.
- `dispatch_push_fermyon_cloud_errors_when_spin_application_name_missing`
  pins the actionable error.
- The cloud writer's "not linked" branch is now exercised via
  the `not linked` / `no store linked` / generic `link` stderr
  classification.

## L (medium): Stale docs

- `docs/guide/adapters/spin.md:158-174`: rewrote points 1-4 of
  the "Seeding the store" resolution order to reflect ALL three
  recent semantic changes:
  - `--local` requires runtime-config stanzas for non-default
    labels (from earlier round);
  - Fermyon Cloud writer uses `--app <APP> --label <LABEL>
    KEY=VALUE` shape with chunking semantics named explicitly;
  - default branch only auto-fallback for `default` label.
- `docs/guide/cli-reference.md:248`: same updates to the spin
  row of the push table.
- `docs/guide/cli-walkthrough.md:179-198`: same updates to the
  spin bullet, including link to Fermyon's label model docs.

## Gates

- cargo fmt + host clippy (--all-targets --all-features) green.
- cargo test --workspace --all-targets green: spin adapter at
  103 (was 102, +1 for the cloud-shape + app-name-required
  tests collapsed), CLI at 97 (env tests now consistently
  serialize).
- 5/5 stress runs of `cargo test -p edgezero-cli` under default
  parallelism all 97/97 green.
- Wasm clippy on spin (wasm32-wasip2) green.
- App-demo workspace tests green.

The reviewer's failing invocation `cargo test -p edgezero-cli
spin` (default parallelism) now passes deterministically.
…in tests

Three fixes against the reviewer's fourth-round findings on tip
762c426.

## F1 (medium): `link key-value` hint suggested wrong syntax

The previous hint:

  spin cloud link key-value --app <APP> --label <LABEL> <store>

is invalid. Per fermyon/cloud-plugin's
`src/commands/link.rs::KeyValueStoreLinkCommand`, the actual
shape is:

  spin cloud link key-value --app <APP> --store <STORE> <LABEL>

Where the LABEL is positional (no `--label` flag), and `--store`
takes the cloud KV resource name. The `link` and `set` commands
have ASYMMETRIC argument shapes — `set` uses `--label`, but
`link` does NOT.

The fix-suggestion path is the path operators will need most
when first linking; getting it wrong defeats the actionable
purpose of the hint. Corrected to the verified upstream shape.

## F2 (low): Scaffold runtime-config.toml.hbs still showed old Cloud command

The implementation switched to `--app <APP> --label <LABEL>
KEY=VALUE` in commit 762c426, and `docs/guide/` was updated
alongside. The scaffold template's comment (line 24) was
missed: newly scaffolded projects would carry stale `--store
<label> KEY=VALUE …` wording.

Rewritten to reflect the actual shape AND the link command
operators need to run before the first push.

## F3 (low): Hermetic mock-spin tests for actual argv

`chunk_entries` + `format_pair` cover the per-pair / chunking
logic in isolation, but they don't prove `write_batch`
assembles the exact argv. Past command-shape regressions
(`--store` → `--app/--label`) would have been caught by an
end-to-end argv test against a fake `spin` binary.

Four new tests stand up a tempdir with a `spin` script that
captures its argv via `printf '%s\n' "$arg"` and stderr via
`cat stderr.txt`, prepend the dir to `PATH` under a per-test
mutex, and assert:

1. `write_batch_assembles_app_label_keyvalue_argv_against_a_mock_spin`
   — exact argv shape: `[cloud, key-value, set, --app, my-app,
   --label, app_config, greeting=hello, svc.timeout=1500]`.

2. `write_batch_translates_not_logged_in_stderr_to_actionable_error`
   — fake spin exits 1 with "not logged in" stderr; the wrapped
   error suggests `spin cloud login`.

3. `write_batch_translates_unlinked_stderr_to_actionable_link_hint`
   — fake spin exits 1 with "label `app_config` is not linked to
   a store" stderr; the wrapped error suggests the CORRECTED
   `link key-value --app ... --store <store-name> app_config`
   shape AND does NOT contain `--label` in the link-command
   region (specifically scoped to avoid false-positive matches
   against the failing SET command which legitimately uses
   `--label`).

4. `write_batch_chunks_large_batch_into_multiple_invocations_against_mock_spin`
   — same fixture as the existing chunker unit test (7 × ~30
   KiB), but verifies the actual mock-spin invocation count
   matches the chunker's chunk count, proving the loop in
   `write_batch` actually shells once per chunk.

The tests are `#[cfg(unix)]` because the mock relies on a
`#!/bin/sh` script and chmod +x. A separate `path_mutation_guard`
mutex serializes them so two parallel mock tests don't race on
`PATH`. Stderr payloads contain backticks and `$` characters
that would be reinterpreted by the shell, so the script reads
stderr from a file rather than interpolating it into the script
body.

## Gates

- cargo fmt + host clippy (--all-targets --all-features) green.
- cargo test --workspace --all-targets green: spin adapter
  jumps to 107 tests (was 103, +4 hermetic mock-spin tests).
- Wasm clippy on spin (wasm32-wasip2) green (the new mock
  infrastructure is `#[cfg(unix)] #[cfg(test)]`-gated so it
  doesn't ship to wasm).
- App-demo workspace tests green.
Brings in PR #257 review-feedback fixes (e788714) on top of the
extensible-CLI / per-backend `config push` work. Conflicts resolved
in three files — all by keeping HEAD where upstream targeted
APIs/forms that this branch has since superseded:

- crates/edgezero-adapter-fastly/src/request.rs
  Upstream re-touched `dispatch_raw`/`dispatch_with_config`/
  `dispatch_with_config_handle` doc-strings; this branch's
  9d31015 Hard-cutoff pass deleted those functions. Kept HEAD —
  the block stays removed.
- crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs
- examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs
  Upstream switched to an unconditional `#![allow(unsafe_code, …)]`
  with `spin_sdk::http_component` wording. This branch is already
  on Spin SDK 6 (`#[http_service]` + `Request`), so kept HEAD's
  `cfg_attr(target_arch = "wasm32", allow(unsafe_code, reason = …))`
  form which both narrows the allow to wasm builds and references
  the macro this branch actually uses.

Auto-merged without conflict: .github/workflows/test.yml,
crates/edgezero-adapter-cloudflare/src/lib.rs,
crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs,
crates/edgezero-adapter-spin/src/lib.rs.

Verified on the merged tree:
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo test --workspace --all-targets (865 passed)
- cargo clippy -p edgezero-adapter-spin --target wasm32-wasip2 …
- cargo clippy -p edgezero-adapter-fastly --target wasm32-wasip1 …
- cargo clippy -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown …
- cd examples/app-demo && cargo test --workspace --all-targets
…s cleanup

Addresses the June-05 PR #257 review.

1. Important — Spin adapter CLI tests are no longer hermetic under
   parallel execution. The `push_sqlite::write_batch` writer shells
   `spin --version` once-per-process via `verify_spin_runtime_compat`.
   When `push_cloud`'s tests prepend a fake `spin` to `$PATH` (guarded
   only by a module-local mutex), a concurrently-running push_sqlite
   test's first `write_batch` can hit that fake spin and append
   `--version` into the cloud test's argv log, failing the cloud
   assertion and poisoning the mutex.

   Fix: early-return from `verify_spin_runtime_compat` under
   `cfg!(test)`. The function is best-effort warning logic with no
   production semantic impact, and its parser is unit-tested
   independently, so dropping it in tests doesn't lose coverage.
   `cfg!()` is a const expression (not a `#[cfg(not(test))]`
   attribute) so it doesn't trip strict-clippy's `cfg_not_test`.
   `cfg!(test)` resolves only for THIS crate's test target, so the
   shellout still runs when `write_batch` is called from production
   code or from downstream crates' tests (e.g. `app-demo-cli`).

2. Medium — `dispatch_push` parsed `runtime-config.toml` before
   branching, so a malformed local runtime-config blocked even
   Fermyon Cloud `--dry-run` previews — even though the cloud path
   only needs `[application].name` from `spin.toml`.

   Restructure so the cloud branch never reads runtime-config: keep
   the path math at the top, read inside `--local`, skip read in the
   cloud branch, read again in the SQLite-direct fallthrough.
   Regression covered by
   `dispatch_push_fermyon_cloud_dry_run_ignores_malformed_runtime_config`
   which seeds the tempdir with `this is not [valid toml` and asserts
   the cloud preview still renders.

3. Low — `edgezero config push`'s CLI reference omitted `--local`
   and `--runtime-config`. Added both to the synopsis and to the
   argument list, calling out that `--local` is Spin/Fastly/CF and
   `--runtime-config` is Spin-only (and explicitly noting cloud
   pushes don't consult it).

4. Low — stale wording in:
   - `docs/guide/adapters/spin.md`: example showed
     `type = "azure"` for the managed-backend swap, but the parser
     only recognises `azure_cosmos`. Changed to `azure_cosmos`.
   - `crates/edgezero-adapter-spin/src/templates/spin.toml.hbs`:
     the `key_value_stores = ["app_config"]` comment claimed
     `app_config` was the default `[stores.config].ids` declared in
     the generated `edgezero.toml`, but that section is
     commented-out by default. Reworded to reflect the
     opt-in-default model (uncomment in edgezero.toml to wire it
     up; delete here + in runtime-config.toml to disable).

Verified on this branch (all gates green):
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo test --workspace --all-targets
- cargo clippy -p edgezero-adapter-spin --target wasm32-wasip2 --features spin --all-targets -- -D warnings
- cargo clippy -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly --all-targets -- -D warnings
- cargo clippy -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare --all-targets -- -D warnings
- cd examples/app-demo && cargo test --workspace --all-targets
- (cd docs && npx prettier --check guide/cli-reference.md guide/adapters/spin.md)
…, refresh stale Spin wording

Addresses the June-06 PR #257 review.

1. High — Generated Cloudflare projects did not compile. The
   scaffold's `lib.rs.hbs` called `run_app::<App>(include_str!(...),
   req, env, ctx)` (4 args), but the current runtime API is
   `run_app(req, env, ctx)` (3 args). Verified by
   `cargo test -p edgezero-cli --test generated_project_builds --
   --ignored`, which failed pre-fix with E0061 "this function takes
   3 arguments but 4 arguments were supplied" and passes after.

   Fix: drop the obsolete `include_str!` argument; the template now
   matches `examples/app-demo/crates/app-demo-adapter-cloudflare`
   (which has been on the 3-arg form for a while).

2. Medium — Fermyon Cloud `--dry-run` returned before calling the
   real argv validation (key contains `=`, per-pair/per-chunk argv
   cap). A "successful" dry-run could be followed by a hard failure
   on the real push.

   Fix: call `push_cloud::chunk_entries(entries)?` inside the
   dry-run arm. Same validation as the real write_batch path, so a
   green dry-run is a real predictor of push success. Also surfaces
   the chunk count in the preview line ("for N entries across M
   invocation(s)") so operators can see the batching decision the
   real push would make.

   Regression covered by
   `dispatch_push_fermyon_cloud_dry_run_rejects_equals_in_key`.

3. Low — Stale Spin-config wording in places where the runtime
   moved from Spin variables to KV (which stores dotted keys
   verbatim):
   - `crates/edgezero-core/src/config_store.rs:212`: trait doc
     listed `SpinConfigStore` as "Spin component variables" —
     updated to "Spin KV (`spin_sdk::key_value::Store`)".
   - `examples/app-demo/app-demo.toml:24-28`: comment claimed
     `feature.new_checkout` was translated to
     `feature__new_checkout` on Spin's flat namespace — Spin now
     reads `feature.new_checkout` verbatim, no translation.
   - `examples/app-demo/crates/app-demo-core/src/config.rs:27-33`:
     same fix, on the typed AppConfig field's doc comment.

   These were doc drift only — runtime code already does the right
   thing, but the stale wording would mislead anyone seeding stores
   manually or migrating an older app.

Verified (all gates green):
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo test --workspace --all-targets
- cargo test -p edgezero-cli --test generated_project_builds -- --ignored
- cargo clippy -p edgezero-adapter-spin --target wasm32-wasip2 --features spin --all-targets -- -D warnings
- cargo clippy -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly --all-targets -- -D warnings
- cargo clippy -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare --all-targets -- -D warnings
- cd examples/app-demo && cargo test --workspace --all-targets
Addresses the post-f1179df PR #257 review.

1. Medium — Spin Cloud pushes are non-atomic across chunks (one
   `spin cloud key-value set` shellout per ≤96 KiB argv chunk), but
   the pre-fix error only named the failing chunk's size + exit
   status. If chunk 1 committed and chunk 2 failed, the operator
   was left with partially-updated live cloud state and no resume
   boundary. Fastly already produces a committed / failed /
   not-attempted diagnostic (cli.rs::push_entries_with_committer).

   Mirror Fastly's shape in `push_cloud::write_batch`: track a
   cursor through `entries` as committed-chunks accumulate, and on
   failure emit a structured error naming
   - committed keys (already on Fermyon Cloud, safe to skip),
   - failed-chunk keys (the cloud API is atomic per shellout, so a
     non-zero exit means none of these landed),
   - not-attempted keys (subsequent chunks, fully re-push needed),
   - a resume hint pointing out that `set` is idempotent so
     re-running with the full set is also safe.

   Regression covered by
   `write_batch_partial_failure_reports_committed_failed_and_not_attempted_keys`,
   which stands up a fake `spin` that succeeds on invocation 1 and
   fails on invocation 2, feeds 7 × ~30 KiB entries (>=3 chunks),
   and asserts the diagnostic names all three buckets plus the
   upstream stderr.

2. Medium — `docs/guide/manifest-store-migration.md`'s capability
   table still listed spin's Config as `Single (flat variables)`,
   but the Spin runtime is KV-backed and Multi for Config (one
   `spin_sdk::key_value::Store` per declared `[stores.config].id`).
   This page is linked from the loader's hard-error message when
   it encounters a pre-rewrite manifest, so the stale entry can
   actively mislead migrations. Changed to `Multi (KV label)`.

3. Low — Axum config-push docs claimed it was "future Stage 7
   work" and told users to populate `.edgezero/local-config-<id>.json`
   directly. `config push --adapter axum` ships and writes the same
   file the runtime reads. Updated:
   - `docs/guide/configuration.md` — bullet now points operators
     at `edgezero config push --adapter axum`.
   - `docs/guide/adapters/axum.md` — same bullet under the Config
     Store section; also calls out the typed flow from a
     downstream `<your-cli>` for `#[secret]` stripping.

Verified (all gates green):
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo test --workspace --all-targets
- cargo clippy -p edgezero-adapter-spin --target wasm32-wasip2 --features spin --all-targets -- -D warnings
- cd examples/app-demo && cargo test --workspace --all-targets
- (cd docs && npx prettier --check guide/manifest-store-migration.md guide/configuration.md guide/adapters/axum.md)
The June-07 review caught a clippy regression in the freshly-added
`write_batch` partial-failure diagnostic: under
`cargo clippy --workspace --all-targets --all-features -- -D warnings`
the direct slice indexing (`entries[..cursor]`, `entries[cursor..chunk_end]`,
`entries[chunk_end..]`) tripped `clippy::indexing_slicing`, and the
single-letter destructured binding `(k, _)` tripped
`clippy::min_ident_chars`. Both are part of this repo's strict-clippy
restriction set (Stage 8 chore/strict-clippy merge), so the previous
form failed CI on `cargo clippy` even though `cargo test` and
`cargo fmt` were clean.

Refactor: replace the three slice indices with `.get(..)` calls that
fall through to `&[]` if the in-bounds invariant is ever violated
(it can't be — `cursor` and `chunk_end` are produced from
`entries.len()` and `chunk_entries`'s structure) — and rename the
destructured binding to `(key, _value)`. Pure refactor: the
partial-failure error string and the
`write_batch_partial_failure_reports_committed_failed_and_not_attempted_keys`
regression test are unchanged. All 16 push_cloud tests still pass.

Verified (clean):
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo test -p edgezero-adapter-spin --features cli push_cloud
The June-07 review caught `npx prettier --check .` failing on
`docs/guide/cli-walkthrough.md` lines 190 / 202-204. Prettier doesn't
preserve indentation on continuation lines inside backtick-wrapped
inline-code spans (`KEY=VALUE`, `SET <key> <value>`, `azure_cosmos`),
so the de-indent it wants is purely a source-format change — the
rendered VitePress output is unchanged because the surrounding
backticks group the whole multi-line span as one inline code block.

Pure formatting; no content edits.

Verified: `(cd docs && npx prettier --check .)` is clean.
Adds the EdgeZero outbound HTTP design spec under
docs/superpowers/specs/2026-05-21-outbound-http-design.md. Targets the
PR #269 (feature/extensible-cli) baseline.

The spec covers six requirements:

- portable OutboundHttpClient trait with single send + concurrent
  send_all on every adapter (Axum, Cloudflare, Fastly, Spin)
- per-request and shared deadline / timeout primitives with a
  documented dispatch budget and bounded-cooperative semantics on
  Fastly
- bounded buffering with explicit persistent vs transient memory
  accounting and pre-append cap checks
- manifest-driven [capabilities] declaration (nine capabilities total)
  with pre-dispatch enforcement gates at five CLI entry points
- adapter contract test plan in three tiers (core mock, per-adapter
  translation, runtime)
- four canonical URI accessors (backend_target, host_authority,
  sni_hostname, cert_host) so adapters share one canonical
  host/port/SNI/cert split

Revised through 49 review rounds; non-normative resolution journal
lives in Appendices A through AX.
@aram356 aram356 changed the title Outbound HTTP design spec (midbid driver) Outbound HTTP design spec Jun 8, 2026
@aram356 aram356 marked this pull request as draft June 8, 2026 06:55
aram356 added 2 commits June 8, 2026 00:02
Removes references to the originally-named driving consumer and the
specific external protocol used as motivation:

- midbid       → "the driving pattern" / generic
- Prebid-style → "fan-out-style"
- OpenRTB      → "the external batch protocol"
- bidder       → "target"
- auction      → "fan-out batch"
- tmax         → "batch deadline"

The technical motivation (N concurrent outbound requests under a shared
wall-clock deadline, results harvested in input order, small response
bodies, homogeneous-budget common case) is preserved; only the named
consumer and its protocol are scrubbed so the spec reads as a portable
substrate rather than a single-consumer design.

Section §3.3.2 retitled "Mapping an external batch deadline to EdgeZero
deadlines"; status header gains a "Driving pattern" line in place of
the old "Driving consumer" pointer.
Spec previously claimed Fastly behaviour that did not match the actual
SDK / public API. This commit corrects the four normative claims, adds
an app-facing consuming body accessor, and records the corrections in
Appendix AY.

Findings addressed:

- lazy-streamed-response-passthrough downgraded Native -> BestEffort
  on Fastly. `Response::with_streaming_body` does not exist;
  `Response::stream_to_client()` is the actual API and is documented
  as incompatible with `#[fastly::main]`. Default scaffold falls back
  to buffered passthrough; lazy passthrough requires the non-main
  entry-point template tracked in new section 8 risk 12.

- NameInUse semantics rewritten. Fastly's session-uniqueness rule is
  unconditional; the previous "identical name + identical properties
  is a re-registration that returns Ok" carve-out was false. SDK's
  `Backend::from_str(name)` returns a handle only and exposes no
  registered properties, so a NameInUse on a name not in this
  adapter's collision map is now an explicit fail-closed internal
  error rather than a silent property-trust fallback.

- between_bytes_timeout is receive-side only per Fastly's Backend
  API docs. The previous claim that it bounded guest-to-origin
  writes is removed; the streamed-upload host-write phase is
  downgraded to BestEffort with the cooperative inter-chunk check
  as the only adapter-side bound.

- Streamed-upload response overshoot tightened from per-chunk
  accumulator to closed-form bound: first_byte_ms (headers wait)
  plus one between_bytes_timeout (worst-case first-body-chunk
  read), one-shot. Footnote 1 single-send section + section 5.4
  test row updated.

- OutboundResponse::into_body() added as the app-facing consuming
  accessor for streamed-response orchestration. The send_all
  rustdoc recommends single send + futures::join_all + into_body()
  on Axum/CF/Spin as the canonical path; into_parts(..) stays
  adapter-facing.

Appendix AY records the five resolutions. Status header bumped to
"rounds 1-50, Date: 2026-06-08"; superseded-AR pointer extended to
include AY.
@aram356

aram356 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Round 50 — Fastly SDK correctness pass (commit 647489a)

Reviewer flagged four normative claims about Fastly that didn't match the actual SDK / public API. All fixes verified against docs.rs/fastly and Fastly's public Backend API docs before edit.

HIGH — lazy-streamed-response-passthrough on Fastly: downgraded NativeBestEffort. Response::with_streaming_body does not exist (only on Request). Response::stream_to_client() is the real API but is incompatible with #[fastly::main]. Default scaffold falls back to buffered passthrough; the non-#[fastly::main] entry-point template is tracked in new §8 risk 12.

HIGH — NameInUse semantics: rewritten. Fastly's session-uniqueness rule is unconditional — no "identical re-registration returns Ok" carve-out. Backend::from_str(name) returns a handle only, no property inspection. NameInUse on a name not in this adapter's collision map is now an explicit fail-closed EdgeError::internal.

MEDIUM — between_bytes_timeout direction: dropped the false write-side claim per Fastly's public Backend API docs (receive-side only). Streamed-upload host-write phase downgraded to BestEffort; cooperative inter-chunk check is the only adapter-side bound.

MEDIUM — Streamed-upload response overshoot: tightened from per-chunk accumulator to closed-form bound — first_byte_ms (headers wait) + one between_bytes_timeout (worst-case first-body-chunk read), one-shot. Footnote 1 single-send section and new §5.4 test row added.

LOW — App-facing consuming body accessor: added OutboundResponse::into_body(self) -> Body. send_all rustdoc now recommends single send + futures::join_all + into_body() as the canonical streamed-response orchestration path on Axum/CF/Spin.

Appendix AY records all five resolutions. Status bumped to rounds 1–50.

Five round-50 carry-over findings:

- Early section 4.3 dynamic-backend prose still preserved the stale
  identical-properties-re-register carve-out, contradicting the corrected
  step-5 algorithm. Rewritten in place to match the unconditional
  session-uniqueness contract. Two historical appendix entries (round-37
  in Appendix AK) marked superseded by Appendix AY.

- Fastly buffered-fallback for lazy passthrough named max_response_bytes
  as the cap, but that per-request cap is unavailable at response-converter
  time. Added FASTLY_RESPONSE_STREAM_BUFFER_BYTES adapter-level constant
  (mirrors AXUM_RESPONSE_STREAM_BUFFER_BYTES). Three section 5.4 rows
  rebucketed so Fastly is no longer in the CF/Spin lazy group; new
  Axum-and-Fastly buffered-fallback row carries both adapter constants.

- Residual between_bytes_timeout write-side claim removed from the
  remaining section 5.4 stalled-upload mechanics row and from section 8
  risk 7. Fastly write phase is BestEffort uniformly now; the public
  Backend API docs are cited as the source.

- Spin host-write race rewritten against actual WASI output-stream
  semantics. Old wording said each write() is raced against a timer;
  WASI write() is nonblocking and readiness-polled, so the implementable
  pattern is subscribe-pollable + futures::select! vs timer + nonblocking
  check_write() + write() within the permitted byte count.

- Typo: "docsare migrated" -> "docs are migrated" in section 1.3 non-goals.

Status header bumped to rounds 1-51; AR-superseded pointer extended to
include AZ.
@aram356

aram356 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Round 51 — round-50 carry-overs + Spin WASI write mechanics (commit 8df9d80)

Five follow-up findings from the round-50 reviewer pass. All verified against the cited external docs before edit.

HIGH — Fastly dynamic-backend semantics contradictory across sections. Round 50 fixed the §4.3 step-5 algorithm but the earlier Dynamic backends introductory paragraph still preserved the false "identical name + identical properties re-registers / returns Ok" carve-out. Rewritten in place: session-uniqueness is unconditional per the SDK; EdgeZero owns the entire uniqueness story at the guest layer via the adapter-local cache; NameInUse outside that cache is fail-closed EdgeError::internal. Two historical Appendix-AK entries marked "Superseded by Appendix AY."

MEDIUM — Fastly buffered-fallback named an unavailable cap; §5.4 still bundled Fastly with CF/Spin. Added FASTLY_RESPONSE_STREAM_BUFFER_BYTES (default 16 MiB, mirrors AXUM_RESPONSE_STREAM_BUFFER_BYTES). §5.4 lazy-passthrough rows split: (a) CF/Spin yield-first-bytes row excludes Fastly; (b) CF/Spin mid-stream abort row excludes Fastly; (c) new Axum-and-Fastly buffered-fallback row with both adapter constants named.

MEDIUM — Residual between_bytes_timeout write-side claims. The "stalled streamed-upload mechanics differ per adapter" §5.4 row and §8 risk 7 both still claimed the Fastly between-bytes-timeout bounded guest-to-origin writes. Scrubbed both — Fastly's write phase is BestEffort uniformly. §8 risk 7 retitled and rewritten to track the symmetric "if Fastly adds a documented guest-write timeout in the future" follow-up.

MEDIUM — Spin host-write race mechanically wrong vs WASI. Old wording raced each OutgoingBody::write against a timer; WASI output-stream is nonblocking + readiness-polled. Rewritten as the four-step pattern: subscribe() pollable → futures::select! pollable-ready vs monotonic-clock timer → on timer-win drop the handle + return gateway_timeout → on pollable-win call nonblocking check_write() for permitted byte count + write() within that bound, looping. §5.4 row updated to match.

LOW — Typo: docsare migrateddocs are migrated in §1.3.

Appendix AZ records all five resolutions; status bumped to rounds 1–51.

@aram356 aram356 force-pushed the feature/extensible-cli branch 2 times, most recently from 161a244 to 93f72fe Compare June 22, 2026 19:24
Base automatically changed from feature/extensible-cli to main June 29, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant