Skip to content

DO NOT MERGE: integration-only AWS prospect demo RC (not for review) - #431

Draft
ChristianPavilonis wants to merge 435 commits into
mainfrom
rc/aws-prospect-demo
Draft

ChristianPavilonis wants to merge 435 commits into
mainfrom
rc/aws-prospect-demo

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

DO NOT MERGE — integration-only RC, NOT FOR REVIEW.

This draft supplies one reproducible Git identity for a later Trusted Server compatibility task. It does not replace, close or change ownership of the seven source PRs. No TS changes, real AWS deployment/provisioning, release, tag or registry publication were performed.

Exact pin

# Use this identical Git URL and full rev for EVERY EdgeZero dependency.
git = "https://github.com/IABTechLab/edgezero.git"
rev = "61bfe3b931fd97f5300287e4a91e33f32aa0fbee"

Branch: rc/aws-prospect-demo. Base main: 41f478726727dc1c8abdafcbd6ac9a2b5c3094da (still current when rechecked before push). All seven frozen heads below are ancestors of the pin, integrated with ordinary merge commits.

Source Frozen head
#275 — portable HTTP/lifecycle foundation 7cccaf10734914161803317aec6c55c22c2bccc9
#406 — native production lifecycle 3ab22096fd117f7506cf437b5ba128e0789e059c
#407 — buffered JSON limits 3308041b910b648af746c15e809ee86b96adc2e9
#409 — native diagnostics/trusted ingress 66a05e25bee674ced69387467acec3c87b06c8b6
#414 — native AWS providers 350527e4b7387a86da3f4cdaea0e8459665aa0f6
#401 — generated container packaging b8b82965a0464faecc4eb4c606fb5b98afca621a
#389 — request timing 018529795767effa1bf0e5761367f4559aa47b30

Tracked integration record: bases, decisions, commands and boundaries.

Reconciliation

  • Graft the older 406→407→409 stack and 414's separate lifecycle bridge onto 275's newer bounded parser, acceptance-time deadlines, application clock, bounded immutable snapshots and current outbound/provenance APIs. Do not restore retired request/proxy APIs or stronger capability claims.
  • Retain one readiness/drain owner and one response-completion owner. Join diagnostics to actual completion; timing marks/spans remain diagnostic and never imply egress or client receipt.
  • Keep logging before preflight/configure, logger failure propagation and application-owned logging. Reject binary ingress/snapshot limit values at both capture boundaries before hooks/listen.
  • Preserve provider feature/target isolation, startup config/secret snapshots and live ordinary DynamoDB KV semantics. Combine AWS startup with JSON 64/65-byte boundaries, checked Forwarded metadata and diagnostics with request records disabled.
  • Preserve main's build/deploy finalizer ownership. Update the generated builder to the verified Rust 1.98.1 multiarch digest; direct Docker builds remain provider-free unless the application explicitly selects AWS features.
  • The real container TLS test exposed empty GET/HEAD forwarding being misclassified as streamed. Preserve transport-proven emptiness; keep unknown/nonempty bodies lazy and rejected without speculative polling/discarding. Unit regressions and the real proxy test pass.
  • Reconcile demo/reusable lockfiles to exact shared pins, retaining demo rustls 0.23.45; fix target-layout lint expectations, actual test cfg, timing clock examples and published guide links.

Executed local validation

These are local execution results, not claims that this draft's GitHub CI or hosted providers are qualified.

  • Final stable-code ledger: 24 gates, all exit 0, at e3f13b699b03e99d21af92f12c6496c4d0c59d2b; the final pin adds only the internal validation record. Required workspace fmt/tests/strict all-feature Clippy/combined-feature check pass. Serialized Axum all-feature/all-target tests pass, including five AWS startup and 33 production-hosting tests. Strict Clippy passes on all three WASM targets.
  • Generated all-adapter workspace, generated AWS startup and generated DynamoDB default-off/CRUD/list/logical TTL/caps/restart proofs pass. Independent pinned DynamoDB Local, Moto and real AppConfig Agent suite passes with synthetic local credentials.
  • Shared pins, flate/Brotli, serde map, native dependency isolation, retired-API and outbound-docs guards pass. Prepared-context Node container suites: 54 passed, zero skipped. Docs format/lint/build pass with Node 24.20.0.
  • Earlier unchanged-runtime evidence: all four adapter feature matrices; core doctests; AWS no-feature/individual/all-feature matrix (74 all-feature tests); demo 48; reusable core/harness 5 + 26. Real local engines: Fastly/Viceroy 26 contract + 161 library; Spin/Wasmtime 37 contract + 7 resource; Cloudflare/headless Chromium 31 contract + 29 library. Action pins, actionlint, offline zizmor, ShellCheck and four action shell suites pass.

Post-push proof against this exact final pin

PASS. A fresh generated consumer replaces all six declared EdgeZero dependencies with the canonical URL/full rev above. Cargo metadata verifies all nine transitive EdgeZero packages share exactly one Git source, with no EdgeZero path copies.

  • Default-off native build and normal dependency tree: no AWS SDK/config/Smithy graph. Selecting an AWS provider fails before listen or any Agent request.
  • Individual AppConfig Agent, Secrets Manager and DynamoDB feature checks pass; aggregate aws-stores,aws-dynamodb native build passes.
  • AWS-enabled consumer performs typed production initialization before HTTP serving, makes exactly one controlled Agent request, continues serving its startup snapshot after the Agent closes, rejects missing required local config before listen, and exits 0 on SIGTERM drain.
  • Exact-pin generated container packaging PASS on linux/amd64: clean-source fixture records this pin; ELF/shared libraries, UID 10001, read-only root, capabilities, no-new-privileges, read-only config/writable data mounts, HTTP and controlled HTTPS trust/hostname checks all pass. This is local packaging evidence, not a registry-qualified release or production certification.

Evidence on the coordinator machine: target/rc-late-evidence/results.tsv, git-identity.log, git-proof-progress.log, git-consumer-*.log, git-consumer-inputs.sha256, final-container-proof/, and final-container-packaging.log. Earlier ledgers are under /tmp/edgezero-rc-run/.

