Skip to content

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

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

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

Conversation

@aram356

@aram356 aram356 commented Jun 8, 2026 •

Copy link
Copy Markdown
Collaborator

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.
  • Clarifies that start_batch_until takes an observation cutoff: reaching it stops observation even when individual slots retain budget, while earlier cutoffs remain a supported partial-result operation.
  • Replaces the memory-only adapter seam with typed PlatformMetadata facts for memory ceilings, live inbound request population, and host-ingress accounting, preserving source and explicit unknown reasons.
  • Adds checked, scope-aware memory-envelope validation with Fits, Exceeds, and typed Indeterminate outcomes; publishes canonical Axum, Cloudflare, Fastly, generic Spin, and Akamai Functions profiles.
  • Hard-cuts retired platform-memory APIs, updates the demo and generator, and records exact signature migrations in the unreleased changelog.
  • 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. MonotonicClock::deadline_after anchors relative cutoffs to that clock. 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 plus core-owned App::build::<A>(platform). Successful hooks must preserve the complete adapter-selected metadata; core rejects changed facts instead of restoring them. Typed configuration failures propagate unchanged and stop serving before request handling. The generated scaffold keeps finite ingress policy with core-owned empty lifecycle tokens; the checked-in demo retains the full application-owned resource-composition example.
  • 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.

Reviewer correctness follow-up

Addressed R1–R4 in 92111839:

  • Axum validates exact-length source EOF before handing Hyper the final frame, validates transmitted zero-length streams before handoff under the original deadline, and preserves detached fallback payloads with HEAD suppression.
  • Batch completion requires clean driver EOF; trailing failures and malformed events are checked even after the last item or with zero slots. Collected slots survive driver failure.
  • Application assembly rejects changed platform metadata after successful configuration while preserving hook-error precedence.
  • Added regression coverage and aligned specs, implementation plans, guides, changelog, and the docs checker. Local workspace tests (720 core unit tests), strict native/WASM clippy, explicit adapter contracts, and demo/docs checks pass. Local WASM results are compilation/lint evidence; hosted lifecycle qualification requires separate deployed runtime artifacts.

Main merge and shared-policy follow-up

Addressed R5 and R6 in 91f4af38, merging main 683202c6 without restoring superseded APIs:

  • Axum, Cloudflare, and Spin share OutboundBatch::from_futures; adapters retain preflight validation, exchange ownership, clocks, and terminal samples. Ready results remain retained, driver EOF/index invariants remain enforced, and cutoff releases pending futures before reporting termination. Fastly retains its distinct provider-handle driver.
  • All four adapters share ResponseBodyPolicy and apply_response_body_policy, preserving declared-length checks, encoded limits, decoding/header rewriting, decoded limits, passthrough, and rechunk order. Transport reads, bodyless handling, deadlines, cancellation, and final buffering remain adapter-owned.
  • Main's opt-in reusable application lifecycles now use fallible assembly, fresh request registries, canonical admission, and lazy owned egress. Fastly custom callbacks return terminal Result<(), E> through a wrapper that prevents SDK-generated second responses.
  • Migrated the reusable fixtures to the current SDK pins and hard-cut APIs; expanded the legacy guard and CI coverage. Updated the outbound specification, lifecycle specification, implementation plans, guides, and changelog.
  • Local verification passes: full workspace tests (733 core unit tests), strict native/provider-WASM Clippy, Cloudflare/Fastly/Spin runtime contract suites, reusable fixture tests (5 core and 26 harness), Fastly fixture finalization and Viceroy 0.21.0 HTTP smoke, demo host/lint/WASM checks, fresh generated-project compilation, and docs/dependency/legacy gates.
  • Physical partial delivery is proved by a client-observed handshake before upstream truncation. Immediate zero-byte source failures are verified through exactly-once correlated completion with no fallback; only no-status EOF/reset is accepted, never timeouts or malformed responses. Local smoke evidence does not promote deployed capability guarantees.

R7's dependency-audit-script deduplication remains deferred; dependency contract checks remain unchanged and pass.

Bounded native ingress and config follow-up

  • Axum owns a bounded HTTP/1 accept loop and parser boundary: finite live connections, joint wire-head bytes, field count, parser buffers, absolute header-read deadlines, and idle keep-alive expiry. Capacity is acquired before parser construction; saturation closes the one accepted overshoot without spawning a refusal task. HTTP/2 is not enabled.
  • Axum loads immutable local config snapshots before listener binding. Validated file, entry, decoded key/value, registry-residency, and sequential startup-staging limits are enforced before owned snapshot allocation. Duplicate keys are rejected; allocation overflow fails startup; unavailable declarations remain typed failed bindings.
  • Hard-cut all four adapters and fixtures to shared UTF-8 ConfigValue returns. Axum constructors require ConfigStoreLimits; from_map is fallible. Reads share payloads, and local config push updates require a server restart.
  • Pin the parser allocation audit to serde_json 1.0.150, hash canonical JSON incrementally, and release extraction's raw handle before secret retrieval. A moving-reallocation malformed-root probe measured 36,428 requested peak bytes under a 100,060-byte staging cap, with zero retained bytes after rejection.
  • Add real local TCP tests for HEAD/204/304 suppression, stalled producers, non-reading clients, disconnects, exactly-once completion/resource release, and connection-capacity recovery. These prove the adapter-owned native boundary, not confirmed client delivery.
  • Align the demo, generator, migration guide, specs, plans, and changelog. Fresh local verification passes: full workspace tests (737 core unit tests), strict native and three-provider WASM Clippy, demo tests/lint, generated all-adapter host/WASM builds, docs checks, and dependency audits.
  • Hosted config preallocation and response-lifecycle qualification remain open. Provider whole-value materialization and complete RSS accounting are not certified; broad config-allocation support remains Unsupported and response egress remains BestEffort. Local emulators and guest contract tests are not deployed runtime evidence.

Bounded provider reads and lifecycle qualification infrastructure

  • Cloudflare bounded config reads now use caller-sized BYOB byte streams, strict UTF-8, independent byte allowances, clock checks and cooperative task yields. Reader termination cancels the stream; an acquisition owner also cancels a stream arriving after the waiter was abandoned. Cancellation rejections cannot publish provider diagnostics.
  • Bounded KV dispatch requires FinalizationRegistry cleanup support. Generated and demo Workers explicitly enable enable_weak_ref, and regression tests inspect their TOML rather than assuming an exact one-flag string.
  • Fastly uses the pinned config-store host ABI with one initialized caller-sized buffer and no oversized-buffer retry. Root/pointer/chunk limits, declared-total preflight, transport integrity and reassembly bounds precede envelope materialization. Duplicate reserved discriminators fail closed, deadline expiry wins metadata errors, and try_open now returns typed ConfigStoreError.
  • Publish conservative requested-payload charges and retain explicit exclusions for opaque provider buffering, JS GC retention, synchronous interruption and complete allocation certification.
  • Add default-off qualification applications through all four standard adapter entrypoints, an allowlisted finite HTTPS probe runner and a fail-closed correlated ownership-evidence validator. CI runs the validator/fixture unit tests and compiles every qualification target without deploying anything.
  • Local verification: workspace tests, strict native and three-provider WASM Clippy, feature checks, Cloudflare WASM tests (28), Fastly bounded-config Viceroy tests (18), fixture tests (7 core/26 harness), validator tests (5), demo-core tests (48), dependency/legacy/documentation checks and docs build. A temporary native Axum fixture exercised nine normal/bodyless/failure/deadline scenarios with one terminal report and one resource release each, then was stopped.
  • Still open: a bounded Spin host/backend interface or independently verified read-allocation ceiling, plus actual hosted qualification with authorized isolated targets and complete revision-pinned telemetry. The validator qualifies only ownership for scheduled probes; it does not authenticate telemetry, prove same-instance recovery, confirm client delivery or promote capabilities.

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 App::build::<A>(platform) 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
  • Reconciled reusable-app fixture tests, provider-target lint, and Fastly lifecycle HTTP smoke
  • 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 27, 2026 12:56
Spec §15 + §6.7 require every Spin variable used by the component
to be both declared in `[variables]` and bound under
`[component.<component>.variables]`. The `app-demo` spin manifest
had bindings for the non-secret config keys (greeting,
feature__new_checkout, service__timeout_ms) and the smoke-test
variable, but the two `AppDemoConfig` secret-form fields were
missing entirely.

