Skip to content

feat: implement portable outbound HTTP and request lifecycle contracts - #275

Open
aram356 wants to merge 362 commits into
mainfrom
docs/outbound-http-spec
Open

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

Conversation

@aram356

@aram356 aram356 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the portable outbound HTTP and request-lifecycle contracts across core and the Axum, Cloudflare, Fastly, and Spin adapters.

  • Adds buffered and streamed outbound requests/responses plus a hard-cut, completion-driven batch API: start_batch_until, indexed next, explicit cancel, ordered collect, and the send_all_until convenience path.
  • Makes batch termination explicit as Completed or Cutoff. Only Cutoff permits unresolved slots in a successful result. Explicit adapter failure, premature driver EOF, and duplicate or out-of-range indices return OutboundBatchFailure, preserving the precise error and every terminal slot collected before failure.
  • Preserves per-slot elapsed time and failure provenance. A batch cutoff keeps completed slots and leaves unresolved slots explicit instead of converting them into request timeouts.
  • Adds explicit cache-bypass and wire-authority policies. URI routing, SNI, and certificate identity remain independent where the provider supports that separation.
  • Enforces independent encoded-transport, decoded-output, and final-buffer limits; header count/byte limits; Brotli window and decoder-state policy; opt-in response rechunking; and typed timeout, transport, protocol, and content-coding failures.
  • Uses fixed category-only public error messages so provider diagnostics, URLs, and credentials cannot become wire-visible.
  • Carries one application-owned monotonic clock through ingress, outbound dispatch and deferred streams, returned response drains, configuration extraction, and response egress. Equality expiry wins and backwards time fails closed.
  • Adds route-aware ingress admission on every standard adapter, stable route metadata, request-start-relative deadlines, opaque request-owned grants, and bounded 404/405 fallback reads with application-selected overflow/timeout responses.
  • Gives every response-producing admission decision one non-clone ResponseEgressCompletion, transferred directly through PreparedIngress, ResponseEgressEnvelope, and the sole ResponseEgressAttempt. Terminal state is stored before the response-scoped completion and global observer run, each exactly once and behind independent panic boundaries. No response-extension carrier or envelope extraction bypass remains.
  • Adds deterministic ResponseEgressCompletion::join composition so independent response-owned resources share one exactly-once terminal report, and hard-cuts detached normalized errors to Send { completion, deadline } | Abort with request-start-relative bounded deadlines.
  • Adds explicit non-response AdmissionDecision::Abort. Axum closes the owned HTTP/1 connection before body polling; Cloudflare, Fastly, and Spin return a fixed provider error at their strongest available pre-response boundary and remain conservatively classified BestEffort.
  • Hard-cuts application assembly to fallible Hooks::configure and Hooks::build_app, propagating typed configuration failures before request serving. The demo, generator, templates, adapter entrypoints, and guides all use the new contract.
  • Implements adapter-owned response delivery through each platform's strongest boundary: Hyper connection ownership on Axum, a Workers writer plus AbortController, Fastly's low-level stream_to_client, and Spin's WASI response/body/result writers.
  • Adds a closed Fastly lifecycle runner that keeps request conversion, routed execution, response finalization, framing, streaming, and platform delivery under adapter ownership.
  • Uses Fastly pending-handle selection with index reassociation while retaining documented sequential dispatch, bounded selection-group, synchronous selected-body-drain, and cancellation limitations.
  • Preserves duplicate response fields, canonical host authority, bodyless response framing, strict content-length validation, hop-by-hop stripping, and source release on every terminal path.
  • Hard-cuts superseded APIs and configuration error variants. The checked-in demo, generated applications, templates, guides, capability matrices, CI sentinels, and legacy guards use only the current contracts.

Downstream acceptance impact

Cloudflare outbound-deadlines and streamed-upload-deadlines are BestEffort, not Native. Downstream acceptance suites must use best-effort deadline wording and must not claim exact cancellation enforcement until deployed host-observed evidence exists.

The response completion, ingress abort, and fallible application-assembly changes are intentional breaking changes. Consumers must provide a completion on every response-producing admission decision and propagate Hooks::build_app() failures.

Evidence boundaries

Capability declarations remain conservative where provider behavior is not proved:

  • Raw request-target/header accounting and parser-boundary CL/TE rejection are Unsupported on all adapters.
  • Inbound read deadlines are Native on Axum and BestEffort on Cloudflare, Fastly, and Spin because finite host-side teardown is not proved or synchronously preemptible.
  • Ingress admission abort is Native on Axum's owned HTTP/1 connection and BestEffort on Cloudflare, Fastly, and Spin because provider transport reset/close is not directly observable.
  • Response-egress abort, backpressure, completion, and write deadlines remain BestEffort on every adapter because no boundary proves end-client receipt; provider-specific blocking and teardown limits are documented.
  • Complete outbound and configuration-read allocation accounting is Unsupported; provider parsing/materialization, SDK copies, informational responses, trailers, allocator overhead, and other host-owned memory remain outside guest-visible caps.
  • Configuration-read deadlines are BestEffort on every adapter because provider operations are not proved cancellable within a finite wall-clock interval.
  • Cloudflare outbound deadlines and streamed-upload deadlines remain BestEffort pending a deployed host-observed cancellation artifact. Fastly and Spin retain their separately documented deadline, phase-budget, and isolation limitations.
  • Batch completion/cancellation/isolation and authority-override support are reported independently per adapter in the public capability matrix.

Validation

  • scripts/run_tests.sh
  • 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"
  • Native and WASM adapter feature matrices for Axum, Cloudflare, Fastly, and Spin
  • Fastly WASM/Viceroy contract and runtime suites
  • Fresh generated all-adapter project host tests and all provider WASM builds
  • Checked-in demo host tests, strict Clippy, and all provider WASM builds
  • Documentation format, lint, build, contract, dependency, and legacy API guards

aram356 added 30 commits May 21, 2026 21:44
Rewrites the manifest store model to the §6.6 portable schema:

- `[stores.<kind>]` now carries only logical `ids` (non-empty) and an
  optional `default` (required when >1 id, must be a declared id). The
  five per-adapter store config types collapse into one reusable
  `StoreDeclaration`.
- The pre-rewrite store schema (`[stores.<kind>] name`,
  `[stores.config.defaults]`, `[stores.<kind>.adapters.*]`, `enabled`)
  is a hard load error whose message points at the migration guide.
- Store helper methods resolve a store's name to its logical default
  id (interim — `EDGEZERO__*` env overrides arrive in Task 2.2).
- `[stores.config.defaults]` and its axum dev-server seeding are gone.
- Migrated `examples/app-demo/edgezero.toml` and the generated
  `edgezero.toml.hbs` template to the new schema.

Scoped to store types only; `[adapters.*]`, the env layer, and adapter
store registries are later Stage 2 tasks.
New `edgezero-core::env_config` module: parses `EDGEZERO__`-prefixed
environment variables (`__` = key-path separator, segments lower-cased)
into an `EnvConfig` value with accessors for store platform names +
tuning, bind host/port, and logging level.

- `from_env()` reads the process environment; `from_vars()` lets the
  Cloudflare adapter supply its `Env` binding (no `std::env` there).
- `store_name(kind, id)` falls back to the logical id when unset.

Additive only — wired into the runtime in later Stage 2 tasks.
…st_src from run_app

Couples the macro/runtime change with the adapter signature change so the
workspace and `examples/app-demo` stay buildable in a single commit.

- `Hooks::stores() -> StoresMetadata` replaces `config_store()`. The
  `app!` macro emits portable `StoreMetadata { default, ids }` for
  `[stores.config|kv|secrets]`. `Hooks::stores()` defaults to empty so
  apps built without the macro still compile.
- `run_app::<A>()` no longer takes `manifest_src` on any adapter — axum,
  cloudflare, fastly, spin. Each reads `A::stores()` and layers
  `EDGEZERO__*` env config on top (logging level, bind host/port, store
  platform names). All four entrypoint templates, all four
  `app-demo-adapter-*` consumers, and `edgezero-cli/src/demo_server.rs`
  drop `include_str!("edgezero.toml")`.
- axum `resolve_addr` reads `EDGEZERO__ADAPTER__HOST`/`PORT` only; the
  `[adapters.axum.adapter]` fallback is gone (consistent with the §6.6
  no-runtime-tables rule).