Failed attempts, skips and limits

  • A parallel-load disconnect test once timed out before observing dispatch; it passed alone and in the complete serialized Axum suite. Do not describe that parallel attempt as green.
  • Initial generated build failed on /tmp quota. A temporary directory inside the checkout also invalidated a no-parent-manifest test assumption. Both passed after moving temporaries outside any workspace. Initial dependency-contract and WASM-lint failures were fixed and successfully rerun, not waived.
  • Earlier Node run had three missing-prepared-context skips; final prepared-context suites ran all 54 cases with zero skips.
  • No arm64 execution, hosted Fastly/Spin/Cloudflare qualification, real AWS IAM/KMS/capacity/AppConfig polling qualification, TS compatibility, registry-qualified strict release scan or publication. Completion does not prove end-client receipt; DynamoDB is not CAS/exactly-once/celld.
  • Docs install reports five dependency vulnerabilities (two moderate, three high). GitHub push separately reports eight default-branch alerts (two high, six moderate; overlap not determined). No dependency/security remediation or security qualification is claimed. Third-party SDK DEBUG/TRACE logging can expose metadata despite EdgeZero's own redaction.

Next task — not done here

Adapt TS separately: pin every EdgeZero dependency to the one Git identity above, migrate intentional API changes and run TS-specific checks. Only then choose AWS infrastructure/provisioning/deployment. Keep the seven original PRs in their own review/merge flows.

aram356 added 30 commits May 29, 2026 19:06
Wrap the integration test functions in `#[cfg(test)] mod tests` so they
satisfy `tests_outside_test_module` and pick up the `allow-expect-in-tests`
exemption from clippy.toml (no `#[allow(expect_used)]` needed). Replace
the unused-prefixed `_assert_provider_impl` compile check with the
standard `const _: fn() = …` pattern, and add a reason on the
file-level `allow(deprecated)`.

Pre-target clippy wasn't part of CI, so wasm-gated test code was
unchecked.
CI was only running clippy on the host target. The wasm-gated bodies
of the fastly/cloudflare/spin adapters — the actual production code
for each provider — were never linted, which is why this branch
shipped with no workspace-allow workaround but ~270 clippy errors
sitting in code that never crossed the gate.

The new `adapter-wasm-clippy` matrix runs
`cargo clippy --target <wasm-target> --features <feature> --all-targets -- -D warnings`
per provider so the same strict gate applies to each compilation
target. fastly is green; cloudflare and spin are still being cleaned
up.
Workspace clippy already lives in format.yml alongside cargo fmt; the
new per-target clippy matrix belongs in the same static-checks
workflow rather than alongside the wasm-runtime tests.
- Alphabetize fields and impl items in CloudflareRequestContext
- Add `#[inline]` to all public methods
- Use `Self` instead of repeating the type name
- Same for CloudflareSecretStore
response.rs: backtick `EdgeZero` in doc, add # Errors section,
#[inline], rename shadowed binding, import block_on / drop the now
unused StreamExt re-import, add reason to #[ignore].

proxy.rs: import PROXY_HEADER, #[inline] on send, change
`http_method_to_cf(Method)` and `worker_error_to_io(worker::Error)`
to take their args by reference and drop the redundant
`Method::GET => CfMethod::Get` arm (the wildcard already returns
`Get`), drop `async` from build_cf_request / convert_response
which had no awaits, replace the brotli setup scope-block with
an explicit `drop(compressor)`, rename shadowed bindings, move
`type ChunkStream` ahead of `http_method_to_cf`.
- Import PoisonError into scope (no more std::sync absolute path)
- Add #[inline] to public methods
- Replace Option<Option<Arc<ConfigMap>>> with a CacheEntry enum
  (Missing/Present) and an entry_to_value helper — semantically the
  same but avoids the option_option restriction lint
- Rename single-char binding (`v` → `value`) and the shadowed
  `key` (→ `owned_key`)
- Reorder items: type/enum/struct group → impls → fns
- Add # Errors doc and #[inline] on every public method
- Rename single-char closure bindings (`e` → `err`, `c` → `value`)
- Carry the source TryFromIntError into the limit validation message
  instead of `map_err(|_| …)`
- Stop shadowing the param `limit` (now `limit_u64`) and the param
  `cursor` (now `cursor_str`)
- Alphabetize the trait impl method order
- Alias `worker::kv::KvStore as WorkerKvStore` to avoid the
  absolute path collision with edgezero_core's KvStore trait
- Import core/future + core/pin to drop ::core absolute paths
- Rename single-char lifetimes (`'a` → `'app` / `'binding`)
- Move trait/impl ahead of init_logger for the module-grouping lint
- drop(init_logger()) instead of `init_logger().expect(...)`
- Import CloudflareKvStore / CloudflareSecretStore / Arc / Display /
  DEFAULT_KV_STORE_NAME to clear absolute_paths
- Add # Errors + #[inline] on the public dispatch_* surface
- Rename single-char `e` / non-descriptive shadows
- Pass EdgeError and worker::Method by reference (needless_pass_by_value)
- Drop `pub(crate)` from Stores fields (the struct itself is
  pub(crate))
- Bundle the four binding-resolution inputs into a RuntimeBindings
  struct so dispatch_with_bindings fits under too_many_arguments

Cloudflare lib now passes `cargo clippy --target wasm32-unknown-unknown
--features cloudflare --lib -- -D warnings`. The contract test still
has the same tests_outside_test_module pattern fastly already fixed —
follow-up.
Same wrap pattern as the fastly contract fix: move tests + helpers
into #[cfg(test)] mod tests so #[wasm_bindgen_test] is inside a test
module and the expect/unwrap/panic exemptions in clippy.toml apply.
Replace _check / _assert_provider_impl with a const _: fn() pattern,
import worker::js_sys / worker_sys symbols at the top of the test
mod to clear absolute_paths, add messages to every assert_eq / assert
(missing_assert_message), and add reason to #![allow(deprecated)].
response.rs: saturating_add for the size check, rename single-char
binding, add # Errors + #[inline] on the public conversion fn.

secret_store.rs: #[inline] on every method, rename `Err(e)` → `err`.

