Skip to content

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

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

aram356 wants to merge 394 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.
  • 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 08:33
Aligns `config validate` with the build/deploy/serve/auth pattern:
per-adapter validation rules now live in each adapter crate's
`Adapter` impl, not in `edgezero-cli`. The CLI orchestrates by
looping over `registered_adapters()` and calling trait methods —
zero adapter-name strings outside the registry lookup itself.

- `edgezero_adapter::registry::Adapter` gains four default-no-op
  validation methods: `validate_app_config_keys(keys: &[&str])`,
  `validate_adapter_manifest(manifest_root, adapter_manifest_path,
  component_selector)`, `validate_typed_secrets(config_keys,
  plain_secrets)`, and `single_store_kinds() -> &'static
  [&'static str]`. Parameters are primitive (`&[&str]`, `&Path`,
  `Option<&str>`), so `edgezero-adapter` stays decoupled from
  `edgezero-core`'s `Manifest` / `SecretField` types — the CLI
  destructures the rich types and hands each adapter a flat slice.

- axum/cloudflare override only `single_store_kinds = &["secrets"]`
  (`#[expect(missing_trait_methods)]` documents the deliberate
  fall-through to the defaults). Fastly is Multi for every store
  kind, so all four defaults stand. Spin implements all four
  methods — key-syntax (§6.7 check 1), component discovery
  (check 3), config/secret namespace collision (check 2,
  typed-only, `KeyInDefault`-only), and `&["config", "secrets"]`.
  `is_valid_spin_key` + `collect_spin_component_ids` (free fns)
  moved from `edgezero-cli/src/config.rs` to
  `edgezero-adapter-spin/src/cli.rs`.

- `edgezero-cli/src/config.rs` drops the CLI-internal
  `trait AdapterCheck`, the `struct Axum` / `Cloudflare` / `Spin`
  impls, the `ADAPTER_CHECKS` const, and `adapter_checks_for`
  iterator helper. Two new functions —
  `run_adapter_shared_checks` and `run_adapter_typed_checks<C>` —
  loop over `manifest.adapters.keys()`, look each up via
  `adapter_registry::get_adapter`, and invoke the trait. The CLI
  still owns `flatten_keys` (the dotted-path producer every
  adapter consumes), `typed_secret_checks` (manifest-shape
  `#[secret]` / `#[secret(store_ref)]` checks that don't need
  adapter knowledge), and the `--strict` handler-path checks.

- `strict_capability_completeness` now driven by
  `adapter.single_store_kinds()` from the registry, replacing the
  CLI's hardcoded `is_single_store_adapter` `matches!` table.

- Test split mirrors the new ownership. Adapter-internal helper
  tests (`is_valid_spin_key_*`, the three `validate_*` method
  unit tests + the `single_store_kinds` assertion) land in
  `edgezero-adapter-spin/src/cli.rs`. CLI tests keep the
  end-to-end orchestration shape (`spin_key_syntax_rejects_*`,
  `spin_component_discovery_*`, `spin_config_secret_collision_*`,
  `strict_capability_completeness_rejects_*`) — they exercise
  `run_config_validate` through the registered adapter and still
  pass without rewriting because the adapter dispatch is
  transparent.

- `edgezero-adapter-spin` gains `toml` as an optional dep under
  the `cli` feature for the component-discovery parser.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo
test --workspace --all-targets (44 spin tests, 25 cli config
tests) / 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
`edgezero config validate --strict --manifest …/app-demo/edgezero.toml`
(raw) and `app-demo-cli config validate --strict` (typed) exit 0.
First of four commits for `edgezero provision` (spec §12). This one
ships the trait extension, CLI wiring, and the axum impl (the
only trivial one). Cloudflare / fastly / spin land their real
implementations in follow-up commits.

- `edgezero_adapter::registry`: new `ProvisionStores<'stores>`
  struct + `Adapter::provision(manifest_root,
  adapter_manifest_path, component_selector, stores, dry_run) ->
  Result<Vec<String>, String>` trait method with a no-op default.
  Parameters are primitive (`&Path`, `Option<&str>`, slice of
  `String` ids) — `edgezero-adapter` stays decoupled from
  `edgezero-core`'s `Manifest` type, same as the validation
  methods.