- cloudflare derives the exact `EDGEZERO__STORES__<KIND>__<ID>__NAME`
  keys from baked metadata to query the worker `Env` (workers cannot
  enumerate). Deprecated `run_app_with_manifest` is removed.
- spin drops `dispatch_with_manifest`; the KV label resolves from
  `EDGEZERO__STORES__KV__<ID>__NAME` or the declared default id.

All five CI gates green + `examples/app-demo` tests pass.

Tasks 2.5–2.9 (async ConfigStore, store registries, extractors,
app-demo/templates/docs migration, ship gate) follow.
…stry, id-keyed RequestContext

Lands the runtime-API shape for §6.6 multi-store support. No adapter
yet builds a `StoreRegistry` — that arrives in Task 2.6. The new
RequestContext accessors are wired with a legacy single-handle
fallback so all four adapters keep compiling and tests stay green
through the transition.

- `ConfigStore::get` → `async` (`#[async_trait(?Send)]`). Required for
  Cloudflare's KV-backed config store (§8 Task 2.6) which is async at
  the SDK boundary. All four adapter impls + `FixedConfigStore` test
  doubles + app-demo's `MapConfigStore` / `UnavailableConfigStore` are
  updated; the contract-test macro switches to `block_on` (same pattern
  as the KV contract macro). `ConfigStoreHandle::get` follows.
- New `KvError` variants with `From<KvError> for EdgeError` mappings:
  - `Unsupported { operation }` → `EdgeError::not_implemented` (501).
    Used by Spin TTL writes (§6.7), where `key_value::Store::set` has
    no expiry parameter.
  - `LimitExceeded { message }` → `service_unavailable` (503). Used by
    Spin's `get_keys` cap (`max_list_keys`, §6.7).
  A new `EdgeError::NotImplemented` variant + constructor backs the
  501 mapping.
- New `edgezero_core::store_registry` module:
  - `StoreRegistry<H> { by_id: BTreeMap<String, H>, default_id: String }`
    with `default()`, `default_id()`, `ids()`, `named()`, `new()`.
  - `KvRegistry` / `ConfigRegistry` / `SecretRegistry` type aliases.
  - `BoundKvStore` / `BoundConfigStore` / `BoundSecretStore` aliases for
    the existing handle types so future call sites can speak in
    registry terms without coupling to the legacy names.
- `RequestContext` gains id-keyed accessors `kv_store(id)` /
  `config_store(id)` / `secret_store(id)` and the `_default()` helpers.
  Each reads from the matching registry in extensions when present
  (strict lookup — unknown ids yield `None`); otherwise falls back to
  the legacy single-handle stash so today's adapters continue to work.
  The pre-existing no-arg `config_store()` is renamed to
  `config_handle()` to free the symmetric name; the few in-tree
  call sites (axum service + four adapter contract tests + app-demo's
  config handler) are updated. Handler migration to the id-keyed API
  lands in Task 2.8.

Tests added: 4 RequestContext registry/fallback cases (kv, config,
secret), 2 KvError → EdgeError mappings (Unsupported → 501,
LimitExceeded → 503), 6 StoreRegistry unit tests. All five CI gates
green plus `examples/app-demo` tests.
…rror variants

First slice of Task 2.6. The remaining adapters (fastly, spin, cloudflare)
follow in subsequent commits; the legacy single-handle path stays live
in parallel so the workspace and `examples/app-demo` keep building
through the transition.

axum:
- `EdgeZeroAxumService` and `AxumDevServer` gain
  `with_{kv,config,secret}_registry` setters alongside the existing
  single-handle ones. Both can coexist; the registries are inserted
  into request extensions in addition to the legacy handles, and
  `RequestContext` prefers a registry when present.
- `dev_server::run_app` builds the three registries from `A::stores()`
  + `EDGEZERO__STORES__*`:
  - KV: one `PersistentKvStore` per declared id at
    `.edgezero/kv-<slug>-<hash>.redb` (file name derived from the
    platform name from `EDGEZERO__STORES__KV__<ID>__NAME` or the id
    default).
  - Config: one empty `AxumConfigStore` per declared id; the
    `.edgezero/local-config-<id>.json` read path lands when stage 7's
    `config push` is wired.
  - Secrets: axum is `Single` (§6.6) — every declared secrets id maps
    to the same env-backed `EnvSecretStore`.

spin:
- `SpinKvStore::put_bytes_with_ttl` now returns
  `KvError::Unsupported { operation: "put_bytes_with_ttl" }` (was
  `KvError::Validation`) — semantically correct per §6.7 and matches
  the variant added in Task 2.5.
- `SpinKvStore::list_keys_page` now returns
  `KvError::LimitExceeded` (was `KvError::Validation`). Paginated
  listing with an env-driven `max_list_keys` cap lands in the spin
  registry-wiring commit.

All five CI gates green; `examples/app-demo` tests pass.
Adds a registry-aware dispatch path (`dispatch_with_registries`) that
builds per-id `KvRegistry`/`ConfigRegistry`/`SecretRegistry` from
baked `Hooks::stores()` + `EDGEZERO__STORES__*`. `run_app` switches to
it; the legacy single-handle helpers (`run_app_with_config`,
`run_app_with_logging`, the individual `dispatch_with_*` entry points)
remain for back-compat.

Fastly is `Multi` for all three kinds (§6.6). Per kind:
- KV: each declared id opens its own `FastlyKvStore` via the platform
  name from `EDGEZERO__STORES__KV__<ID>__NAME` (default = id). The
  per-id open is required — declaring `[stores.kv]` means failure to
  open is a runtime error, not silent degradation.
- Config: each declared id opens its own `FastlyConfigStore` via
  `EDGEZERO__STORES__CONFIG__<ID>__NAME`; missing stores log a
  one-time warning and the id is dropped from the registry.
- Secrets: `FastlySecretStore` is stateless (it opens the named store
  per call), so one shared `SecretHandle` is registered under every
  declared id for now. Per-id platform-name binding lands when Task
  2.7 reshapes `BoundSecretStore` to capture the name.

All five CI gates green; `examples/app-demo` tests pass.
…ys_page

Wires Spin to the registry model and replaces the unconditional
listing error with real client-side paging.

Per §6.6 / §6.7 Spin capability map:
- KV (Multi): each declared id opens its own `SpinKvStore` under the
  Spin label resolved from `EDGEZERO__STORES__KV__<ID>__NAME`. The
  `max_list_keys` paging cap is per-id, read from
  `EDGEZERO__STORES__KV__<ID>__MAX_LIST_KEYS` and defaulting to 1000.
  Required: a `[stores.kv]` id failing to open is a runtime error.
- Config (Single): the one shared `SpinConfigStore` is registered
  under every declared id. Capability validation that catches
  `ids.len() > 1` on Spin lands in `config validate` (§10).
- Secrets (Single): the one shared `SpinSecretStore` is registered
  under every declared id.

`SpinKvStore::list_keys_page` materialises `Store::get_keys()`, filters
by prefix, sorts, applies the `max_list_keys` cap, and slices the page
via the new pure `crate::kv_pagination::paginate_keys` helper. Pagination
invariants are unit-tested on the host (8 tests covering: empty prefix,
prefix filter + sort, page-smaller-than-match cursor, cursor advance,
final page, cap exceeded → `LimitExceeded`, cap=0 disables, cursor past
end). The wasm-only `SpinKvStore` is the production consumer.

`run_app` switches from the legacy `SpinStoreSettings` settings path to
`request::dispatch_with_registries`. The now-dead
`SpinStoreSettings` + `resolve_store_settings` +
`dispatch_with_store_settings` slice is removed; the old single-handle
tests in `lib.rs` are superseded by the upcoming registry-aware
contract tests (Task 2.6 final).

All five CI gates green; `examples/app-demo` tests pass.
…nfigStore [vars] → KV async

Replaces the pre-rewrite JSON-string config store with a Cloudflare KV
namespace backing (§6.6) and wires per-id registries through dispatch.

CloudflareConfigStore rewrite:
- Old: `[vars]` string binding whose value is a JSON object;
  parse-and-cache at construction with O(1) map reads.
- New: a Cloudflare KV namespace binding opened at construction
  (`env.kv(binding_name)`); `get(key)` reads asynchronously via
  `worker::kv::KvStore::get(key).text().await`.
- `[vars]` bindings are restricted to JavaScript identifier syntax —
  arbitrary dotted keys (e.g. `feature.checkout`) used to require JSON
  packing; the KV backing has no such restriction.