config_store.rs: reorder enum variants alphabetically (InMemory before
Spin), reorder match arms, #[inline] on every method, rename `e` →
`err`.
key_value_store.rs: alias spin_sdk::key_value::Store to SpinSdkStore
and import DEFAULT_KV_STORE_NAME to drop absolute paths; add # Errors
+ #[inline] on every public surface; rename single-char closure
bindings; alphabetize trait impl method order.

lib.rs: import core::future::Future, core::pin::Pin, App, Hooks,
ManifestLoader, and spin_sdk::http::{IncomingRequest, IntoResponse,
Response as SpinResponse} into scope; rename single-char lifetime
`'a` → `'app`; convert the wasm-gated `pub use` block into
`pub mod` to match the fastly/cloudflare adapter shape (drops 6
`pub_use` lints and exposes the same items at edgezero_adapter_spin::
<module>::<Item>); update tests/contract.rs to reach the wasm-gated
types via their module paths; #[inline] + # Errors on AppExt::dispatch
and run_app; drop init_logger() instead of `let _ = …` (the latter
trips let_underscore_must_use); add messages to the test asserts.
proxy.rs: import header types (HeaderName, HeaderValue, CoreMethod,
PROXY_HEADER) and spin_sdk::http::{send, Method as SpinMethod,
Request, Response} to clear the absolute-paths sprawl; rename single-
char closure bindings; replace `"spin".parse().expect(...)` with
HeaderValue::from_static (infallible at compile time, no expect_used);
drop `ref other` ref pattern by cloning the method.

request.rs: import parse_client_addr, ManifestLoader, HeaderValue,
CoreMethod, SpinMethod, SpinResponse, resolve_store_settings,
SpinStoreSettings; drop pub(crate) field shorthand on Stores; add
# Errors + #[inline] on every public surface; rename single-char
binding `|k, _|` → `|header_name, _|`; capture the
TryFromBytes error in into_core_method's map_err so it doesn't fire
map_err_ignore.
Mirror the fastly contract wrap: move the integration test fns + their
fixture types into #[cfg(test)] mod tests so the tests_outside_test_module
lint is satisfied and the .expect()/unwrap()/panic exemptions from
clippy.toml apply. Replace the _check / _assert_*_impl pair in
store_trait_compile_checks with the standard const _: fn() pattern.
Add messages to the assert!/assert_eq! calls, alphabetize FixedKvStore
trait methods, and import Duration to drop the std::time::Duration
absolute path. The nested from_core_response_tests module groups the
spin-SDK-specific tests; placing it ahead of the outer tests module's
use block satisfies module item ordering.
Two prk-Jr items that the earlier rounds left partially open:

- **wasmtime install**: the existing tarball install landed the binary
  in /usr/local/bin/, which sits outside the format.yml cache (the
  cache pinned `~/.wasmtime/bin/` — a holdover from the older
  install.sh approach). Result: the `command -v wasmtime` guard was
  effectively dead and the curl+tar ran on every spin matrix run.
  Install to `~/.wasmtime/bin/` instead and push that directory onto
  $GITHUB_PATH so the cached binary is actually picked up by both
  this step and the subsequent test step. Drops sudo while we're at
  it.

- **Spin contract tests file comment**: expand the file-level note so
  it explicitly calls out that these are in-process unit-style tests
  (no `spin_sdk::IncomingRequest` construction, no
  `spin_sdk::http::send`) and point at `store_trait_compile_checks`
  as the existing type-level pin against an SDK type change. End-to-
  end ABI coverage is called out as belonging in a separate suite.
Spin 6.0 is a substantial overhaul. Beyond the three changes called
out in the old in-tree TODO (Method enum -> constants, IncomingRequest
removal, Builder::build -> .body()), it ships a wasip2/wasip3-based
runtime with a new macro name, a new feature-gated module layout,
fully-async key-value/variables APIs, and a streaming body model.

WASI target switch:

- spin-sdk 6.0 examples target wasm32-wasip2 (was wasip1). Switch the
  spin adapter, scaffold template, app-demo Spin component, CI matrix
  entry, local run/smoke scripts, generated_project_builds matrix,
  CLAUDE.md / README / docs / pull-request template, app-demo
  edgezero.toml + spin.toml, and the CLI's spin AdapterRegistration.
  Fastly stays on wasm32-wasip1.
- .github/workflows/test.yml: spin matrix now uses target =
  wasm32-wasip2 with CARGO_TARGET_WASM32_WASIP2_RUNNER; the
  workspace rustup-target install line adds wasip2 alongside wasip1.

Dependency + features:

- spin-sdk = "6", default-features = false, features =
  ["http", "key-value", "variables"]. 6.0 gates each module behind a
  feature; this opts in only to what the adapter uses (drops the old
  default-features-false comment that no longer applies).
- New workspace dep http-body-util = "0.1" + dev-dep on the spin
  adapter for the contract test's body collection.
- examples/app-demo and crates/edgezero-cli/src/generator.rs scaffold
  default both bumped to spin-sdk = "6".

Runtime API rewrite (the actual breaking changes):

- request.rs: IncomingRequest -> spin_sdk::http::Request. The whole
  headers() -> drop -> into_body() WASI-handle dance is gone:
  req.into_parts() yields http::request::Parts + IncomingBody;
  body.bytes().await reads via IncomingBodyExt. spin_sdk::http::Method
  is a re-export of http::Method so the 10-arm into_core_method helper
  is deleted. spin-client-addr / spin-full-url are read via
  parts.headers.get(...).
- proxy.rs: Request::builder()...build() -> .body(FullBody::new(Bytes
  ::from(body_bytes)))?. Outbound into_spin_method also deleted (same
  type, identity). Response reading also uses IncomingBodyExt::bytes.
  Drops the unused StatusCode -> from_u16 conversion (status flows
  through unchanged).
- response.rs: from_core_response now returns
  Response<FullBody<Bytes>> instead of bare Response.
- key_value_store.rs: every Store op is async in 6.0. Store::open and
  open_with_max_list_keys gain async/await; get/set/delete/exists/
  get_keys all gain .await. get_keys returns Keys (a paginator) so
  list_keys_page now does .get_keys().await.collect().await?. The
  callers in request.rs (resolve_kv_handle, build_kv_registry) become
  async to propagate.