- `edgezero-adapter-axum/src/cli.rs`: real `provision` impl —
  prints one human-readable note per declared store id explaining
  the local-only backing (KV in-memory; config in
  `.edgezero/local-config-<id>.json`; secrets from env vars) and
  exits 0. `--dry-run` is identical to a real run; nothing to
  actually perform.

- `edgezero-adapter-{cloudflare,fastly,spin}/src/cli.rs`:
  explicit `Err("... not yet implemented; landing in a follow-up
  commit")` stubs so the dispatch wiring is exercised today
  without pretending to provision against real platforms. Each
  stub's TODO comment names the upcoming work — wrangler
  shell-out + `wrangler.toml` writeback, `fastly *-store create`
  + `fastly.toml` writeback, `spin.toml` `key_value_stores`
  editing.

- `edgezero-cli/src/provision.rs` (new): five-step thin
  delegate — load manifest, ensure adapter declared, look up the
  registered `Adapter`, build `ProvisionStores` from
  `manifest.stores.*`, call `adapter.provision(...)`, log each
  status line. Mirrors `auth.rs`'s shape exactly.

- `ProvisionArgs { adapter, dry_run, manifest }` in
  `args.rs`, `Command::Provision(ProvisionArgs)` in the default
  binary, `Cmd::Provision` in `app-demo-cli`. Parse tests cover
  `--adapter <name>` + `--dry-run`, and the missing-`--adapter`
  rejection.

- 4 orchestration tests in `lib.rs`: axum apply + dry-run both
  exit 0 against a multi-adapter fixture; unknown adapter errors
  by name; the three stub adapters return
  `"... not yet implemented"`. The orchestration tests will keep
  passing as the stubs are replaced with real implementations —
  the "not yet implemented" check moves out adapter-by-adapter,
  the others stay.

- `docs/guide/cli-reference.md`: new `### edgezero provision`
  section + per-adapter behaviour table with the four built-ins.

Gates: cargo fmt / clippy --all-features (-D warnings) / cargo
test --workspace --all-targets (65 cli tests, 4 new) / 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:
`./target/debug/edgezero provision --help` lists `--adapter` /
`--dry-run` / `--manifest`; `app-demo-cli provision --adapter
axum` exits 0 with the local-only notes; `app-demo-cli provision
--adapter cloudflare` exits 1 with the explicit
"not yet implemented" message.
…riteback)

Replace the cloudflare provision stub with the real implementation: for
each declared KV / config id, shell out to `wrangler kv namespace
create <id>`, parse the namespace id from stdout, and append a
`[[kv_namespaces]] binding = "<id>", id = "<extracted>"` block to the
project's wrangler.toml. Existing bindings with the same name are
detected and skipped (idempotent across re-runs). Secrets stay
runtime-managed via `wrangler secret put` — provision is a no-op for
them. Dry-run skips the shell-out and the writeback.

toml_edit (workspace) is added to the cloudflare adapter's `cli`
feature for in-place editing that preserves formatting and comments.
The append path requires `kv_namespaces` to be an array-of-tables
(the form wrangler itself prints); inline-array layouts trigger a
clear "convert it manually" error rather than a silent rewrite.

Coverage: 10 new tests on the cloudflare side (parser, writeback,
provision dry-run) plus a CLI dispatch test that drives the adapter
without needing wrangler on PATH. Docs/cli-reference updated with the
real flow and the prerequisite that `[adapters.cloudflare.adapter]
.manifest` point at wrangler.toml.
…back)

Replace the fastly provision stub with the real implementation: for
each declared KV / config / secret id, shell out to `fastly
<kind>-store create --name=<id>`, then append empty
`[setup.<kind>_stores.<id>]` and `[local_server.<kind>_stores.<id>]`
tables to the project's fastly.toml. The empty tables are enough to
declare the resource link for `fastly compute deploy` and viceroy
local dev; `config push` fills in entries later (spec §13).