- The whole `lookup_cached` / `CONFIG_CACHE_LIMIT` parse cache and the
  `try_new` / `new_or_empty` / `empty` constructors are gone.
- The module now compiles on the host (with a `#[cfg(test)] InMemory`
  backend) so the shared contract-test macro can run there, matching
  the `SpinConfigStore` pattern.

Per §6.6 Cloudflare capability map:
- KV (Multi): each declared id opens its own KV namespace via
  `EDGEZERO__STORES__KV__<ID>__NAME`. Required: failure is a runtime
  error rather than a silent skip.
- Config (Multi): each declared id opens its own KV namespace via
  `EDGEZERO__STORES__CONFIG__<ID>__NAME`. Missing bindings log a
  one-time warning (`warn_missing_config_binding_once`) and the id
  is dropped from the registry.
- Secrets (Single): one shared `CloudflareSecretStore` is registered
  under every declared id.

`run_app` switches to the new `dispatch_with_registries`. The legacy
`dispatch_with_config` / `dispatch_with_bindings` entry points stay
for back-compat; their config-binding semantics are updated to open
KV namespaces (with a one-time `warn` on missing bindings, mirroring
the old quiet-skip behaviour).

All five CI gates green; `examples/app-demo` tests pass.
Adds two registry-aware service tests proving the new wiring works
through the full dispatch path:

- `with_kv_registry_resolves_named_and_default`: builds a two-id
  `KvRegistry` (`sessions` + `cache`, default = `sessions`) backed by
  two distinct `PersistentKvStore`s, attaches it via
  `EdgeZeroAxumService::with_kv_registry`, and exercises three routes
  that call `ctx.kv_store("sessions")`, `ctx.kv_store("cache")`, and
  `ctx.kv_store_default()`. Each route must hit the correct backing
  store — verified by writing distinct values to each before dispatch.

- `kv_registry_lookup_is_strict_for_unknown_ids`: confirms the
  strict-lookup contract from §6.9 — when a registry is wired,
  `ctx.kv_store("missing")` yields `None` rather than falling back to
  the default.

These tests cover the registry path end-to-end on the most-used adapter.
Per-adapter registry construction in fastly/spin/cloudflare is
exercised at compile time + via the shared core `StoreRegistry` tests
(Task 2.5) and the per-adapter `config_store_contract_tests` (running
against the InMemory backends). Wasm-only contract tests for the spin
TTL → `Unsupported` and listing-cap paths live alongside the existing
`tests/contract.rs` wasm bundle and are exercised when CI runs the
spin wasm32 build.

All five CI gates green; `examples/app-demo` tests pass.
…med() shape

Reshapes the per-request store extractors to wrap registries instead of
single handles (§6.9). Handler code now picks a bound store by id at the
call site.

- `Kv` / `Secrets` are no longer tuple structs wrapping a handle. Each
  now wraps the matching `*Registry` and exposes:
    * `default() -> Option<Bound*Store>` — the registered default id
    * `named(id: &str) -> Option<Bound*Store>` — strict lookup; unknown
      ids yield `None`
    * `registry() -> &*Registry` — escape hatch for advanced use
- New `Config` extractor follows the same shape; rounds out the trio
  promised by spec §6.9.
- Each `FromRequest` impl reads the matching registry from extensions
  when present, else falls back to the legacy single handle, wrapping
  it in a synthetic one-id registry under the conventional `"default"`
  id (`single_id_registry`). This keeps adapters that have not yet
  wired registries working through Task 2.7's transition.

Migrations in this commit (all that consumed the old destructure
pattern):
- `examples/app-demo/crates/app-demo-core/src/handlers.rs`:
  `kv_counter`, `kv_note_put`, `kv_note_get`, `kv_note_delete`,
  `secrets_echo` now call `<extractor>.default()` and bubble a
  `service_unavailable` error when no store is registered.
- `crates/edgezero-adapter-axum/src/dev_server.rs::secret_value_handler`
  (demo-only) likewise.

Tests: replaced the legacy `Kv` / `Secrets` extractor tests with
registry-aware variants. New coverage:
- `Kv` falls back to legacy single handle when no registry is wired.
- `Kv` prefers a wired `KvRegistry` over a coexisting legacy handle,
  with strict `named` lookups.
- `Config` resolves named and default ids from a wired `ConfigRegistry`
  and falls back to a legacy `ConfigStoreHandle`; missing both yields a
  500 with the expected guidance string.

All five CI gates green; `examples/app-demo` tests pass.
Closes Stage 2 of the CLI-extensions work. The portable manifest +
EDGEZERO__* env-config + per-id store registries + id-keyed extractors
are in. This commit ships the user-facing docs that explain the new
shape and run the full gate one last time as the ship-gate.

Task 2.8 docs:
- New `docs/guide/manifest-store-migration.md`: the page the loader's
  hard-error message points at. TL;DR; field-by-field old → new;
  capability matrix; runtime env-var table; handler-code migration;
  the `[stores.config.defaults]` → `.edgezero/local-config-<id>.json`
  story; the Cloudflare config `[vars]` → KV namespace migration.
- `docs/guide/kv.md`: store declaration uses portable `ids` schema;
  `Kv` examples switch to `default()` / `named()`; Spin TTL writes
  return `KvError::Unsupported`; Spin listing-cap pagination via
  `EDGEZERO__STORES__KV__<ID>__MAX_LIST_KEYS` is described.
- `docs/guide/configuration.md`: `[stores.secrets]` and `[stores.config]`
  sections rewritten to portable schema with the per-adapter capability
  matrix; `ManifestLoader` summary updated for `Hooks::stores()`; one
  inline secret-store example uses `ids = ["default"]`.
- `docs/guide/adapters/cloudflare.md`: config-store section rewritten —
  `[vars]` JSON binding replaced with KV namespace binding + async
  reads. Links to the migration guide.
- `docs/guide/adapters/axum.md`: config-store section explains the
  `.edgezero/local-config-<id>.json` flow and shows the `Config`
  extractor.
- `docs/guide/adapters/overview.md`: "Config Store Resolution" replaced
  with "Store Registry Resolution" — covers all three kinds and points
  at the id-keyed extractors / `RequestContext` accessors.
- `docs/guide/adapters/fastly.md`: config-store id-keyed example +
  `Config` extractor; migration-guide link.
- `docs/guide/architecture.md`: Prettier reformat of the features table
  (table-column widening only).