- config_store.rs + secret_store.rs: variables::get is async in 6.0;
  both lookups gain .await.
- lib.rs: AppExt::dispatch signature switched to Request +
  Response<FullBody<Bytes>>. run_app takes Request. Rustdoc example
  uses #[http_service].

Macro rename:

- #[http_component] -> #[http_service]. Update the scaffold
  templates/src/lib.rs.hbs, app-demo/crates/app-demo-adapter-spin/
  src/lib.rs, the unsafe_code allow reason, and the rustdoc on
  run_app. spin 6.0 only exports http_service, not http_component
  (no alias).

Contract tests (wasm32 + spin block):

- The from_core_response_* tests assumed the 5.2 Response had a
  pointer-to-u16 status, an iterator-of-(String, Vec<u8>) headers,
  and an into_body returning Vec<u8>. With Response<FullBody<Bytes>>
  these are now StatusCode, &HeaderMap, and FullBody<Bytes>. Tests
  switched to spin_response.status() == StatusCode::CREATED,
  headers().get("x-edgezero-res"), and
  into_body().collect().await.to_bytes() via http_body_util::BodyExt.

Docs:

- docs/guide/adapters/spin.md: entrypoint example uses #[http_service]
  + Request; target switched to wasm32-wasip2; build command updated.
- docs/guide/adapters/overview.md, getting-started.md, cli-reference.md:
  Spin target shown as wasm32-wasip2; cli-reference now lists wasip1
  and wasip2 as separate install lines so operators don't pick the
  wrong one.

Verified locally: cargo fmt --all --check, cargo clippy --workspace
--all-targets --all-features -D warnings, cargo test --workspace
--all-targets, cargo check --workspace --all-targets --features
"fastly cloudflare spin", cargo check -p edgezero-adapter-spin
--features spin --target wasm32-wasip2, examples/app-demo workspace
tests, cargo check -p app-demo-adapter-spin --target wasm32-wasip2.
The spin contract test under wasmtime is exercised only in CI
(local wasmtime shim absent).
Three independent hardening fixes from the PR #257 review batch
(the Spin 6 migration is the prior commit; this commit is the
non-migration findings).

Important: [stores.<kind>].ids accepted invalid logical ids.

validate_store_declaration only checked for non-empty list and
default membership, so:

  [stores.config]
  ids = [""]

passed `config validate`, and `config push --adapter axum
--dry-run` then resolved store `` and targeted
`.edgezero/local-config-.json`. Duplicates
(`ids = ["app_config", "app_config"]`) also passed --strict.
Under the hard-cutoff model these should be load-time errors,
not silent runtime degradation.

Fix: reject ids whose trimmed form is empty OR contain any control
character with `store_id_blank`, and reject the first duplicate
with `store_id_duplicate`. The blank check trims whitespace
(rejects "   ") AND checks for embedded control chars (rejects
"good\n", "\0"); the printable-character spec lets future error
sites quote the value in a single-line message. Three new tests
pin: ids = [""], ids = ["   "], ids = ["good", "\n"], and
ids = ["app_config", "app_config"]. Adds BTreeSet to the existing
std::collections import.

Low: stale singular helper `StoreDeclaration::config_store_name`.

The post-rewrite multi-store model has no per-adapter platform-name
flow at the manifest layer (platform names resolve from env). The
helper still existed, took a `_adapter: &str` it ignored, and just
forwarded to `default_id()`. Misleading public API; no production
callers (only one test asserted on it). Removed the helper and the
test line that called it.

Medium: docs ESLint walked into VitePress build artifacts.

`.vitepress/.temp` is gitignored, but eslint.config.js only ignored
`.vitepress/cache/**` and `.vitepress/dist/**`. After a docs build,
`npm run lint` would surface ~500 generated-file errors from
`.vitepress/.temp/*.js`. Added `.vitepress/.temp/**` to the
ignores list so the lint gate is build-state-independent.

Verified: cargo fmt --all --check, cargo clippy --workspace
--all-targets --all-features -D warnings (the new validator
predicate uses `char::is_control` to satisfy
`redundant_closure_for_method_calls`), cargo test --workspace
--all-targets (3 new manifest tests pass), examples/app-demo
workspace tests, `cd docs && npm run lint` (clean after a
docs build).
Brings in 16 commits from #257: per-target wasm-clippy CI matrix,
the security fix to stop logging adapter passthrough args, and the
clippy hygiene passes on cloudflare/fastly/spin adapter sources.

Conflict resolution:

- Spin source files (config_store, key_value_store, lib, proxy,
  request, response): take ours. The incoming branch's clippy
  passes targeted the 5.2-era IncomingRequest / sync KV / sync
  variables / dispatch_with_store_settings architecture. Our
  branch replaced all of that with the 6.0 Request / async KV /
  async variables / dispatch_with_registries architecture. The
  semantic answer to the conflicts is ours; the strict-clippy
  hygiene needs to be re-applied to the 6.0 code (separate commit).
- Cloudflare source files (config_store, lib, request): take ours.
  Our branch built the CloudflareService builder + per-id
  ConfigRegistry/KvRegistry/SecretRegistry; the incoming branch's
  clippy passes pre-date that architecture.
- All three adapter contract.rs files: take ours. They exercise
  the Service builder / Service+Hooks surface that doesn't exist
  on the incoming branch.
- .github/workflows/format.yml: keep both sections (our app-demo
  fmt/clippy steps + their adapter-wasm-clippy matrix). Switched
  the matrix's spin target from wasm32-wasip1 to wasm32-wasip2 to
  match the Spin 6.0 migration in the parent commit.

Auto-merges accepted:

- Cloudflare context/key_value_store/proxy/response/secret_store:
  small clippy-driven changes (field reorder, import aliasing,
  err naming) that compose cleanly with our Service builder code.
- crates/edgezero-cli/src/adapter.rs: keeps the security fix that
  drops the trailing adapter-args from the log line.
- .github/workflows/test.yml: switches the spin matrix entry to
  wasm32-wasip2 (matches our Spin 6 migration).

Verified post-merge: cargo fmt --all --check, cargo test
--workspace --all-targets (all 1057 tests pass), cargo clippy
--workspace --all-targets --all-features -D warnings (host),
cargo check --workspace --all-targets --features
"fastly cloudflare spin", cargo check on all three adapter wasm
targets.

Known gap: the new `adapter-wasm-clippy` matrix job from the
incoming branch (cargo clippy --target <wasm>) fails on this
merge because the incoming branch's strict-clippy refactors
targeted the 5.2 / pre-Service-builder code that no longer
exists on our branch. Equivalent wasm-clippy cleanup against
the current 6.0 Spin + Service-builder Cloudflare code is the
next commit.
Brings the `adapter-wasm-clippy` CI matrix added by the strict-clippy
merge to green. Cloudflare (~44 errors), fastly (~14), spin (~115) —
all real refactors, no `#[allow]` / `#[expect]` sprinkles, matching
the chore/strict-clippy precedent.

Cross-cutting patterns applied:

- `pub use mod::Foo;` re-export-from-private-mod -> `pub mod foo;`
  with external callers using full module paths (six sites in spin's
  lib.rs).
- `absolute_paths` -> `use` imports at file top across lib/request/
  proxy/response/store files.
- `min_ident_chars` -> rename closure params (`|e|` -> `|err|`,
  `|v|` -> `|value|`, `|c|` -> `char::is_control`).
- `missing_inline_in_public_items` -> `#[inline]` on every public
  fn/method.
- `missing_errors_doc` -> `# Errors` doc section above pub fns
  returning Result.
- `arbitrary_source_item_ordering` -> canonical ExternCrate -> Use ->
  Mod -> Static -> Const -> TyAlias -> Enum -> Struct -> Trait ->
  Impl -> Fn ordering, alphabetical within each kind; struct fields,
  enum variants, and impl methods all alphabetised.
- `tests_outside_test_module` + `expect_used` in tests -> wrap
  contract-test bodies in `#[cfg(test)] mod tests { ... }` so
  `clippy.toml`'s `allow-expect-in-tests = true` exemption kicks
  in, eliminating per-call attributes.
- `used_underscore_items` (the test compile-check pattern) ->
  `const _: fn() = assert_provider_impl::<T>;` idiom instead of
  the `_assert_*_impl::<T>() {}` + manual `_check()` caller.
- `scoped_visibility_modifier` on fields -> drop redundant
  `pub(crate)` when the type itself is `pub(crate)`.
- `let_underscore_must_use` + `let_underscore_untyped` ->
  `drop(init_logger())` instead of `let _ = init_logger();`.

Adapter-specific notes:

Spin: introduced public type alias
`SpinFullResponse = Response<FullBody<Bytes>>` because the unaliased
form appeared in five signatures across lib.rs/request.rs/response.rs
(satisfies `very_complex_type`). Replaced `"spin".parse().expect(...)`
with `HeaderValue::from_static("spin")` (compile-time infallible).
Replaced `collected.len() + bytes.len()` with `.saturating_add()`
(`arithmetic_side_effects`). Renamed shadowing inner bindings
(`kv` -> `kv_registry`, `secret` -> `secret_registry`,
`body_bytes` -> `request_body_bytes` / `response_body_bytes`).
Added `edgezero-core` dev-dep with `features = ["test-utils"]` so
the `request::synthesis_tests` mod can use `NoopKvStore` /
`NoopSecretStore` (mirrors the fastly/cloudflare pattern).

Cloudflare: folded the four-arg `dispatch_with_registries` call into
a `RegistryInputs<'env>` struct to clear `too_many_arguments`. Made
`edge_error_to_worker` and `into_core_method` take `&T` instead of
owned `T` (`needless_pass_by_value`); call sites switched to
`|err| edge_error_to_worker(&err)`. Renamed shadowing bindings
(`mut warned_bindings` -> `mut guard`, `kv` -> `kv_registry`,
`secret` -> `secret_registry`, inner `meta` -> `store_meta`).

Fastly: a single-file pass on `tests/contract.rs` (the source files
all live inside the wasm gate; the host clippy gate already covered
them). Moved `mod secret_store_compile_check` above `#[test]` fns
for item ordering.

Verified post-commit:
- `cargo fmt --all -- --check`
- `cargo test --workspace --all-targets` (1057 tests pass)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`
- `cargo clippy -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --all-targets -- -D warnings`
- `cargo clippy -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --all-targets -- -D warnings`
- `cargo clippy -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --all-targets -- -D warnings`
Closes the High blocker and five Low findings from the post-merge
self-review of the Spin 6 + strict-clippy integration.

High: Spin wasm contract CI was red.

Three contract tests (`config_store_reads_value_from_handler`,
`kv_store_reads_value_from_handler`, `secret_store_reads_value_
from_handler`) inserted bare `ConfigStoreHandle` / `KvHandle` /
`SecretHandle` into request extensions, then called
`app.router().oneshot(...)` -- bypassing the Spin dispatch
boundary. Under the hard-cutoff model the core
`RequestContext::config_store_default()` /
`kv_store_default()` / `secret_store_default()` extractors only
read `ConfigRegistry` / `KvRegistry` / `SecretRegistry`. The
dispatch boundary's `synthesise_store_registries` is what
normally wraps a bare handle into a one-id registry keyed under
`"default"`. Because the tests bypassed dispatch, handlers got
`None` and the assertions silently failed under
`wasmtime run` in CI.

Fix: the three tests now insert `*Registry::single_id("default"
.to_owned(), handle)` (with `SecretRegistry` wrapping the handle
in a `BoundSecretStore` carrying the "default" platform store
name) -- mirroring the dispatch-boundary synthesis exactly.
Verified locally with
`CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run' cargo test
-p edgezero-adapter-spin --features spin --target wasm32-wasip2
--test contract` (was failing pre-fix per reviewer; not
re-runnable on this machine without a pinned wasmtime, but the
compile + clippy gates against `wasm32-wasip2` pass).

Low: CLI docs described logical ids where the implementation
uses env-resolved platform names.

Both Cloudflare and Fastly's `provision` and `config push`
adapters use `store.platform.as_str()` to look up the
binding/namespace name in `wrangler.toml` / `fastly.toml`.
`store.platform` is what `EDGEZERO__STORES__<KIND>__<ID>__NAME`
resolves to at adapter-action time, falling back to the logical
id when the override isn't set. Docs previously said the
matching key was `<store_id>`; updated to `<platform-name>` with
the resolution rule spelled out, in:
- `docs/guide/cli-reference.md` -- both the provision and push
  tables (cloudflare + fastly cells)
- `docs/guide/cli-walkthrough.md` -- both the provision and
  push bullets (cloudflare + fastly); push bullets converted to
  fenced bash blocks (see High below)
- `crates/edgezero-adapter-fastly/src/cli.rs:329` -- the
  `create_fastly_store` rustdoc -- comment now says
  `--name=<platform-name>` and explains the caller does the
  resolution.

High: docs CI red because of multi-line inline backticks.

`docs/guide/cli-walkthrough.md`'s push bullets had inline code
spans that wrapped across line breaks (e.g.
`` `wrangler kv bulk put <tempfile>\n--namespace-id=<id>` ``).
Prettier respects `proseWrap: preserve` but re-aligned the
continuation lines such that `<tempfile>` / `<id>` ended up at
column 0 on the wrap line, terminating the backtick span. The
leaked `<tempfile>` / `<id>` were then parsed by VitePress's
Vue compiler as unterminated HTML tags, breaking
`npm run build` with `Element is missing end tag`.

Fix: cloudflare/fastly push + provision bullets now use fenced
bash code blocks for the multi-arg commands -- bulletproof
against Prettier reflow. Verified
`cd docs && npm run build` succeeds in 1.5s.

Low: docs/guide/adapters/spin.md described the wrong secret
translation rule.

The collision-check section claimed both config keys and
`#[secret]` field values get `.→__`-translated. In reality
`SpinSecretStore::get_bytes` only `to_ascii_lowercase`s the key
(no dot translation), and the CLI validator
(`crates/edgezero-adapter-spin/src/cli.rs:434-439`) mirrors that
exactly with an explicit code comment. Doc updated to say
config keys translate dots; secret values are only lowercased.

Low: docs/superpowers/plans/2026-05-20-cli-extensions.md was
stale on three points.

(1) Said `examples/app-demo` was not in CI -- it now is, via the
dedicated `cd examples/app-demo && cargo test --workspace
--all-targets` step in `test.yml` + a parallel fmt/clippy pass
in `format.yml`. (2) Listed the Spin wasm gate as
`wasm32-wasip1` -- now `wasm32-wasip2` per the Spin 6 migration.
(3) Task 8.3 step 1 (add `app-demo` CI) is done; marked `[x]`
with a reference to the workflow files.

Low: `crates/edgezero-cli/tests/generated_project_builds.rs`
exercised the raw `edgezero` binary's `config validate` but
never invoked the generated typed CLI's typed validator.

Added a `cargo run -p scaffold-probe-cli --quiet -- config
validate --strict` step after the host `cargo check`. Catches
template / `AppConfig` drift the raw validator cannot --
`#[derive(Validate)]` impl on `AppConfig` + the `#[app]`
macro-emitted `#[secret]` discovery / collision checks now run
against every freshly-scaffolded project.

Low: `crates/edgezero-adapter-axum/src/secret_store.rs` rustdoc
example used the removed `cargo edgezero dev` command. Replaced
with `API_KEY=mysecret edgezero serve --adapter axum`.

Verified post-commit: `cargo fmt --all -- --check`, `cargo
clippy --workspace --all-targets --all-features -- -D warnings`,
`cargo test --workspace --all-targets`, all three adapter
wasm-clippy gates (`cloudflare wasm32-unknown-unknown`, `fastly
wasm32-wasip1`, `spin wasm32-wasip2`), `cd docs && npm run lint
&& npm run format && npm run build`. The Cargo.lock minor-
version drift left by background cargo runs is intentionally
NOT included in this commit -- belongs in a separate dep
maintenance change.
The Fastly adapter's `run_app` was unconditionally calling
`init_logger("stdout", ...)` because `logging_from_env` returned
`use_fastly_logger: true` with `endpoint: None`, and the run-app
fallback was `"stdout"`. Viceroy 0.17 treats `stdout` as a reserved
endpoint name and rejects the `Endpoint::from_name("stdout")` hostcall,
surfacing `failed to build Fastly logger: endpoint not found, or is
reserved` on every request.

This made `app-demo` (or any project using the manifest-free `run_app`
entrypoint) unrunnable under `fastly compute serve` without a manual
`fastly.toml` patch.

env_config: add `EnvConfig::logging_endpoint()` that reads
`EDGEZERO__LOGGING__ENDPOINT`. Adapters that wire a platform-specific
logger now have a single env-side knob for the endpoint name.

adapter-fastly: `logging_from_env` now reads the endpoint from env and
sets `use_fastly_logger = endpoint.is_some()`. Production deploys set
`EDGEZERO__LOGGING__ENDPOINT=<name>` (matching a `[log_endpoints]`
entry in `fastly.toml`); local Viceroy runs leave it unset and the
existing `if logging.use_fastly_logger` gate in `run_app` skips the
init -- no init means no reserved-name error, and Viceroy still
surfaces `println!` from the wasm via its own stdout pass-through.

Verified: cargo fmt, host clippy --workspace, edgezero-core +
edgezero-adapter-fastly tests pass, fastly wasm32-wasip1 clippy +
cargo check pass.
Operators developing against a local emulator (Viceroy for Fastly,
`wrangler dev --local` for Cloudflare) had no way to seed config-store
contents without either reaching for prod credentials or hand-editing
the adapter's local-server toml. Add `--local` so `config push` writes
to the local-emulator state for all four adapters.

New CLI surface:

  app-demo-cli config push --adapter <name> --local [--dry-run]

CLI / trait wiring:

- `ConfigPushArgs::local: bool` (`--long`).
- New trait method `Adapter::push_config_entries_local` on
  `edgezero-adapter`, mirroring `push_config_entries`. Default returns
  "adapter `<name>` does not implement `config push --local`" so any
  third-party adapter that hasn't opted in fails loudly rather than
  silently writing nothing.
- `dispatch_push` routes by `args.local`; the `--dry-run` log line
  picks up `--local` so the preview is unambiguous.