Idempotency is manifest-driven (same model as cloudflare): if the
setup block is already present in fastly.toml, the id is skipped
with no shell-out and no edit. Re-runs after a partial failure
converge — the writeback runs unconditionally after a successful
create, and the skip path bypasses both. Store IDs are not persisted
in the manifest; `config push` resolves them on demand.

toml_edit (workspace) is added to the fastly adapter's `cli` feature
for in-place editing that preserves formatting and comments. The
writeback creates the parent `[setup]` / `[local_server]` tables if
absent and tolerates a missing fastly.toml on first run.

Coverage: 11 new tests on the fastly side (3 setup_block_present,
5 append_fastly_setup, 3 provision orchestration including the
already-declared skip path) plus a CLI dispatch test that drives the
adapter without needing fastly on PATH. cli-reference.md's
per-adapter table marks fastly as shipped with the prerequisite that
`[adapters.fastly.adapter].manifest` point at fastly.toml.
Replace the spin provision stub with the real implementation: for
each declared KV id, append the label to the resolved
`[component.<component>].key_value_stores = [...]` array in
spin.toml. No native CLI is invoked — Spin KV stores are
runtime-resolved by the Spin runtime / Fermyon at deploy, so the
only operator-side work is declaring the labels per component.

Component resolution mirrors the rule already used by Spin's
`validate_adapter_manifest` (spec §6.7): a single `[component.*]`
resolves implicitly; multi-component manifests require
`[adapters.spin.adapter].component = "<id>"`. A selector that
doesn't match any declared component is an error. The labels are
written via toml_edit so formatting, comments, and sibling fields
survive the edit, and re-runs are idempotent on the label.

Config and secret ids are intentionally NOT handled by provision
(spec §6.7): the manifest carries only store ids, not app-config
field keys or secret key names. `provision` prints one line per
config/secret id pointing the operator at `config push --adapter
spin` (for config variables) and to the manual `[variables]
secret = true` / `[component.*.variables]` declarations Spin
requires for secrets. This matches the spec wording and keeps
provision focused on what it can know unambiguously.

toml_edit (workspace) is added to the spin adapter's `cli`
feature. Coverage: 12 new tests on the spin side (4
resolve_spin_component, 5 ensure_kv_label_in_component, 5
provision orchestration including the config/secrets out-of-scope
rows) plus a CLI dispatch test that drives the adapter with a
single-component spin.toml. The CLI's old "spin stub reports not
yet implemented" test is replaced by the new dry-run dispatch
test. cli-reference.md's per-adapter table marks spin as shipped.
Extend the Adapter trait with `push_config_entries(...)`: per-adapter
config-store push that takes a resolved store id and pre-flattened
`(dotted_key, string_value)` entries. Default returns Err so adapters
opt in by overriding; axum lands the real impl in this commit and
cloudflare / fastly / spin carry stubs that surface their target
implementation crate when called (Stages 7.2 / 7.3 / 7.4 land them).

The axum push writes the flattened payload as a flat
`string -> string` JSON object to `.edgezero/local-config-<id>.json`
— the same file `AxumConfigStore` reads back at runtime. Creates the
`.edgezero/` directory on first use; honours `--dry-run` by writing
nothing.

CLI plumbing follows the same raw vs typed split `config validate`
already uses:

  - `run_config_push(args)` — raw flow. Loads the on-disk TOML via
    the existing env-overlay-aware loader, converts to JSON, walks
    the tree producing dotted keys with arrays JSON-encoded into
    single values (spec §6.5). No secret filtering — the raw flow
    has no `AppConfigMeta` to read `SECRET_FIELDS` from.
  - `run_config_push_typed::<C>(args)` — typed flow for downstream
    CLIs. Runs strict pre-flight validation (`validator::Validate`,
    secret presence, store-ref membership, adapter dispatch), then
    serialises `C` via `serde_json`, strips every `#[secret]` and
    `#[secret(store_ref)]` top-level field, flattens, and pushes.

