Skip to content

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

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

aram356 wants to merge 329 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.
  • Preserves per-slot elapsed time and failure provenance. A batch cutoff keeps completed slots and leaves unresolved slots explicit instead of converting them into request timeouts.
  • Adds explicit cache-bypass and wire-authority policies. URI routing, SNI, and certificate identity remain independent where the provider supports that separation.
  • Enforces independent encoded-transport, decoded-output, and final-buffer limits; header count/byte limits; Brotli window and decoder-state policy; opt-in response rechunking; and typed timeout, transport, protocol, and content-coding failures.
  • Uses fixed category-only public error messages so provider diagnostics, URLs, and credentials cannot become wire-visible.
  • Carries one application-owned monotonic clock through ingress, outbound dispatch and deferred streams, returned response drains, configuration extraction, and response egress. Equality expiry wins and backwards time fails closed.
  • Adds route-aware ingress admission on every standard adapter, stable route metadata, request-start-relative deadlines, opaque request-owned grants, and bounded 404/405 fallback reads with application-selected overflow/timeout responses.
  • 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.

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.
  • Response-egress abort, backpressure, completion, and write deadlines remain BestEffort on every adapter because no boundary proves end-client receipt; provider-specific blocking and teardown limits are documented.
  • Complete outbound and configuration-read allocation accounting is Unsupported; provider parsing/materialization, SDK copies, informational responses, trailers, allocator overhead, and other host-owned memory remain outside guest-visible caps.
  • Configuration-read deadlines are BestEffort on every adapter because provider operations are not proved cancellable within a finite wall-clock interval.
  • Cloudflare outbound deadlines and streamed-upload deadlines remain BestEffort pending a deployed host-observed cancellation artifact. Fastly and Spin retain their separately documented deadline, phase-budget, and isolation limitations.
  • Batch completion/cancellation/isolation and authority-override support are reported independently per adapter in the public capability matrix.

Validation

  • scripts/run_tests.sh
  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-targets
  • cargo check --workspace --all-targets --features "fastly cloudflare spin"
  • Native and WASM adapter feature matrices for Axum, Cloudflare, Fastly, and Spin
  • Fastly WASM/Viceroy contract and runtime suites
  • Fresh generated all-adapter project host tests and all provider WASM builds
  • Checked-in demo host tests, strict Clippy, and all provider WASM builds
  • Documentation format, lint, build, contract, dependency, and legacy API guards

aram356 added 30 commits May 20, 2026 08:34
- Commit 2 bisectability vs AppDemoConfig: §8 now states commit 2's
  app-demo handler migration is store-accessor-only (ctx.kv_store(id),
  config_store, the refactored extractors). AppDemoConfig and any
  typed-app-config handler work are commit 3 — commit 2 never
  references a type that lands in commit 3.
- #[secret(store_ref)] vs Single-secrets capability: §6.8 spells out
  that axum/cloudflare/spin are all Single for secrets, so any app
  including one of them has exactly one secrets id, and every
  #[secret(store_ref)] field must resolve to it. store_ref only buys
  multiple secret stores on a Fastly-only project. §15 / the
  walkthrough show this for the all-four-adapter app-demo.
- Spin variable-name rule drift guard: commit 7 gets a golden-file
  test on the generated spin.toml — asserts every variable name
  matches ^[a-z][a-z0-9_]*$ and that the generated manifest parses
  (round-trips through the same parser the runtime uses), so the rule
  cannot drift from Spin's actual manifest behaviour.

Reviewer confirms no blocking design issues remain.
- Spin manifest validation strength: the spin.toml golden test now
  specifies a strongest-first ladder — (1) the spin CLI's own manifest
  validation when present (the wasm32 spin CI job already installs it),
  (2) a spin_sdk validation entry point if exposed, (3) toml + regex
  as the weakest acceptable fallback. The regex is the floor, not the
  ceiling; real Spin validation is preferred wherever reachable.
- Generated template vs app-demo example made explicit: `edgezero new`
  scaffolds the common case — greeting, nested service section, a
  single plain #[secret] — and deliberately does NOT include
  #[secret(store_ref)] (a commented line shows how to add it).
  store_ref only helps Fastly-only projects, so it should not be the
  default in every fresh scaffold. app-demo remains the full-capability
  showcase that exercises both secret forms.
- Commit 2 flagged as the explicit review hotspot in §16: the atomic
  manifest+runtime rewrite warrants the most reviewer attention; its
  per-adapter contract tests are the primary mitigation and should be
  reviewed alongside the code.