Task 2.9 ship gate:
- `cargo fmt --all -- --check` ✅
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` ✅
- `cargo test --workspace --all-targets` ✅ (335 core, 138 axum, 31 spin,
  68 macros, 0 failures across all suites)
- `cargo check --workspace --all-targets --features "fastly cloudflare spin"` ✅
- `cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin` ✅
- `cd examples/app-demo && cargo test` ✅
- `cd docs && npm run lint && npm run format` ✅

An adapter binary built with no `edgezero.toml` and no `EDGEZERO__*`
env vars runs against the §6.6 defaults: `App::build_app()` produces a
`Hooks::stores()` with all three kinds `None`; the registry-builder
helpers in each adapter return `None`, the request context exposes no
store registries (and the legacy single-handle accessors return
`None` too), and bind host/port/logging fall back to `127.0.0.1:8787`
/ `info`. The four generated entrypoint templates and `examples/app-demo`
already drop `include_str!("edgezero.toml")`.
…fault scaffold; doc + warning fixes

P1 (correctness) — generated/app-demo non-axum runtimes were broken
after the portable store rewrite. Each adapter built a `KvRegistry`
keyed by the declared logical ids, but the platform manifests still
declared the legacy single-store bindings, so `spin up`, `wrangler dev`,
and `fastly compute serve` would error before any handler ran.

- Scaffold (`templates/root/edgezero.toml.hbs`): drop the default
  `[stores.kv]`, `[stores.config]`, `[stores.secrets]` blocks. The
  scaffold handlers don't read any store, so declaring them forced
  every new project to provision matching `wrangler.toml` /
  `spin.toml` / `fastly.toml` bindings (which the scaffold templates
  for those files do not generate). They're now commented out with a
  pointer to the migration guide so users opt in when they need them.
- app-demo: provision matching platform bindings for the declared
  logical ids (`sessions` + `cache` for KV; `app_config` for config;
  `default` for secrets):
  - `app-demo-adapter-spin/spin.toml`: `key_value_stores =
    ["sessions", "cache"]` (was `["default"]`).
  - `app-demo-adapter-fastly/fastly.toml`: rename `EDGEZERO_KV` → two
    stores `sessions` + `cache`; keep the `app_config` config store
    (id already matches); keep the Fastly secret store named
    `EDGEZERO_SECRETS` because `BoundSecretStore` does not yet
    capture per-id platform names (the handler hardcodes
    `SECRET_STORE_NAME = "EDGEZERO_SECRETS"` and passes it as the
    `store_name` argument).
  - `app-demo-adapter-cloudflare/wrangler.toml`: replace `[vars]
    app_config` (dead since Task 2.6E rewrote the config store to KV)
    + the lone `EDGEZERO_KV` namespace with three KV namespace
    bindings (`sessions`, `cache`, `app_config`). Local KV seed data
    is no longer provided inline; populate via `wrangler kv key put`
    when running the cloudflare smoke tests.

P2 — Spin is missing from user-facing CLI docs even though it has
been a default-feature built-in since the workspace ships with
`spin` in `default = […]`.

- `docs/guide/cli-reference.md`: add `spin` to `--adapter` argument
  lists for `serve` and `deploy`, the provider-behaviour rundowns
  (mapping to `spin up` / `spin deploy`), and the built-in adapters
  list under "Adapter Discovery".
- `docs/guide/getting-started.md`: add a Spin prerequisites bullet
  (wasm32-wasip1 target + Spin CLI).

P3 — Cloudflare wasm dead-code warning.

- `crates/edgezero-adapter-cloudflare/src/request.rs`: the
  `dispatch_with_bindings` helper was orphaned by Task 2.6E's switch
  to `dispatch_with_registries`. No callers in-tree (it was
  `pub(crate)`); deleted outright. `cargo test -p
  edgezero-adapter-cloudflare --target wasm32-unknown-unknown
  --features cloudflare --no-run` now compiles warning-free.

Verification: all five workspace gates pass; `examples/app-demo`
tests pass; docs lint + format pass; `cargo test -p edgezero-cli
--test generated_project_builds -- --ignored` passes (51s; the
scaffold-probe workspace compiles cleanly under the trimmed
manifest); the Cloudflare wasm `--no-run` build emits no `dead_code`
warning.
…lare build passthrough, build script rerun, Spin docs

P2 — `EDGEZERO_MANIFEST` typos no longer silently fall back to built-ins.
- `load_manifest_optional` now distinguishes a missing default
  `edgezero.toml` (still `Ok(None)` — built-in adapters handle the
  request) from an explicit `EDGEZERO_MANIFEST` pointing at a missing
  file (hard error naming the bad path). The previous test that
  locked in the silent-fallback behaviour is replaced with one that
  asserts the hard error; added a companion test that proves the
  default-path case stays permissive.

P2 — Cloudflare built-in build now honors `BuildArgs.adapter_args`.
- `cli.rs::Adapter::execute` was the only built-in action call site
  that dropped the passthrough `args` slice. `pub fn build(extra_args:
  &[String])` now takes the slice and appends it after the canonical
  cargo flags (mirroring `fastly`/`spin`). The cloudflare adapter
  registration was the only in-tree consumer; the public signature
  change is symmetrical with the existing `deploy`/`serve` helpers.

P3 — `build.rs` rerun trigger.
- `crates/edgezero-cli/build.rs` reads `Cargo.toml` to discover
  optional `edgezero-adapter-*` deps but only requested re-runs on
  `build.rs` changes. Added `cargo:rerun-if-changed=Cargo.toml` so the
  generated `linked_adapters.rs` stays in sync when adapters are
  added/removed or flip `optional`.

P3 — Spin in CLI build docs.
- `docs/guide/cli-reference.md`: add `spin` to `edgezero build`'s
  `--adapter` argument list and examples; extend the wasm-target
  troubleshooting hint (`wasm32-wasip1` for Fastly or Spin); add the
  Spin CLI install pointer to the provider-CLI section.

Verification: all five workspace gates ✅; `examples/app-demo` ✅;
docs lint + format ✅; Cloudflare wasm `--no-run` warning-free; new
manifest tests pass (`load_manifest_optional_hard_errors_when_explicit_env_path_missing`,
`load_manifest_optional_returns_none_when_default_missing`).
P3 — Two stale documentation surfaces flagged in review.

`crates/edgezero-cli/src/templates/root/README.md.hbs` (generated
project README): the scaffold now writes a `crates/{{proj_cli}}` crate
(Stage 1's main user-visible change), but the workspace-summary list
in the generated README only mentioned the core crate plus per-adapter
crates. Added a line for the downstream CLI crate that explains it is
the intended customization point and is built on the reusable
`edgezero-cli` library.

`crates/edgezero-cli/README.md`: was a leftover from before Stage 1.
It still said build/deploy were "soon" and only documented
Fastly-specific subcommands. Rewritten to cover the actual surface:
- the binary-plus-library shape;
- the four built-in subcommands across `fastly` / `cloudflare` /
  `spin` / `axum`, with the manifest-vs-built-in fallback noted;
- the `(<Cmd>Args, run_<cmd>)` library API and the
  generated-project pattern;
- the link-time adapter discovery via `build.rs`;
- the contributor pointer to the opt-in `generated_project_builds`
  scaffold test.
Adds a cross-link to `docs/guide/cli-reference.md` so the user-facing
reference is the source of truth and the README stays a crate-level
overview.

Verification: `cargo test --workspace --all-targets` ✅; clippy ✅;
fmt ✅; `cargo test -p edgezero-cli --test generated_project_builds
-- --ignored` ✅ (50s, scaffold-probe workspace compiles cleanly with
the new README line); docs lint + format ✅.
…, Spin adapter doc

P2 — `crates/edgezero-cli/README.md` distributable-build command.
- Old `cargo build -p edgezero-cli --no-default-features --features cli`
  dropped every `edgezero-adapter-*` feature dep (which the default
  set brings in), so the resulting binary had no built-in adapter
  helpers and `edgezero new` would scaffold nothing. The features
  table now lists each adapter feature individually (all four enabled
  by default); the "distributable build" command uses `--release`
  alone; a separate "slim build" example shows the
  `--no-default-features --features "cli edgezero-adapter-axum"`
  pattern with an explicit warning that dropping default features
  also drops every adapter.

P3 — `crates/edgezero-cli/README.md` library API sample.
- Imports were wrong: the args structs live under `edgezero_cli::args::*`,
  not the crate root. Aligned with the canonical sample in
  `docs/guide/cli-reference.md`.
- The previous `BuildArgs { … ..Default::default() }` snippet does
  not compile from downstream code because the struct is
  `#[non_exhaustive]` (Rust forbids struct-literal expressions across
  crates for those types). Replaced with the correct
  `let mut args = BuildArgs::default(); args.adapter = …` idiom, with
  an explicit note on the constraint.

P3 — Spin missing from `docs/guide/adapters/overview.md` "Available
Adapters" table even though the Spin blueprint has been registered
since Stage 1.
- Added the Spin row (`wasm32-wasip1`, Stable) linking to
  `/guide/adapters/spin`.
- Wrote `docs/guide/adapters/spin.md` covering prerequisites, project
  setup, the `#[http_component]` entrypoint shape, build / serve /
  deploy commands, and Spin-specific store semantics from spec §6.7:
  label-backed multi-store KV with `EDGEZERO__STORES__KV__<ID>__NAME`
  and the `MAX_LIST_KEYS` cap; `Unsupported`/`LimitExceeded` error
  variants; flat-variable single-store Config and Secrets; Spin
  variable naming rules + `.`→`__` translation; config/secret
  namespace collision check; the component-discovery rules driving
  `provision` / `config push`.
- Registered the new page in the VitePress sidebar
  (`docs/.vitepress/config.mts`).

Note: `npm run build` still trips on a pre-existing Vue-compiler
parse error in `docs/superpowers/specs/2026-05-19-cli-extensions-design.md`
at line 1069 (`<key> = "{{ <key> }}"` inside inline code — Vue treats
the double braces as an interpolation expression). This is unrelated
to this commit, predates it, and is not in the documented CI gates
(`npm run lint` and `npm run format` are; both pass). Cleaning it up
is a separate follow-up — either escape the braces or wrap that block
in `<span v-pre>…</span>`.

Verification: cargo fmt + clippy + workspace tests + feature-combo
check + spin wasm32 check all green; `examples/app-demo` tests pass;
docs `npm run lint` + `npm run format` pass.
…ress build