Adds:
  - `[variables].api_token` declaration with `required = true` +
    `secret = true`. `api_token` is the `#[secret]` field — its
    value resolves through the Spin secret store, and the wasm
    component is only allowed to read it because the variable
    declares itself a secret. No `default` — the operator must
    set `SPIN_VARIABLE_API_TOKEN=<value>` at run time.
  - `[variables].vault` declaration with `default = "default"`.
    `vault` is `#[secret(store_ref)]` — the value is a runtime
    store id, not material to keep secret, so a default is fine.
    Bound the same way as the other component variables for
    consistency with the AppDemoConfig surface.
  - Both bound under `[component.app-demo.variables]` so the
    wasm component can actually read them.

This is the manifest-side gap from Stage 8's task list. Handler
and integration-test work follow in Stage 8.2.
Add `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` —
three tests that drive `edgezero-cli`'s typed entry points through
`AppDemoConfig` (the downstream-CLI surface this example exists to
exercise). The env-overlay path the plan also calls out is already
covered by `app-demo-core/src/config.rs::env_overlay_overrides_
nested_value`, so this commit adds only the missing pieces.

Tests construct an app-demo-shaped manifest + `app-demo.toml` in a
tempdir rather than pointing at the in-repo example, so a push
writeback can't corrupt the example checked into git. Each helper
builds args via `Default::default()` + field assignment because
`ConfigValidateArgs` / `ConfigPushArgs` are `#[non_exhaustive]` —
an out-of-crate test can't use the struct-literal form.

  - `config_validate_strict_passes_against_app_demo_config` —
    confirms the typed `--strict` validator passes against the
    AppDemoConfig shape (catches drift between the struct and the
    validator contract).
  - `config_push_axum_writes_local_config_json_without_secrets` —
    pushes via axum, asserts the generated `.edgezero/local-
    config-app_config.json` contains greeting / feature /
    service.timeout_ms and that BOTH `#[secret]` (`api_token`)
    AND `#[secret(store_ref)]` (`vault`) fields are absent.
  - `config_push_spin_dry_run_prints_translated_keys_and_
    preserves_manifest` — pushes via spin --dry-run, asserts
    spin.toml is byte-identical after the call (no half-written
    manifest).

`tempfile` and `serde_json` (workspace deps) added to
app-demo-cli's dev-dependencies. The remaining Stage 8 work
(named-KV handler updates and the e2e demo-server lifecycle) is
deferred to a focused follow-up — both require meaningful
behavioural changes beyond what fits in a test-only commit.
Per Stage 8's plan task list, the demo handlers must exercise both
named KV ids declared in `[stores.kv]` — not just `kv.default()`.
The previous handlers all routed through `kv.default()`, which
resolves to `sessions` but never makes the multi-store surface
visible end-to-end.

Updates:

  - `kv_counter` now resolves via `kv.named("sessions")?`. Counter
    state is session-flavoured — the per-deployment visit count
    naturally lives in `sessions`.
  - `kv_note_put` / `kv_note_get` / `kv_note_delete` now resolve
    via `kv.named("cache")?`. Notes are short-lived snippet
    payloads that pair well with cache semantics, and routing
    them through a distinct id from the counter is what makes
    the multi-store dispatch observable.
  - Error messages updated to name the actual missing store id
    instead of "no default KV store registered".

The `context_with_kv` test helper used to insert a single
`KvHandle` into request extensions, hitting the extractor's
legacy fallback path that wraps the handle in a synthetic
single-id registry under "default". After the handler rewrite
that path can no longer service `.named("sessions")` /
`.named("cache")` lookups.

`context_with_kv` now builds a real `KvRegistry` containing both
named stores (each backed by its own `MockKv`) with `sessions`
as the default, inserts the cloned registry into request
extensions, and returns `(RequestContext, KvRegistry)` so tests
that need cross-request persistence (put-then-get,
put-then-delete) can share the same registry — `KvHandle` is
`Arc`-backed so a cloned registry shares the underlying store.

The two cross-request tests (`kv_note_put_and_get`,
`kv_note_delete_returns_no_content`) drop their dangling
`_handle` discards and reuse the registry directly. All 26
app-demo-core tests still pass.
The Status block was stale at "Stages 6–8 — pending. Stage 6
(provision command) is next." Brings it current with what
shipped on the branch.

Stage 6 (provision) — done across four commits, one per adapter:
9a0369b (trait + axum + CLI delegate + stubs), d905e42
(cloudflare wrangler shell-out + wrangler.toml writeback),
79a54b6 (fastly fastly-CLI shell-out + fastly.toml writeback),
0933440 (spin pure spin.toml editing). Notes the implementation
choice that was open in plan task 6.1 — provision uses a
dedicated `Adapter::provision` trait method rather than an
`AdapterAction::Provision` variant, because the typed
`ProvisionStores` + paths/dry-run signature doesn't fit
`AdapterAction`'s `&[String]` shape.

Stage 7 (config push) — done across four commits, mirroring the
Stage 6 split: bc0a705 (trait + axum + raw + typed CLI), 74d596a
(cloudflare wrangler kv bulk put), d852f3f (fastly
config-store-entry create per key), 57c7eb3 (spin pure spin.toml
editing — both [variables] and [component.*.variables] tables
with `.→__` lowercase key translation). Calls out the same
trait-method choice as Stage 6, and that the typed flow strips
BOTH `#[secret]` and `#[secret(store_ref)]` top-level fields.

Stage 8 — partially shipped. Documents the three sub-commits
that landed task 8.1: 3d3f87c (spin.toml manual secret
declarations — api_token with required + secret, vault with
default, both bound under [component.app-demo.variables]),
9fdd1f4 (three typed-CLI integration tests in app-demo-cli),
26fddcc (handlers rewired to Kv::named("sessions") +
Kv::named("cache") with context_with_kv rebuilt around a real
KvRegistry). Notes the env-overlay path was already covered by
app-demo-core's existing unit test, so 8.1 only added the
missing CLI integration tests.

Calls out the remaining Stage 8 work explicitly: task 8.1
step 2's e2e demo-server test (needs ephemeral-port +
readiness + RAII-teardown lifecycle helper), task 8.2 (generator
template upgrade to emit the full seven-command Cmd), task 8.3
(CI wiring), and task 8.4 (cli-walkthrough.md + doc audit). The
individual Stage 6/7/8 task checkboxes further down the file are
intentionally not touched — the Status block is the source of
truth for what's shipped, and flipping every checkbox would
churn 50+ lines without adding signal.
Plan task 8.2. The Stage 1 cli template only emitted the four
pre-Stage-4 commands (`Build`, `Deploy`, `New`, `Serve`) because
`auth`, `provision`, and `config` didn't exist yet. Now that
Stages 4–7 have shipped them, a freshly-scaffolded project must
expose the full seven-command surface or new users silently lose
access to every command landed in the last five stages.

Updates:

  - `templates/cli/Cargo.toml.hbs` gains a path dep on
    `{{proj_core}}` so the scaffolded CLI can reference
    `{{NameUpperCamel}}Config` from its sibling core crate.
    Without this dep the generated `use` line wouldn't resolve.

  - `templates/cli/src/main.rs.hbs` is rewritten to mirror
    `app-demo-cli/src/main.rs`:
      * imports `AuthArgs` / `ConfigPushArgs` / `ConfigValidateArgs`
        / `ProvisionArgs` alongside the original four args types,
      * imports `{{NameUpperCamel}}Config` via the *underscored*
        core module name — `use {{proj_core_mod}}::config::...`,
        not `{{name}}_core::...` which would render the invalid
        `my-app_core` for `my-app` (plan task 8.2 step 2 warning),
      * declares a nested `{{NameUpperCamel}}ConfigCmd` enum for
        `Config(Validate|Push)`,
      * dispatches `Config` to the **typed** entry points
        (`run_config_validate_typed::<{{NameUpperCamel}}Config>` /
        `run_config_push_typed::<...>`) — the whole reason a
        downstream CLI exists.

New `assert_scaffold_cli_full_command_set` test asserts the
scaffolded `Cargo.toml` depends on the core crate; that
`<name>-cli/src/main.rs` imports every args type, brings in the
typed config via the underscored module name, lists all seven
Cmd variants + the nested ConfigCmd, and dispatches via the
typed `run_config_*_typed::<...>` functions. The opt-in
`generated_project_builds.rs::generated_workspace_compiles`
test (run with `--ignored`) confirms the rewritten template
compiles end-to-end across all four adapter targets — verified
locally before commit.
Plan task 8.1 step 2: prove `config push --adapter axum writes
the file AND a demo server returns greeting on /config/greeting`.

A full HTTP-server lifecycle (subprocess + ephemeral port +
readiness poll + RAII teardown) adds significant test machinery
for what is, for app-demo, a data-contract verification: the
JSON `config push` writes must be exactly the payload
`AxumConfigStore` reads back, and the demo's `config_get`
handler dispatched against that store must surface the value.
The HTTP transport layer is axum's responsibility and is covered
by the axum adapter's own contract tests (`tests/contract.rs`).

The new test exercises the contract end to end without a server:

  1. `config push --adapter axum` against a tempdir manifest
     (typed flow — strips `#[secret]` / `#[secret(store_ref)]`).
  2. `AxumConfigStore::from_path(&local_config_path)` — the
     SAME loader the axum runtime uses (so a JSON-format drift
     between push and read-back fails the test).
  3. Wrap in a `ConfigRegistry` keyed by `app_config`.
  4. Build a `/config/greeting` request, dispatch
     `app_demo_core::handlers::config_get` directly.
  5. Assert status 200 + body == "hello from app-demo".

Two small enabling changes:

  - `AxumConfigStore::from_path` promoted from private to `pub`.
    Documented as the supported path for downstream integration
    tests that load JSON written to a tempdir (the
    `from_local_file` default reads `.edgezero/local-config-
    <id>.json` relative to CWD, which is racy when concurrent
    tests change directories). The existing `from_local_file`
    behaviour is unchanged.
  - `app_demo_core::handlers` flipped from `mod` to `pub mod`
    so the e2e test can invoke `config_get` from outside the
    crate. app-demo is an example, not a stable library, so
    this carries no backwards-compat cost; the `app!` macro
    already used `handlers` internally — the visibility change
    is purely additive.

`edgezero-adapter-axum` / `edgezero-core` / `futures` added to
app-demo-cli's dev-dependencies. The handler tests inside
`app-demo-core/src/handlers.rs` continue to pass unchanged. The
`tests/config_flow.rs` count goes 3 → 4.
Plan task 8.3. `examples/app-demo` is excluded from the root
workspace (`exclude = ["examples/app-demo"]` in Cargo.toml), so
the existing CI gates — `cargo test --workspace --all-targets`,
`cargo fmt --all -- --check`, `cargo clippy --workspace
--all-targets --all-features -- -D warnings` — never touched it.
Three concrete consequences this commit closes:

  - The end-to-end push→AxumConfigStore→handler roundtrip Stage
    8.5 added in `app-demo-cli/tests/config_flow.rs` would have
    silently rotted (the test exists but never ran in CI).
  - The Stage 8.3 named-KV handler tests in `app-demo-core`
    were in the same blind spot.
  - The app-demo `Cargo.lock` could drift without anyone noticing
    until a contributor tried to build the example locally.

Adds three steps total:

  - `test.yml`: "Run app-demo workspace tests" after the
    generated-project compile check (`working-directory:
    examples/app-demo`, `cargo test --workspace --all-targets`).
    Kept on the existing test job (not the wasm matrix), per
    plan §8.3 — axum-only, no live external calls.
  - `format.yml`: "Run cargo fmt (app-demo workspace)" and
    "Run cargo clippy (app-demo workspace)" after the
    corresponding root-workspace steps, same `working-directory`.
    App-demo carries the same strict-clippy config as the root
    workspace, so this catches drift in the showcase a new user
    actually clones.

`scripts/run_tests.sh` is intentionally untouched — it's a
developer-local convenience script not invoked from CI, and the
plan's task 8.3 wording is "Modify: `.github/workflows/test.yml`
(or `scripts/run_tests.sh`)". The CI workflows are what enforce
coverage.
Closes plan task 8.4. Five additive changes that together
finish Stage 8:

  - **New `docs/guide/cli-walkthrough.md`** — the full myapp
    loop in one page: scaffold → auth → provision → validate
    → push → build → deploy, with per-adapter behaviour for
    each command, the env-overlay example, the Spin manual
    secret-variable section, and end-to-end recipes for
    Cloudflare / Spin / local-axum. Companion to
    `cli-reference.md` (which catalogues each command
    exhaustively) — the walkthrough tells the story.

  - **`.vitepress/config.mts` sidebar update** — adds the
    walkthrough and the existing `manifest-store-migration.md`
    (which had no sidebar entry) under "Reference".

  - **`cli-reference.md` build fix** — Stage 7.4 added a row
    to the per-adapter `config push` table containing the
    literal Spin template syntax `{{ <key> }}` inside inline
    code. Vue's template compiler treats `{{ … }}` as an
    interpolation expression even inside `<code>` blocks
    inside markdown table cells, so VitePress's `npm run
    build` has been failing since `57c7eb3`. The docs lint
    job covers prettier + eslint only, so the regression
    didn't surface in CI. Fix: HTML-escape the literal braces
    (`&#123;&#123; <key> &#125;&#125;`). The walkthrough uses
    the same pattern wherever the template syntax appears in
    inline code; fenced ```toml``` blocks need no escaping.

  - **`roadmap.md` stale-ref fix** — "hot reload for
    `edgezero dev`" referenced the pre-rewrite `dev`
    subcommand name. The CLI now uses `serve --adapter axum`
    for local dev (`dev` is reserved for a future
    dev-workflow command, per spec). Reworded to point at the
    current command + link to the CLI reference.

  - **`.prettierignore` adds `superpowers/`** — the
    handwritten specs and plans use prose-style continuation
    indentation that prettier mangles (it rewrites lines
    starting with `[[…]]` as link references, eating the
    leading whitespace). Mirrors VitePress's existing
    `srcExclude: ['superpowers/**']`. Without this entry,
    every plan-status update from here on would either be
    fought by prettier or include unrelated reformatting
    churn. The 8.1 plan-status commit (`723dafe`) already
    suffered from this — the next plan update would have hit
    the same problem.

Audit findings: the only real stale ref in the public docs
was the roadmap `edgezero dev` mention. The `[stores.*]` /
old-singular-store-API hits were all in
`manifest-store-migration.md` (intentional historical
context), `kv.md` (links to the migration guide), and
`adapters/axum.md` (explains the removed `[stores.config.
defaults]`). Pre-built `.vitepress/dist/` artifacts contain
the old text but those are build outputs, not sources.

Full gate: `cargo test --workspace`, both clippy gates,
`npm run build`, `npm run format`, `npm run lint` — all
green. The opt-in `generated_project_builds.rs` test from
Stage 8.4 also passes (it depends on the Stage 8.4 template
updates, not Stage 8.7's docs).
Review finding (High): typed config push did not run the
strict/shared preflight it promises. `run_config_push_typed`
loaded typed config and ran typed secret checks, but never
called `run_shared_checks`; `load_push_context` explicitly
created `ConfigValidateArgs { strict: false }`. The raw flow
had the same gap.

The skipped checks include:
  - per-adapter shared checks (`validate_app_config_keys`,
    `validate_adapter_manifest`) — Spin's `^[a-z][a-z0-9_]*$`
    key syntax and `[component.*]` discovery.
  - `--strict` capability-aware completeness (rejects multi-id
    stores against Single-capable adapters).
  - handler-path well-formedness.

Spec §13: "strict pre-flight validation; load app-config".

This matters most for Spin (Single-capable for config and
secrets), where pushing a manifest with multi-id stores would
silently accept invalid config in the preflight and only
surface at the per-adapter writeback — potentially after a
partial mutation in an adapter without a belt-and-braces
adapter-side guard.

Fix:
  - `load_push_context` synthesises `strict: true`.
  - Both `run_config_push` and `run_config_push_typed` call
    `run_shared_checks(&ctx.validation)` before flattening +
    dispatch.

Coverage: two regression tests
  - `raw_push_runs_spin_key_validation_before_push` — dashed
    config key against a Spin-targeted manifest must fail
    inside `run_shared_checks`, not inside Spin's
    push_config_entries belt-and-braces guard.
  - `typed_push_runs_strict_capability_completeness_before_push`
    — a Single-capable adapter declared alongside multi-id
    stores must fail the `--strict` capability completeness
    check, even if the push targets a different adapter.

App-demo integration tests pass unchanged — the existing
fixture is single-id `[stores.config]` + `[stores.secrets]`
with axum + spin, which passes the strict preflight.
…canonicalisation

Review finding (Medium): `validate_typed_secrets` did
`value.replace('.', "__")` against `#[secret]` values and
checked the result against the config-key set. But the runtime
`SpinSecretStore::get_bytes` does NOT translate dots — it
lowercases the key before `variables::get`. So validation and
runtime diverge:

  - Config key `greeting` + `#[secret]` value `"GREETING"`
    passed validation (`greeting` != `GREETING`) but collided
    at runtime (both resolve to Spin variable `greeting`).
  - `#[secret]` value `"api-token"` passed validation entirely
    but reached Spin at runtime with an `InvalidName` error.

The two runtime stores are asymmetric:
  - `SpinConfigStore::translate_key` — `.→__`, case-preserving.
    (Uppercase config keys are rejected upstream by
    `validate_app_config_keys`, so by the time
    `validate_typed_secrets` runs the config-key set is
    guaranteed lowercase.)
  - `SpinSecretStore::get_bytes` — `to_ascii_lowercase()`,
    no dot translation.

Fix: `validate_typed_secrets` now canonicalises each secret
value via `to_ascii_lowercase()` (matching the secret-store
runtime exactly) before inserting into the collision-detection
set, and also runs `is_valid_spin_key` on the canonical form
so invalid Spin variable chars (dashes, digit-first values,
dots) fail validation rather than runtime. Config-key
canonicalisation is unchanged (`.→__`, case-preserving) —
they already passed `validate_app_config_keys` and are
guaranteed lowercase + dot-only at that point.

Coverage: three regression tests
  - `validate_typed_secrets_detects_collision_after_lowercasing_secret_value`
    — the `greeting`/`"GREETING"` case the review flagged.
  - `validate_typed_secrets_rejects_invalid_spin_variable_in_secret_value`
    — the `"api-token"` case the review flagged.
  - `validate_typed_secrets_detects_collision_between_two_lowercased_secret_values`
    — sanity check that the `seen` set still catches
    secret-vs-secret collisions post-fix.
Review finding (High): hard-cutoff was not enforced for the runtime
store API. Per the spec intro:

> There is no backward compatibility with the pre-rewrite manifest
> schema or runtime store API.

But `RequestContext::{config,kv,secret}_store*` still silently fell
back to the legacy bare-handle accessors when no registry was
wired, and the `Kv` / `Config` / `Secrets` extractors synthesised a
one-id registry from a lone `*_handle()`. Either path masked
missing registry wiring — old handler code kept compiling AND ran
without anyone noticing the registry never got wired.

Fix is in two layers:

1. **Dispatcher boundary** normalises legacy bare-handle inputs to
   single-id registries. New `StoreRegistry::single_id(id, handle)`
   helper. Every adapter (axum service, fastly / cloudflare / spin
   dispatch_core_request) now, before inserting handles into request
   extensions, synthesises a one-id registry under the conventional
   `"default"` id when the caller supplied a handle but no registry.
   Both go into extensions — `ctx.kv_handle()` still works as the
   explicit-opt-in escape hatch.

2. **Accessor + extractor** fallbacks removed. `ctx.kv_store*` /
   `config_store*` / `secret_store*` and `Kv` / `Config` / `Secrets`
   extractors now strictly require a registry. A bare handle in
   extensions without a registry yields `None` (accessor) / Err
   (extractor) — a missing registry is a real bug, not a
   transparent legacy upgrade. The private `single_id_registry`
   helper in `extractor.rs` is gone; the public
   `StoreRegistry::single_id` is the only synthesis path and it
   runs at the dispatch boundary, not behind the extractor.

Combined effect: `with_*_handle` / `dispatch_with_*_handle` still
work as convenience wrappers (the dispatcher synthesises a
registry), but missing wiring no longer masks. Net behavioural
change for in-tree code: a registry is always wired when a handle
is wired, so the change is observable only in edge cases (e.g. a
test that bypasses a dispatcher and inserts a bare handle into
extensions directly).

Test updates:
  - 3 RequestContext fallback tests + 3 extractor fallback tests
    rewritten as regression tests for the new hard-cutoff
    semantics ("registry-aware accessor must not auto-upgrade").
  - 2 app-demo `config_get` test helpers (`context_with_config_key`
    + `context_with_unavailable_config_store`) updated to wire
    `StoreRegistry::single_id` instead of bare
    `ConfigStoreHandle`. The handler tests stay otherwise
    unchanged.

The legacy `RequestContext::{config,kv,secret}_handle()` methods
remain as explicit escape hatches. They're not the masking surface
the review flagged; deleting them is a separate broader migration
(would require updating `dispatch_with_config_handle` callers in
fastly/cloudflare contract tests + axum service's internal dev-
server routes). Reserving that work for a future commit if the
spec intent requires full removal.
…ed keys + both tables)

Review finding (Medium/Low): Spin dry-run tests only asserted
non-mutation, not the printed translated keys and table mentions
the spec calls out for the e2e showcase.

Two strengthening passes:

1. **`edgezero_adapter_spin::cli::tests::push_dry_run_does_not_
   edit_spin_toml`** hardened. Now exercises a multi-entry input
   whose `.→__` translation isn't a no-op (`service.timeout_ms`,
   `feature.new_checkout`), then asserts:
     - the summary line names the count (`would write 3 Spin
       variable`),
     - the summary line references BOTH `[variables]` and
       `[component.<c>.variables]` tables,
     - every translated key (`greeting`, `service__timeout_ms`,
       `feature__new_checkout`) appears in a preview line,
     - NO dotted source key (`service.timeout_ms`, etc.) leaks
       through — regression guard for the `.→__` step,
     - the component-binding template (`{{ <key> }}`) appears
       paired with each translated key.

2. **New `spin_dry_run_preview_lists_app_demo_translated_keys_
   and_both_tables`** in
   `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs`.
   Drives `Adapter::push_config_entries` directly with the
   entries the typed CLI flow produces from `AppDemoConfig`
   (secrets stripped, dotted keys), so the `Vec<String>` status
   lines can be inspected without process-global logger surgery.
   Asserts the AppDemoConfig-derived translated keys
   (`greeting`, `feature__new_checkout`,
   `service__timeout_ms`) and both spin.toml table references
   appear, AND that the stripped `api_token` / `vault` fields
   do NOT leak. The existing `config_push_spin_dry_run_*` test
   keeps the CLI integration contract (typed flow dispatches
   cleanly + spin.toml byte-identical) and gains a comment
   pointing at this companion for the printed-content
   assertions.

Adds `edgezero-adapter` to the app-demo workspace deps (was
missing — only the four `edgezero-adapter-*` registrants were
declared) and `edgezero-adapter` + `edgezero-adapter-spin` to
app-demo-cli's dev-dependencies so the test can resolve the
registered spin adapter via `adapter_registry::get_adapter`.
…guide

Review finding (Low): docs/tooling drift across three places:
  - PR template omitted `spin` from the feature check, said WASM
    targets were only Fastly + Cloudflare, and the manual-test
    bullet referenced the pre-rewrite `edgezero-cli dev`
    subcommand (renamed to `serve --adapter axum` since Stage 1).
  - `scripts/run_tests.sh` only covered the workspace test +
    Fastly-specific wasm — no spin compile check, no app-demo
    workspace (which is excluded from the root workspace), no
    feature-combination check.
  - The manifest-store migration guide still said `config push`
    is "landing in Stage 7" — Stage 7 shipped four commits ago
    (commits 7.1 / 7.2 / 7.3 / 7.4).

Fixes:

  - `pull_request_template.md`:
      * `cargo check --features "fastly cloudflare spin"` (spin
        added).
      * WASM target list includes `wasm32-wasip1 (Spin)`.
      * Added `cargo fmt --check` to the test-plan checklist
        (was missing despite being a CI gate).
      * Added `examples/app-demo` workspace test + docs build
        bullets — both are CI gates Stage 8.6 wired but the PR
        template hadn't been updated to match.
      * Manual-test bullet points at `edgezero serve --adapter
        axum` with a link to the cli-reference note about the
        renamed `dev` subcommand.
      * Checklist gains a "registry not legacy single-handle"
        item — Stage 9.3 enforced the hard-cutoff and this
        catches regressions at review time.

  - `scripts/run_tests.sh`:
      * `workspace` test gains `--all-targets`.
      * Adds workspace feature-combination check
        (`--features "fastly cloudflare spin"`).
      * Adds Spin wasm32 compile check (matching the CI gate;
        the contract test stays CI-only — needs wasmtime + the
        pinned runner).
      * Runs the `examples/app-demo` workspace tests in a
        subshell so the in-repo example stays validated locally.

  - `docs/guide/manifest-store-migration.md`: the
    "What about local config-store seeding?" section now points
    at `edgezero config push --adapter axum` with a link to the
    CLI reference, instead of "Stage 7 is coming". `config push`
    has been shipped + documented for a while.
Review finding (High): runtime store API still had backward
compatibility surfaces. `RequestContext::{config,kv,secret}_handle()`
existed and adapter dispatchers still inserted bare handles into
request extensions, so pre-rewrite handler code kept compiling
and running. Spec §intro is unambiguous:

> no backward compatibility with the pre-rewrite manifest schema
> or runtime store API

Stage 9.3 closed the masking surface (accessor + extractor
fallbacks) but explicitly deferred the full removal. This commit
finishes the cutoff.

Removed:
  - `RequestContext::config_handle()` / `kv_handle()` /
    `secret_handle()` and their imports + dedicated tests
    (the registry-aware accessors cover the same present/absent
    contract).
  - Bare-handle insertion into request extensions in all four
    adapter dispatchers (axum `EdgeZeroAxumService`, fastly
    `dispatch_core_request`, cloudflare `dispatch_core_request`,
    spin `dispatch_with_handles`). The synthesis step that Stage
    9.3 added — convert a wired bare `KvHandle` /
    `ConfigStoreHandle` / `SecretHandle` to a one-id registry
    under the conventional `"default"` id — still runs, so the
    `with_*_handle` / `dispatch_with_*_handle` convenience APIs
    keep working as before from the caller's perspective. The
    only difference: nothing in extensions exposes a bare
    handle.
  - Two Stage 9.3 hard-cutoff regression tests
    (`kv_store_returns_none_when_only_legacy_handle_wired`,
    `secret_store_default_returns_none_when_only_legacy_handle_
    wired`) lose their "ctx.*_handle() still works" assertion
    branch — those methods no longer exist. The "bare handle in
    extensions yields None from the registry-aware accessor"
    contract is still asserted.

Migrated to registry-aware accessors:
  - Axum `service.rs`: 3 internal demo routes
    (config/kv/secret store wiring tests) +
    `service_without_*_handle_still_works` smoke tests.
    The secret-handle test fixture's keys move from
    `"env/<key>"` to `"default/<key>"` to match the platform
    name the dispatcher's synthesis pins.
  - Axum `dev_server.rs`: 9 callers — mass `s/kv_handle/
    kv_store_default/g`.
  - Fastly / cloudflare / spin contract tests
    (`config_value`, `config_presence`, `kv_value`,
    `secret_value`): `ctx.config_handle()` → `config_store_
    default()`, `ctx.kv_handle()` → `kv_store_default()`,
    `ctx.secret_handle()` → `secret_store_default()`. The
    spin `secret_value` handler's two-arg
    `handle.get_bytes(store_name, key)` becomes single-arg
    `bound.get_bytes(key)` — `BoundSecretStore` bundles the
    store name.

Net effect: pre-rewrite handler code referencing
`ctx.{kv,config,secret}_handle()` now fails to compile, which
is exactly what the spec's hard-cutoff requires. The
`dispatch_with_*_handle` adapter wiring APIs and `with_*_handle`
axum service constructors stay public — they take legacy bare
handles as input but route them through the registry path
internally.

Full gate green: 350 core tests (down from 356, six dedicated
legacy-handle accessor tests removed), 25 fastly tests, 28
cloudflare tests, 73 spin tests, 26 app-demo-core tests, 5
app-demo-cli integration tests, both clippy gates, feature
combos, spin wasm32 check.

Docs / plan status / CLAUDE.md refresh land separately in
Stage 10.2.
Companion to Stage 10.1. The second-pass review flagged three
docs/agent-instruction drift sites that contradicted (or hadn't
caught up with) the hard-cutoff. Each is now consistent with the
post-10.1 code.

- `docs/guide/manifest-store-migration.md`: the "What this means
  for handler code" section used to say the pre-rewrite no-arg
  accessors were "preserved as legacy single-handle helpers and
  continue to work". They don't — they're gone per spec
  hard-cutoff. The section now explains the migration is
  mechanical (`s/_handle/\_store_default/`, plus the
  `BoundSecretStore::get_bytes(key)` single-arg shape) and
  documents that adapter setup-side `with_*_handle` /
  `dispatch_with_*_handle` constructors stay public — they
  internally synthesise a one-id registry under `"default"`, so
  setup code keeps working while handler code gets the
  hard-cutoff.

- `docs/superpowers/plans/2026-05-20-cli-extensions.md` Status
  block: Stage 8 flipped from "partially shipped" to "shipped"
  (with commit hashes for plan tasks 8.1 / 8.2 / 8.3 / 8.4 +
  the e2e roundtrip in 8.5 + the noted intentional deferral of
  the full HTTP-subprocess lifecycle). New entries summarise
  the five Stage 9 review followups (9.1–9.5) and Stage 10.1's
  full hard-cutoff. The deeper task checkboxes further down the
  plan stay untouched — the Status block is the source of
  truth.

- `CLAUDE.md`: two narrow corrections the reviewer called out
  explicitly. The `edgezero-cli/` blurb listed only
  `new, build, deploy, demo, serve` — now it lists the full
  surface including `auth`, `provision`, `config (validate|
  push)`. The adapter-pattern section described `cli.rs` as
  "build/deploy commands" — now it describes the actual
  Stage 6/7 surface (auth via `Adapter::execute`, dedicated
  `provision` and `push_config_entries` trait methods, ctor
  self-registration).

No code changes. `npm run format` + `npm run build` (docs)
green; main + app-demo gates unchanged from Stage 10.1's
green run.
Per-commit and spec-section tags ("Stage 9.3 hard-cutoff:", "Task 3.4
step 1", "§6.6", "per §13", etc.) tracked HOW the code got to its
current state — useful in commit messages and the internal plan, not
in source comments. They make comments brittle (a future change
under "Stage 11.x" mid-comment confuses readers), couple the code
to a doc the public won't see (the spec lives under
docs/superpowers/ which is gitignored), and add noise without
explaining what the code does.

Mechanical scrub across 26 files + targeted hand-edits where the
regex left awkward phrasing. The internal spec and plan docs
under docs/superpowers/ keep their original references (they
ARE the spec). Public docs under docs/guide/ are untouched —
they reference the spec sections as cross-links and the published
site needs those.

Comment content is otherwise preserved. The two pre-existing
"Hard-cutoff:" descriptors that previously prefixed
"Stage 10.1 hard-cutoff:" are kept as standalone descriptors
(they describe the actual invariant — "no fallback to legacy
handle" — not the commit that introduced it).

Full gate green: cargo fmt, both clippy gates, 788 workspace tests,
32 app-demo workspace tests, feature combos, spin wasm32 check.
- `find_config_store_id` now returns `ConfigStoreLookup` (Found /
  NotFound / SchemaDrift), so the caller distinguishes "store not
  provisioned yet" (operator runs `provision`) from "fastly CLI
  output schema drifted" (operator pins a known-compatible CLI
  version). Each variant produces a distinct, actionable error.

- `push_config_entries` records each successfully-committed key
  before attempting the next. On failure, the error names what
  was committed (safe to skip on retry), what failed, and what
  was not attempted -- so re-running push doesn't blindly re-push
  the already-committed prefix.

- `provision` wraps `append_fastly_setup` failure with recovery
  guidance: the remote store was already created, so the operator
  needs to either manually append the setup block or delete the
  orphan remote via `fastly <kind>-store delete --name=<id>`.

- Dry-run push now previews each entry it would create alongside
  the resolve+publish header, so callers can eyeball the keyset
  before running for real.

- `single_store_kinds` is explicitly overridden to `&[]` with an
  inline rationale for each `validate_*` no-op, so the rationale
  for fastly diverging from spin's overrides lives next to the
  impl block.
- `provision` now checks for an existing `[[kv_namespaces]]` entry
  with a real 32-char-hex namespace id BEFORE shelling out to
  `wrangler kv namespace create`. A re-run on an already-provisioned
  wrangler.toml is a clean skip with a status line that names the
  existing id; a fresh scaffold (where ids are placeholders like
  `local-dev-placeholder`) is treated as unprovisioned and the
  placeholder gets rewritten in place. Previously the wrangler
  shell-out fired unconditionally, orphaning every newly-created
  namespace on re-run.

- `upsert_kv_namespace` replaces the previous pure-append helper:
  if a binding entry exists, its `id` is updated in place; else a
  new entry is appended. This is what lets provision replace the
  scaffold placeholder cleanly.

- `push_config_entries` dry-run is now lenient when the binding is
  not yet provisioned -- emits a preview line with `<unresolved>`
  in place of the namespace id and points at provision, instead
  of erring. Real-run still errors loudly so we never silently
  push to a non-existent namespace.

- Dry-run now previews each entry it would create alongside the
  header (matching fastly's shape).

- `create_kv_namespace` error names version sensitivity so a
  wrangler CLI bump that changes output format surfaces as
  "pin a known-compatible wrangler version" rather than a bare
  "did not parse".
- `is_valid_spin_key` failures used to surface as
  "does not match \`^[a-z][a-z0-9_]*\$\`" -- correct but unhelpful;
  the operator still has to figure out WHICH bit of the rule
  broke. New `spin_key_rule_violation` helper returns a
  per-failure-mode phrase (digit-first, uppercase, stray punct,
  empty) that gets spliced into the error so the operator sees
  the actionable hint directly. Applied at all three sites:
  push (translates config key), validate_app_config_keys,
  validate_typed_secrets.

- `write_spin_variables` used to err with bare
  "`variables` is not a table" / "[component.X] is not a table"
  when the TOML doc had the slot as a non-table value. New
  `not_a_table_error` helper wraps the message with migration
  guidance ("Spin requires [variables] block syntax; if you
  previously set `variables = ...` inline, replace it with
  block form"). Applied at all five sites.

- Tests cover both helpers directly (per-mode diagnostics +
  migration-hint phrasing).
- `AuthArgs` doc now explains WHY it has no `Default` impl by
  contrasting with the other `*Args` types in the same module:
  AuthSub is a required subcommand with no neutral variant, so a
  default-constructed `AuthArgs` would have no sensible meaning.

- `AxumConfigStore::from_path` rustdoc now shows the expected JSON
  shape inline (flat object of `string -> string`, with dotted keys
  preserved verbatim and non-string values rejected). The shape was
  documented before only by reference to `config push --adapter axum`;
  callers reading the doc directly now see the schema without having
  to find the push code.
When an adapter uses the setup APIs (`with_kv_handle`,
`with_config_handle`, `with_secret_handle`), the handle is wrapped
in a `StoreRegistry::single_id` keyed under `"default"`. Handlers
should then be able to resolve it via:
  - `ctx.kv_store_default()` (existing test covers this)
  - `ctx.kv_store("default")` -- the named-lookup path
  - and observe `None` for `ctx.kv_store("any-other-id")`

`with_kv_handle_synthesises_one_id_registry_under_default` in the
axum service tests asserts all three at once, end-to-end through
the service stack -- so the precedence guarantee documented in
`StoreRegistry::single_id` is verified at the adapter boundary too,
not just inside the core test for the registry type.

(The auth.rs / provision.rs inline-test follow-up was de-scoped:
the existing run_auth_* and run_provision_* tests in lib.rs cover
both flows end-to-end through the shared manifest_guard / env-
override fixture, and the modules themselves are pure dispatch
with no unit-testable logic that doesn't already pass through
that fixture.)
L2 (spin push invalid-key wording): the original hint said "run
`config validate --strict` to surface this earlier"; rewording
now explains that `config validate` -- typed OR raw -- runs the
same Spin-variable check against the manifest, so the operator
path is clear without needing the `--strict` flag specifically.

L4 (registry-over-handle precedence test): add
`kv_registry_wins_over_bare_handle_when_both_wired` to axum
service tests. Previously the precedence rule was implied by
two separate tests (synthesised one-id registry + bare handle
returns None); now a single test wires BOTH a real
`KvRegistry` (declaring only `sessions`) AND a bare handle on
the same service, then asserts the registry's ids resolve to
its store, the registry's own default resolves, and the bare
handle's synthesised "default" id is NOT exposed (registry
wins outright, not as a merge or fallback).

L5 (execute is the last loose-typed trait surface): flagged
inline in the `Adapter::execute` rustdoc as a long-term
cleanup target (typed per-action parameter structs mirroring
provision / push_config_entries). No behaviour change.

L6 (AuthArgs non_exhaustive note): doc now also explains that
`#[non_exhaustive]` is purely forward-compat scaffolding
(no struct-literal construction it blocks today), reserving
the option to add a non-Default field later without it
counting as a SemVer break.

Plus a comment in cloudflare provision noting why we deliberately
do NOT cross-check the stored namespace id against Cloudflare's
API on every re-run (M2 trade-off): the skip line names the id
explicitly so the operator can verify it themselves and remove
stale entries by hand if the namespace was deleted out-of-band.
…p + docs

H1 (scaffold strict validation): edgezero.toml.hbs now ships with
[stores.secrets] uncommented because the scaffolded AppConfig
declares a `#[secret] api_token`. Strict typed validation rejects
the field unless the manifest declares a secret store, so the
out-of-the-box scaffold flow (`edgezero new <name>` +
`edgezero config validate`) was failing. The comment block now
explains why secrets is uncommented and which kinds the operator
needs to uncomment as their handlers grow.

H2 (spin push inline-table writeback): write_spin_variables used
to call `as_table_mut()` on each existing `[variables].<key>`
entry, which returns None for inline-table values (`<key> =
{ default = "..." }`). Real-world spin.toml files and the
app-demo's checked-in manifest declare variables in inline-table
form, so push was failing with "is not a table" -- not a useful
error since the entry IS a table, just inline. The writeback now
matches on both `Item::Table` and `Item::Value(InlineTable)`,
preserving whichever shape the user chose and updating `default`
in place. A new unit test locks this in against an inline-table
fixture.

M (fastly half-setup repair): setup_block_present only checked
[setup.<kind>_stores.<id>], so a half-edited manifest with the
[setup.*] block present but [local_server.*] missing was treated
as "already provisioned" and the missing block never got
repaired. The probe now requires BOTH parents; append_fastly_setup
is idempotent per parent (it skips the present one and writes the
missing one) so re-running provision now repairs the partial
state. New test covers both half-present permutations.

L (axum service.rs handle setters): the docstrings for
with_config_store_handle / with_kv_handle / with_secret_handle
still pointed at `ctx.config_handle()` / `ctx.kv_handle()` /
`ctx.secret_handle()` -- accessors removed by the Stage 10.1
hard-cutoff. Docs now point at `*_store_default()` / the
extractors and call out the hard-cutoff explicitly.

L (plan doc): 2026-05-20-cli-extensions.md still described the
singular handle accessors as remaining "as fallbacks". Updated to
reflect the actual Stage 10.1 hard-cutoff: the accessors are gone;
the setup APIs synthesise a one-id registry instead.
…fold)

Cloudflare:
- H2: is_real_namespace_id rejects hex-shape sentinels with fewer
  than 6 distinct hex chars (all-zeros, deadbeef-style). Real
  Cloudflare-API ids effectively never collide with this guard
  (P < 10^-9); hand-typed placeholders consistently do.
- H3: upsert_kv_namespace doc spells out that toml_edit drops the
  trailing inline comment on the id line under replacement; sibling
  fields and table-level decor are preserved.
- H4: doc explicitly states provision is NOT safe to run
  concurrently against the same wrangler.toml; the orphan-namespace
  hazard is named.
- H5: read_namespace_id now errors loudly when kv_namespaces exists
  but is neither array-of-tables nor inline-array (e.g.
  `kv_namespaces = "oops"`). New item_kind() diagnostic helper.
- H6: provision skip-line names the existing id AND the
  `wrangler kv namespace delete` step needed to free the orphan
  before re-provisioning.
- H7: upsert_kv_namespace treats NotFound as "start with empty
  document" symmetrically with read_namespace_id, so a
  `wrangler kv namespace create` followed by writeback against a
  missing wrangler.toml no longer orphans the remote namespace.
- H8: tests cover sibling-field preservation under upsert,
  hex-shape-sentinel rejection (all-zeros / deadbeef / boundary),
  multi-id-line extract pinning, and the new non-array
  read_namespace_id error.
- H9: find_namespace_id rejects placeholder ids (non-32-char-hex)
  with a "did you run provision?" hint, so real-run push never
  shells out to wrangler with `--namespace-id=local-dev-placeholder`.
  Existing tests updated to use 32-char hex fixtures.

Fastly:
- MED1: setup_block_present doc states it only proves table
  presence -- the fastly CLI is the authoritative deploy-time
  checker for inner shape.
- MED2: create_fastly_store now treats "already exists" stderr
  (and similar phrasings) as idempotent success. Without this the
  documented recovery path for an append-failure ("manually append
  setup blocks and re-run provision") would re-trigger the wrangler
  create and fail at "already exists" forever. New
  looks_like_already_exists helper with multi-phrasing tests.
- GAP: push_entries_with_committer extracted from push_config_entries
  as a pure helper. Unit-tested for all-succeed, zero-entries,
  middle-failure (with committed/failed/not-attempted counts), and
  first/last-entry-failure boundary cases. The shell-out is
  factored as a closure parameter so the partial-failure error
  shape is testable without fastly on PATH.
- dispatch_with_secrets doc surfaces the platform-name binding:
  the synthesised SecretRegistry binds to a Fastly Secret Store
  literally named "default"; operators with a different name must
  use dispatch_with_kv_and_secrets or the manifest-aware run_app.

Spin:
- H2: write_spin_variables handles inline-table form of
  [component.<id>.variables] symmetrically with the [variables]
  inline fix from fe79025. New test covers updating an inline
  component binding in place.
- M1: not_a_table_error wording for variables.<key> (scalar leaf)
  now points the operator at `key = { default = "..." }` rather
  than the misleading `[variables.key]` block-form suggestion.
- M2/M3: spin_key_rule_violation gains a comment explaining
  per-branch reachability (uppercase-first is reachable via
  validate_app_config_keys / validate_typed_secrets but NOT via
  push, which lowercases first). The catch-all is kept defensive
  with a debug_assert so a future regex tweak surfaces the gap.
- L1: spin_key_rule_violation test now asserts exact diagnostic
  strings per failure mode, so a branch reorder can't pass by
  accident.
- L2: validate_app_config_keys now mirrors translate_key_for_spin
  exactly (replace + lowercase) so the diagnostic names the
  actual written form. Uppercase source keys are still surfaced
  via an explicit source-key check, so a silent case-mismatch
  between source and runtime is impossible.
- Coverage: new test pins the bare-scalar [variables].key error
  wording, locking in the targeted M1 hint.

Scaffold:
- The scaffolded AppConfig no longer ships with a live #[secret]
  field; demonstrating it without wiring a handler that calls
  ctx.secret_store_default() was empty signaling. The api_token
  field becomes a commented example with a longer explanation of
  when to uncomment it. The matching [stores.secrets] block is
  re-commented; name.toml drops the live api_token entry. The
  fresh-scaffold flow now passes `edgezero config validate` AND
  `cargo check` AND `cargo run` out of the box without operator
  intervention.
- generated_project_builds.rs runs `edgezero config validate` on
  the scaffolded project BEFORE `cargo check` so a manifest/config
  drift surfaces as a fast, clear error instead of a compilation
  cascade.

Cross-adapter:
- synthesise_store_registries extracted as a pure helper in each
  of cloudflare/fastly/spin's request.rs. Unit-tested in each
  adapter for: bare-handle wraps under "default", registry wins
  over bare handle when both wired (no merge, no fallback), all-
  None inputs return all-None, config+secret bare handles handled
  symmetrically. Axum already had end-to-end coverage; this lands
  parallel asserts in the other three adapters so the precedence
  contract is locked in at every adapter boundary.
…named adapter

The reviewer noticed that `edgezero provision --adapter spin --dry-run`
silently accepted a manifest declaring two config ids -- even though
`edgezero config validate --strict` correctly rejected the same shape.
The strict-validate path runs `strict_capability_completeness` (which
iterates every declared adapter); `run_provision` skipped it entirely
and dispatched straight into the adapter's `provision` impl. So the
mismatch only surfaced at runtime as a confusing "wrong store" miss.

Fix:
- Factor `enforce_single_store_capability(manifest, adapter_name)` out
  of `strict_capability_completeness`. The shared helper is gated only
  on the registry lookup -- unregistered (feature-disabled) adapters
  are a no-op, mirroring the existing skip in the strict path.
  Single_kinds_empty short-circuit avoids walking the manifest for
  fully-Multi adapters (fastly, cloudflare KV/config).
- `strict_capability_completeness` now delegates per-adapter via the
  same helper -- no behaviour change for `config validate --strict`.
- `run_provision` calls `enforce_single_store_capability(manifest,
  &args.adapter)` unconditionally (NOT --strict-gated) before
  dispatching: the capability mismatch isn't stylistic, the platform
  genuinely cannot honour the declaration.

Tests:
- `run_provision_spin_rejects_multi_config_ids_via_capability_gate`
  reproduces the reviewer's scenario: spin manifest with two config
  ids, dry-run, expect the same "Single-capable for config" error
  shape as `config validate --strict`.
- `run_provision_skips_capability_gate_for_kinds_within_single_id_floor`
  sanity-checks the floor: ids.len() <= 1 must still dispatch
  cleanly (the existing PROVISION_MANIFEST fixture lands here).

Note on the reviewer's stale findings: the cloudflare clippy + test
regression and the fastly half-setup repair were already closed in
6a5d89e; the reviewer was looking at fe79025 + WIP. Both pass cleanly
at current HEAD.
High: apply_environment was unconditionally calling `cmd.env(...)`
for every `[environment.variables]` binding, which overwrote any
matching parent env var the operator had exported. Per the plan,
manifest `value` is a DEFAULT -- the parent env wins. Without the
guard, `EDGEZERO__ADAPTER__HOST=parent_env edgezero build
--adapter axum` would observe the manifest default instead of
`parent_env`. Fix: skip `cmd.env(...)` when `env::var_os(binding.env)`
is already set. Two regression tests pin both directions
(parent set -> parent wins; parent unset -> manifest default fills).

Medium: axum's `read_axum_project` was falling back from
`EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` to legacy
`EDGEZERO_HOST` / `EDGEZERO_PORT`, with docs advertising it as
back-compat. The core runtime stopped reading the legacy names
in the env-config rewrite; the axum wrapper was silently
reviving a path the rest of the codebase had cut, contradicting
the hard-cutoff spec. Drop the fallback; update the configuration
guide to call out that operators must rename their CI scripts to
the canonical double-underscore form.
The reviewer noticed that the scaffold's documentation and the
app-demo comments showed the env-var overlay prefix in lowercase
source form (`{{name}}__...` / `app_demo__...`), but the runtime
normalises with `to_ascii_uppercase().replace('-', '_')` --
so `app-demo` becomes `APP_DEMO__...`. Setting the lowercase
form is silently ignored at runtime, which the reviewer
reproduced (`app_demo__SERVICE__TIMEOUT_MS=9000` left the value
at 1500; `APP_DEMO__SERVICE__TIMEOUT_MS=9000` took effect).

Fix:
- Add `env_prefix` to `ProjectLayout` and the `EnvPrefix`
  Handlebars value, derived via `env_prefix_from_name` which
  mirrors `edgezero_core::app_config::app_name_prefix` exactly.
- `name.toml.hbs` and `config.rs.hbs` now use `{{EnvPrefix}}`
  in every env-var comment, with a worked example
  (`{{EnvPrefix}}__SERVICE__TIMEOUT_MS=2500`).
- `examples/app-demo/app-demo.toml` and
  `examples/app-demo/crates/app-demo-core/src/config.rs` switch
  to `APP_DEMO__...` literals.
- Two new tests pin the derivation against the runtime's rule:
  one with concrete inputs, one that loops over names and asserts
  agreement with `to_ascii_uppercase().replace('-', '_')` so a
  future runtime change would surface immediately.
…est relocation

This is the close-out for the 18-item review batch covering 3 Critical/
Important findings, 6 Medium, and 9 Low (review #N+1 atop 2a3ad63...5cc742b).

Important / structural:

- Thread env-resolved PLATFORM names through `Adapter::provision` and
  `Adapter::push_config_entries`. The runtime resolves
  `EDGEZERO__STORES__<KIND>__<ID>__NAME`; provision/push were passing
  only logical ids, so `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config`
  was silently ignored at create time and the runtime later looked up
  a binding the CLI never created. New `ResolvedStoreId { logical,
  platform }` rides through `ProvisionStores` and the push signature;
  every adapter writes the platform name into the per-platform
  manifest, with the logical id preserved for human-facing wording.
  Regression tests in cloudflare + spin assert that an explicit
  platform name lands in wrangler.toml / spin.toml.

- Per-adapter strict-validate gating in `run_provision`. Provision
  now runs `enforce_single_store_capability` (was already there)
  AND `strict_handler_paths` AND the target adapter's
  `validate_adapter_manifest` -- so a malformed handler path or a
  broken spin.toml fails BEFORE expensive remote calls happen. Two
  new regression tests cover both gates.

- Remove `run_app_with_logging` (fastly) and `open_default` (spin)
  legacy wrappers per the hard-cutoff. Both hardcoded
  `DEFAULT_KV_STORE_NAME`/`EDGEZERO_KV`. Updated doc references.

- Relocate `run_auth_*` (2 tests) into `auth.rs` and `run_provision_*`
  (10 tests) into `provision.rs`, per the CLAUDE.md
  "colocate tests with implementation" convention. New
  `crates/edgezero-cli/src/test_support.rs` exposes `manifest_guard`,
  `EnvOverride`, BASIC_MANIFEST, PROVISION_MANIFEST as
  `pub(crate)` so the per-module test mods share one harness
  instead of forking.

Medium / wording + parser:

- `extract_namespace_id` (cloudflare): now anchors on
  `[[kv_namespaces]]` AND requires the value to be a real
  Cloudflare-shaped id (32-char lowercase hex with diversity floor).
  Previously the first `id = "..."` line anywhere in wrangler stdout
  won -- vulnerable to wrangler version drift.
- `looks_like_already_exists` (fastly): now requires BOTH a conflict
  signal AND a store-kind reference (`kv-store` / `config_store` /
  `secret store`), so an unrelated 409 "Conflict" from a different
  fastly subcommand can no longer be misread as idempotent
  store-create success. New negative tests pin the regression.
- `cli-walkthrough.md`: env-overlay example used the literal
  `APP_NAME__...` as if it were the prefix; now uses concrete
  `MYAPP__...` for the walkthrough app `myapp` and explains the
  derivation rule.
- `env_prefix_from_name` test now calls the actual runtime
  `edgezero_core::app_config::app_name_prefix` (newly made `pub`) so
  a future drift in either side surfaces immediately. The prior test
  was tautological.
- `assert_scaffold_app_config` now pins that `DEMO_APP__` (uppercase)
  appears in the rendered scaffold and that `demo-app__` / `demo_app__`
  (source-form lowercase) does NOT.

Low / docs + tests:

- Cloudflare hex-diversity rate updated (10^-9 -> 10^-13).
- Spin `write_spin_variables` doc explicitly lists the outer-table
  block-form requirement so the limitation is discoverable.
- Fastly idempotent-skip line now mirrors cloudflare's: names the
  delete steps the operator needs to force a fresh remote.
- `synthesis_tests` rationale comments aligned across cloudflare /
  spin / fastly (BoundSecretStore platform-name binding).
- `#[expect(missing_trait_methods)]` reasons across axum/cloudflare/
  fastly now name `single_store_kinds` as the overridden one.
- Scaffold README drops the `API_TOKEN` mention (no secret wiring
  in the no-secret-default scaffold).
- `generated_project_builds.rs` now also runs `--strict`.
- Spec + kv.md doc references to `[adapters.spin.stores.kv.<id>].name`
  and `EDGEZERO_KV` rewritten to the hard-cutoff
  `EDGEZERO__STORES__KV__<ID>__NAME` model.

Critical / CI:

- `cloudflare/src/cli.rs:399` `starts_with("[")` -> `starts_with('[')`
  (single_char_pattern clippy regression introduced by the anchor fix).
…ice builder

This commit closes the latest reviewer findings (empty platform-name
override flows through to wrangler/fastly calls; legacy EDGEZERO_*
default constants still exist in public + manual paths) and the
follow-up design change to collapse the dispatch_with_* variant
fan-out.

env_config: reject empty / whitespace-only / control-bearing override
values in `EnvConfig::store_name` and fall back to the logical id.
Without this guard an exported `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=`
(blank or accidentally cleared) would flow into
`wrangler kv namespace create ""` / `fastly config-store create
--name=` / blank TOML table names. Five new unit tests pin the
behaviour for empty, whitespace, newline, NUL, and the
realistic-punctuation acceptance path.

Hard-cutoff defaults: remove `DEFAULT_KV_STORE_NAME` /
`DEFAULT_CONFIG_STORE_NAME` / `DEFAULT_SECRET_STORE_NAME` from
`edgezero_core::manifest`. Drop the `Manifest::kv_store_name` /
`secret_store_binding` accessors that returned the constants when
no store was declared. Drop the fastly `DEFAULT_KV_STORE_NAME`
re-export and cloudflare `DEFAULT_KV_BINDING` re-export. Stop the
silent KV injection in `dispatch_with_config` / `dispatch_with_
config_handle` / `dispatch_with_secrets` -- KV is now wired
explicitly via `with_kv(name)` or not at all. Axum dev_server's
`kv_store_path` no longer special-cases the legacy `EDGEZERO_KV`
name to the bare `kv.redb` shortcut; every name gets a slug+hash
filename.

Service builder: collapse the per-store dispatch variants on
fastly + cloudflare into `FastlyService` / `CloudflareService`
builders. Per-request store wiring is the fluent form
`Service::new(&app).with_kv("name").require_kv().with_config("name")
.with_secrets().dispatch(req[, env, ctx])`. Each builder method is
independent; `require_*` promotes the previous `with_*` to required;
`with_config_handle` is the explicit-handle escape hatch. The
manifest-driven `run_app` is still the recommended entrypoint and
internally builds a Service. The legacy `dispatch_with_*` free
functions, `AppExt::dispatch` trait, fastly `dispatch_raw`, and
cloudflare `dispatch_raw` are gone.

Spec + plan: §6.9 added describing the Service builder + hard-
cutoff (no DEFAULT_*_STORE_NAME, no implicit KV injection); existing
§6.10-6.12 renumbered. Spec intro reworded to match the actual
portable-manifest reality (no per-adapter mapping block, only
logical ids + env-resolved platform names). Plan §11 calls out the
dispatch-variant consolidation.

Contract tests updated to use the Service builder; the
cloudflare router smoke tests dropped a meaningless
`.with_kv("kv")` that was carried over from the silent-default
shim. Fastly contract test renamed
`dispatch_with_config_handle_injects_handle` ->
`service_with_config_handle_injects_handle` for parity.

docs/.gitignore: add `.vitepress/.temp` so VitePress build
artifacts don't get accidentally staged.
The fixture-string literals `"sessions"` / `"app_config"` / `"default"`
/ `"cache"` / `"demo"` were repeated 10-30 times per test mod across
the provision and push tests. The literals serve double duty as
SETUP (passed into the production function) AND as ASSERTION INPUT
(checked in dry-run output / error wording), so a silent typo
that drifted both ends together would pass even though neither
matched the production code.

Hoisting them to module-level consts at the top of each test mod:

- crates/edgezero-adapter-cloudflare/src/cli.rs:
  TEST_KV_ID = "sessions", TEST_KV_ID_ALT = "cache",
  TEST_CONFIG_ID = "app_config", TEST_SECRET_ID = "default".
- crates/edgezero-adapter-fastly/src/cli.rs:
  TEST_KV_ID, TEST_CONFIG_ID, TEST_SECRET_ID (same shapes).
- crates/edgezero-adapter-spin/src/cli.rs:
  TEST_KV_ID, TEST_KV_ID_ALT, TEST_CONFIG_ID, TEST_SECRET_ID,
  TEST_COMPONENT_ID = "demo".

A few `find_config_store_id_*` test fixtures had the fixture JSON as
a raw string with the const-named id embedded; those switched to
`format!()` so the JSON still parses with the const value
interpolated.

Axum cli.rs left as-is (only 2-3 fixture uses; the indirection cost
exceeds the typo-prevention benefit).
Preserve the outbound hard-cut contract while integrating opt-in reusable application lifecycles with fallible assembly and adapter-owned delivery.

Address R5 with shared future-based batch orchestration and R6 with shared response-body policy. Add deterministic cutoff cleanup and lifecycle evidence, and align fixtures, CI, specifications, plans, guides, and changelog.
Validate finite connection, raw-head, field-count and idle/header limits at startup. Own a bounded connection pool, preserve application deadlines, and compact response headers before parser reuse. Publish audited allocation assumptions and align CLI defaults, demo, scaffold and lifecycle documentation.
Load immutable config snapshots under validated file, entry, payload, registry residency, and staging limits before binding. Migrate all adapters to shared ConfigValue returns and hash canonical JSON incrementally.

Add native TCP lifecycle and allocation regressions; align the demo, generator, specs, plans, and changelog while preserving conservative hosted capability claims.
Run each logging probe in a separate temporary working directory so pre-bind KV initialization cannot contend between concurrent subprocesses. Preserve startup ordering and exercise the exact demo CI sentinel.
@ChristianPavilonis ChristianPavilonis mentioned this pull request Oct 8, 2026
15 of 17 tasks
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.
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.
Add paired outbound time and pending ownership primitives, original-coding and marker policy, and matching demo and generator examples.

Repair Spin response-owned streaming progress and retain local runtime characterization with fail-closed evidence checks. Keep the failed Fastly readiness gate and remaining head-first, eager and hosted qualification work explicit.
Extend the existing coding fixture with direct response handoff and bounded binary TCP capture. Verify 24 downstream relay cases per adapter, representation fields, framing and origin receipts.

Retain local runtime evidence and update the adoption checkpoint. Keep head-first/eager migration, hosted qualification and the failed Fastly readiness gate explicitly open.

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.

2 participants