From a9c15fe441fc2198aeb9176c3287b8c02fabd7fd Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:36:40 +0000 Subject: [PATCH 1/8] docs(net): net-http construction-surface spec + Slice 0 foundation plan Refs #57 --- .../plans/2026-06-30-net-http-foundation.md | 459 +++++++++++++++++ ...30-net-http-construction-surface-design.md | 470 ++++++++++++++++++ 2 files changed, 929 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-net-http-foundation.md create mode 100644 docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md diff --git a/docs/superpowers/plans/2026-06-30-net-http-foundation.md b/docs/superpowers/plans/2026-06-30-net-http-foundation.md new file mode 100644 index 0000000..c19be99 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-net-http-foundation.md @@ -0,0 +1,459 @@ +# net-http Foundation (Slice 0) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lay the foundation the net-http construction surface sits on — execute the ADR-0029 transport-split repartition (move `Service` out of the kernel), add the kernel `Timer` contract, and birth `oath-adapter-net-http-api` — so the contract work in PRs 2–4 has a place to land. + +**Architecture:** The kernel (`oath-adapter-net-api`) keeps the transport-neutral composition machinery (`Layer`, `ServiceBuilder`, `Stack`, `Identity`), classification (`ErrorKind`, `HasErrorKind`), and gains the runtime-neutral `Timer` clock — and becomes **std-only** (the signal the cut is clean, ADR-0029 §3). `Service` moves up into the new per-transport contract crate `oath-adapter-net-http-api`, which depends inward on the kernel. No `dyn`, RPITIT throughout (ADR-0007/0029 §5). + +**Tech Stack:** Rust (edition 2024, MSRV 1.90), `just` task runner, the workspace `[workspace.lints]` gate. PR 1 introduces no new external dependencies. + +**Source spec:** [docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md](../specs/2026-06-30-net-http-construction-surface-design.md). This plan is **Slice 0 (Foundation)** of that spec; Slices 1–2 (layers, assembly+backend) are later plans. + +## Global Constraints + +Every task implicitly includes these (verbatim from the workspace manifest, CLAUDE.md, and the spec): + +- **Edition 2024, MSRV 1.90** (`rust-version = "1.90"`; toolchain pinned `1.96.0`). Validate MSRV with `just msrv`. +- **No `unsafe`** — `unsafe_code = "deny"` workspace-wide. Body impls (later PRs) use `pin-project-lite`, never manual `unsafe`. +- **No `unwrap`/`expect`/indexing/panic in non-test code** — `unwrap_used`/`expect_used`/`indexing_slicing`/`panic_in_result_fn` are warn-level but **CI is warning-free**, so treat as deny. Test code is exempt via `.clippy.toml`. +- **clippy `all` = deny**; `pedantic`/`nursery`/`cargo` = warn (must stay clean); `dbg_macro` = deny. +- **Document all public items** (`missing_docs` = warn); **derive `Debug`** everywhere (`missing_debug_implementations` = warn); **no `pub` that isn't reachable** (`unreachable_pub` = warn). +- **Definition of done per PR:** `just ci` green (`fmt fmt-toml typos lint check test deny doc machete gitleaks actionlint shellcheck`). The pre-push hook enforces this. +- **Dependencies:** internal crates via `[workspace.dependencies]` with an explicit `version`; external shared deps also via `[workspace.dependencies]`. +- **`oath-adapter-net-http-api` charter:** no `tokio`/`hyper`/`reqwest`/`serde`; free of any async runtime (ADR-0030, as amended by the spec). In PR 1 it has **zero** dependencies. +- **Workflow:** one issue → one branch off `main` → isolated git worktree under `.claude/worktrees/` (never switch the primary checkout) → one PR that `Closes #N`. Update `CHANGELOG.md` `[Unreleased]` in each PR. + +--- + +## File Structure + +PR 1 touches: + +- `crates/adapter/net/api/src/timer.rs` — **new.** The `Timer` clock contract (kernel). +- `crates/adapter/net/api/src/compose.rs` — **renamed** from `service.rs`; holds `Layer`, `ServiceBuilder`, `Stack`, `Identity` (the `Service` trait removed). +- `crates/adapter/net/api/src/lib.rs` — re-exports updated (`Timer` added, `Service` dropped, module renamed). +- `crates/adapter/net/api/Cargo.toml` — stripped to **std-only** (external deps + machete block removed). +- `crates/adapter/net/http/api/Cargo.toml` — **new crate** `oath-adapter-net-http-api`. +- `crates/adapter/net/http/api/src/lib.rs` — **new.** Crate root. +- `crates/adapter/net/http/api/src/service.rs` — **new.** The `Service` trait (moved from the kernel). +- `Cargo.toml` (workspace) — add the new member + its `[workspace.dependencies]` entry. +- `README.md` — crate table + mermaid dependency graph. +- `CHANGELOG.md` — `[Unreleased]`. + +## Slice 0 PR map + +| PR | Scope | Status | +|----|-------|--------| +| **PR 1** | ADR-0029 repartition + kernel `Timer` + birth of `net-http-api` | **this plan, full detail** | +| PR 2 | HTTP transport contract (`HttpError`, `HttpClient`, `ResponseBody`+forwarding, `BufferMode`) + the `net-http-mock` harness (`MockClient`, `MockTimer`, `MockBody`) | roadmap below → own plan | +| PR 3 | `AuthSource` + `Auth` layer + `NoAuth`; `Guarded` body (forwarding + eager release) | roadmap below → own plan | +| PR 4 | `RateKey` + `RateLimitConfig` + `LimitDecl` + `BuildError` + boot-time coverage validation | roadmap below → own plan | + +--- + +## PR 1: ADR-0029 repartition + kernel `Timer` + `net-http-api` birth + +**Issue:** "feat(net): ADR-0029 repartition — move `Service` to `net-http-api`, add kernel `Timer`". **Branch:** `feat/net-http-api-repartition`. + +### Task 1.1: Kernel — add the `Timer` contract + +**Files:** +- Create: `crates/adapter/net/api/src/timer.rs` +- Modify: `crates/adapter/net/api/src/lib.rs` + +**Interfaces:** +- Produces: `oath_adapter_net_api::Timer` — `trait Timer: Clone + Send + Sync { fn sleep(&self, dur: Duration) -> impl Future + Send; fn now(&self) -> Instant; }` + +- [ ] **Step 1: Write the failing test** + +Create `crates/adapter/net/api/src/timer.rs` with only the test, and add `pub mod timer;` to `lib.rs` (temporarily, before the trait exists): + +```rust +//! The `Timer` clock contract — placeholder; trait added in step 3. + +#[cfg(test)] +mod tests { + use super::Timer; + use std::time::{Duration, Instant}; + + #[derive(Clone)] + struct FixedTimer(Instant); + + impl Timer for FixedTimer { + fn sleep(&self, _dur: Duration) -> impl std::future::Future + Send { + std::future::ready(()) + } + fn now(&self) -> Instant { + self.0 + } + } + + #[test] + fn now_returns_the_configured_instant() { + let t0 = Instant::now(); + assert_eq!(FixedTimer(t0).now(), t0); + } +} +``` + +- [ ] **Step 2: Run it to confirm it fails** + +Run: `just check` +Expected: FAIL — `cannot find trait Timer in this module`. + +- [ ] **Step 3: Add the `Timer` trait** + +Prepend to `timer.rs` (above the `#[cfg(test)]` block), and replace the placeholder `//!` line with real module docs: + +```rust +//! The `Timer` clock contract — a runtime-neutral clock for timing layers. + +use std::future::Future; +use std::time::{Duration, Instant}; + +/// A clock abstraction for timing layers, decoupled from any async runtime. +/// +/// Timing middleware (`Timeout`, `Retry` backoff, `RateLimit` refill, +/// `CircuitBreaker` cooldown) is generic over `Timer` so a mock clock can drive +/// it deterministically in tests while production passes a runtime-backed impl. +/// A trait — not a runtime — so the kernel stays std-only (ADR-0029 §4). +pub trait Timer: Clone + Send + Sync { + /// Complete after `dur` has elapsed. + fn sleep(&self, dur: Duration) -> impl Future + Send; + + /// The current instant — for elapsed-time reads (token-bucket refill, + /// circuit cooldown). + fn now(&self) -> Instant; +} +``` + +- [ ] **Step 4: Re-export and run the test** + +In `lib.rs` add `pub use timer::Timer;` next to the other re-exports, and add `//! - [`timer`] — `Timer`` to the module-list doc comment. + +Run: `just check && cargo test -p oath-adapter-net-api timer` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/adapter/net/api/src/timer.rs crates/adapter/net/api/src/lib.rs +git commit -m "feat(net): add Timer clock contract to the kernel" +``` + +> **Runtime-neutrality demonstration (optional, recommended).** Add one dev test that polls `FixedTimer::sleep(..)` via a minimal std `block_on` (a noop-waker loop — zero new deps) instead of `#[tokio::test]`, proving the kernel's futures run off *any* runtime. The token gesture is weak here (`sleep` is a ready future); the meaningful demonstration — a layer driven over a mock executor — lands with the layer slices. + +### Task 1.2: Create `oath-adapter-net-http-api` and move `Service` into it + +**Files:** +- Modify: `Cargo.toml` (workspace `members` + `[workspace.dependencies]`) +- Create: `crates/adapter/net/http/api/Cargo.toml` +- Create: `crates/adapter/net/http/api/src/service.rs` +- Create: `crates/adapter/net/http/api/src/lib.rs` + +**Interfaces:** +- Produces: `oath_adapter_net_http_api::Service` — `trait Service { type Response; type Error; fn call(&self, req: Req) -> impl Future> + Send; }` (moved verbatim from the kernel). + +- [ ] **Step 1: Register the crate in the workspace** + +In the root `Cargo.toml`, add to `members` (after `"crates/adapter/net/api"`): + +```toml + "crates/adapter/net/http/api", +``` + +and to `[workspace.dependencies]` (after the `oath-adapter-net-api` line): + +```toml +oath-adapter-net-http-api = { path = "crates/adapter/net/http/api", version = "0.1.0" } +``` + +- [ ] **Step 2: Create the crate manifest** + +Create `crates/adapter/net/http/api/Cargo.toml`. **No `[dependencies]`** — `Service` is generic and uses only `std::future::Future` (HTTP/kernel deps arrive in PR 2): + +```toml +[package] +name = "oath-adapter-net-http-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true +``` + +- [ ] **Step 3: Move the `Service` trait in** + +Create `crates/adapter/net/http/api/src/service.rs` with the trait lifted verbatim from the kernel's `service.rs` (its doc-comment included): + +```rust +//! The request/reply connection-shape contract for the HTTP transport. +//! +//! `Service` models **request → one reply** — it fits REST and unary RPC but +//! not WebSocket subscriptions or multicast, so it is a per-transport contract, +//! not a kernel primitive (ADR-0029 §2). It is transport-*neutral* (names no +//! HTTP type); it lives here, the first request/reply transport, until a second +//! one justifies hoisting it into a shared `net-req-reply-api` crate. + +use std::future::Future; + +/// A single async call: request in, `Result` out. +/// +/// `call` takes `&self` (not `&mut self`) — a service is shared, not owned, by +/// its callers. The returned future is **`Send`** (enforced here) so it runs on +/// a multithreaded runtime. The service *value* is expected to be +/// `Send + Sync + 'static` too, but that is enforced at the **composition +/// boundary** (`stack()`'s return bound, ADR-0030), not on this trait — so a +/// service may be non-`Sync` in a context that never shares it. Backpressure is +/// handled inside `call` (e.g. awaiting a permit), not via a separate +/// `poll_ready`. +/// +/// Because the `call` future borrows `&self`, it is **not** `'static`-spawnable: +/// to `tokio::spawn` a call, clone the (cheap, `Arc`-backed) service and move the +/// clone in. RPITIT return — no `async-trait`, no `dyn`, no per-call allocation. +pub trait Service { + /// The value produced on success. + type Response; + + /// The error produced on failure. + type Error; + + /// Drive the request to completion. + fn call(&self, req: Req) -> impl Future> + Send; +} +``` + +- [ ] **Step 4: Create the crate root** + +Create `crates/adapter/net/http/api/src/lib.rs`: + +```rust +//! `oath-adapter-net-http-api` — the HTTP transport contract over the kernel. +//! +//! Builds on `oath-adapter-net-api` (composition machinery + `ErrorKind` + +//! `Timer`) and adds the request/reply [`Service`] connection shape. The HTTP +//! data plane (`HttpError`, `HttpClient`, `ResponseBody`, the layers) lands in +//! later slices. No async runtime, `hyper`, `reqwest`, or `serde` here. +#![forbid(unsafe_code)] + +pub mod service; + +pub use service::Service; +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cargo check -p oath-adapter-net-http-api` +Expected: PASS. (`Service` now exists in both crates transiently — resolved in Task 1.3.) + +- [ ] **Step 6: Commit** + +```bash +git add Cargo.toml crates/adapter/net/http/api +git commit -m "feat(net): create oath-adapter-net-http-api with the Service contract" +``` + +### Task 1.3: Remove `Service` from the kernel; rename `service` → `compose`; go std-only + +**Files:** +- Rename: `crates/adapter/net/api/src/service.rs` → `crates/adapter/net/api/src/compose.rs` +- Modify: `crates/adapter/net/api/src/lib.rs` +- Modify: `crates/adapter/net/api/Cargo.toml` + +- [ ] **Step 1: Rename the module file** + +```bash +git mv crates/adapter/net/api/src/service.rs crates/adapter/net/api/src/compose.rs +``` + +- [ ] **Step 2: Delete the `Service` trait and fix the doctest** + +In `compose.rs`: remove the `pub trait Service { … }` block and the now-unused `use std::future::Future;`. Replace the module-doc example (which used `Service`) with a `Layer`-only one, and update the module heading: + +```rust +//! Composition machinery: `Layer`, `ServiceBuilder`, `Identity`, `Stack`. +//! +//! These compose **anything** — `Layer` carries no `Service` bound (ADR-0029 +//! §3), so the same machinery composes an HTTP `Service` stack today and a WS +//! subscription stack tomorrow. +//! +//! # Ordering invariant +//! +//! The **first** `.layer()` call is permanently the outermost wrapper and +//! therefore the first to handle each request. +//! +//! ``` +//! # use oath_adapter_net_api::compose::{Layer, ServiceBuilder}; +//! # struct TracingLayer; +//! # struct MetricsLayer; +//! # impl Layer for TracingLayer { type Service = S; fn layer(&self, s: S) -> S { s } } +//! # impl Layer for MetricsLayer { type Service = S; fn layer(&self, s: S) -> S { s } } +//! // TracingLayer is added first → outermost → wraps everything else. +//! let _svc = ServiceBuilder::new() +//! .layer(TracingLayer) // outermost +//! .layer(MetricsLayer) // inner +//! .service(()); // leaf: any value (a `Service` leaf lives in net-http-api) +//! ``` +``` + +Also fix the `ServiceBuilder` struct's own doctest import path (`oath_adapter_net_api::service::` → `oath_adapter_net_api::compose::`). + +- [ ] **Step 3: Update `lib.rs`** + +```rust +//! `oath-adapter-net-api` — transport-neutral composition primitives + contracts. +//! +//! This crate is **std-only** (zero deps — the signal the ADR-0029 cut is +//! clean). It defines the shared abstractions every transport's layers depend +//! on: +//! +//! - [`compose`] — `Layer`, `ServiceBuilder`, `Identity`, `Stack` +//! - [`error_kind`] — `ErrorKind`, `HasErrorKind` +//! - [`timer`] — `Timer` +//! +//! `Service` is **not** here — it is a per-transport contract in +//! `oath-adapter-net-http-api` (ADR-0029 §2). +#![forbid(unsafe_code)] + +pub mod compose; +pub mod error_kind; +pub mod timer; + +pub use compose::{Identity, Layer, ServiceBuilder, Stack}; +pub use error_kind::{ErrorKind, HasErrorKind}; +pub use timer::Timer; +``` + +- [ ] **Step 4: Strip the kernel to std-only** + +Replace `crates/adapter/net/api/Cargo.toml` `[dependencies]` and the `[package.metadata.cargo-machete]` block — the HTTP deps belong in `net-http-api`, and the kernel uses none of them: + +```toml +[package] +name = "oath-adapter-net-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true +``` + +- [ ] **Step 5: Verify the whole workspace** + +Run: `just check && just test && just lint` +Expected: PASS — kernel is std-only with no `Service`; doctests compile; `net-http-api` owns `Service`. (`just machete` no longer has an ignore block to satisfy.) + +- [ ] **Step 6: Commit** + +```bash +git add crates/adapter/net/api +git commit -m "refactor(net): kernel keeps composition+ErrorKind+Timer, sheds Service and all deps" +``` + +### Task 1.4: Update the README dependency graph and CHANGELOG + +**Files:** +- Modify: `README.md` (crate table + mermaid graph) +- Modify: `CHANGELOG.md` (`[Unreleased]`) + +- [ ] **Step 1: Update the crate table** + +In `README.md`, replace the `oath-adapter-net-api` row and add the new crate's row: + +```markdown +| `oath-adapter-net-api` | Transport-neutral composition primitives (`Layer`, `ServiceBuilder`, `Stack`) + `ErrorKind` / `Timer` | +| `oath-adapter-net-http-api` | HTTP transport contract (`Service`, …) over the kernel | +``` + +- [ ] **Step 2: Update the mermaid graph** + +In the `graph TD` block, replace the standalone `netapi[oath-adapter-net-api]` line with the kernel node plus the new crate depending on it: + +``` + netapi[oath-adapter-net-api] + nethttpapi[oath-adapter-net-http-api] --> netapi +``` + +- [ ] **Step 3: Update the CHANGELOG** + +In `CHANGELOG.md` under `## [Unreleased]` → `### Changed`, add: + +```markdown +- Began the ADR-0029 network-adapter repartition: `oath-adapter-net-api` is now the + transport-neutral, **std-only** kernel (composition machinery + `ErrorKind` + + the new runtime-neutral `Timer` clock); the `Service` request/reply contract moved + into the new per-transport crate `oath-adapter-net-http-api`. +``` + +- [ ] **Step 4: Full CI gate** + +Run: `just ci` +Expected: PASS (all of fmt, fmt-toml, typos, lint, check, test, deny, doc, machete, gitleaks, actionlint, shellcheck). + +- [ ] **Step 5: Commit, push, open the PR** + +```bash +git add README.md CHANGELOG.md +git commit -m "docs(net): record the ADR-0029 repartition (Service → net-http-api, kernel Timer)" +git push -u origin feat/net-http-api-repartition +gh pr create --fill --base main +``` + +The PR body must reference `Closes #`. + +--- + +## Remaining Slice 0 work (PRs 2–4) — roadmap + +Each becomes its **own full plan** (authored when its predecessor merges, so the code is written against the types that actually exist). The contract shapes are already fixed in the [design spec](../specs/2026-06-30-net-http-construction-surface-design.md) — these entries capture scope, files, the key interfaces, and the test focus. + +### PR 2 — HTTP transport contract + mock harness + +- **Crate deps (net-http-api):** add `oath-adapter-net-api`, `http`, `bytes`, `http-body`, `http-body-util`, `pin-project-lite`, `thiserror`, `tracing` to `[workspace.dependencies]`/the crate (as used). +- **`HttpError`** (`src/error.rs`) — one concrete `thiserror` enum for **transport/middleware failures only**: `Timeout`, `Connection(#[source] BoxError)`, `Throttled` (a `RateLimit` max-wait *decision*, not a 429 response), `Auth(String)` + `HttpError::auth(msg)`, `Other(#[source] BoxError)`; `impl HasErrorKind`. (`CircuitOpen` is added with the CircuitBreaker layer — Slice 1.) **HTTP error *statuses* (4xx/5xx) are NOT converted to `HttpError`** — they flow through as `Ok(http::Response)` with the body intact, so the adapter reads the venue's rejection payload and the stack never discards it. This matches ADR-0030 §5 (whose `HttpError` examples are middleware failures — timeout, retry-exhausted, body-read — never statuses); the earlier `Client(StatusCode)`/`Server(StatusCode)` sketch was lossy and is dropped. The resilience layers (`Retry`/`CircuitBreaker`, Slice 1) decide by **peeking `Response::status()`** (5xx → server-error signal) and the 429 **`Retry-After`** header (so `CircuitBreaker` honours the server's cooldown hint instead of a fixed 15 min) — read-only; the `Response` continues downstream unchanged. +- **`HttpClient`** (`src/client.rs`) — the blanket-impl'd `Service` sub-trait with `type Body` and `send` sugar (spec/0030 §6). +- **`ResponseBody`** (`src/body.rs`) — newtype over `Either>, B>` with `buffered`/`streaming` constructors; **`Body` impl forwards `poll_frame`, `is_end_stream`, AND `size_hint`** (the spec's transparency fix). +- **`BufferMode`** (`src/body.rs`) — `enum { Buffer, Stream }`, `Copy`. +- **`oath-adapter-net-http-mock`** (`crates/adapter/net/http/mock`, **new crate**, consumed only via `[dev-dependencies]`): `MockClient` (impl `Service>` → canned `http::Response`, records requests), `MockBody` (configurable frames + `is_end_stream`/`size_hint`), and `MockTimer`. + - **`MockTimer` is a controllable clock, not a no-op.** `std::time::Instant` has no value constructor, so it anchors to `Instant::now()` at construction and advances via a stored offset behind interior mutability (`now(&self)` is `&self` but the clock mutates → `Arc>` / atomic-nanos). `sleep(dur)` must make elapsed time **observable**: register a wake at `now()+dur`, and a test-only `advance(dur)` bumps the clock and wakes due sleepers (prefer register-wake + `advance` over naive *sleep-advances-now* so concurrent sleepers don't double-count). A no-op `sleep` + fixed `now()` makes Slice 1's token-bucket-refill / circuit-cooldown tests **vacuous**. Prior art: `governor::clock::{Clock, FakeRelativeClock}` and tokio's `pause()`/`advance()`. Keep concrete `std::Instant` (a governor-style `Clock::Instant` associated type was rejected — it makes every timing layer generic over the instant type; concrete keeps them monomorphic, at the cost of this anchor-and-offset recipe). + - **Dev-dep cycle (legal, flagged):** `net-http-api` dev-depends on `net-http-mock`, which normal-depends on `net-http-api` — Cargo permits this (dev-deps are outside the library build graph). If `just machete` or rust-analyzer trips on the cycle, add a `cargo-machete` ignore; or keep `net-http-api`'s own unit tests on tiny inline doubles and reserve `net-http-mock` for downstream consumers, avoiding the cycle entirely. +- **Tests:** `MockClient` satisfies `HttpClient` (blanket impl applies); `ResponseBody` reports `size_hint`/`is_end_stream` identical to its inner `MockBody` (parity); `MockTimer` makes elapsed time observable — `advance(dur)` after a `sleep(dur)` moves `now()` (driven on a non-tokio executor where practical, to demonstrate runtime-neutrality); `HttpError::kind()` maps each variant. +- **Deliverable:** the contract compiles and is exercised end-to-end over the mock harness. + +### PR 3 — `AuthSource` + `Auth` layer + `Guarded` body + +- **`AuthSource`** (`src/auth.rs`) — `trait AuthSource: Clone + Send + Sync { fn authorize(&self, req: &mut http::Request) -> impl Future> + Send; }`; `NoAuth` (ready `Ok(())`); the `Auth` layer (`self.auth.authorize(&mut req).await?; self.inner.call(req).await`). `SetHeaders` sits just outside `Auth` (precedence pinned). +- **`Guarded`** (`src/body.rs`, add `async-lock` dep) — `struct Guarded { #[pin] inner: B, permit: Option }`; `Body` impl forwards `is_end_stream`/`size_hint` and `take()`s the permit on the terminal frame (eager release). +- **Tests:** `authorize` runs once per attempt with a fresh value (recording `MockClient`); `NoAuth` is ready-`Ok`; an `authorize` error surfaces as `ErrorKind::Auth`; `Guarded` parity; eager release on terminal frame **and** on early drop (over a real `async_lock::Semaphore` + a `MockBody`). +- **Deliverable:** `Auth` over `MockClient` and `Guarded` correctness, both green. + +### PR 4 — `RateKey` + `RateLimitConfig` + boot-time coverage + +- **`RateKey`** (`src/rate.rs`) — `trait RateKey: Hash + Eq + Clone + Send + Sync + 'static { fn all() -> &'static [Self] where Self: Sized; }`. +- **Config types** (`src/rate.rs`) — `LimitPolicy { TokenBucket { rate, burst }, Concurrency { max } }`; `LimitDecl { Policy(LimitPolicy), GlobalOnly }`; `RateLimitConfig { global: LimitPolicy, local: HashMap }`; `BuildError` (`thiserror`: `UndeclaredKey`, bad-policy-params, missing-global). +- **`validate_coverage`** — the construction-time check: `local` total over `K::all()`, `global` present, param sanity (`rate > 0`, `burst >= 1`, `max >= 1`) → `Result<(), BuildError>`. (Wired into `stack()`/`build()` in Slice 2; unit-tested standalone here.) +- **Tests:** a config missing a `K` variant → `Err(BuildError::UndeclaredKey)`; a total config → `Ok`; bad params / missing global → `Err`. Uses a test `RateKey` enum whose `all()` is guarded by an exhaustive-`match` test (drift-proofing). +- **Deliverable:** the coverage validator is correct and ready for Slice 2's `stack()`/`build()` to call. + +--- + +## Forward notes (deferred — recorded, not for Slice 0) + +- **Read `now()` once per request and thread it down.** The timing layers each call `Timer::now()` independently (`clock_gettime(CLOCK_MONOTONIC)` via the vDSO, ~15–25 ns — negligible at IBKR's ≤10 req/s). If a high-throughput venue ever lands, the cheap win is one `now()` read threaded through the stack, and/or a TSC-backed production `Timer` impl (`quanta`) — both behind the `Timer` seam, so zero churn to the layers. Do **not** do either now (YAGNI). + +## Self-Review + +**Spec coverage (PR 1 scope — the ADR-0029 foundation the construction surface assumes):** +- Repartition `Service` → `net-http-api` ✅ Tasks 1.2–1.3. +- Kernel keeps composition + `ErrorKind`, gains `Timer`, goes std-only ✅ Tasks 1.1, 1.3. +- README graph updated (ADR-0029 Consequences) ✅ Task 1.4. +- The spec's *seam* requirements (AuthSource, Guarded, RateKey/coverage) are **out of PR 1 scope by design** — they are PRs 2–4, roadmapped above, each its own plan. This matches the writing-plans Scope Check (the spec spans multiple subsystems → multiple plans). + +**Placeholder scan:** No `TBD`/`TODO`/"handle edge cases" in PR 1 tasks; every code step shows the actual content. The PR 2–4 roadmap is explicitly a roadmap (scope + interfaces), not placeholder tasks. + +**Type consistency:** `Timer` signature identical in Task 1.1 and the kernel re-export; `Service` moved verbatim (Task 1.2) and removed at the source (Task 1.3) — one definition after the PR; module rename `service` → `compose` applied consistently in `lib.rs`, the doctest import paths, and the re-export list. diff --git a/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md b/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md new file mode 100644 index 0000000..32dd0bc --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md @@ -0,0 +1,470 @@ +# `net-http` construction surface — design + +**Status:** Approved design, pre-implementation. +**Date:** 2026-06-30. +**Crates:** `oath-adapter-net-http-api` (`crates/adapter/net/http/api`), +`oath-adapter-net-http-hyper` (`crates/adapter/net/http/hyper`), +`oath-adapter-net-http-mock` (`crates/adapter/net/http/mock`, **new, dev-only**). + +## Context + +[ADR-0029](../../adr/0029-network-adapter-stack-transport-split-compile-time-composition.md)–[ADR-0031](../../adr/0031-http-resilience-venue-pacing.md) +specify the network adapter stack: the transport split over a runtime-neutral +kernel (0029), the HTTP transport contract (0030), and the resilience/pacing layer +stack (0031). Those three ADRs answer every *structural* question — the crate cut, +why `Service` is not a kernel primitive, the bytes-in/streaming-bytes-out body +model, the layer order and its invariants. + +This spec closes the **three construction-surface seams** those ADRs left +underspecified — each a place where an implementer must make a type or trait +decision before `net-http-api` compiles: + +1. **`AuthSource`** — referenced three times in 0030 (`build()` arg, the `Auth` + layer, the adapter boundary) but never defined. Its trait shape is a + `net-http-api` contract. +2. **The concurrency permit's lifetime** — 0031 §3 says a concurrency `Permit` is + "attached to the response body," but 0030 §3's `ResponseBody` has nowhere to + carry it; and 0031 §3 names `OwnedSemaphorePermit` (a `tokio` type) inside + `net-http-api`, which 0030 declares `tokio`-free. A mechanism *and* a + contradiction. +3. **The `build()` generic surface** — 0030 §8 sketches `build(…)`, but + `RateLimit` is generic over the `RateKey`, the keyed rate map's type is unnamed, + and the bounds are unstated. + +### Governing ADRs + +- **ADR-0029** — transport split, runtime-neutral kernel, `Timer` contract, + compile-time `impl`/RPITIT binding (no `dyn`). The `Send`-bounded `impl Future` + return style and the "abstract only what needs mocking" principle both come from + here. +- **ADR-0030** — HTTP contract: `Service>` → bytes/streaming, + `ResponseBody`, one concrete `HttpError`, `HttpClient`, the hyper backend, the + three-tier `build()`. *Amended by this spec* (charter wording, `Auth` variant). +- **ADR-0031** — resilience/pacing: the layer stack and order, retryability-aware + `Retry`, keyed `RateLimit` (rate *xor* concurrency), `CircuitBreaker`, `Tracing`, + all generic over `Timer`. *Amended by this spec* (permit type, boot-time coverage). +- **ADR-0003** — adapter anti-corruption: serialisation/typing stays in the + concrete adapter; the net layer is outward plumbing. +- **ADR-0007** — in-process ⇒ compile-time binding; no runtime `dyn`. +- **CLAUDE.md** — no `unwrap`/`expect`/panic in non-test code; constructors return + `Result`. Auditable dependency tree under `cargo-deny`. + +## Goal + +Pin the three contracts so `net-http-api` is fully specified and the whole stack is +deterministically testable off a mock leaf and a mock clock: define `AuthSource`, +the `Guarded` permit-carrying body + the runtime-neutral semaphore choice, and +the `stack()`/`build()` split with boot-time pacing-coverage validation. + +## Scope (in) + +- **`AuthSource`** trait in `net-http-api`; the `Auth` layer; a `NoAuth` impl; the + `HttpError::Auth` variant. +- **`Guarded`** response-body newtype carrying `Option`; the + `Permit` enum over a runtime-neutral semaphore (`async-lock`); the + `RateLimit`-always-returns-`Guarded` discipline and the two release timings. +- **`stack()`** (assembly over an arbitrary leaf, in `net-http-api`) + + **`build()`** (hyper leaf, in `net-http-hyper`); `HttpConfig` (non-generic) and + `RateLimitConfig` (the `K`-generic arg); the `RateKey` trait; `BuildError`; + boot-time total pacing coverage. +- **`oath-adapter-net-http-mock`** — a dev-only crate providing the `MockClient` + leaf and `MockTimer`, used to regression-test the stack's ordering invariants. +- **ADR reconciliation** — the two amendments to 0030/0031 (below), recorded so the + landed ADRs are not silently edited. + +## Non-goals (deferred — each its own issue/PR) + +| Deferred item | Why deferred | Lands with | +| --- | --- | --- | +| Per-layer *algorithms* (token-bucket maths, retry backoff, CB state machine, tracing fields) | Specified by ADR-0031; this spec fixes only the construction-surface contracts they plug into | The per-layer implementation slices | +| The hyper leaf internals (pooled HTTPS connector, rustls wiring, `hyper::Error → HttpError`) | ADR-0030 §7; mechanical given the `HttpClient` seam | The hyper-backend slice | +| `oath-adapter-ibkr` (model ↔ JSON ↔ Bytes, the IBKR `RateKey` enum + pacing table, `tickle` keepalive) | ADR-0003 boundary; the adapter is a separate role crate | The IBKR adapter slices | +| WebSocket transport (`net-ws-api` + backend) | ADR-0029 §"WS is a deliberate later session" | The WS slice | +| `tickle` keepalive mechanism (background task vs layer) | An adapter concern, explicitly "not a net-http layer" (0030 §8); the 0030/0031 contradiction is noted but resolved in the adapter slice | The IBKR adapter slice | + +## Decisions + +### Seam #1 — `AuthSource` + +The named dependency-inversion seam the adapter implements; the `Auth` layer (in +`net-http-api`) calls it innermost, *inside* `Retry`, so it runs once per attempt +against the final, buffered request — which is what makes per-attempt re-signing +(fresh HMAC timestamp/nonce) and current-token stamping correct. + +```rust +pub trait AuthSource: Clone + Send + Sync { + /// Stamp current credentials onto an outgoing request, immediately before send. + /// Mutates in place (no clone — `Retry` already owns a per-attempt request). + /// A failure (e.g. token refresh failed) is an `ErrorKind::Auth` `HttpError`. + fn authorize(&self, req: &mut http::Request) + -> impl Future> + Send; +} + +/// IBKR: the local Client Portal gateway holds the session cookie, so there is +/// nothing to stamp. `authorize` returns a ready `Ok(())`. +#[derive(Clone)] +pub struct NoAuth; +``` + +The `Auth` layer is then `self.auth.authorize(&mut req).await?; self.inner.call(req).await` +— a straight `?`, no conversion. + +**Shape — `fn -> impl Future + Send`, not `async fn` (required).** The composed +stack future must be `Send` (multithreaded tokio). `async fn` in a trait yields +`fn -> impl Future` *without* a `Send` bound and offers no clean way to add one; +the explicit desugared form is the only way to *require* `Send`. This matches +`Timer::sleep` (0029 §4) and `HttpClient::send` (0030 §6). + +**Operates on `&mut http::Request` (superset).** Covers all four real +schemes: static bearer/API-key (one header insert), HMAC-signed (needs +method/path/the buffered `Bytes` body), async OAuth refresh (the rare path awaits +inline), and cookie-session/no-op. A "produce `HeaderMap`" alternative was rejected: +it allocates a map per call on the hot path *and* cannot sign over the body. + +**Error is `HttpError`, not a new `AuthError`/`BoxError`.** `AuthSource` and +`HttpError` both live in `net-http-api`, and the adapter already speaks `HttpError` +(it receives classified errors back from the stack), so returning it adds no +coupling. `HttpError` needs an `Auth` variant regardless (the layer must classify +auth failures for `Tracing`/Core). Returning it directly *deletes* the `From` impl +and the layer's `map_err`. A boxed `#[source]` is added to the `Auth` variant only +if/when a venue's refresh path needs to preserve an underlying chain — not now +(IBKR's `NoAuth` never errors). + +**Performance.** On the hot path the future is immediately-ready for static/HMAC/ +cookie schemes — a single-state state machine whose first `poll` returns `Ready`, +no heap allocation (RPITIT, not `async-trait`). The `await` of a ready future +collapses to straight-line code; overhead over a hand-written sync call is in the +noise (nanoseconds), dwarfed by the header insert, the HMAC, or the network. + +**Static headers vs `Auth` precedence.** `HttpConfig.headers` (the static default +stamp — a trivial `SetHeaders` folded in near `Auth` per 0031 §1) sits **just +outside** `Auth`, so dynamic credentials win on any key collision (`Auth` is the last +writer before the leaf). Pinned in the assembly order so it isn't accidentally +flipped. + +### Seam #2 — concurrency permit on the body, runtime-neutral semaphore + +**Mechanism — `Guarded` body newtype (in `net-http-api`, owned by the +`RateLimit` layer).** A streaming response returns at *headers*, so a permit held by +the `call` future would release too early; it must ride with the body. `ResponseBody` +(0030 §3) is pacing-agnostic and stays so — `RateLimit` wraps the body in its **own** +newtype. The permit is a plain `Option` — the rate +path holds nothing (token consumed, not held), so a `Permit::Rate` ZST would be +redundant with `None`; only a **concurrency** permit is ever carried (global is +always a rate bucket, local is rate-xor-concurrency, so at most one guard rides the +body even for `Scope::Both`): + +```rust +/// Wraps any response body, carrying an optional concurrency permit released at +/// the earlier of stream-end or drop. `Body` is delegated via `pin-project-lite` +/// (no `unsafe`). +struct Guarded { #[pin] inner: B, permit: Option } + +impl> http_body::Body for Guarded { + type Data = Bytes; + type Error = HttpError; + fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) + -> Poll, HttpError>>> { + let this = self.project(); + let frame = ready!(this.inner.poll_frame(cx)); + if frame.is_none() { *this.permit = None; } // eager release at stream-end + Poll::Ready(frame) + } + fn is_end_stream(&self) -> bool { self.inner.is_end_stream() } // MUST forward + fn size_hint(&self) -> http_body::SizeHint { self.inner.size_hint() } // MUST forward +} +``` + +Two non-obvious correctness points the `/* delegate */` shorthand would hide: + +- **Forward `is_end_stream` and `size_hint` explicitly.** In http-body 1.x only + `poll_frame` is required; the other two are provided methods (defaulting to `false` + and unbounded), and `pin-project-lite` helps only `poll_frame`. Letting the two + `&self` methods fall back to defaults is **not** a wire-framing bug *here* — + `Guarded` is a client-side *received-response* body, and hyper settles + keep-alive/framing at the `Incoming` layer **below** `Guarded`, never seeing it. It + is a **consumer-side metadata bug**: `BodyExt::collect()` loses its pre-size hint + (reallocs), streaming consumers lose `is_end_stream`, and — with teeth — any + **max-response-size guard reading `size_hint().upper()` sees `None` (unbounded) and + fails open.** The *same forwarding requirement applies to `ResponseBody` + (0030 §3)*, which has the identical newtype-over-`Either` shape. A test asserts both + newtypes report `size_hint`/`is_end_stream` identical to their inner body. +- **Release at the earlier of stream-end or drop.** The struct field alone gives + *drop-only* release — a body read to completion but still held keeps the permit, + wasting one of IBKR `/history`'s 5. So `poll_frame` `take()`s the permit on the + terminal frame (`None`), while the field stays for the early-abort case (a cancelled + read drops the still-`Some` guard). Dropping the guard is synchronous/runtime-free + (decrement a counter, wake one `event-listener` waiter — safe in both `Drop` and + `poll_frame`). + +`RateLimit` **always** returns `http::Response>` (one static type): + +- `permit: None` — rate-scoped, unscoped, **or buffered** responses: the concurrency + permit (if any) is dropped at `call`-return per 0031 §3 (buffered work is done when + the fetch returns). +- `permit: Some(_)` — a **streaming** concurrency-scoped response (the real IBKR + `/history` case): the guard rides the body and releases at the earlier of + stream-end or drop. No caller discipline, no response-extension hand-off (0031 + rejected that). + +Final assembled body type: `Guarded>`, opaque to adapters +behind `impl HttpClient`. + +**Why body-attach, not the response-future (a recorded design strength).** This is +deliberately stronger than tower's stock `ConcurrencyLimit`, which attaches the +permit to the *response future* — for a streaming response that resolves at headers, +so tower would free the `/history` permit *before* the body downloads and under-count +concurrency through the (large) transfer. Body-attach is instead the **hyper +connection-pool model** (`Pooled` ties the checked-out connection to the response +body, returning it on drop). Recorded so nobody "simplifies" it toward the tower +default. + +**Semaphore — `async-lock`, not `tokio::sync` (resolves the 0030/0031 +contradiction).** 0031 §3 named `OwnedSemaphorePermit` (`tokio`) inside a crate +0030 declares `tokio`-free. Resolution: `net-http-api` depends on +**`async-lock`** (`Permit::Concurrency(SemaphoreGuardArc)`), keeping the contract +crate's dependency graph free of any async runtime. + +Rationale, in the order that decided it: + +- **A semaphore needs no mock** (unlike `Timer`, which must fake a 15-minute + cooldown): acquiring permits is deterministic in real time, so tests use the real + thing. The `Timer`-style trait abstraction (considered option **Q**) buys nothing + and would thread a generic through `RateLimit` *and* `Guarded` — rejected as + over-engineering until a second backend supplies its own semaphore (YAGNI). +- **Neither candidate forces a backend's runtime.** The semaphore lives in the + `RateLimit` *layer*, never the leaf; and both `tokio::sync::Semaphore` and + `async_lock::Semaphore` are runtime-agnostic (no reactor). So a future non-tokio + backend is not precluded either way. +- The deciding axis is therefore **declared vs implicit neutrality.** `async-lock` + keeps `tokio` out of `net-http-api`'s graph, so the graph *states* runtime- + neutrality rather than relying on the reader knowing `tokio/sync` is reactor-free. + **Honest cost accounting:** option **P** (`tokio` with `default-features = false, + features = ["sync"]`) is *not* heavy — that subset is `mio`-free and runtime-free, + and since `tokio` is already a workspace dep, P would add **zero** new crates. **R** + adds **two** (`async-lock`, `event-listener`). So R's real price is two small + smol-rs crates bought purely for *graph-level* neutrality — the technical delta is + just "does `net-http-api`'s graph name `tokio`." Chosen deliberately on the + neutrality principle (ADR-0029's runtime-neutral-contract charter), with the thin + margin recorded so the trade reads straight. + +### Seam #3 — the `build()` / `stack()` construction surface + +**Split assembly so the ordering invariants are testable.** Every layer +(`Tracing`…`Auth`) already lives in `net-http-api`; only the leaf and `TokioTimer` +are backend-specific. So the canonical assembly lives **once** in `net-http-api`, +over an arbitrary leaf, and the hyper backend delegates: + +```rust +// net-http-api — assembles the canonical stack over ANY leaf +pub fn stack( + leaf: S, cfg: HttpConfig, timer: T, auth: A, rate_limits: RateLimitConfig, +) -> Result +where S: HttpClient + Clone + Send + Sync + 'static, T: Timer, A: AuthSource, K: RateKey; + +// net-http-hyper — constructs the hyper leaf, then delegates to stack() +pub fn build( + cfg: HttpConfig, timer: T, auth: A, rate_limits: RateLimitConfig, conn: ConnConfig, +) -> Result +where T: Timer, A: AuthSource, K: RateKey; // = stack(hyper_leaf(conn), …) +``` + +**Return bound `impl HttpClient + Clone + Send + Sync + 'static`.** Bounding the +opaque return (not just `impl HttpClient`) turns a `Send`/`Clone`/`'static` +regression in any layer into a compile error *at `stack()`* — rather than a cryptic +failure at the adapter's `tokio::spawn`/clone site — and promises adapters the +share/spawn they need. The whole stack satisfies it: every layer holds `Arc` state + +`Clone` config over a `Clone` hyper leaf. + +The decisive reason for the split is **not** DRY: several stack properties are +*ordering* invariants, not per-layer behaviours — `CircuitBreaker` *outside* `Retry` +so it never retries open-circuit rejections (0031 §5); `RateLimit` *inside* `Retry` +so each attempt spends budget (0031 §1). No per-layer isolation test can catch a +builder reorder; only a full-stack test over a deterministic leaf +(`stack(MockClient, …, MockTimer)`) can. The assembly must exist once, where the +layers live, so those invariants are regression-testable. + +**Config split — non-generic data + the one generic arg.** `HttpConfig` is +non-generic plain data (timeout, retry, headers, circuit-breaker config — +`serde` stays in the adapter). The single `K`-generic is isolated to a separate +`RateLimitConfig` arg (0030 §8 already passes the rate-limiter separately), so +`HttpConfig` carries no type parameter. + +**`RateKey` — a typed enum with a finite universe (the K fork, chosen +deliberately).** The fork is real: `K` could be *erased* to a non-generic +`RateKey(u32)`/`&'static str`, dropping the type parameter everywhere — the runtime +fail-closed (below) is what guarantees safety, not the type. We keep `K` **generic** +because a finite enum is the only thing that enables the **boot-time coverage +check**: + +```rust +pub trait RateKey: Hash + Eq + Clone + Send + Sync + 'static { + fn all() -> &'static [Self] where Self: Sized; // the finite universe +} +``` + +`Clone` is doubly-earned: `http::Extensions::insert` demands it, and `Retry` clones +the request per attempt (0031 §2) so the `RateLimit` extension's `K` survives +replay. This is documented in the ADR as a *chosen* fork, so nobody "simplifies" K +away and silently drops coverage to runtime-only. + +**`all()` must not drift** — the boot-time coverage guarantee is only as trustworthy +as `all()`'s exhaustiveness, and a hand-written slice silently rots when a variant is +added. Two drift-proof options for the adapter (the *impl* side; `net-http-api`'s +trait stays dependency-free): derive it via **`strum::VariantArray`** (an *adapter* +dep, regenerated automatically), or — dependency-free — back `all()` with a slice and +add an **exhaustive-`match` test** (no wildcard arm) that fails to compile when a +variant is added without being listed. + +**Boot-time total pacing coverage (hardening beyond 0031 §3).** 0031 §3 validates +config sanity and fails closed at runtime. For the order path that is a missed +hardening: a missing bucket = an unthrottled request = IBKR's 429 → 15-minute IP +penalty box. So `RateLimitConfig` is a **total** map requiring every endpoint to +be *explicitly classified* (rate, concurrency, **or** explicit global-only — not +"absent"): + +```rust +enum LimitDecl { Policy(LimitPolicy), GlobalOnly } +struct RateLimitConfig { global: LimitPolicy, local: HashMap } +``` + +`build()`/`stack()` validate `local` is total over `K::all()` and return +`Err(BuildError::UndeclaredKey(k))` at construction otherwise. Adding a `RateKey` +variant and forgetting to pace it becomes a **boot failure**, not a first-live-order +429. The runtime `Throttled` fail-closed (0031 §3) stays as an **unreachable +backstop** (defense in depth). + +**Return + errors.** Fully opaque `impl HttpClient` — adapters never name +`Tracing>>>>>`; +they use `Self::Body` through the `http_body::Body` trait. `BuildError` is a +`thiserror` enum (`UndeclaredKey`, bad policy params — rate ≤ 0, burst < 1, +concurrency max < 1 — missing global). No panic (CLAUDE.md). + +**Mock leaf behind a dev-only *crate*, not a feature (unification discipline).** A +`default-off` feature is still unification-reachable: if any crate in the graph flips +`net-http-api/mock`, a canned-response leaf becomes constructible in release. A +separate **`oath-adapter-net-http-mock`** crate, depending on `net-http-api` and +pulled only through `[dev-dependencies]`, has **no production dependency edge** — the +feature graph cannot turn it on. It provides `MockClient` (canned responses, +recorded requests) and `MockTimer` (fake clock for the timing layers). The invariant +"the mock cannot exist in production" is enforced by the dependency graph, not by +convention. + +### Recorded tradeoffs and deferred refinements + +- **No `poll_ready` backpressure (chosen).** The hand-rolled RPITIT `Service` + ([service.rs](../../../crates/adapter/net/api/src/service.rs)) handles backpressure + *inside* `call` (awaiting a permit), deliberately dropping tower's `poll_ready` + readiness — and with it `LoadShed`, `Balance`, and readiness backpressure. A + non-loss for a single-endpoint signer/retrier (IBKR); named as a known, + addressable-later limit for a hypothetical high-throughput venue. +- **401-on-`POST` refresh-retry (deferred to the `Retry` slice).** 0031 §2's blanket + "never retry `POST`" is over-broad for a **definitive 401**: a 401 is *pre-execution* + (the venue rejected on auth, the order did not execute), so refresh-credentials-and- + retry is duplicate-safe — unlike an ambiguous timeout. The per-attempt-`Auth`-inside- + `Retry` order already composes to do it (a `Retry` on 401 re-invokes `authorize`, + which refreshes). Implement in the `Retry` slice with guards: **once only**, + **definitive 401 only** (never a timeout/5xx), **venue opt-in**. Not part of this + construction surface. +- **High-throughput perf cliffs (deferred, YAGNI).** Per-bucket mutex contention, + `async-lock`-vs-`tokio` semaphore behaviour under load, and per-request span cost are + all irrelevant at IBKR's ≤10 req/s (network + deliberate pacing waits dominate by + orders of magnitude; the stack monomorphises to a flat, box-free state machine). + Named as known limits, addressable when a high-throughput venue lands. + +## ADR reconciliation + +Recorded as amendments rather than silent edits to landed ADRs (0029–0031 were +landed append-only): + +- **ADR-0030 §3 + Consequences** — reword *"free of `tokio`/`hyper`/`reqwest`/`serde`"* + to *"free of any async **runtime** — and of `hyper`/`reqwest`/`serde`."* The + concurrency semaphore is `async-lock` (runtime-agnostic), so `net-http-api`'s graph + names no runtime. Add the `Auth` variant to `HttpError` and `async-lock` to the dep + list. Specify that `ResponseBody`'s `Body` impl forwards `is_end_stream` and + `size_hint` to its inner body (the wrapper-transparency rule `Guarded` also needs), + not only `poll_frame`. And: `HttpError` carries **transport/middleware failures + only** — HTTP 4xx/5xx statuses are *not* error-ified; they pass through as + `Ok(Response)` with body intact for the adapter to classify, while `Retry`/ + `CircuitBreaker` peek `status()` + the 429 `Retry-After` header (Slice-0 plan + refinement — §5's `HttpError` examples were always middleware failures, never + statuses). +- **ADR-0031 §3** — replace `enum Permit { Rate, Concurrency(OwnedSemaphorePermit) }` + with a plain `Option` carried by a new `Guarded` + body newtype (the `Rate` arm was redundant with `None`); specify that `Guarded` + forwards `is_end_stream`/`size_hint` and eagerly releases on the terminal frame, and + that body-attach (vs tower's response-future) is the deliberate, stronger guarantee + for streaming concurrency; correct the §3 wording "stream-end/drop" → "the earlier of + stream-end or drop"; add the `LimitDecl` total-coverage requirement and the + `RateKey::all()` boot-time check, demoting the runtime `Throttled` path to an + unreachable backstop. + +## Testing + +The construction surface is verified through the mock crate, deterministically: + +- **Ordering invariants (the reason `stack()` exists)** — over `MockClient` + + `MockTimer`: a `CircuitBreaker`-open state rejects *without* `Retry` re-attempting + it (CB outside Retry); a rate-limited request spends budget on *each* `Retry` + attempt (RateLimit inside Retry); `BufferMode` survives the `Retry` request clone. +- **`AuthSource`** — `authorize` runs once per attempt (a recording `MockClient` + asserts the stamped header/signature is present on every attempt, with a fresh + value per attempt for a signing mock); `NoAuth` is a ready `Ok(())`; an + `authorize` error surfaces as `ErrorKind::Auth`. +- **Body transparency** — `Guarded` *and* `ResponseBody` report `size_hint` and + `is_end_stream` identical to their inner body (the metadata-forwarding fix); a + `size_hint().upper()` size-guard is not silently widened to unbounded by the wrapper. +- **Permit lifetime** — a concurrency-scoped **buffered** response releases its permit + at `call`-return (`permit: None`); a **streaming** one releases at the *earlier of + stream-end or drop*: assert the (N+1)-th concurrent acquire unblocks when the N-th + body is **read to its terminal frame** (eager `take()`), and in a separate test when + the N-th body is **dropped early** (mid-read); a rate-scoped response carries + `permit: None`. +- **Acquire fairness** — an ordering test pins the no-starvation behaviour `RateLimit` + inherits from `async-lock`/`event-listener` (FIFO-ish), recorded as an *inherited* + property rather than an API guarantee (not strictly depended on at 5 permits, but + locked so a dep bump can't regress it unnoticed). +- **Boot-time coverage** — `build()` with a `RateLimitConfig` missing a `K` variant + returns `Err(BuildError::UndeclaredKey)`; a total config builds; bad policy params + and a missing global are rejected at construction. +- **Production-reachability guard** — a CI assertion that + `cargo tree -e no-dev -i oath-adapter-net-http-mock` yields no non-dev dependents + (the mock cannot reach a release build through the feature/dep graph). + +## Dependencies + +- `oath-adapter-net-http-api` — adds **`async-lock`** (runtime-agnostic semaphore), + on top of the 0030 deps (`http`, `http-body`, `http-body-util`, `bytes`, + `pin-project-lite`, `thiserror`, `tracing`). Still **no** `tokio`/`hyper`/`reqwest`/ + `serde`. (`async-lock` pulls `event-listener`/`event-listener-strategy` — small, + widely-used, runtime-agnostic.) +- `oath-adapter-net-http-hyper` — owns the only `hyper`/`tokio`/`rustls` deps and + `TokioTimer`. +- `oath-adapter-net-http-mock` — **new**, dev-only; depends on `net-http-api`; pulled + by other crates only through `[dev-dependencies]`. Provides `MockClient` + `MockTimer`. + +All new workspace deps go through `[workspace.dependencies]` per the repo pattern. + +## Definition of done + +- The three contracts (`AuthSource`, `Guarded` + `Permit` + the semaphore choice, + `stack()`/`build()` + `RateKey`/`RateLimitConfig`/`BuildError`) are implemented + as specified, with the mock crate and the tests above. +- The two ADR amendments are landed (as ADR edits or a short follow-up ADR per the + repo's append-only norm — decided in the implementation plan). +- `just ci` green (fmt, lint = deny, test + doctests, doc, deny, typos, …); no new + warnings; no `unsafe`/`unwrap`/`expect`/indexing in non-test code. +- `CHANGELOG.md` `[Unreleased]` updated. +- Delivered as one-issue-one-PR slices (the construction surface may split into more + than one PR; each `Closes` its issue). + +## Open questions (for the implementation plan) + +1. **ADR form** — fold the two amendments into 0030/0031 directly, or land a short + ADR-0032 that records them and the construction-surface decisions? (The repo has + landed 0029–0031 append-only; leaning ADR-0032.) +2. **Slice boundaries** — does the construction surface land as one PR or several + (e.g. `AuthSource` + `Auth` layer; `Guarded`/semaphore + `RateLimit`; + `stack`/`build`/coverage; the mock crate)? A `writing-plans` concern. +3. **`MockTimer` home** — confirmed in `net-http-mock` alongside `MockClient`, or a + thinner test-util tied to `net-api` where `Timer` is defined? From 2277a4c1f2143b974952dc3930bf4cb02c438f11 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:40:11 +0000 Subject: [PATCH 2/8] feat(net): add Timer clock contract to the kernel --- crates/adapter/net/api/src/lib.rs | 3 ++ crates/adapter/net/api/src/timer.rs | 43 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 crates/adapter/net/api/src/timer.rs diff --git a/crates/adapter/net/api/src/lib.rs b/crates/adapter/net/api/src/lib.rs index b98c14c..2a57adf 100644 --- a/crates/adapter/net/api/src/lib.rs +++ b/crates/adapter/net/api/src/lib.rs @@ -5,6 +5,7 @@ //! //! - [`service`] — `Service`, `Layer`, `ServiceBuilder`, `Identity`, `Stack` //! - [`error_kind`] — `ErrorKind`, `HasErrorKind` +//! - [`timer`] — `Timer` //! //! No `tokio`, `hyper`, `reqwest`, `serde`, or `thiserror` may appear in this //! crate's dependency graph. @@ -12,6 +13,8 @@ pub mod error_kind; pub mod service; +pub mod timer; pub use error_kind::{ErrorKind, HasErrorKind}; pub use service::{Identity, Layer, Service, ServiceBuilder, Stack}; +pub use timer::Timer; diff --git a/crates/adapter/net/api/src/timer.rs b/crates/adapter/net/api/src/timer.rs new file mode 100644 index 0000000..592071d --- /dev/null +++ b/crates/adapter/net/api/src/timer.rs @@ -0,0 +1,43 @@ +//! The `Timer` clock contract — a runtime-neutral clock for timing layers. + +use std::future::Future; +use std::time::{Duration, Instant}; + +/// A clock abstraction for timing layers, decoupled from any async runtime. +/// +/// Timing middleware (`Timeout`, `Retry` backoff, `RateLimit` refill, +/// `CircuitBreaker` cooldown) is generic over `Timer` so a mock clock can drive +/// it deterministically in tests while production passes a runtime-backed impl. +/// A trait — not a runtime — so the kernel stays std-only (ADR-0029 §4). +pub trait Timer: Clone + Send + Sync { + /// Complete after `dur` has elapsed. + fn sleep(&self, dur: Duration) -> impl Future + Send; + + /// The current instant — for elapsed-time reads (token-bucket refill, + /// circuit cooldown). + fn now(&self) -> Instant; +} + +#[cfg(test)] +mod tests { + use super::Timer; + use std::time::{Duration, Instant}; + + #[derive(Clone)] + struct FixedTimer(Instant); + + impl Timer for FixedTimer { + fn sleep(&self, _dur: Duration) -> impl std::future::Future + Send { + std::future::ready(()) + } + fn now(&self) -> Instant { + self.0 + } + } + + #[test] + fn now_returns_the_configured_instant() { + let t0 = Instant::now(); + assert_eq!(FixedTimer(t0).now(), t0); + } +} From 240e57270dcfaf827819ed1246e17d39dee61904 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:45:06 +0000 Subject: [PATCH 3/8] feat(net): create oath-adapter-net-http-api with the Service contract --- Cargo.toml | 2 ++ crates/adapter/net/http/api/Cargo.toml | 9 ++++++ crates/adapter/net/http/api/src/lib.rs | 11 +++++++ crates/adapter/net/http/api/src/service.rs | 34 ++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 crates/adapter/net/http/api/Cargo.toml create mode 100644 crates/adapter/net/http/api/src/lib.rs create mode 100644 crates/adapter/net/http/api/src/service.rs diff --git a/Cargo.toml b/Cargo.toml index aef5add..e12aab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ resolver = "3" members = [ "crates/model", "crates/adapter/net/api", + "crates/adapter/net/http/api", "crates/bus/api", "crates/event-log/api", "crates/persistence/api", @@ -40,6 +41,7 @@ categories = ["finance", "asynchronous"] # does not treat { workspace = true } path deps as wildcard requirements. oath-model = { path = "crates/model", version = "0.1.0" } oath-adapter-net-api = { path = "crates/adapter/net/api", version = "0.1.0" } +oath-adapter-net-http-api = { path = "crates/adapter/net/http/api", version = "0.1.0" } oath-bus-api = { path = "crates/bus/api", version = "0.1.0" } oath-event-log-api = { path = "crates/event-log/api", version = "0.1.0" } oath-persistence-api = { path = "crates/persistence/api", version = "0.1.0" } diff --git a/crates/adapter/net/http/api/Cargo.toml b/crates/adapter/net/http/api/Cargo.toml new file mode 100644 index 0000000..7db5fc5 --- /dev/null +++ b/crates/adapter/net/http/api/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "oath-adapter-net-http-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[lints] +workspace = true diff --git a/crates/adapter/net/http/api/src/lib.rs b/crates/adapter/net/http/api/src/lib.rs new file mode 100644 index 0000000..c93ae59 --- /dev/null +++ b/crates/adapter/net/http/api/src/lib.rs @@ -0,0 +1,11 @@ +//! `oath-adapter-net-http-api` — the HTTP transport contract over the kernel. +//! +//! Builds on `oath-adapter-net-api` (composition machinery + `ErrorKind` + +//! `Timer`) and adds the request/reply [`Service`] connection shape. The HTTP +//! data plane (`HttpError`, `HttpClient`, `ResponseBody`, the layers) lands in +//! later slices. No async runtime, `hyper`, `reqwest`, or `serde` here. +#![forbid(unsafe_code)] + +pub mod service; + +pub use service::Service; diff --git a/crates/adapter/net/http/api/src/service.rs b/crates/adapter/net/http/api/src/service.rs new file mode 100644 index 0000000..2209bb3 --- /dev/null +++ b/crates/adapter/net/http/api/src/service.rs @@ -0,0 +1,34 @@ +//! The request/reply connection-shape contract for the HTTP transport. +//! +//! `Service` models **request → one reply** — it fits REST and unary RPC but +//! not WebSocket subscriptions or multicast, so it is a per-transport contract, +//! not a kernel primitive (ADR-0029 §2). It is transport-*neutral* (names no +//! HTTP type); it lives here, the first request/reply transport, until a second +//! one justifies hoisting it into a shared `net-req-reply-api` crate. + +use std::future::Future; + +/// A single async call: request in, `Result` out. +/// +/// `call` takes `&self` (not `&mut self`) — a service is shared, not owned, by +/// its callers. The returned future is **`Send`** (enforced here) so it runs on +/// a multithreaded runtime. The service *value* is expected to be +/// `Send + Sync + 'static` too, but that is enforced at the **composition +/// boundary** (`stack()`'s return bound, ADR-0030), not on this trait — so a +/// service may be non-`Sync` in a context that never shares it. Backpressure is +/// handled inside `call` (e.g. awaiting a permit), not via a separate +/// `poll_ready`. +/// +/// Because the `call` future borrows `&self`, it is **not** `'static`-spawnable: +/// to `tokio::spawn` a call, clone the (cheap, `Arc`-backed) service and move the +/// clone in. RPITIT return — no `async-trait`, no `dyn`, no per-call allocation. +pub trait Service { + /// The value produced on success. + type Response; + + /// The error produced on failure. + type Error; + + /// Drive the request to completion. + fn call(&self, req: Req) -> impl Future> + Send; +} From ca8e795a941b6ef121482961bde0bba93ebd93d1 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:51:02 +0000 Subject: [PATCH 4/8] refactor(net): kernel sheds Service trait and all deps --- Cargo.lock | 49 ++----------- crates/adapter/net/api/Cargo.toml | 11 --- .../net/api/src/{service.rs => compose.rs} | 70 +++++-------------- crates/adapter/net/api/src/lib.rs | 17 ++--- 4 files changed, 32 insertions(+), 115 deletions(-) rename crates/adapter/net/api/src/{service.rs => compose.rs} (60%) diff --git a/Cargo.lock b/Cargo.lock index db903a5..78d4298 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,12 +29,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "bytes" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" - [[package]] name = "cfg-if" version = "1.0.4" @@ -63,18 +57,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "getrandom" version = "0.3.4" @@ -98,26 +80,6 @@ dependencies = [ "r-efi 6.0.0", ] -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - [[package]] name = "itoa" version = "1.0.18" @@ -162,13 +124,10 @@ dependencies = [ [[package]] name = "oath-adapter-net-api" version = "0.1.0" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "http", - "http-body", -] + +[[package]] +name = "oath-adapter-net-http-api" +version = "0.1.0" [[package]] name = "oath-bus-api" diff --git a/crates/adapter/net/api/Cargo.toml b/crates/adapter/net/api/Cargo.toml index 04897bd..f1f4f0b 100644 --- a/crates/adapter/net/api/Cargo.toml +++ b/crates/adapter/net/api/Cargo.toml @@ -7,14 +7,3 @@ license.workspace = true [lints] workspace = true - -[dependencies] -bytes = { workspace = true } -http = { workspace = true } -http-body = { workspace = true } -futures-core = { workspace = true } -futures-sink = { workspace = true } - -# cargo-machete: deps declared ahead of first use; prune from `ignored` as they are adopted. -[package.metadata.cargo-machete] -ignored = ["bytes", "futures-core", "futures-sink", "http", "http-body"] diff --git a/crates/adapter/net/api/src/service.rs b/crates/adapter/net/api/src/compose.rs similarity index 60% rename from crates/adapter/net/api/src/service.rs rename to crates/adapter/net/api/src/compose.rs index f6d869b..6a6baed 100644 --- a/crates/adapter/net/api/src/service.rs +++ b/crates/adapter/net/api/src/compose.rs @@ -1,64 +1,32 @@ -//! Core composition primitives: `Service`, `Layer`, `ServiceBuilder`, `Identity`, `Stack`. +//! Composition machinery: `Layer`, `ServiceBuilder`, `Identity`, `Stack`. //! -//! These are the building blocks for the entire network stack. Every middleware -//! concern is expressed as a [`Layer`] that wraps any [`Service`], and -//! [`ServiceBuilder`] composes them at compile time with no virtual dispatch. +//! These compose **anything** — `Layer` carries no `Service` bound (ADR-0029 +//! §3), so the same machinery composes an HTTP `Service` stack today and a WS +//! subscription stack tomorrow. //! //! # Ordering invariant //! //! The **first** `.layer()` call is permanently the outermost wrapper and -//! therefore the first to handle each request. Subsequent calls are nested -//! progressively further inward. +//! therefore the first to handle each request. //! -//! ```no_run -//! # use oath_adapter_net_api::service::{Layer, Service, ServiceBuilder}; -//! # use std::future::Future; +//! ``` +//! # use oath_adapter_net_api::compose::{Layer, ServiceBuilder}; //! # struct TracingLayer; //! # struct MetricsLayer; -//! # struct Transport; //! # impl Layer for TracingLayer { type Service = S; fn layer(&self, s: S) -> S { s } } //! # impl Layer for MetricsLayer { type Service = S; fn layer(&self, s: S) -> S { s } } -//! # impl Service<()> for Transport { -//! # type Response = (); -//! # type Error = (); -//! # fn call(&self, _: ()) -> impl Future> + Send { -//! # async { Ok(()) } -//! # } -//! # } -//! // TracingLayer is added first → it is outermost → handles every request first. -//! let svc = ServiceBuilder::new() -//! .layer(TracingLayer) // outermost: first to see each request -//! .layer(MetricsLayer) // innermost of the two wrappers -//! .service(Transport); // leaf: performs actual I/O +//! // TracingLayer is added first → outermost → wraps everything else. +//! let _svc = ServiceBuilder::new() +//! .layer(TracingLayer) // outermost +//! .layer(MetricsLayer) // inner +//! .service(()); // leaf: any value (a `Service` leaf lives in net-http-api) //! ``` -use std::future::Future; - -/// A single async call: request in, `Result` out. -/// -/// Implementations must not require `&mut self` — services are shared across -/// tasks and must therefore be `Send + Sync`. Backpressure is handled inside -/// `call` (e.g. by awaiting a semaphore permit) rather than through a separate -/// `poll_ready`. -/// -/// Use RPITIT for the return type — no `async-trait`, no `dyn`, no per-call -/// allocation. -pub trait Service { - /// The value produced on success. - type Response; - - /// The error produced on failure. - type Error; - - /// Drive the request to completion. - fn call(&self, req: Req) -> impl Future> + Send; -} - -/// Transform one [`Service`] into another [`Service`]. +/// Transform one [`Layer`] into another [`Layer`]. /// -/// Typically a struct that holds configuration and owns an inner service. The -/// outer layer's [`Layer::layer`] method wraps the inner service, producing a -/// new [`Service`] that adds the layer's behaviour. +/// Typically a struct that holds configuration and owns an inner value. The +/// outer layer's [`Layer::layer`] method wraps the inner value, producing a +/// new value that adds the layer's behaviour. pub trait Layer { /// The wrapped service type produced by this layer. type Service; @@ -73,7 +41,7 @@ pub trait Layer { /// the outermost wrapper and therefore the first to execute on each request. /// /// ``` -/// # use oath_adapter_net_api::service::{Identity, ServiceBuilder}; +/// # use oath_adapter_net_api::compose::{Identity, ServiceBuilder}; /// let _builder = ServiceBuilder::new(); // starts with Identity (no-op) /// ``` #[derive(Debug, Clone)] @@ -111,9 +79,9 @@ impl ServiceBuilder { } } - /// Finalize the stack by wrapping a concrete service. + /// Finalize the stack by wrapping a concrete value. /// - /// Consumes the builder and returns the fully composed `Service` value. + /// Consumes the builder and returns the fully composed value. /// The concrete type is fully resolved at compile time — no boxing, no /// `dyn`. pub fn service(self, service: S) -> L::Service diff --git a/crates/adapter/net/api/src/lib.rs b/crates/adapter/net/api/src/lib.rs index 2a57adf..772d0e3 100644 --- a/crates/adapter/net/api/src/lib.rs +++ b/crates/adapter/net/api/src/lib.rs @@ -1,20 +1,21 @@ -//! `oath-adapter-net-api` — composition primitives and capability trait contracts. +//! `oath-adapter-net-api` — transport-neutral composition primitives + contracts. //! -//! This crate is **zero I/O, zero runtime**. It defines the shared -//! abstractions that every layer in the network stack depends on: +//! This crate is **std-only** (zero deps — the signal the ADR-0029 cut is +//! clean). It defines the shared abstractions every transport's layers depend +//! on: //! -//! - [`service`] — `Service`, `Layer`, `ServiceBuilder`, `Identity`, `Stack` +//! - [`compose`] — `Layer`, `ServiceBuilder`, `Identity`, `Stack` //! - [`error_kind`] — `ErrorKind`, `HasErrorKind` //! - [`timer`] — `Timer` //! -//! No `tokio`, `hyper`, `reqwest`, `serde`, or `thiserror` may appear in this -//! crate's dependency graph. +//! `Service` is **not** here — it is a per-transport contract in +//! `oath-adapter-net-http-api` (ADR-0029 §2). #![forbid(unsafe_code)] +pub mod compose; pub mod error_kind; -pub mod service; pub mod timer; +pub use compose::{Identity, Layer, ServiceBuilder, Stack}; pub use error_kind::{ErrorKind, HasErrorKind}; -pub use service::{Identity, Layer, Service, ServiceBuilder, Stack}; pub use timer::Timer; From 5bb7b19d24844bf5976d4fb032da01cb955dade2 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:54:59 +0000 Subject: [PATCH 5/8] =?UTF-8?q?docs(net):=20ADR-0029=20repartition=20(Serv?= =?UTF-8?q?ice=20=E2=86=92=20net-http-api,=20kernel=20Timer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ README.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a38e5..2e918eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Began the ADR-0029 network-adapter repartition: `oath-adapter-net-api` is now the + transport-neutral, **std-only** kernel (composition machinery + `ErrorKind` + + the new runtime-neutral `Timer` clock); the `Service` request/reply contract moved + into the new per-transport crate `oath-adapter-net-http-api`. - Restructured the workspace to the process-aligned, spine-inverted crate topology of ADR-0009: deleted `oath-engine` and `oath-ingest-core`; split `oath-messaging-core` into `oath-bus-api` + `oath-event-log-api`; renamed the diff --git a/README.md b/README.md index 6b96920..7107ddd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ Every subsystem is defined behind a trait. Backends, adapters, transports, and s | `oath-core-kernel` | The `Kernel` single-writer loop | | `oath-core` | The Core process binary | | `oath-adapter-api` | Harness + `Broker` / `DataProvider` traits for venue adapters | -| `oath-adapter-net-api` | HTTP/WS composition primitives (`Service`, `Layer`) for adapters | +| `oath-adapter-net-api` | Transport-neutral composition primitives (`Layer`, `ServiceBuilder`, `Stack`) + `ErrorKind` / `Timer` | +| `oath-adapter-net-http-api` | HTTP transport contract (`Service`, …) over the kernel | | `oath-strategy-api` | User-facing `Strategy` trait and Signal ergonomics (the canonical `Signal` payload lives in `oath-model`, per ADR-0028) | | `oath-strategy-host` | Strategy Node binary: hosts user strategies, isolated from Core | | `oath-cli` | The first Frontend (MVP) | @@ -41,6 +42,7 @@ graph TD adapterapi[oath-adapter-api] --> model stratapi[oath-strategy-api] --> model netapi[oath-adapter-net-api] + nethttpapi[oath-adapter-net-http-api] --> netapi risk[oath-core-risk] --> coreapi risk --> model From db426c18383e69a0c311c5808fa6b30ec4b18029 Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:00:33 +0000 Subject: [PATCH 6/8] docs(net): disambiguate net-http-api README row from oath-core-kernel Final-review Minor: 'over the kernel' collided with oath-core-kernel; name the crate explicitly. Refs #57 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7107ddd..b2dc4f4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Every subsystem is defined behind a trait. Backends, adapters, transports, and s | `oath-core` | The Core process binary | | `oath-adapter-api` | Harness + `Broker` / `DataProvider` traits for venue adapters | | `oath-adapter-net-api` | Transport-neutral composition primitives (`Layer`, `ServiceBuilder`, `Stack`) + `ErrorKind` / `Timer` | -| `oath-adapter-net-http-api` | HTTP transport contract (`Service`, …) over the kernel | +| `oath-adapter-net-http-api` | HTTP transport contract (`Service`, …) over the `oath-adapter-net-api` kernel | | `oath-strategy-api` | User-facing `Strategy` trait and Signal ergonomics (the canonical `Signal` payload lives in `oath-model`, per ADR-0028) | | `oath-strategy-host` | Strategy Node binary: hosts user strategies, isolated from Core | | `oath-cli` | The first Frontend (MVP) | From 58e13bb792267450a14cfcc382b7227d8637fb6b Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:18:14 +0000 Subject: [PATCH 7/8] docs(net): fix Layer doc wording and reconcile plan/spec docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - compose.rs: the `Layer` trait heading was mechanically find-replaced from "Transform one Service into another Service" to "...one Layer into another Layer" when Service left the crate, which misdescribes the trait — a Layer wraps a value (S -> Self::Service), not a Layer. Reword accurately. - plan md: fix the malformed inline code span for the `timer` module-list bullet (markdownlint MD038) using a padded double-backtick span so it stays copyable (CodeRabbit). - spec md: the Scope-in summary and Definition-of-done still named the superseded `Permit` enum; the finalized model carries `Option` directly (CodeRabbit). CodeRabbit's third comment (add a `mermaid` fence in the ADR-reconciliation section) was refuted — that region is prose bullets, not a diagram. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/api/src/compose.rs | 2 +- docs/superpowers/plans/2026-06-30-net-http-foundation.md | 2 +- .../2026-06-30-net-http-construction-surface-design.md | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/adapter/net/api/src/compose.rs b/crates/adapter/net/api/src/compose.rs index 6a6baed..3c7ae3e 100644 --- a/crates/adapter/net/api/src/compose.rs +++ b/crates/adapter/net/api/src/compose.rs @@ -22,7 +22,7 @@ //! .service(()); // leaf: any value (a `Service` leaf lives in net-http-api) //! ``` -/// Transform one [`Layer`] into another [`Layer`]. +/// Wrap a value of type `S`, producing a new value that adds behaviour. /// /// Typically a struct that holds configuration and owns an inner value. The /// outer layer's [`Layer::layer`] method wraps the inner value, producing a diff --git a/docs/superpowers/plans/2026-06-30-net-http-foundation.md b/docs/superpowers/plans/2026-06-30-net-http-foundation.md index c19be99..2d404e7 100644 --- a/docs/superpowers/plans/2026-06-30-net-http-foundation.md +++ b/docs/superpowers/plans/2026-06-30-net-http-foundation.md @@ -130,7 +130,7 @@ pub trait Timer: Clone + Send + Sync { - [ ] **Step 4: Re-export and run the test** -In `lib.rs` add `pub use timer::Timer;` next to the other re-exports, and add `//! - [`timer`] — `Timer`` to the module-list doc comment. +In `lib.rs` add `pub use timer::Timer;` next to the other re-exports, and add the ``//! - [`timer`] — `Timer` `` line to the module-list doc comment. Run: `just check && cargo test -p oath-adapter-net-api timer` Expected: PASS. diff --git a/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md b/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md index 32dd0bc..7ce74bc 100644 --- a/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md +++ b/docs/superpowers/specs/2026-06-30-net-http-construction-surface-design.md @@ -60,8 +60,9 @@ the `stack()`/`build()` split with boot-time pacing-coverage validation. - **`AuthSource`** trait in `net-http-api`; the `Auth` layer; a `NoAuth` impl; the `HttpError::Auth` variant. -- **`Guarded`** response-body newtype carrying `Option`; the - `Permit` enum over a runtime-neutral semaphore (`async-lock`); the +- **`Guarded`** response-body newtype carrying + `Option` (a runtime-neutral semaphore guard — no + `Permit` enum; the rate path holds `None`); the `RateLimit`-always-returns-`Guarded` discipline and the two release timings. - **`stack()`** (assembly over an arbitrary leaf, in `net-http-api`) + **`build()`** (hyper leaf, in `net-http-hyper`); `HttpConfig` (non-generic) and @@ -447,7 +448,7 @@ All new workspace deps go through `[workspace.dependencies]` per the repo patter ## Definition of done -- The three contracts (`AuthSource`, `Guarded` + `Permit` + the semaphore choice, +- The three contracts (`AuthSource`, `Guarded` (permit-carrying) + the semaphore choice, `stack()`/`build()` + `RateKey`/`RateLimitConfig`/`BuildError`) are implemented as specified, with the mock crate and the tests above. - The two ADR amendments are landed (as ADR edits or a short follow-up ADR per the From 130b90f2d99353bf74fef5a26f94200623cd2f4d Mon Sep 17 00:00:00 2001 From: NotAProfDev <84450364+NotAProfDev@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:28:43 +0000 Subject: [PATCH 8/8] docs(net): tidy compose/timer wording and test import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup from /simplify review (doc + test only, no behaviour change): - compose.rs: drop residual "service"/request-centric wording on the now transport-neutral composition types — `Layer::Service` ("wrapped type"), `Identity` ("inner value"), `Stack` ("outermost wrapper") — so the docs match the module's "composes anything" contract (Layer carries no Service bound). - timer.rs: import `Future` in the test module instead of the fully-qualified `std::future::Future`, matching how the module imports its std types. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/adapter/net/api/src/compose.rs | 6 +++--- crates/adapter/net/api/src/timer.rs | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/adapter/net/api/src/compose.rs b/crates/adapter/net/api/src/compose.rs index 3c7ae3e..5b57d56 100644 --- a/crates/adapter/net/api/src/compose.rs +++ b/crates/adapter/net/api/src/compose.rs @@ -28,7 +28,7 @@ /// outer layer's [`Layer::layer`] method wraps the inner value, producing a /// new value that adds the layer's behaviour. pub trait Layer { - /// The wrapped service type produced by this layer. + /// The wrapped type produced by this layer. type Service; /// Wrap `inner` with this layer's behaviour. @@ -92,7 +92,7 @@ impl ServiceBuilder { } } -/// The no-op layer — passes the inner service through unchanged. +/// The no-op layer — passes the inner value through unchanged. /// /// `Identity` is the initial state of a fresh [`ServiceBuilder`]. #[derive(Debug, Clone, Copy)] @@ -109,7 +109,7 @@ impl Layer for Identity { /// Compose two [`Layer`] impls into one. /// /// When assembling the stack, `Inner.layer(leaf)` is applied first, then -/// `Outer.layer(result)`. `Outer` is therefore the outermost service and the +/// `Outer.layer(result)`. `Outer` is therefore the outermost wrapper and the /// first to handle each request. /// /// Because [`ServiceBuilder::layer`] produces `Stack` with `New` in diff --git a/crates/adapter/net/api/src/timer.rs b/crates/adapter/net/api/src/timer.rs index 592071d..21340a8 100644 --- a/crates/adapter/net/api/src/timer.rs +++ b/crates/adapter/net/api/src/timer.rs @@ -21,13 +21,14 @@ pub trait Timer: Clone + Send + Sync { #[cfg(test)] mod tests { use super::Timer; + use std::future::Future; use std::time::{Duration, Instant}; #[derive(Clone)] struct FixedTimer(Instant); impl Timer for FixedTimer { - fn sleep(&self, _dur: Duration) -> impl std::future::Future + Send { + fn sleep(&self, _dur: Duration) -> impl Future + Send { std::future::ready(()) } fn now(&self) -> Instant {