P2 — Spin config store now honors the canonical dotted handler-facing
key surface (spec §6.7). Previously `SpinConfigStore::get(key)` passed
the key straight through to `spin_sdk::variables::get`, which rejects
dotted keys (Spin variable names must match `^[a-z][a-z0-9_]*$`). The
documented translation rule (`service.timeout_ms` →
`service__timeout_ms`) was a doc promise the implementation didn't
keep; `scripts/smoke_test_config.sh` had a workaround that skipped
dotted keys for Spin.

- `SpinConfigStore::translate_key(key) -> String` (`pub(crate)`):
  every `.` becomes `__`, other characters pass through (case-
  preserving; uppercase still hits `InvalidName` at the backend as
  the spec says).
- `get` translates before delegating to either backend.
- `from_entries` (test fixture) translates on insert so the InMemory
  representation mirrors what the real Spin runtime would store. The
  existing contract tests (`store.get("contract.key.a")`) keep
  exercising the same translation path as production.
- New unit tests cover the translation directly (dots → `__`, flat
  keys pass through, case-preserving) plus a round-trip that asserts
  `get("feature.new_checkout")` resolves against the
  `feature__new_checkout` storage form.
- `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` now
  declares the translated variables (`feature__new_checkout`,
  `service__timeout_ms`) and binds them in the component-variables
  table, so the existing dotted-key smoke checks light up end-to-end
  on Spin.
- `scripts/smoke_test_config.sh` drops the `if [ "$ADAPTER" != "spin" ]`
  skip; the spin adapter now goes through the same dotted-key checks
  as fastly/cloudflare/axum. Updated the header note to describe the
  translation rather than the old skip.

P3 — VitePress build was failing on
`docs/superpowers/specs/2026-05-19-cli-extensions-design.md:1069`,
where the inline-code snippet `<key> = "{{ <key> }}"` was being
Vue-parsed as an interpolation expression. `superpowers/` is the
internal design-doc folder — it sits under `docs/` so the doc
tooling (prettier/eslint) covers it, but it's not in the VitePress
sidebar and is not part of the published site. Added
`srcExclude: ['superpowers/**']` to `.vitepress/config.mts` so Vue
skips parsing those pages. `npm run build` now succeeds (1.4s, full
site rendered) without touching the spec content.

Verification: `cargo fmt --all -- --check` ✅; clippy ✅; workspace
tests ✅ (spin lib tests 31 → 35, +4 new); feature-combo check ✅;
spin wasm32 ✅; `examples/app-demo` tests ✅; docs `npm run lint`,
`npm run format`, **`npm run build`** all ✅.
… config, ManifestAdapter hard-cutoff, BoundSecretStore per-id binding

H2 — app-demo's `/config/<key>` was 503'ing under the Stage 2 run_app
path on every registry-backed adapter. The `config_get` handler still
called `ctx.config_handle()` (legacy single-handle accessor that
ignores `ConfigRegistry`), so once Task 2.6 switched adapters to wire
`ConfigRegistry` instead of `ConfigStoreHandle`, the handler stopped
finding a store. Migrated to `ctx.config_store_default()` (registry-
aware; falls back to the legacy handle when no registry is wired).
Added a registry-path test that proves the new accessor reads the
wired `ConfigRegistry`.

H3 — Hard cutoff for the manifest's `[stores.<kind>]` legacy fields
was in place, but `[adapters.<name>.<sub>]` legacy subtables (the
pre-rewrite `[adapters.spin.stores.kv.default]` /
`[adapters.fastly.stores.config]` shape, plus legacy `runtime`
tuning) were silently deserialized into nothing. Added a
`#[serde(flatten)] legacy: BTreeMap<String, toml::Value>` catch-all on
`ManifestAdapter` plus `validate_manifest_adapter` that yields a
load error pointing at the migration guide when anything other than
`adapter` / `build` / `commands` / `logging` shows up. Four new
fixtures cover the regression.

H1 — Axum's Stage 2 config-file read path was missing.
`build_config_registry` was building empty `AxumConfigStore`s via
`from_env(iter::empty())`, even though the spec / migration guide /
adapter doc all promise `.edgezero/local-config-<id>.json` is read.
Rewrote `AxumConfigStore`:
- New `from_local_file(id)` reads `.edgezero/local-config-<id>.json`
  and parses it as a flat `string -> string` JSON object. Missing
  file → empty store (permissive); malformed file →
  `ConfigStoreError::Unavailable` (surfaces in the dev-server log).
- Dropped the old env-vars-shadow-defaults model — Stage 2's portable
  manifest carries no inline defaults and `EDGEZERO__*` does not
  layer over config-store keys.
- `dev_server::build_config_registry` now opens one file-backed store
  per declared id; a malformed file logs a warning and drops the id
  rather than failing startup. `serde_json` added to the axum adapter
  deps. New contract tests cover missing file, flat-JSON read,
  malformed JSON, and non-string values; the in-tree
  `local_path_is_keyed_by_logical_id` test pins the path convention.

M1 — `BoundSecretStore` is now a real type that captures the per-id
platform store name; `EDGEZERO__STORES__SECRETS__<ID>__NAME` actually
binds.
- `BoundSecretStore { handle: SecretHandle, store_name: String }`
  exposes `get_bytes(key)` / `require_bytes(key)` / `require_str(key)`
  (no `store_name` arg at the call site) plus accessors for the
  underlying handle and bound name.
- `SecretRegistry = StoreRegistry<BoundSecretStore>` (was
  `StoreRegistry<SecretHandle>`); the four adapter
  `build_secret_registry` helpers now construct `BoundSecretStore` per
  id from `env.store_name("secrets", id)`. Fastly's `Multi` capability
  is now wired end-to-end — each declared id resolves to its own
  Fastly secret-store name via the env var (default = the logical id).
- Context's `secret_store(id)` / `secret_store_default()` and the
  `Secrets` extractor return the new `BoundSecretStore`. The legacy
  single-handle fallback wraps the handle under the conventional
  `"default"` platform name so adapters that haven't yet wired a
  registry keep working.
- app-demo: dropped the `SECRET_STORE_NAME = "EDGEZERO_SECRETS"`
  hardcode; `secrets_echo` calls `store.require_str(&params.name)`
  with the platform name now coming from the registry binding. Fastly
  `fastly.toml` renames the secret store from `EDGEZERO_SECRETS` to
  `default` so it matches the logical id without needing an env
  override. The axum dev-server `secret_value_handler` test
  re-keys its `InMemorySecretStore` fixture under the conventional
  `default/` prefix.
- New tests: `secret_store_resolves_named_handle_from_registry`
  asserts each id keeps its bound platform name; a complementary
  fallback test pins the `"default"` legacy-handle binding; the
  `Secrets` extractor gains a per-id-platform-name preservation test.

All five workspace gates + cloudflare wasm + spin wasm + app-demo
tests + docs lint / format / build green.
…in smoke script, hard-cutoff scope, registry default-id invariant

H1 — Axum bind config was split across three contracts. The Stage 2
runtime reads only `EDGEZERO__ADAPTER__HOST/PORT` via `EnvConfig`,
but the axum CLI wrapper still set the pre-Stage-2 `EDGEZERO_HOST/PORT`
on subprocesses (the runtime ignored them), and the generic
manifest-command path in `edgezero-cli/src/adapter.rs` never
translated `[adapters.<name>.adapter] host`/`port` into anything the
runtime would see. Net effect: documented manifest host/port and the
old `EDGEZERO_HOST=... cargo run` overrides did not reach the runtime.

- `crates/edgezero-adapter-axum/src/cli.rs`: emit
  `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` on the
  subprocess env (was: legacy `EDGEZERO_HOST/PORT`). Read the parent
  env in canonical-first order, with `EDGEZERO_HOST/PORT` accepted as
  a back-compat fallback; warning strings reference the new names.
- `crates/edgezero-cli/src/adapter.rs`: extract
  `[adapters.<name>.adapter] host/port` from the manifest in the
  shell-dispatch path and inject `EDGEZERO__ADAPTER__HOST/PORT` on
  the subprocess env. Parent-env values win — if the canonical
  variable is already set, the manifest value is skipped.
- `crates/edgezero-core/src/addr.rs`: warning strings reference
  `EDGEZERO__ADAPTER__HOST/PORT`. Tests updated.
- `docs/guide/configuration.md`: precedence list and the override
  example use `EDGEZERO__ADAPTER__HOST/PORT`.

H2 — `scripts/smoke_test_config.sh axum` was bound to fail. After
H1 of the previous review, Stage 2 Axum config reads
`.edgezero/local-config-<id>.json` per logical id. No fixture is
tracked (`.edgezero/` is gitignored), so the script's `/config/greeting`
etc. checks would 404. Added a heredoc seed step that writes
`.edgezero/local-config-app_config.json` with the same demo values
Fastly's `[local_server.config_stores.app_config.contents]` and
Spin's `[variables]` defaults carry; the dotted keys
(`feature.new_checkout`, `service.timeout_ms`) align with the
allowlist in `app-demo`'s `config_get` handler.

M1 — Spec language said "no `[adapters.*]` table", which over-claims
relative to the implementation (the table is retained for
`crate`/`build`/`commands`/`logging` adapter discovery + shell-command
wiring). Narrowed the wording in
`docs/superpowers/specs/2026-05-19-cli-extensions-design.md` to "no
per-adapter store / runtime tables" and called out explicitly that
`[adapters.<name>.adapter] host/port` survives as a hint the CLI
translates into `EDGEZERO__ADAPTER__HOST/PORT` env vars.

M2 — `StoreRegistry::new` only `debug_assert!`-ed the default-id
invariant. In release, adapter builders that skipped a failed-to-open
default could call `StoreRegistry::new(by_id, missing_default)`,
yielding a silent registry whose `default()` returned `None` despite
a declared default — masking the underlying backend / config error.

- `crates/edgezero-core/src/store_registry.rs`: `new` now panics in
  both debug and release if the default id is not in `by_id` (the
  invariant is part of the public contract; silent violation was a
  bug). Added a `should_panic` test that pins the new behaviour in
  release builds too.
- Added `StoreRegistry::from_parts(by_id, default_id) -> Option<Self>`:
  the safe constructor for builders that skip failed backends. It
  returns `None` when `by_id` is empty OR when the declared default
  is not registered. All four adapter builders (axum, fastly,
  cloudflare, spin × kv/config/secret) now call `from_parts` instead
  of `new`, with a `log::warn!` on the dropped-default path. The
  axum KV builder's previous "salvage the registry under a different
  default id" fallback is gone — a silent default swap was the same
  class of bug, just less subtle.

Verification: all five workspace gates + cloudflare wasm + spin
wasm + `examples/app-demo` tests + docs lint/format/build green.
… axum doc API drift

P2 — Spin `component` field was documented and spec'd but ignored by
the manifest model. `docs/guide/adapters/spin.md` (and the design spec
section on component discovery) call for `[adapters.spin.adapter]
component = "<id>"` to disambiguate multi-component `spin.toml`s,
but `ManifestAdapterDefinition` had no `component` field — and unknown
adapter-definition fields silently deserialized into nothing.

- `crates/edgezero-core/src/manifest.rs`:
  - `ManifestAdapterDefinition` gains `component: Option<String>` with
    a length-validator (round-trips, ignored at runtime — read by the
    future `provision` / `config push` commands).
  - Added a `#[serde(flatten)] legacy` catch-all plus
    `validate_manifest_adapter_definition`: any field other than the
    five declared (`component`, `crate`, `host`, `manifest`, `port`)
    is a hard load error pointing at the migration guide. Closes the
    same silent-drop gap H3 closed at the parent
    `[adapters.<name>]` level.
- Two new tests pin both behaviours: the Spin component field
  round-trips, and an unknown `[adapters.<name>.adapter]` field
  yields an `InvalidData` error naming both the offending key and
  the migration guide.

P2 — Manifest-provided `EDGEZERO__ADAPTER__PORT` (via
`[environment.variables]`) was being overwritten by the
`[adapters.<name>.adapter] host/port` injection.
- `crates/edgezero-cli/src/adapter.rs::run_shell`: invert the order
  so the bind hint is injected first, then `apply_environment` runs
  and explicit manifest variables (set with a `value`) win over the
  bind hint. The pre-existing parent-env-wins check on the bind
  injection remains, so the documented precedence
  (parent env > manifest variable > bind hint) holds end-to-end.

P3 — `docs/guide/adapters/axum.md` still showed the pre-Stage-2
`run_app(include_str!("../../../edgezero.toml"))` API. The current
`pub fn run_app<A: Hooks>() -> anyhow::Result<()>` is no-arg
(`crates/edgezero-adapter-axum/src/dev_server.rs::run_app`); the
entrypoint sample is updated, with a pointer to the migration guide
and a note that `EDGEZERO__*` env vars drive runtime config.

Verification: all five workspace gates + cloudflare wasm + spin
wasm + `examples/app-demo` tests + docs lint/format/build green;
core test count 339 → 341 (+1 component round-trip, +1 unknown-field
hard-error), macros 69 → 71 (re-exported `manifest_definitions`
tests pick up the same).
…tLoader rustdoc

P3 — Fastly + Cloudflare adapter docs still showed the pre-Stage-2
`run_app::<App>(include_str!("…/edgezero.toml"), req[, env, ctx])`
API and prose pointing at `edgezero.toml` as the runtime config
source. The current signatures are `run_app::<App>(req)` /
`run_app::<App>(req, env, ctx)` (manifest source dropped in Stage 2);
runtime config flows through `EDGEZERO__*` env vars + the portable
store metadata baked by the `app!` macro.

- `docs/guide/adapters/fastly.md`: entrypoint sample is the no-
  manifest `run_app::<App>(req)`. Surrounding prose updated to call
  out `EDGEZERO__*` env vars and the per-id `KV`/`Config`/`Secret`
  registries; cross-link to the migration guide.
- `docs/guide/adapters/cloudflare.md`: entrypoint sample is the
  no-manifest `run_app::<App>(req, env, ctx).await`. Prose explains
  the Workers-specific env-config probe (`Env` cannot be enumerated,
  so the canonical key set is derived from baked store ids) and the
  per-id registries; cross-link to the migration guide.

P3 — `docs/superpowers/plans/2026-05-20-cli-extensions.md` Status
block was misleading. Stage 1 was marked DONE but Stage 2 was
collapsed into "Stages 2-8 pending" — yet Stage 2 has fully shipped
on this branch. Updated the bullet to enumerate the substrate
landed across the commit chain rooted at `f5bd432` (Task 2.1)
through `8942ec2` (Stage 2 review fixes), then narrows the open
work to Stages 3-8 so the next worker starts from a coherent
status.

P3 — `crates/edgezero-core/src/manifest.rs::ManifestLoader::load_from_str`
rustdoc still described it as the "binary-embedded manifest" path,
which contradicts the Stage 2 hard cutoff: `run_app` no longer
consumes a manifest source string at all. Rewrote the doc to
describe the current callers (the `app!` macro at compile time and
test fixtures), kept the rationale for the panic-on-bad-input
behaviour, and adjusted the `#[expect(clippy::panic, …)]` reason
string to match.

Verification: all five workspace gates + cloudflare wasm + spin
wasm + `examples/app-demo` tests + docs lint/format/build green.
P3 — `docs/superpowers/plans/2026-05-20-cli-extensions.md` still
described the pre-Stage-1/2 codebase under "facts this plan relies
on":

- edgezero-cli "is a binary-only crate" — wrong since Stage 1: it's
  now a library + binary with `(<Cmd>Args, run_<cmd>)` pairs under
  `edgezero_cli::args`.
- ConfigStore::get "is synchronous" — wrong since Task 2.5: the
  trait is `#[async_trait(?Send)]` and all four adapter impls are
  async (Cloudflare needs it for KV-backed reads).
- RequestContext exposes only singular `config_store()` /
  `kv_handle()` / `secret_handle()` — wrong since Task 2.5: the
  registry-aware id-keyed `kv_store(id)` / `config_store(id)` /
  `secret_store(id)` accessors (plus the `_default()` helpers)
  are the primary surface; the singular legacy accessors only
  survive as fallbacks for unmigrated adapters.

A Stage 3 implementer who treated those bullets as ground truth
would code against the wrong substrate. Rewrote the section to
enumerate the post-Stage-2 reality:

- edgezero-cli lib+bin shape, args module, link-time adapter
  discovery via `build.rs`.
- async `ConfigStore::get` plus the new `KvError` variants
  (`Unsupported` → 501, `LimitExceeded` → 503).
- `BoundSecretStore { handle, store_name }` reshape so per-id
  platform names actually bind (Fastly multi-secret).
- `StoreRegistry` panics on missing default; safe `from_parts`
  constructor for builders that skip failed backends.
- id-keyed `RequestContext` accessors + the legacy-fallback rule.
- `Kv`/`Secrets`/`Config` `.default()` / `.named(id)` extractor shape.
- Portable manifest model + the three hard-cutoff surfaces
  (`[stores.<kind>]`, `[adapters.<name>.<sub>]`,
  `[adapters.<name>.adapter].<unknown>`).
- `run_app::<A>()` no-`manifest_src` signature on all four adapters,
  with the CLI's `EDGEZERO__ADAPTER__HOST/PORT` translation and
  documented precedence.
- Per-adapter store shapes: axum KV file convention, axum
  `.edgezero/local-config-<id>.json` flow, Spin KV cap +
  `Unsupported`/`LimitExceeded` semantics, Cloudflare config
  KV-namespace rewrite.
- The opt-in `generated_project_builds` test as a constraint on
  any Stage 3 generator-template changes.

Verification: docs `npm run lint` / `format` / `build` green. No
Rust changes; the prior commit's workspace + wasm + app-demo gates
remain the latest authoritative pass.
Stage 3 of the CLI-extensions plan: introduces the typed application
config layer (`<name>.toml` → `<Name>Config`) alongside the existing
`edgezero.toml` manifest.

- `edgezero_core::app_config`: loader (`load_app_config`,
  `load_app_config_with_options`, `load_app_config_raw[_with_options]`)
  with a six-variant `AppConfigError`. Reads the `[config]` table only
  and applies the `<APP_NAME>__<SECTION>__…__<KEY>` env-var overlay
  (§6.10): existing keys only, sibling-segment ambiguity rejected up
  front, type coercion driven by the parsed TOML scalar type.
- `#[derive(AppConfig)]` in `edgezero_macros`, re-exported as
  `edgezero_core::AppConfig`. Emits `impl AppConfigMeta` with the
  `SECRET_FIELDS` array; enforces §6.8 constraints (`#[secret]` only on
  bare `String`; rejects `serde(flatten|rename|skip*)`). Four trybuild
  compile-fail fixtures pin the error messages.
- Generator: new `core_src_config_rs` and `app_name_toml` templates,
  plus a `NameUpperCamel` Handlebars key derived from the sanitised
  project name (`my-app` → `MyApp`, digit-leading → `App` prefix).
  Seeds the `validator` workspace dep so `<name>-core` builds out of
  the box. Generator-test asserts the new artifacts; the opt-in
  `generated_project_builds` smoke test verifies the scaffolded
  workspace still compiles end to end across host + wasm targets.
- `app-demo`: `app-demo.toml` and `AppDemoConfig` (greeting,
  feature_new_checkout, nested ServiceConfig, `#[secret] api_token`,
  `#[secret(store_ref)] vault`). Three round-trip tests in
  `app-demo-core` cover loader, `SECRET_FIELDS` metadata, and the env
  overlay on a nested value.
- Docs: new "Application config" section in `configuration.md`
  documenting the file, derive, secret annotations, and env-var
  overlay; `getting-started.md` notes that `edgezero new` now emits
  `<name>.toml` + `<Name>Config`.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. Opt-in `generated_project_builds` test also passes.
…nfigMeta bound, docs format

Four review findings on the freshly-landed Stage 3:

- P2: `#[derive(AppConfig)]` now rejects container-level
  `#[serde(rename_all = ...)]` whenever a `#[secret]` field is
  present. Without the guard, a `kebab-case` rename would deserialise
  `api-token` while `SECRET_FIELDS` keeps reporting `api_token`,
  silently desyncing Stage 4's typed secret validation and the Spin
  collision check (spec §6.7/§6.8). New trybuild fixture
  `secret_with_serde_container_rename_all` pins the error message.

- P3: `load_app_config_raw[_with_options]` now returns a new
  `AppConfigError::ConfigNotATable { actual }` variant when the
  top-level `config` key resolves to a scalar / array instead of a
  table, so `config validate` (raw) doesn't have to rediscover the
  mismatch downstream. Added unit test for `config = "..."` covering
  the new variant.

- P3: tightened the typed loader bounds to
  `C: DeserializeOwned + Validate + AppConfigMeta`, matching the
  spec's published `load_app_config` signature (§4). The bound forces
  every downstream typed config to derive `AppConfig`, which Stage 4
  already assumes for `SECRET_FIELDS` access. The internal
  `FixtureConfig` test struct gains a hand-rolled `AppConfigMeta`
  impl with empty `SECRET_FIELDS` rather than invoking the derive
  (proc-macro absolute paths don't resolve inside the defining
  crate); the public derive remains exercised by the
  `edgezero-macros` integration tests.

- P2: ran `prettier --write` on `docs/guide/configuration.md` to
  satisfy the docs CI gate; no content changes, only whitespace /
  list-marker normalisation that prettier introduced when the
  "Application config" section landed.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. `prettier --check` on `docs/` now passes.
…ret] fields

Closes the last serde-skip variant the §6.8 contract requires the
derive to reject. `skip_serializing_if = "..."` conditionally omits
the field from serialisation; combined with `#[secret]`, that would
make `config push` drop the secret key whenever the predicate fires —
desyncing the on-the-wire shape from the SECRET_FIELDS invariant
Stage 4 depends on. Matcher now lists the attribute alongside the
unconditional skip family, with a trybuild fixture pinning the error
message.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets all green; the six secret_* compile-fail
fixtures still match their goldens.
Stage 4 of the CLI-extensions plan: ships `edgezero config validate`
on the default binary (raw flavour) and a typed-validator hook
downstream CLIs use for the full §10 contract.

- `ConfigValidateArgs { manifest, app_config, strict, no_env }` and a
  `ConfigCmd` subcommand enum added to `edgezero-cli/src/args.rs`,
  wired into the top-level `Command` enum and the default `edgezero`
  binary as `Command::Config(ConfigCmd::Validate(a))` →
  `run_config_validate` (raw).
- `crates/edgezero-cli/src/config.rs` (new):
  - `run_config_validate` (raw) — manifest via `ManifestLoader` +
    app-config TOML / `[config]`-table shape + Spin key-syntax (§6.7
    check 1) + Spin component discovery (check 3) + `--strict`
    capability completeness + handler-path well-formedness.
  - `run_config_validate_typed<C>` — typed deserialise into `C`,
    `validator::Validate::validate()`, `#[secret]` non-empty /
    `#[secret(store_ref)]` ∈ `[stores.secrets].ids` (§6.8) and the
    Spin config/secret namespace collision check (§6.7 check 2,
    typed-only — needs `AppConfigMeta::SECRET_FIELDS`).
- 26 unit tests cover every failure mode the spec calls out: bad
  manifest, missing `[config]`, missing `[app].name`, unknown field,
  validator-rule failure, empty secret, store_ref miss, secret w/o
  `[stores.secrets]`, Spin uppercase / dashed keys, zero / multi
  components without selector, multi components with matching
  selector, typed-only collision detection, strict capability
  matrix, malformed handler path; plus helper-fn tests.
- `app-demo-cli` adds `app-demo-core = { path = "..." }` and a
  `Config(AppDemoConfigCmd::Validate(...))` arm dispatched to
  `run_config_validate_typed::<AppDemoConfig>` — the canonical
  example of a downstream CLI driving the typed flow.
- `docs/guide/cli-reference.md` documents the subcommand, the
  raw-vs-typed split, and the `--strict` / `--no-env` flags.

Capability matrix correction: spec §6.6 lists Spin's KV as Multi
(label-backed) and only Config/Secrets as Single — the strict
checker now matches, so `app-demo-cli config validate --strict`
exits 0 on the real `app-demo` manifest (2 KV ids declared).

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. Ship gate: both `./target/debug/edgezero config validate
--strict` (raw) and `cargo run -p app-demo-cli -- config validate
--strict` (typed) exit 0 against the in-tree `app-demo` fixture.
docs `prettier --check` passes.
…drift

Three review findings on the Stage 4 landing:

- Important: Spin component validation now rejects an explicit
  `[adapters.spin.adapter].component` selector that does not match
  any declared `[component.*]` id, even when `spin.toml` declares
  exactly one component. The earlier auto-select path returned
  before checking the selector, so a typo would slip through
  `config validate` and only fail later in `config push` /
  `provision`. New regression test
  `spin_component_discovery_rejects_bad_selector_against_single_component`.