Wired into both binaries: the default `edgezero` binary dispatches
`config push` to the raw flow; `app-demo-cli` dispatches to
`run_config_push_typed::<AppDemoConfig>`. Store-id resolution falls
back to `[stores.config].default` when `--store` is unset; an
unknown `--store` errors with the declared id list.

Coverage: 4 axum tests (write file, dry-run no-op, dir creation,
empty entries), 13 CLI tests (raw + typed flows, secret stripping,
explicit store selection, missing `[stores.config]`, undeclared
adapter, default-id resolution, array JSON-encoding, dotted-key
flattening, dry-run, validator failure, cloudflare stub error), and
3 args parse tests. Docs/cli-reference.md gets a full `edgezero
config push` section covering both flavours and the per-adapter
status (axum shipped; cloudflare / fastly / spin coming soon).
Replace the cloudflare push stub with the real implementation: read
the namespace id from wrangler.toml (matched by `binding = <store_id>`),
write the pre-flattened entries to a temp file in wrangler's bulk
format (`[{"key": "...", "value": "..."}]`), and shell out to
`wrangler kv bulk put <tempfile> --namespace-id=<id>`. The temp file
auto-deletes on drop. Keys arrive already-dotted from the CLI;
cloudflare passes them through unchanged.

The namespace resolver tolerates both `[[kv_namespaces]]`
(array-of-tables, what `provision` writes and wrangler's own
post-create hint prints) and the inline-array form. A missing
binding or a missing wrangler.toml errors with a "did you run
`edgezero provision --adapter cloudflare`?" hint — the most common
cause is forgetting to provision first.

Dry-run resolves the namespace id (so misconfigured manifests fail
fast) but never invokes wrangler and never writes a temp file. A
zero-entry push is a no-op that still resolves the namespace and
prints a friendly status line.

tempfile (workspace) is added to the cloudflare adapter's `cli`
feature. Coverage: 10 new tests on the cloudflare side (4
find_namespace_id covering array-of-tables / inline-array / missing-
binding / missing-file, 2 bulk_payload shape tests, 4 push
orchestration tests including the dry-run / missing-manifest /
provision-hint / empty-entries paths) plus the CLI's existing
cloudflare stub test rewritten as a real dry-run dispatch test that
exercises the resolver. cli-reference.md's per-adapter push table
marks cloudflare as shipped.
Replace the fastly push stub with the real implementation: resolve
the platform config-store id on demand via `fastly config-store
list --json` (matched by `name = <store_id>`), then shell out to
`fastly config-store-entry create --store-id=<id> --key=<k>
--value=<v>` per entry. Keys arrive pre-flattened from the CLI
(dotted form, spec §6.4); fastly passes them through.

The provision flow doesn't persist the platform store id (spec §12)
so push re-fetches every time — that's the whole reason this
adapter takes the list-then-create route rather than reading the id
from fastly.toml. The JSON parser accepts both a bare array and the
`{"items": [...]}` envelope so this stays compatible across fastly
CLI versions. A missing match errors with "did you run `edgezero
provision --adapter fastly`?" — the most common cause.

Re-runs on entries that already exist will fail loudly (fastly's
`config-store-entry create` refuses to overwrite). That's a known
gap documented in cli-reference.md; operators can delete the entry
or use `config-store-entry update` manually until a follow-up adds
upsert semantics.

Dry-run skips both the resolver shell-out and the per-entry create,
so CI exercises dispatch without fastly on PATH. An empty entries
list is a no-op with a friendly status line — no shell-out either.

serde_json (workspace) is added to the fastly adapter's `cli`
feature. Coverage: 6 new tests on the fastly side (4
find_config_store_id covering bare-array / items-envelope / no-
match / malformed-JSON, 2 push orchestration covering dry-run and
empty-entries) plus a CLI dry-run dispatch test that drives the
adapter without needing fastly installed. cli-reference.md's
per-adapter push table marks fastly as shipped with the
already-exists gotcha documented.
Replace the spin push stub with the real implementation: for each
entry, translate the dotted CLI key to a Spin variable name (`.` →
`__`, lowercased per §6.7) and write BOTH `spin.toml` tables — the
application-level declaration `[variables].<key>` with `default =
"<value>"`, and the component binding
`[component.<component>.variables].<key>` with `<key> = "{{ <key>
}}"`. Without both tables the wasm component cannot read the
variable; per the spec, `config push` (not `provision`) declares
variables because only push loads `<name>.toml` and thus knows the
keys.

Component resolution mirrors Stage 6.4's KV writeback: a single
`[component.*]` resolves implicitly, multi-component needs
`[adapters.spin.adapter].component`, a selector that doesn't match
any declared id is an error. Writeback is idempotent via toml_edit
— re-running updates the `default` value in place and overwrites
the binding without duplicating entries. Formatting, comments, and
sibling fields are preserved.

A belt-and-braces check rejects any key whose translation violates
Spin's `^[a-z][a-z0-9_]*$` rule before touching spin.toml — `config
validate` should already have caught it, but the adapter-side
guard keeps spin.toml well-formed if a raw push slips an invalid
key through. Secret variables stay manual: the typed CLI flow
strips `SECRET_FIELDS` upstream, and the raw flow leaves
`secret = true` declarations to the developer (spec §6.7).

Dry-run prints the would-be edits without writing — including
both the count line and one preview line per variable showing the
translated key, default value, and component binding. An empty
entries list is a no-op with a friendly status line.

Coverage: 12 new tests on the spin side — 3 translate_key_for_spin
(dot translation, passthrough, lowercase normalisation), 4
write_spin_variables (both tables present, idempotent re-run,
preserves siblings, plus a §13 golden test asserting every written
variable name passes the Spin regex floor of the validation
ladder), 5 push orchestration (dry-run no-edit + count line,
real writeback round-trip including `.→__`, missing manifest
path, dashed-key rejection, empty-entries no-op). CLI gets a
spin dry-run dispatch test that drives the adapter with a
single-component spin.toml. cli-reference.md's per-adapter push
table marks spin as shipped — closing Stage 7 with all four
adapters owning their own push impl via `Adapter::push_config_entries`.
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.)

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Reviewed 98930917d96cb665c7255f36ca8bbd61fc7539e1 → 5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a against the confirmed portable outbound HTTP and request-lifecycle intent, including intentional API replacements and conservative provider claims.

Requesting changes for R1–R4. R5–R7 are non-blocking maintenance suggestions. Comment prefixes follow the Code Review Emoji Guide: 🔧 requests a change; ♻️ suggests refactoring. All seven findings are inline. No verified style findings.

Verification

Workspace tests passed: 1,843 passed, one ignored generated-workspace test. Root and demo rustfmt, strict all-feature workspace Clippy, workspace feature compilation, Cloudflare/Fastly/Spin WASM compilation, docs format/lint/build, and outbound/dependency contract scripts passed. Initial offline attempts lacked cached dependencies; checks passed after fetching them. Focused scratch reproductions used actual core sources, the actual Axum response implementation, and locked Hyper 1.10.1.

Evidence boundaries

Four independent lane reviews plus coordinator verification covered selected production boundaries. This is a partial review of the 192-file change, not exhaustive approval. Manual coverage remains incomplete in generator/macros/demo, provider store and CLI paths, and substantial tests/specs. Generated-project acceptance, full feature matrices, provider runtime suites, and deployed cancellation behavior were not verified.

Axum terminal classification has raw-socket runtime counterexamples; batch failure classification has executed actual-core counterexamples. Exactly-once resource lifetime across every cancellation path remains unproven. The scratch harness did not exercise the full standard service/supervisor. Passing checks do not clear those remaining safety claims.

Comment thread crates/edgezero-adapter-axum/src/response.rs
Comment thread crates/edgezero-core/src/outbound.rs Outdated
Comment thread crates/edgezero-adapter-axum/src/response.rs Outdated
Comment thread crates/edgezero-core/src/app.rs
Comment thread crates/edgezero-adapter-axum/src/outbound.rs
Comment thread crates/edgezero-adapter-axum/src/outbound.rs Outdated
Comment thread scripts/check_flate_dependency_contract.mjs
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.

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