Per-adapter impls:

- **fastly** -- `toml_edit`s `[local_server.config_stores.<platform>]`
  in the adapter's `fastly.toml`. Sets `format = "inline-toml"` and
  replaces `[local_server.config_stores.<platform>.contents]` with the
  pushed entries. Idempotent on re-push (the contents block is replaced
  wholesale, so stale keys don't linger); other tables in `fastly.toml`
  (setup, scripts, secret_stores, kv_stores) are preserved by
  `toml_edit`. Viceroy reads the file on startup, so a follow-up
  `fastly compute serve` exposes the new values to the wasm component.
  Four new tests pin the helper (minimal file, re-push replaces,
  preserves unrelated blocks, creates the file when missing).

- **cloudflare** -- same flow as the prod push but appends `--local` to
  the wrangler invocation. Wrangler writes entries into
  `.wrangler/state/v?/kv/<namespace_id>/...`; a follow-up
  `wrangler dev --local` (or `edgezero serve --adapter cloudflare`) reads
  them from the emulator instead of the live account. Tempfile-backed
  bulk put -- same idempotency guarantees as the prod path.

- **spin** -- delegates to the existing `push_config_entries` (spin
  has no separate local-emulator state for config; `spin up` reads the
  same `spin.toml` `[variables]` + `[component.<id>.variables]` tables
  `spin deploy` ships). Prepends a one-line notice so an operator who
  typed `--local` for parity with fastly/cloudflare knows there was
  nothing extra to write.

- **axum** -- delegates to the existing `push_config_entries` (axum's
  default already writes `.edgezero/local-config-<id>.json`, which the
  dev server reads). Same one-line notice pattern as spin.

Tests:

- `cli::tests::write_fastly_local_config_store_creates_inline_block_in_minimal_file`
  -- exercises the happy path against a minimal `fastly.toml`, asserts
  both the store table and the contents block are emitted with the
  correct quoting (dotted keys quoted).
- `..._replaces_existing_block_on_re_push` -- proves stale keys are
  dropped on re-push (the contents block is replaced, not merged).
- `..._preserves_unrelated_blocks` -- asserts the helper does not
  stomp `[setup.*]` / `[[local_server.kv_stores.*]]` / `[scripts]`
  blocks that already exist in `fastly.toml`.
- `..._creates_file_when_missing` -- the file is created on first push.

Verified post-commit: cargo fmt, host clippy --workspace, full
workspace tests (45 fastly cli tests including the 4 new ones), all
three adapter wasm-clippy gates (cloudflare wasm32-unknown-unknown,
fastly wasm32-wasip1, spin wasm32-wasip2).

Cargo.lock has unrelated transitive bumps from background cargo runs;
intentionally not part of this commit.
Per the spin-kv-config plan (Stage 2 of
docs/superpowers/specs/2026-06-01-spin-kv-config.md), back
SpinConfigStore with the Spin KV API instead of Spin variables.
Brings Spin config into structural parity with Cloudflare (KV-backed)
and Fastly (Config Store-backed), and resolves four problems the
variables backend carried:

  1. No dynamic config -- variables are baked into spin.toml at deploy.
  2. Variables namespace is shared with `#[secret]` values, requiring
     an explicit collision check in validate_typed_secrets.
  3. Spin was forced into the `single_store_kinds` axis for config
     (one flat namespace per app) while CF + Fastly are Multi.
  4. config push targeted spin.toml editing instead of the platform's
     idiomatic bulk-write API.

Hard cutoff: existing deployments that read config via
`spin_sdk::variables::get(...)` directly need to migrate to the
KV-backed surface. Apps that read via `ctx.config_store_default()`
keep working unchanged after a fresh `config push --adapter spin`.

config_store.rs: rewrite SpinConfigStore as a cfg-gated backend enum.
The wasm variant holds `(label, key_value::Store)` -- the label is
recorded so multi-store error messages name which platform store
fired the failure. The test variant is `BTreeMap<String, Bytes>`
(was `HashMap<String, String>`) so contract tests exercise bytes-
backed values end-to-end, with strict UTF-8 decoding to match the
wasm error path (rejected `from_utf8_lossy`, which would hide a
prod/test divergence). Drops `translate_key` and the `.->__`
dotted-key translation entirely -- KV accepts arbitrary key bytes.
The async `SpinConfigStore::open(label)` constructor replaces
infallible `new()` so missing `key_value_stores = [...]`
declarations surface as a clean dispatch-setup error rather than
on first read.

request.rs: rewrite build_config_registry to async +
`anyhow::Result<Option<ConfigRegistry>>` mirroring build_kv_registry.
Per declared id: `env.store_name("config", id)` ->
`SpinConfigStore::open(label).await?` -> per-id ConfigStoreHandle.
`dispatch_with_registries` awaits the build and propagates with `?`.
The legacy low-level `dispatch_with_kv_label` path's
resolve_config_handle becomes async too, opening the config store
against the same label as KV (the rustdoc explains it -- registry
callers use the env-resolved per-id labels via run_app).

Three new contract tests in config_store.rs:
- `dotted_get_resolves_verbatim_under_kv` -- proves the KV backend
  stores keys verbatim, no `.->__` fallback.
- `non_utf8_value_returns_unavailable` -- pins the strict-UTF-8
  contract so the test InMemory backend can't drift from wasm.
- `missing_key_returns_none` -- baseline negative.

All seven config_store_contract_tests! macro rows pass under the
new InMemory backend (rewritten to take `(String, Bytes)` entries).

Verified: cargo fmt, host clippy workspace -D warnings, cargo test
workspace (78 spin-adapter tests pass), wasm32-wasip2 check + clippy
on edgezero-adapter-spin.
Stage 3 of docs/superpowers/specs/2026-06-01-spin-kv-config.md.
Adds the wasm-side seeding surface so `config push --adapter spin
--local` can flow values into the KV-backed config store from
Stage 2 without relying on a `spin kv put` CLI subcommand (which
doesn't exist in spin-sdk 6.0).

Design (D2 in the spec): the adapter exposes a single fixed route,
`/__edgezero/config/seed`, that accepts JSON `{store, entries}`
bodies. `app-demo-cli config push --adapter spin` (Stage 4) HTTP
POSTs that body. The handler validates -> writes -> 204s. Same
mechanism works for prod (operator sets seed URL) and local
(builtin default http://127.0.0.1:3000/__edgezero/config/seed).

D10 split: the security surface lives in
`handle_seed_request_core` (host-compilable, takes
`edgezero_core::http::Request`) so 14 unit tests cover every D9
status code on host. `handle_seed_request_spin` is the thin
wasm-only wrapper that translates Spin <-> core via the existing
`into_core_request` / `from_core_response` helpers. Both return
`anyhow::Result<SpinFullResponse>` so `run_app_with_seeder`'s
seed-branch / fall-through if/else is type-coherent.

D9 security contract enforced exactly:

  | Code | Condition                                         |
  |------|---------------------------------------------------|
  | 204  | success                                           |
  | 400  | malformed JSON / empty entries / non-string vals  |
  | 401  | server token unset / blank / whitespace / <16B    |
  |      |   OR wire token header missing -- fail-closed     |
  | 403  | wire token does not match server token            |
  | 404  | body store not in env-resolved platform labels    |
  | 405  | non-POST method                                   |
  | 415  | content-type not application/json                 |
  | 422  | SeedWriter::write errored mid-stream              |

Fail-closed token rule (D9 + v6 16-byte floor): the handler returns
401 on EVERY request when `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is
unset, blank, whitespace-only, or shorter than 16 bytes. An operator
who sets a 4-character placeholder gets a clean error rather than an
open writeable endpoint. Token comparison uses `subtle::ConstantTimeEq`
to prevent timing-oracle leakage of the token prefix.

Testable writer (D10): `SeedWriter` trait is mocked in tests by an
`InMemorySeedWriter` (host) and implemented in production by
`SpinKvSeedWriter` (wasm, calls `spin_sdk::key_value::Store::set`).
The host tests cover all 14 D9 cases including the explicit 15-byte
/ 16-byte boundary checks.

Dep gating (D11): subtle, serde, serde_json, thiserror added as
non-optional adapter-spin deps (NOT under any feature) so the host-
compilable core + its unit tests reach them without `--features spin`.
reqwest stays out of this commit -- it's the Stage 4 CLI-side dep
gated behind `cli` so it can't leak into the wasm bundle.

run_app + run_app_with_seeder (D9 opt-in scaffolding):
- `run_app` signature changes from `anyhow::Result<impl IntoResponse>`
  to `anyhow::Result<SpinFullResponse>` (the concrete type already
  publicly aliased). Source-compatible with the generated scaffold
  handler signature because `SpinFullResponse: IntoResponse`. NOT a
  variables-backwards-compat carve-out -- this stays hard-cutoff.
- New `run_app_with_seeder<A: Hooks>(req) -> anyhow::Result<SpinFullResponse>`
  routes the seed path to `handle_seed_request_spin` (with the env-
  resolved token + per-id platform labels from
  A::stores().config x env.store_name("config", id)) and falls
  through to `run_app::<A>` for everything else.
- IntoResponse dropped from the spin_sdk::http::{...} import line --
  once run_app returns SpinFullResponse, IntoResponse is unused and
  the wasm-clippy `-D warnings` gate would fail on unused_imports.

Scaffold template: switch the body call from `run_app::<App>(req).await`
to `run_app_with_seeder::<App>(req).await`. Handler signature unchanged.
Existing apps that don't want the seeding surface can switch back to
`run_app` -- they're never opted in by default at runtime because
the seed handler is fail-closed without a configured server token.

Cargo.lock has substantial unrelated transitive bumps from
background cargo runs (autocfg 1.5.0->1.5.1, brotli 8.0.2->8.0.3,
etc.) interleaved with the new deps (subtle, thiserror, serde,
serde_json under edgezero-adapter-spin). Both go in together so
the lock matches the manifests; the noise was already pending.

Verified: cargo fmt, host clippy workspace --all-features
-D warnings, workspace tests (92 spin-adapter tests including 14
new seed tests), wasm32-wasip2 clippy + check on adapter-spin,
cloudflare and fastly per-target wasm-clippy gates all clean.
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.
ChristianPavilonis and others added 25 commits October 14, 2026 13:48
Share platform-aware prefix matching across environment capture, duplicate validation, and generated startup selection. Add isolated casing and non-Unicode regressions, and document the checked-in generated store proofs.
Replace Python helpers and tests with dependency-free Node.js tooling, update workflows and documentation, and preserve OCI artifact verification contracts.

Accept valid repeated separators in image repository names and share Compose startup polling with regression coverage.
Run generated application contracts against pinned DynamoDB Local, Moto, and AppConfig Agent services in CI. Add fixture administration and runner tests, document coverage and emulator limitations, and type built-in store sources by binding kind.
Capture typed evidence before response header filtering, retain it across all adapter response paths, and fail closed when original header fidelity is unavailable. Hard-cut response construction and decomposition, cover native and hosted normalization paths, and align documentation and fixtures.
Retain hard-cut outbound APIs, adapter-owned lifecycles, Content-Type provenance, and exact audited dependency requirements. Integrate main's toolchain and dependency updates across the workspace, demo, fixtures, and generator.

Verify Rust 1.98.1 workspace tests, strict native/WASM clippy, runtime contracts, generated applications, documentation, dependency audits, and deployment action contracts.
…ression tests, synchronize local doctest gates, refresh verification records, and remove session-only narrative. Production behavior unchanged.
Integrate main's IABTechLab transfer and retain the PR's outbound guide links when resolving the README. Align remaining active documentation, generated README, and capability diagnostic URLs with the canonical repository and Pages site.

Add regression tests for generated guide links and capability diagnostics. Preserve the hard-cut outbound APIs, adapter runtimes, lifecycle ownership, limits, clocks, and audited dependency pins unchanged.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
Promise.resolve().then(() => fixtureAction(process.argv[2], JSON.parse(process.argv[3])))
.then((result) => console.log(JSON.stringify(result)))
.catch((error) => { console.error(error); process.exitCode = 1; });

This branch has not been deployed

No deployments
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.

4 participants