- Important: the Spin config/secret namespace collision (§6.7
  check 2) now considers only plain `#[secret]` field values.
  `#[secret(store_ref)]` values are *logical store ids* resolved at
  runtime and never enter Spin's flat variable namespace, so they
  cannot collide. Filtering by `SecretKind::KeyInDefault` matches
  the spec and removes a false-positive that rejected
  perfectly-valid configs with a `vault = "default"` plus a
  similarly-named config key. New regression test
  `spin_config_secret_collision_ignores_store_ref_values`.

- Medium: `app-demo` typed config no longer drifts from the runtime
  config-store keys. `feature_new_checkout` is replaced by a nested
  `feature: FeatureConfig { new_checkout: bool }`, so the TOML key
  becomes `feature.new_checkout` — matching the handler that reads
  `feature.new_checkout` from the config store, the seed in
  `fastly.toml`, and Spin's `feature__new_checkout` (after `.`→`__`
  translation). Stage 7's `config push` will now write the value
  the existing route actually reads.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. Ship gate: both `app-demo-cli config validate --strict`
(typed) and `edgezero config validate --strict` (raw) exit 0
against the in-tree `app-demo` fixture.
…caffold

Without `#[validate(nested)]` on a struct field, `validator`'s outer
`validate()` does not recurse into the inner type. The
`ServiceConfig::timeout_ms` `range(min = 100, ...)` rule on
`AppDemoConfig` was therefore silently a no-op — proven on the
prior HEAD by `app-demo-cli config validate --strict --no-env`
exiting 0 against `timeout_ms = 50`. The same hole sat in the
generator template, so every freshly-scaffolded project would have
inherited it.

- `AppDemoConfig` now carries `#[validate(nested)]` on both
  `feature` and `service`, with a comment naming the consequence so
  the next reader doesn't lose it.
- `core/src/config.rs.hbs` (the scaffold template) adds the same
  attribute on `service` with the equivalent note. The opt-in
  `generated_project_builds` test confirms the freshly-scaffolded
  workspace still compiles end-to-end.
- New regression test `nested_validator_rules_propagate_to_outer_validate`
  in `app-demo-core::config`: writes a tempfile fixture with
  `timeout_ms = 50` and loads it via
  `load_app_config_with_options::<AppDemoConfig>` with the env
  overlay disabled, asserting the load fails with a validation
  error naming `timeout_ms`. The fixture-file approach avoids the
  process-env race with the sibling `env_overlay_overrides_nested_value`
  test (both target the same shared `APP_DEMO__SERVICE__TIMEOUT_MS`
  key under parallel `cargo test`). Adds `tempfile` to
  `app-demo` workspace deps to support the fixture.
- Spec §6.8 and Stage 3 plan now describe the nested
  `feature: FeatureConfig` shape (was flat `feature_new_checkout`),
  and the spec snippet shows the required `#[validate(nested)]`
  attributes — so Stage 7/8 implementation reads the corrected
  shape.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. Opt-in `generated_project_builds` passes. Docs
`prettier --check` passes.
The `[config]` table on every app-config file was an unnecessary
indirection: nothing in Stages 1-4 used sibling tables, the loader
discarded them, and end users had to indent every field a level
deeper for no payoff. The flat shape is what new projects intuit on
first read.

- `load_app_config_raw_with_options` returns the parsed root table
  verbatim instead of looking up a `[config]` sub-table; the env
  overlay walks the same root.
- `AppConfigError::MissingConfigTable` and
  `AppConfigError::ConfigNotATable` are gone — neither variant is
  expressible anymore. The remaining error surface (`Io`, `Parse`,
  `Deserialize`, `Validation`, `EnvOverlay`) covers every real
  failure: TOML always parses to a root table, and an empty or
  field-mismatched file surfaces as `Deserialize`. `#[non_exhaustive]`
  keeps this a non-breaking change for pattern-matchers.
- `name.toml.hbs` and `config.rs.hbs` updated to emit the flat
  shape. The opt-in `generated_project_builds` test confirms the
  freshly-scaffolded workspace still compiles end to end.
- `examples/app-demo/app-demo.toml` and the round-trip test fixture
  drop the wrapper. Loader, CLI, and app-demo tests all rebuilt
  against the new shape; `app-demo-cli config validate --strict` and
  `edgezero config validate --strict` still exit 0.
- Small Spin-coupling cleanup in `config.rs`: the
  `ValidationContext::has_spin_adapter()` method is gone; each Spin
  check (`spin_key_syntax_check`, `spin_component_discovery`,
  `spin_config_secret_collision`) now self-gates on
  `manifest.adapters.contains_key("spin")`. The context type stays
  adapter-agnostic — call sites read as a flat list of contract
  checks. A trait-based dispatch over per-adapter check bundles
  comes in a follow-up commit.
- Spec §3 (out-of-scope), §6.5, §6.10, §9 and plan Tasks 3.1 / 3.3 /
  3.4 / 3.5 / 4.1 updated. `prettier --check` on `docs/` passes.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. Opt-in `generated_project_builds` still passes.
Previously the validator string-matched `"spin"` / `"axum"` /
`"cloudflare"` in scattered helpers — `is_single_store_adapter`
was a `matches!` over adapter names, the Spin checks self-gated on
`manifest.adapters.contains_key("spin")`, and the typed flow called
`spin_config_secret_collision` unconditionally. Adding a new
per-adapter rule meant patching every helper.

Replace with an `AdapterCheck` trait + a `const ADAPTER_CHECKS`
registry of unit-struct implementers (`Axum`, `Cloudflare`,
`Spin`). The orchestrator iterates `adapter_checks_for(manifest)`,
which filters the registry by the adapter id the impl advertises
via `adapter_id()` — call sites read as a flat list of contract
checks, and each adapter's policy lives in one place.

- `check_app_config` / `check_manifest` / `check_typed` model the
  three call sites (raw-flow body, manifest-side, typed-flow
  collision). Defaults are `Ok(())`, so Axum and Cloudflare carry
  only `single_store_kinds()` (their `secrets` Single capability).
- `single_store_kinds()` returns the spec §6.6 entries that
  `strict_capability_completeness` walks, replacing the hardcoded
  `matches!` table.
- Spin's three checks (key syntax, component discovery, collision)
  remain as free fns; `Spin::check_*` thin-dispatches into them.
  The internal self-gates inside those fns are gone — the registry
  filter is the one true gate.
- Fastly is deliberately absent from the registry: it has no
  special validation rules, so the default no-op impls would only
  add noise.

The only string-match on adapter ids left in the validator is the
single `manifest.adapters.contains_key(check.adapter_id())` inside
`adapter_checks_for` — exactly the boundary where adapter-name
matching belongs.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. All 27 `config validate` tests still pass.
…xample

Four small leftovers from the [config]-wrapper removal and the
Stage 2 `Kv` API change:

- Generator scaffold test now parses the generated `<name>.toml`
  and asserts the structural contract — no `config` root key, and
  `[service]` is at the root — instead of substring-matching on a
  doc comment. The prior `app_toml.contains("[config]")` check
  passed only because the generated file's *comment* mentions "no
  `[config]` wrapper"; a regression that re-introduced
  `[config.service]` would have slipped through.

- Public `Kv` extractor examples in `docs/guide/kv.md` and the
  `edgezero-core::key_value_store` module rustdoc updated from the
  removed `Kv(store): Kv` tuple-destructure form to the Stage 2
  registry API (`kv.default()` / `kv.named(id)`). Both surfaces are
  read first by anyone learning the KV story.

- Spec §9 ("Generated template vs the `app-demo` example") and the
  plan status block updated to reflect the flat `<name>.toml` shape
  and Stage 3 / 4 shipped state.

- `docs/guide/configuration.md` env-overlay example replaced. The
  earlier text claimed `foo-bar` and `foo_bar` collapse to the
  same env segment, but `env_segment` only uppercases — dashes and
  underscores stay distinct. The actual collision case (same key
  modulo letter case, e.g. `greeting_a` vs `GREETING_A`) is what
  the loader rejects, and the docs now say so.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo test
--workspace --all-targets / cargo check --features
"fastly cloudflare spin" / cargo check -p edgezero-adapter-spin
--target wasm32-wasip1 — all green on both the root and `app-demo`
workspaces. `prettier --check` on `docs/` passes.
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