Reviewer confirms no blocking issues; spec is implementation-ready.
- Spin config-push --dry-run never mutates: plan Task 8.1 and spec §15
  reworded — dry-run PRINTS the would-be both-table content and the
  test asserts spin.toml is unchanged on disk. (The real push writing
  both tables is covered by commit 7's non-dry-run tests.)
- Spin `component` field location: it belongs on the
  [adapters.<x>.adapter] definition struct (with `crate`/`manifest`),
  not the top-level ManifestAdapter — otherwise the accepted TOML
  would wrongly be [adapters.spin] component = ...
- load_app_config API made consistent: AppConfigLoadOptions
  { env_overlay } struct; simple load_app_config / _raw apply the
  overlay (default); load_app_config_with_options / _raw_with_options
  take the struct; --no-env calls the _with_options form with
  env_overlay: false. No hidden bool param. Updated spec §4 + §6.10
  and plan Tasks 3.1 / 3.3.
- Axum multi-KV path rule: one redb file per logical id, file stem
  from [adapters.axum.stores.kv.<id>].name -> .edgezero/kv-<name>.redb.
  Prevents multi-store collapsing into one backing file.
- Generator manual check: stop assuming the project lands in CWD or
  /tmp/throwaway; generate into an explicit mktemp dir via --dir.
- Removed references to a non-existent crates/edgezero-core/src/
  hooks.rs — Hooks + ConfigStoreMetadata both live in app.rs.
- Macro compile-fail tests: Task 3.2 now adds `trybuild = "1"` to
  edgezero-macros [dev-dependencies] explicitly (only `tempfile` was
  there), with a tests/ui/*.rs fixture + .stderr golden per rejected
  case.
- External-consumer test env guard: tests/lib_consumer.rs must restore
  EDGEZERO_MANIFEST via an RAII EnvOverride guard and stay a single
  #[test] (no in-binary parallelism); a shared Mutex guard is required
  if more env-touching tests are ever added.
- WASM contract test commands pinned: Task 2.7 step 6 names the exact
  target / features / runner per adapter (cloudflare wasm32-unknown-
  unknown + wasm-bindgen; fastly wasm32-wasip1 + Viceroy; spin
  wasm32-wasip1 + Wasmtime), deferring to test.yml as source of truth.
- app-demo e2e lifecycle: Task 8.1/8.2 now require an ephemeral port
  (no hard-coded 8787), a readiness poll (no bare sleep), and RAII
  teardown that kills the demo server even on assertion failure;
  the loop is preferably a Rust integration test, not shell-in-YAML.
…ign gate

- Default `edgezero` binary wiring (High): commits 4-7 now have explicit
  steps to add Auth / Provision / Config(Validate|Push) to the default
  edgezero-cli `Command` enum and `main.rs` dispatch (raw run_* — the
  default binary has no app struct), with `edgezero --help` / parse
  tests. Previously only the original five commands and app-demo-cli
  were wired; the spec requires the new subcommands on the default
  binary too. New Task 4.2 covers `config`; Task 5.2/6.1/7.2 extended.
- Generated `<name>-cli` template upgrade (Medium): new Task 8.2 updates
  templates/cli/src/main.rs.hbs to the full eight-command set once
  auth/provision/config exist, wiring the scaffold's config arm to the
  typed functions with the generated project's config struct. Generator
  test asserts it.
- Full-gate alignment (Medium): added a canonical "## The full gate"
  section with the exact five CI commands from CLAUDE.md / the
  workflows (cargo check uses --features "fastly cloudflare spin", not
  --all-features). Every "run the full gate" step references it; fixed
  the commit-1 and commit-8 gate steps and the Codebase-facts CI line
  that had drifted to --all-features.

Commit-8 tasks renumbered (8.2 CI wiring -> 8.3; walkthrough/audit -> 8.4).
- app-demo-cli missing app-demo-core dep (High): Task 4.3 now adds
  `app-demo-core = { path = "../app-demo-core" }` to
  app-demo-cli/Cargo.toml — it references AppDemoConfig once typed
  `config validate` / `config push` are wired, but its deps were only
  edgezero-cli/clap/log.
- Generated <name>-cli template missing core-crate dep (High): Task 8.2
  now also updates templates/cli/Cargo.toml.hbs to depend on
  `{{name}}-core` (path dep), and the generator test asserts the
  scaffold builds with that dependency and resolves the typed config
  type.
- AppConfig macro + validator availability (Medium): chosen route
  stated explicitly — `edgezero-core` re-exports the `AppConfig` derive
  (matching the existing `action`/`app` re-exports), so a config crate
  needs only `edgezero-core` for the macro, no direct edgezero-macros
  dep. Task 3.4 updates templates/core/Cargo.toml.hbs to add
  `validator` (with derive); Task 3.5 verifies app-demo-core already
  carries edgezero-core + validator + serde. Generator test checks the
  scaffolded core crate builds.

Task 3.4 / 4.3 / 8.2 steps renumbered to fit the inserted dependency
steps.
- Generated config type placeholder (Medium): Task 3.4 step 1 now
  explicitly adds a `NameUpperCamel` key to the generator Handlebars
  context (derived from `name`: split on -/_, upper-case each segment,
  join — `my-app` -> `MyApp`), with a unit test. Templates reference
  `{{NameUpperCamel}}Config`; the key was previously unset (generator
  data only had name/proj_core/proj_core_mod/proj_mod).
- validator workspace-dep plumbing (Medium/Low): Task 3.4 step 3 now
  names the generator change explicitly — `templates/core/Cargo.toml.hbs`
  uses `validator = { workspace = true }`, so `validator` must also be
  added to the generator's workspace-dependency seed
  (`seed_workspace_dependencies` in generator.rs), which omits it today.
- Duplicate Step 4 in Task 3.4 (Low): Task 3.4 renumbered cleanly to
  Steps 1-6.
…ident

- Generated CLI import (Medium): the cli template's `use` must
  reference the core crate's Rust module name, not the package name.
  `use {{name}}_core::...` renders `my-app_core` for `my-app` (invalid
  Rust). Task 8.2 now uses `{{proj_core_mod}}` — the hyphen-to-
  underscore module form the generator already exposes.
- NameUpperCamel validity (Medium/Low): Task 3.4 step 1 derivation now
  guarantees a valid Rust type identifier — derive from the sanitized
  crate name, drop empty segments (absorbs a leading `_`), and prefix
  with `App` when the result would start with a non-letter (digit-
  leading project names). Unit test covers `123-app` -> `App123App`,
  `_foo` -> `Foo`, etc.
ChristianPavilonis review:

* Generator dependency seeds were stale relative to the adapters:
    worker         0.7  → 0.8
    fastly         0.11 → 0.12
    simple_logger  4    → 5
  Scaffolded projects pinned older provider SDKs than the adapters
  expect, risking public-type mismatches in generated entrypoints.

* `PersistentKvStore::list_keys_page` lost keys when the scan cap was
  hit. On a cap-hit the loop broke with `reached_end = false`, but the
  cursor was only emitted when `live_keys.len() > limit`. An
  under-filled page (cap reached while skipping a long expired run)
  therefore returned `cursor: None` and callers stopped paginating,
  silently missing live keys past the expired run. Now a cap-hit is
  tracked and the last scanned key is returned as the resume cursor.

  Added `list_keys_page_returns_resume_cursor_when_scan_cap_is_hit`.
  `LIST_SCAN_BATCH_SIZE`/`MAX_SCAN_BATCHES` are lowered under
  `cfg(test)` so the cap path is reachable with a 43-entry fixture
  instead of 25k; pagination correctness is batch-size-independent.
Turn edgezero-cli into lib + bin so downstream projects can build their
own CLI binary reusing any subset of the built-in commands.

- Promote Command variant fields into standalone #[derive(clap::Args)]
  structs (BuildArgs / DeployArgs / ServeArgs; NewArgs already standalone),
  each #[non_exhaustive] + Default for external construction.
- Add src/lib.rs exposing the public API: run_build / run_deploy /
  run_serve / run_new / run_demo, init_cli_logger, and the args module
  (pub mod, not pub use — restriction lint). main.rs becomes a thin
  wrapper over the library.
- Rename the `dev` subcommand to `demo` (dev is reserved for a future
  dev-workflow command): dev_server.rs -> demo_server.rs, run_dev ->
  run_demo (now Result<(), String>), Command::Dev -> Command::Demo.
- Extend the generator to scaffold a crates/<name>-cli crate from new
  templates/cli/ Handlebars templates; seed clap + edgezero-cli as
  workspace dependencies; add crates/<name>-cli to the workspace members.
- Add the handwritten examples/app-demo/crates/app-demo-cli crate as the
  canonical downstream consumer, with a --help smoke test.
- Add crates/edgezero-cli/tests/lib_consumer.rs: external-consumer
  integration test proving the public API is usable from outside.
- Docs: cli-reference.md (demo rename + "Building Your Own CLI"),
  getting-started.md, CLAUDE.md.

All gates green: fmt, clippy -D warnings, cargo test --workspace,
feature cargo check, spin wasm32; app-demo workspace fmt/clippy/test.
After the dev->demo rename, `demo` should mean "run the bundled example",
not "run the project's axum adapter". Drop `try_run_manifest_axum` (and
its `load_manifest_optional` helper) from `demo_server`: `edgezero demo`
now always starts the built-in example server on 127.0.0.1:8787 and never
reads `edgezero.toml`.

`edgezero serve --adapter axum` is now the single, unambiguous way to run
a project's axum adapter (it runs `[adapters.axum.commands].serve`). This
removes the demo / serve --adapter axum behavioral overlap. Docs updated.
…mit 2

- Commit 1 marked DONE (landed 1d582dd + follow-up 06f4b72) with a
  Status section, so workers don't redo already-landed work.
- Working-branch reference corrected: feature/extensible-cli (was the
  stale docs/extensible-cli-library-spec).
- app-demo edgezero-cli dep path fixed to ../../crates/edgezero-cli
  (relative to the workspace manifest; the four-up path was wrong and
  would break the demo workspace).
- Task 2.7 Fastly step expanded from one line to explicit per-kind
  registry steps + contract tests: Fastly is Multi for KV/config/
  secrets, two logical stores per kind, per-id name resolution,
  id-keyed contract coverage under Viceroy — parity with the
  cloudflare/spin acceptance criteria.
Commit 1 shipped `pub mod args` rather than crate-root re-exports: a
root `pub use args::{...}` trips clippy::pub_use (the restriction group
is -D-denied workspace-wide). §4 now documents the supported API as
edgezero_cli::args::BuildArgs etc., with run_* staying at the crate
root, and updates every run_* signature to &args::<T>. Matches what
1d582dd actually exposes and what lib_consumer.rs / cli-reference.md
already use.

(Reviewer's second finding — demo overlapping serve --adapter axum in
1d582dd — was already resolved by 06f4b72; no action.)
Review comment #7: a freshly generated project failed its own
restriction-deny clippy gate immediately.

- Core handler template: mirror app-demo's passing structure —
  fallible `stream`/`IntoResponse` usage (no production `.expect`),
  alphabetically ordered structs, grouped test items, `IntoResponse`
  imported anonymously.
- Adapter host stubs: add `#[expect(clippy::print_stderr, reason)]`
  and `allow(dead_code, reason)`; the axum entrypoint returns
  `anyhow::Result` instead of `eprintln!` + `process::exit`.
- Inline the project-core `App` type in adapter entrypoints so import
  order stays stable regardless of project name.
- key_value_store: replace `#[cfg(not(test))]` consts with
  `if cfg!(test)` and rename a `cursor` binding that shadowed the
  parameter (clippy `cfg_not_test` / `shadow_unrelated`).
- Add scaffold lint-coverage assertions to the generator test.
# Conflicts:
#	crates/edgezero-adapter-axum/src/cli.rs
#	crates/edgezero-adapter-axum/src/dev_server.rs
#	crates/edgezero-cli/src/dev_server.rs
#	crates/edgezero-core/src/lib.rs
…e/extensible-cli

# Conflicts:
#	crates/edgezero-cli/src/dev_server.rs
`edgezero demo` now delegates to `edgezero_adapter_axum::dev_server::run_app`,
running the bundled app-demo example the same way its own axum adapter does.
This wires the complete manifest setup (routing, KV/config/secret stores,
logging, host/port) instead of a hand-rolled echo router.

The demo path requires the `dev-example` feature; without it `run_demo`
returns an actionable error.
The eight numbered work units are now "stages" rather than "commits" —
each stage may span multiple git commits. Literal git-commit actions
(commit steps, `git commit -m`, the PR head commit) keep the "commit"
wording.
Addresses review findings on the demo subcommand:

- demo is exposed only when built with the new `demo-example` feature.
  Generated CLIs and app-demo-cli no longer expose `Demo` at all — a
  downstream project has no bundled app-demo to run. The default
  `edgezero` binary gates `Command::Demo` on `demo-example`, so the
  advertised `--help` surface matches what actually works.
- `demo-example` (renamed from `dev-example`) now also pulls in
  `edgezero-adapter-axum`, making the feature self-contained.
- getting-started.md points generated projects at
  `edgezero serve --adapter axum`; cli-reference.md documents `demo`
  as contributor-only.
- NewArgs now derives Default and is #[non_exhaustive], matching the
  other public *Args structs.
- Generated handler tests serialize API_BASE_URL access behind a
  mutex + RAII env guard.
- Refreshed README, CLAUDE.md, architecture docs, and agent docs for
  the dev->demo / dev-example->demo-example rename.
Addresses review findings on the Stage 1 surface:

- Add a `[[bin]] name = "edgezero"` target so `cargo build` produces
  `target/debug/edgezero` — the name every doc and the clap `about`
  already use.
- Remove the inert `--local-core` flag from `NewArgs`; it was never
  read by the generator.
- Warn when `edgezero new` falls back to a Git dependency for
  `edgezero-cli`: the generated CLI crate needs `edgezero-cli` as a
  published library, so an out-of-repo scaffold only builds once that
  is available on the referenced remote. In-repo generation uses a
  path dependency and is unaffected.
- Replace removed `edgezero dev` references with
  `edgezero serve --adapter axum` in the root README, architecture,
  and axum adapter docs.
- Drop `run_demo` from the "build your own CLI" surface (it is
  contributor-only), and add the generated `*-cli` and Spin adapter
  crates to the scaffold structure docs.
Fixes fresh `edgezero new` projects failing to build outside the repo.

The generated CLI crate imports `edgezero_cli`, but dependency
resolution fell back to a Git dependency whenever the output directory
was outside the repo root — and the published `edgezero-cli` has no
library target, so every `edgezero_cli::...` import failed.

- Locate the edgezero checkout via `CARGO_MANIFEST_DIR` (baked in at
  build time) instead of the current directory, so generation finds the
  checkout regardless of where the project is created or where the
  command runs.
- When the output directory is outside the checkout, emit an absolute
  path dependency rather than the Git fallback. The Git fallback now
  only applies to a binary detached from its source tree.
- Assert in the generator test that the scaffold resolves edgezero
  crates to path dependencies, so a regression to the Git fallback is
  caught by `cargo test -p edgezero-cli`.
- Add an opt-in (`#[ignore]`) integration test that runs `cargo check`
  on the generated CLI crate, proving it compiles against the local
  `edgezero-cli` library.
- Drop the stale `--local-core` option from the CLI reference docs.
Broaden the opt-in scaffold test to `cargo check --workspace` and drop
`--offline`: a freshly generated project has no lockfile, so offline
resolution of transitive registry crates is unreliable (true of any
scaffolded project). Online, the full generated workspace compiles.
# Conflicts:
#	.github/workflows/test.yml
#	crates/edgezero-adapter-spin/src/lib.rs
#	crates/edgezero-adapter-spin/src/request.rs
#	crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs
#	crates/edgezero-core/src/config_store.rs
#	crates/edgezero-core/src/manifest.rs
#	crates/edgezero-core/src/secret_store.rs
- Adapter README `dev_steps` snippets advised `edgezero-cli serve
  --adapter ...`, but the binary is `edgezero` (the `edgezero-cli`
  package builds `target/debug/edgezero`). Corrected all four adapters
  (axum, cloudflare, fastly, spin) so generated-project READMEs show a
  working command.
- Updated the plan and spec acceptance notes: generated and app-demo
  CLIs expose the four downstream built-ins (build/deploy/new/serve),
  not five — `demo` is contributor-only and absent from downstream
  CLIs. Also corrected the Stage 8 generated-CLI command count.
The key_value_store and secret_store modules are already gated at their
mod declaration in lib.rs (#[cfg(all(feature = "spin", target_arch =
"wasm32"))]), so every per-item copy of that same cfg inside the files
was a tautology. Removing the 14 no-op attributes makes both files
consistent with their sibling request.rs/response.rs/proxy.rs, which
already rely on the gated mod declaration.
- Add a CI step that runs the `generated_project_builds` test
  (`-- --ignored`), so the Stage 1 scaffold regression — a fresh
  `edgezero new` project failing to compile — is caught by CI rather
  than only by manual runs.
- Correct two stale Stage 1 plan steps: a default `cargo build
  -p edgezero-cli` exposes four subcommands, not five; `demo` is gated
  behind the `demo-example` feature.
@aram356
aram356 marked this pull request as ready for review September 14, 2026 15:38
@aram356
aram356 marked this pull request as draft September 14, 2026 16:15
@aram356
aram356 marked this pull request as ready for review September 14, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant