DECISIONS.md
Append-only architecture decision record (ADR) log.
Every deviation from PROMPT.md, every crate choice, every GIL/concurrency
decision gets a dated entry: context, options considered, decision, and
the evidence behind it.
ADR-001 — 2026-07-03 — Crate defaults for v1.0
Context: Phase 0 bootstrap. Choosing the dependency stack before any
implementation code, following PROMPT.md Section 1's "build vs. reinvent"
philosophy.
Options considered:
- Hand-roll everything (HTTP parser, TLS, scheduler) — rejected per
Section 1; produces unmaintainable half-finished subsystems. - Use mature, audited crates per Section 1's table — selected.
Decision: Default to the crate table from PROMPT.md Section 1:
| Concern | Crate | Version |
|---|---|---|
| Async runtime | tokio (multi-thread) |
1.x |
| HTTP/1.1 + HTTP/2 | hyper + hyper-util + http-body-util |
1.x |
| TLS | rustls |
(Phase 7) |
| Python bindings | pyo3 |
0.29.x |
| Routing | matchit |
(Phase 2, evaluate vs custom trie) |
| JSON | serde_json baseline, simd-json feature-flagged |
1.x |
| Rate limiting | governor |
(Phase 5) |
| JWT | jsonwebtoken |
(Phase 5) |
| CLI | clap |
4.x |
| Logging/tracing | tracing + tracing-subscriber |
0.1 / 0.3 |
| Error handling | anyhow (app code), thiserror (library boundaries) |
1.x |
Custom replacements only after profiling shows the FFI boundary is the
bottleneck, with evidence attached to a new DECISIONS.md entry.
Evidence: PROMPT.md Section 1 crate table. No profiling data yet.
ADR-002 — 2026-07-03 — Repository skeleton layout
Context: PROMPT.md Section 5.1 specifies the repo layout. Workspace
root is /home/swadhin/RastAPI/ (directory name differs from the canonical
"justapi/" but crate names follow the spec).
Decision: Follow Section 5.1 layout exactly:
- 4 crates:
justapi-core,justapi-py,justapi-cli,justapi-bench - Python package under
python/justapi/built with maturin - Skills under
skills/
No deviation from the specified structure.
ADR-003 — 2026-07-03 — hyper 1.x requires hyper-util
Context: The original skeleton used hyper::server::conn::http1::Builder
which existed in hyper 0.14 but was moved to hyper-util in hyper 1.x.
The server wouldn't compile without this crate.
Options considered:
- Downgrade to hyper 0.14 — rejected; 0.14 is legacy, 1.x is the
maintained branch with the modular architecture we need. - Add
hyper-utilandhttp-body-util— selected; these are the official
companion crates for hyper 1.x server usage.
Decision: Add hyper-util (features: tokio, http1, server) andhttp-body-util as workspace dependencies. The server connection handling
uses hyper_util::server::conn::http1::Builder andhyper_util::rt::TokioIo for the stream wrapper.
Evidence: hyper 1.0 migration guide, hyper-util crate documentation.
ADR-004 — 2026-07-03 — PyO3 version: 0.29.x for Python 3.14 support
Context: The development machine runs Python 3.14.6. PyO3 0.23.x does
not support Python 3.14. PyO3 0.24.x also failed to compile against
Python 3.14.6 — full support required 0.29.
Options considered:
- pyo3 0.23 — rejected; no Python 3.14 support.
- pyo3 0.24 — rejected; still failed to compile against Python 3.14.6.
- pyo3 0.29 — selected; compiles successfully against Python 3.14.6.
Decision: Upgrade to pyo3 = "0.29" with extension-module feature.
Evidence: Compilation failure with 0.23 and 0.24; successful build
with 0.29 on Python 3.14.6.
ADR-005 — 2026-07-03 — Rust edition: 2021 (downgraded from 2024)
Context: The original skeleton specified edition = "2024". While the
Rust toolchain (1.96.0) supports it, edition 2024 caused compatibility
issues with some dependency crates during the bootstrap build.
Options considered:
- Keep edition 2024 and fix all dependency compatibility — higher risk,
delays Phase 0 for no user-facing benefit. - Downgrade to edition 2021 — selected; maximizes dependency compatibility,
can upgrade later when the ecosystem catches up.
Decision: Use edition = "2021". Revisit upgrading to 2024 once all
dependencies (especially pyo3, hyper-util) have confirmed 2024 support.
Evidence: Build failures during Phase 0 bootstrap with edition 2024.
ADR-006 — 2026-07-03 — Benchmark tooling: oha over wrk
Context: PROMPT.md Section 10.1 recommends oha or wrk. Both are
HTTP load generators.
Options considered:
wrk— mature, Lua scriptable, but reports only percentiles (not
histogram), harder to install on some distros.oha— Rust-based, outputs JSON with full latency distribution, easy
to install via cargo, reports p50/p95/p99/p999 natively.
Decision: Primary tool is oha. wrk as a secondary validation tool
if needed. oha version and exact command lines recorded in BENCHMARKS.md
alongside each run.
Evidence: oha natively outputs the exact metrics BENCHMARKS.md needs
(p50/p95/p99, req/sec) in both human-readable and JSON formats.
ADR-007 — 2026-07-03 — ASGI strategy: Tier A + Tier B per Section 2
Context: PROMPT.md Section 2 requires both an ASGI compatibility shim
(Tier A) and a native justapi API (Tier B). Tier A is required for v1.0;
Tier B is the performance ceiling.
Decision: Both tiers ship. Development order:
- Phases 1–9: Build the core runtime using Tier B (native) API internally
- Phase 10: Implement Tier A ASGI shim on top of the established core
Rationale: The native API exercises the full pipeline and establishes
performance baselines. The ASGI shim is an adapter on top — building it
first would mean building the adapter before the thing it adapts.
ADR-008 — 2026-07-03 — GIL strategy: dedicated Python worker thread
Context: Phase 4 design decision for the execution model. justapi's
architecture split (Rust owns I/O, Python executes application logic) means
we must decide how to bridge the two worlds. The Python GIL (Global
Interpreter Lock) prevents multiple native OS threads from executing Python
bytecode concurrently. We need a concurrency model that maximizes throughput
while staying safe and simple.
Options considered:
Option A — Dedicated Python worker thread (selected)
A single OS thread owns the Python interpreter and runs all Python handlers.
Requests arriving via Rust's tokio async runtime are dispatched to this
thread via a lock-free or channel-based mechanism (e.g., tokio::sync::mpsc
or a flume channel). The Rust side never acquires the GIL directly — it
sends a job and awaits the result.
Key properties:
- GIL contention: zero. Exactly one thread ever touches Python.
- Concurrency: Rust I/O is fully concurrent (tokio multi-thread
scheduler). Python execution is serialized, which matches typical
web-app profiles (I/O-bound inside Python — waiting on DB, external APIs). - Safety: Great. No chance of accidentally blocking the tokio runtime
with a slow Python call because Python runs on its own thread. - Complexity: Low. A single producer-consumer channel. No GIL acquire/
release logic scattered through the hot path.
Option B — Direct GIL acquisition on tokio worker threads
Any tokio worker thread that needs to call Python acquires the GIL viaPython::with_gil(), runs the handler, then releases it. Multiple threads
may contend for the GIL.
Key properties:
- GIL contention: High under load. If all N tokio workers are handling
requests that need Python, N-1 threads are blocked on the GIL. - Concurrency: Only one thread runs Python at a time, so no throughput
gain vs Option A for Python-bound work. For Rust-only requests (static
files, caching layer, etc.), all threads are available — same as Option A. - Safety: Moderate risk. A slow Python call blocks the tokio worker
thread, starving other tasks on that thread. Mitigation: usetokio::task::spawn_blockingfor Python calls, which moves them off the
async runtime. - Complexity: Low in principle, but
spawn_blocking+ GIL interactions
need careful handling in the PyO3 boundary.
Option C — Sub-interpreters (PEP 684)
Each connection or request task gets its own Python sub-interpreter, each
with its own GIL. True parallelism for Python code.
Key properties:
- GIL contention: Zero per-sub-interpreter; multiple interpreters run
concurrently. - Concurrency: True Python parallelism, but objects cannot be shared
across interpreter boundaries. This breaks almost every Python web
framework (they assume a single interpreter with shared globals). - Safety: Low. Sub-interpreters in CPython have subtle isolation gaps.
PyO3 support is experimental and incomplete. - Complexity: Very high. Every Python object crossing the boundary
must be serialized or copied. - Verdict: Too immature for v1.0. Revisit for a future major version.
Option D — Free-threaded CPython (PEP 703, --disable-gil)
CPython built with --disable-gil removes the GIL entirely. Multiple
threads can run Python bytecode simultaneously.
Key properties:
- GIL contention: Zero (no GIL).
- Concurrency: True Python parallelism on CPU-bound workloads.
- Safety: Low-medium.
--disable-gilis experimental in CPython 3.13.
PyO3'snogilsupport is preliminary. The broader Python C-extension
ecosystem is largely not thread-safe without the GIL. - Complexity: Medium in PyO3 (needs
nogilfeature flag and careful
review of all unsafe code). High in the ecosystem. - Verdict: Promising long-term, but not ready for production v1.0.
We will feature-flag support so early adopters can test.
Decision:
Select Option A — dedicated Python worker thread as the default for v1.0.
Rationale:
Best match for workload profile. Python web applications are
I/O-bound within Python (database queries, external HTTP calls). A
single Python thread driving async I/O viaasynciois the established
pattern (uvicorn, gunicorn with async workers). Serializing Python
execution doesn't bottleneck throughput — the bottleneck is Rust I/O
and the downstream services Python calls.Runtime safety. Python never blocks a tokio worker thread. Slow or
buggy Python code can't stall the I/O runtime. The Rust side remains
responsive regardless of Python behavior.Simplest correctness model. No GIL acquire/release logic in the hot
path. No risk of deadlocks involving the GIL + Rust locks. The channel
boundary provides natural backpressure: if Python is slow, Rust buffers
requests up to the channel capacity.Clear upgrade path to Option D. When free-threaded CPython matures,
the single-worker-thread is replaced by "acquire GIL on any thread" code
path behind apy-nogilfeature flag. The channel dispatch layer is
removed and Python calls happen inline on tokio workers.
Implementation sketch:
// Python worker runs in a dedicated OS thread.
// Rust handler sends requests via channel, awaits result.
let (tx, rx) = tokio::sync::mpsc::channel::<PythonJob>(256);
// Spawn Python worker
std::thread::spawn(move || {
Python::with_gil(|py| {
// Load ASGI app, register handlers
// Loop: receive jobs from rx, execute, send result back
});
});
// In the request handler (tokio task):
let job = PythonJob { request, response_tx };
tx.send(job).await?;
let response = response_tx.recv().await?;
The channel acts as both dispatch mechanism and backpressure valve.
Channel capacity (256 by default) limits in-flight Python work. If the
channel is full, the Rust side can either buffer (via the BufferPool) or
respond with 503 Service Unavailable.
Feature flag for PEP 703 (py-nogil):
A Cargo feature py-nogil switches the execution model:
- default (
py-nogiloff): Dedicated Python worker thread (Option A). py-nogilon: Direct GIL acquisition on tokio workers (Option A
dispatch removed). Relies on CPython built with--disable-giland
PyO3'snogilsupport.
This flag is hidden/unstable until PEP 703 ecosystem maturity improves.
It exists to prevent architectural deadlock — we can evolve toward it
without rewriting the dispatch layer.
Evidence:
- CPython PEP 703 (free-threaded CPython) status: experimental in 3.13,
targeting default-on in 3.14 or 3.15. - PyO3
nogilfeature tracking issue: currently experimental, API surface
unstable. - Typical Python web workload profile: I/O-bound inside Python handlers
(database, cache, external API calls). CPU-bound Python workloads
(ML inference, image processing) are a niche use case for app servers
and should be offloaded to dedicated worker processes or task queues. - Uvicorn + Gunicorn model: single Python thread per worker process,
achieving production throughput via process-level parallelism (not
thread-level). justapi's Rust I/O layer provides better single-process
throughput than Gunicorn's prefork model.
ADR-009 — 2026-07-03 — WebSocket: TCP-peek approach over hyper upgrade
Context: Phase 8 WebSocket support. Hyper 1.x provides hyper::upgrade::on(req)
to upgrade HTTP/1.1 connections to WebSocket. This mechanism had persistentResetWithoutClosingHandshake errors with tokio-tungstenite.
Options considered:
Option A — hyper upgrade mechanism (rejected)
Call hyper::upgrade::on(req) inside the service function, return 101
response, spawn task to handle upgraded stream. Required careful ordering:
the upgrade future must not be awaited inside the service function (deadlock),
and WebSocketStream::from_raw_socket + Role::Server had protocol errors.
Issues encountered:
hyper::upgrade::onreturningResetWithoutClosingHandshakeconsistently- Double-wrapping
UpgradedinTokioIocaused confusion about trait bounds - Service function return + upgrade completion ordering fragile
Option B — TCP-peek approach (selected)
Peek at the first bytes of the TCP connection to detect WebSocket upgrade
requests before passing the stream to hyper. If the request looks like a
WebSocket upgrade, use tokio_tungstenite::accept_async directly on the
raw TcpStream (or TlsStream<TcpStream>).
Key properties:
- Reliability:
tokio_tungstenite::accept_asynchandles the full HTTP
upgrade handshake correctly every time. - Simplicity: No hyper upgrade state management, no ordering issues.
- Generics:
handle_ws_raw<S>works over anyAsyncRead + AsyncWrite,
supporting both plain TCP and TLS connections. - Trade-off: WebSocket connections bypass hyper entirely — no middleware,
no arena allocation, no connection pool integration. Acceptable because
WebSocket connections are long-lived and the hot path (message framing)
is handled by tokio-tungstenite.
Decision: Select Option B — TCP-peek approach.
Evidence:
- Option A consistently produced
ResetWithoutClosingHandshakein integration
tests despite correct header ordering and response construction. - Option B integration test (send "hello" → receive "hello") passed on first
attempt. - Production WebSocket servers (tungstenite examples, warp) commonly use
this approach. peek()is non-blocking and reads only 4KB — negligible overhead per
connection.
ADR-010 — 2026-07-03 — Response body type: UnsyncBoxBody
Context: Phase 8 introduced SSE streaming responses. The existingResponseBody type was http_body_util::combinators::BoxBody<Bytes, anyhow::Error>,
which requires the inner body to be Send + Sync + 'static. Streaming bodies
(like StreamBody<ReceiverStream<...>>) are not Sync because the underlyingtokio::sync::mpsc::Receiver is Send but not Sync.
Options considered:
Option A — BoxBody (Sync, rejected)
Requires all response bodies to be Sync. Streaming bodies fail to compile.
Option B — UnsyncBoxBody (selected)
http_body_util::combinators::UnsyncBoxBody<Bytes, anyhow::Error> only
requires Send + 'static — drops the Sync bound. All existing static
responses (Full<Bytes>) are still Send + Sync, so they satisfy the
weaker bound. Streaming responses (StreamBody) are Send but not Sync,
which now compiles.
Decision: Use UnsyncBoxBody as the ResponseBody type alias.
Evidence:
BoxBody::new()requiresB: Body + Send + Sync + 'staticUnsyncBoxBody::new()requiresB: Body + Send + 'statictokio::sync::mpsc::ReceiverisSendbut notSync- SSE streaming via
StreamBody<ReceiverStream<...>>compiles withUnsyncBoxBodybut notBoxBody
ADR-011 — 2026-07-05 — Static file serving as 404 fallback
Context: Phase 9 static file serving. The middleware chain handles all requests
first. If it returns a 404, we try to serve a static file before returning 404
to the client. If the middleware chain errors, we also try static files.
Options considered:
Option A — Static files checked before middleware chain
Check StaticDir::resolve() before running middleware. If a file matches, serve
it directly without going through middleware.
Pros: Slightly faster for static files (no middleware overhead).
Cons: Static files bypass auth/CORS/rate-limit middleware — may expose protected
files. Each path gets resolved twice (once by static check, once by chain on miss).
Option B — Static files as 404 fallback after middleware (selected)
Run the full middleware chain first. If it returns 404 (or errors), then try to
resolve and serve a static file.
Pros:
- Middleware applies to all requests consistently (auth protects static files too).
- Single path resolution in the common case (dynamic route hit or cache hit).
- Lets middleware override static file routes (e.g.,
/index.htmlblocked by auth).
Cons: Static file requests always incur middleware overhead. Acceptable because
the middleware chain is lightweight (~0.18% overhead per Phase 5 benchmark).
Decision: Select Option B — static file serving as 404 fallback after middleware.
Evidence: Phase 5 benchmark showed middleware overhead of 0.18% (negligible).
Security principle: middleware should apply uniformly to all requests.
ADR-012 — 2026-07-05 — Metrics via atomic counters (no Prometheus client)
Context: Phase 9 metrics. We need to expose basic metrics at /metrics in
Prometheus text format.
Options considered:
Option A — Prometheus Rust client library (prometheus or prometheus-client)
Full Prometheus client with histogram support, registry, automatic/metrics endpoint. Adds ~20 dependencies.
Pros: Rich metric types (histograms, summaries), automatic handling of
concurrent access, well-known API.
Cons: Heavy dependency tree, overkill for 5 simple counters/gauge.
Histogram support adds memory overhead per-bucket.
Option B — Atomic counters with manual formatting (selected)
Use AtomicU64 for each metric, format Prometheus text on demand at/metrics endpoint. No external client library.
Pros: Zero additional dependencies, minimal overhead (single atomic increment
per metric update), full control over output format.
Cons: No built-in histogram support, need to write Prometheus text format
manually.
Decision: Select Option B — atomic counters with manual Prometheus formatting.
Evidence:
- Only 5 metrics needed: 4 counters (requests, errors, bytes_in, bytes_out)
- 1 gauge (active_connections).
- Histograms can be added later if profiling shows they're needed
(typically via external metrics pipeline, not in-process). - Prometheus text format for counters/gauges is trivial to produce:
# HELP,# TYPE,metric_name valuelines.
ADR-013 — 2026-07-05 — ASGI shim: dedicated thread + channel dispatch
Context: Phase 10 Tier A ASGI compatibility shim. We need to run existing
Python ASGI applications (FastAPI, Starlette) on justapi's Rust runtime.
Options considered:
Option A — Inline GIL acquisition on tokio workers
Acquire the Python GIL inside each request handler (tokio worker thread).
Use pyo3_asyncio::tokio::into_awaitable to bridge Python asyncio coroutines
to Rust futures.
Pros: No extra thread, simpler architecture.
Cons: Blocks tokio worker during Python execution (violates ADR-008 constraint).pyo3-asyncio adds a complex dependency. Risk of GIL deadlocks.
Option B — Dedicated Python worker thread with channel dispatch (selected)
A single OS thread holds the GIL permanently and runs a persistent asyncio
event loop. Rust sends raw HTTP request data (method, path, headers, body)
via std::sync::mpsc and receives the response via tokio::sync::oneshot.
The Python thread builds the ASGI scope dict, creates receive/send async
functions, and calls await app(scope, receive, send).
Pros:
- ADR-008 compliant: zero GIL contention, tokio workers never blocked by Python
- No pyo3-asyncio dependency: asyncio event loop managed entirely in Python
- Simple data contract: plain Rust structs across the channel boundary
- Backpressure: bounded channel capacity naturally limits in-flight Python work
Cons:
- Extra context switch per request (channel send + recv + oneshot round-trip)
- No concurrent Python execution (serialized on the single worker thread)
- Response body fully buffered in memory (no streaming for large responses)
Decision: Select Option B — dedicated Python worker thread.
Evidence:
- ADR-008 established this as the correct GIL strategy for justapi
pyo3-asynciois not needed: the Python side runsloop.run_until_complete()
on each request, using a persistent event loop created once at startup- The
call_asgi()Python helper is embedded as aconst &strand compiled
viaPyModule::from_code— no separate Python file to manage
Known limitations (tracked for resolution):
- Streaming request/response bodies not yet supported (body is fully buffered)
- ASGI lifespan protocol not yet implemented (no startup/shutdown events)
- Single Python worker thread means serialized Python execution
Open questions / risk register
Track these and resolve as phases progress:
-
tokio-uring/monoioevaluation — scheduled for Phase 0 spike if
time permits, otherwise deferred. epoll viatokiois the default and
mandatory fallback regardless. - Free-threaded CPython (PEP 703) ecosystem maturity for PyO3 0.29 —
confirm before committing Phase 4 design. - Sub-interpreter isolation guarantees — research needed before Phase 4.
-
quinn/ HTTP-3 crate maturity — evaluate at Phase 10 time. - ASGI shim edge cases (lifespan protocol, streaming responses, WebSocket
close codes) — track known gaps explicitly in Phase 10.
ADR-014 — 2026-07-05 — Project rename: Hyperion → JustAPI
Context: Phase 10 completion. The project needs a name that reflects its
ambition (comparable to FastAPI) and positions it for production-grade adoption.
"Hyperion" was the working name from the original master prompt.
Decision: Rename to JustAPI — simple, memorable, domain-available.
All crates renamed: hyperion-core → justapi-core, hyperion-py → justapi-py,hyperion-cli → justapi-cli, hyperion-bench → justapi-bench.
Python package renamed: hyperion → justapi. Class renamed: HyperionApp → JustAPIApp.
Metrics prefix renamed: hyperion_* → justapi_*.
Evidence: Name check: domain available, PyPI name available, no trademark conflicts.
ADR-015 — 2026-07-05 — Validation strategy: Rust-native + Pydantic bridge
Context: Phase 11 — Request Validation & Serialization. Need to decide
how to handle type-safe validation.
Decision: Two-tier approach:
- Rust-native (Tier B):
Schemaderive macro +serde+validatorcrate.
Validates at Rust speed — target 10x faster than Pydantic. - Pydantic bridge (Tier A): Python
BaseModel→ Rust struct via PyO3FromPyObject. Runs alongside the Rust-native path for compatibility.
Error format follows RFC 9457 (Problem Details for HTTP APIs).
Evidence: Pydantic v2 is ~5-10x faster than v1 but still has Python overhead.
Rust-native validation eliminates Python serialization entirely for the hot path.
ADR-016 — 2026-07-05 — Database strategy: sqlx with compile-time checks
Context: Phase 12 — Database ORM Integration. Need async SQL driver.
Decision: Use sqlx as the async SQL driver:
- Compile-time query checking (
sqlx::query!("SELECT ...")) - Connection pooling built-in
- Multi-DB (PostgreSQL, MySQL, SQLite)
- Migration system built-in
Query builder inspired by Diesel/SeaORM for Tier B; SQLAlchemy bridge for Tier A.
Evidence: sqlx is the most popular async SQL driver in Rust, with compile-time
query verification that eliminates an entire class of runtime errors.
ADR-017 — 2026-07-05 — Deployment strategy: Helm + distroless + CI/CD
Context: Phase 16 — Multi-Cloud / Kubernetes Support.
Decision:
- Distroless base image (< 50MB, no shell)
- Helm chart as primary K8s deployment mechanism
- Health probes via existing
/health//ready//liveendpoints - GitHub Actions CI → container registry → K8s deploy
- Cloud-specific docs for GKE, EKS, AKS, Fly.io, Railway
Evidence: Distroless images reduce attack surface. Helm is the standard
K8s packaging format. Multi-cloud strategy avoids vendor lock-in.
ADR-018 — 2026-07-05 — Security strategy: SAST in CI, fuzzing, OWASP baseline
Context: Phase 17 — Zero-Day / SAST / Security Hardening.
Decision:
cargo-audit+cargo-deny+trivyin CI as gating checkscargo-fuzz/libfuzzerfor Rust fuzz targets,Atherisfor Python- OWASP Top 10 as security baseline
- All security fixes documented with CVE reference where applicable
- Continuous fuzzing for all parsing paths
Evidence: OWASP Top 10 is the industry standard web security baseline.cargo-audit catches known vulnerabilities in dependency tree.
ADR-019 — 2026-07-05 — Plugin strategy: trait-based, compile-time registration
Context: Phase 19 — Plugin System & Extensibility.
Decision:
Plugintrait with lifecycle hooks (pre_route,post_route,on_startup,on_shutdown)- Compile-time plugin registration for Rust plugins
JustAPIApp.use(plugin)for Python plugins- Plugins run in the Rust middleware chain (no Python overhead for Rust plugins)
- Plugin isolation via panic boundaries (catch_unwind)
Evidence: Trait-based design is idiomatic Rust. Middleware chain integration
means plugins get full performance. Python plugin API enables third-party ecosystem.
ADR-020 — 2026-07-05 — Performance target: beat FastAPI on every benchmark
Context: Phase 20 — Benchmark & Optimization.
Decision: FastAPI is the competition to beat. All Phases 11-19 must include
benchmark gates that compare against FastAPI. If any benchmark shows FastAPI
faster, it's a blocking bug.
Target metrics:
- Throughput: 2x on hello-world, 3x on JSON echo
- Latency p99: < 0.5ms (FastAPI ~2ms)
- Memory: 2x requests per MB
- Startup: < 10ms cold (FastAPI ~300ms)
- Image size: < 50MB (FastAPI ~200MB)
- Test speed: 5x faster than TestClient
Evidence: Phase 1 already showed 2.4x vs Granian on hello-world. Rust gives
a fundamental performance advantage; the gap should widen as optimization progresses.
ADR-021 — 2026-07-05 — GIL-free architecture: remove dedicated Python worker thread
Context: Phase 13 testing utilities required JustAPITestClient to run synchronously without a separate server process. The dedicated Python worker thread (ADR-008) made this impossible — it required an OS thread running a tokio runtime + Python event loop.
Problem: The dedicated worker thread architecture from ADR-008 prevented:
- Synchronous test client execution (needed to start/finish per-request without thread lifecycle)
- Pool initialization inside
py.detach()closures (worker thread owned the GIL) - Request-response cycle that starts and completes within a single function call
Options considered:
Option A — Keep worker thread, add test-mode bypass (rejected)
Add a flag to skip the worker thread when running tests. Would create two code paths (production vs test) with risk of divergence.
Option B — Remove worker thread, GIL-free Python::try_attach everywhere (selected)
Remove the dedicated Python worker thread entirely. Use Python::try_attach() from any tokio worker thread. This function safely acquires the GIL via PyGILState_Ensure regardless of which OS thread calls it.
Key properties:
- No extra OS thread: Saves ~1MB per worker, removes thread lifecycle management
OnceLock<HelperFunctions>: Helper module (Python code that builds request dicts and calls user handlers) compiled once, cached globally withPy<PyAny>(which isSend + Sync + 'static)- Pool initialization: Works inside
py.detach()closure before server starts — critical forJustAPITestClientwhere pool must be ready before any request call_python_handler: Shared function used by bothrun()andJustAPITestClient— single code path for production and testing
Decision: Select Option B — remove dedicated Python worker thread. Python::try_attach for GIL acquisition, py.detach() for releasing GIL during blocking rt.block_on.
Evidence:
Python::try_attach()usesPyGILState_Ensureinternally — works from any OS thread, no prior GIL state requiredOnceLock<Py<PyAny>>is safe becausePy<PyAny>implementsSend + Sync + 'staticvia PyO3's Send/Sync bounds- Works with both GIL-ful and free-threaded Python 3.14
- All 43 Python integration tests pass with the new architecture
ADR-022 — 2026-07-05 — TestClient: tokio duplex + hyper HTTP/1.1 parser (no TCP)
Context: Phase 13 testing utilities. Need a synchronous test client in Rust that runs requests through the full pipeline without opening a TCP socket.
Options considered:
Option A — TCP loopback (rejected)
Open a real TCP connection to 127.0.0.1:0 for each test. Adds ~100μs per connection for TCP handshake + teardown.
Option B — tokio duplex + hyper HTTP/1.1 parser (selected)
Use tokio::io::duplex() to create a pair of (reader, writer) in-memory byte streams. Write raw HTTP request bytes to the writer, pipe the reader through hyper::http1::Builder::serve_connection with the handler as the service function.
Key properties:
- No TCP: All in-memory, sub-millisecond per request
- Full pipeline: Hyper HTTP/1.1 parser still runs — headers, body framing, connection lifecycle are real
Content-Length: 0required: Hyper's HTTP/1.1 parser fails with "connection closed before message completed" if response doesn't includeContent-Lengthfor non-chunked responses
Decision: Select Option B — tokio duplex + hyper HTTP/1.1 parser.
Evidence:
- Sub-millisecond test latency achieved (vs ~1-2ms for TCP loopback)
- Hyper parses both request and response, ensuring real HTTP behavior
Content-Length: 0is always included in the response builder
ADR-023 — 2026-07-05 — Snapshot testing: custom implementation over insta
Context: Phase 13 testing utilities. Need snapshot testing similar to Jest/insta but integrated with Python JustAPITestClient.
Options considered:
Option A — insta crate (rejected)
Rust snapshot testing crate. Would require calling Rust from Python to compare snapshots, adding complexity to the FFI boundary. No native Python integration.
Option B — Custom Python Snapshot class (selected)
Snapshot class with assert_match(), assert_response(), assert_body() methods. .snap files stored in __snapshots__/ directory next to the test file. Caller detection via inspect.stack() frame walk. SNAPSHOT_UPDATE=1 env var for auto-accepting new snapshots. Unified diff output on mismatch.
Decision: Select Option B — custom Python Snapshot class.
Evidence:
- Tighter Python integration (no FFI calls for snapshot comparison)
- Unified diff output is human-readable
SNAPSHOT_UPDATE=1pattern matches Jest/insta UX- 10 Python tests for snapshot functionality
ADR-024 — 2026-07-05 — Bucket-based latency histograms over HDR Histogram
Context: Phase 14 enhanced metrics. Need latency histograms with percentile computation (p50/p95/p99/p999) for Prometheus output.
Options considered:
Option A — hdrhistogram crate (rejected)
Full HDR Histogram implementation. Adds a new dependency with complex internal data structures. Overkill for 13 fixed buckets.
Option B — Bucket-based histograms with AtomicU64 (selected)
13 Prometheus-compatible buckets (1ms, 2ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 2500ms, 5000ms, 10000ms). Each bucket is an AtomicU64 counter. Percentiles computed from bucket distribution during snapshot(). Zero-allocation on the record path.
Decision: Select Option B — bucket-based histograms.
Evidence:
- Zero allocation on record path (single
AtomicU64::fetch_add(1, Relaxed)) - Percentiles computed from cumulative bucket counts at query time
- Prometheus-rendered as
justapi_request_duration_ms_bucket{le="..."}counters - 8 unit tests covering percentiles, bucket accumulation, and Prometheus output
ADR-025 — 2026-07-05 — Structured logging: tracing-subscriber json + tracing-appender
Context: Phase 14 structured logging. Need JSON log output with configurable levels and file rotation. No custom JSON serializer wanted.
Options considered:
Option A — Custom JSON logger (rejected)
Write JSON formatting manually. Higher maintenance burden, risk of formatting bugs.
Option B — slog crate (rejected)
Alternative structured logging ecosystem. Less ecosystem support than tracing, not compatible with tracing crate's existing usage.
Option C — tracing-subscriber json feature + tracing-appender (selected)
tracing-subscriber's built-in JSON layer handles structured JSON output. tracing-appender provides non-blocking file writers with daily/hourly rotation. NonBlocking writer backed by WorkerGuard stored in OnceLock<Mutex<Option<WorkerGuard>>> for safe global access.
Decision: Select Option C — tracing-subscriber json + tracing-appender.
Evidence:
- No new logging infrastructure to learn — leverages existing tracing ecosystem
tracing-subscriberjsonfeature is mature and well-documentedtracing-appenderNonBlockingwriter prevents file I/O from blocking request handling- Environment filter via
RUST_LOGenv var works transparently
ADR-026 — 2026-07-05 — Health check system: composable registry over static endpoints
Context: Phase 14 health checks. Need pluggable health probes that can validate DB, cache, and upstream services.
Options considered:
Option A — Static /health//ready//live endpoints (rejected)
Hard-coded health endpoints. Not extensible — adding a new health check requires modifying server.rs.
Option B — HealthRegistry with pluggable HealthCheck trait (selected)
HealthRegistry struct with register(Box<dyn HealthCheck>) and register_fn(name, F). check_all() runs all registered checks and aggregates results. health_response() generates HTTP response (200 for all healthy, 503 for any unhealthy). DbHealthCheck under db feature flag.
Decision: Select Option B — composable HealthRegistry.
Evidence:
- Backward-compatible with existing static
/healthresponse DbHealthCheckreuses existingAnyPool::health_check_all()from Phase 12- 6 unit tests covering healthy, degraded, unhealthy, and mixed states
ADR-027 — 2026-07-05 — Alerting: HTTP-only webhook dispatcher over reqwest
Context: Phase 14 alerting. Need to send webhook notifications to Slack, PagerDuty, and generic endpoints.
Options considered:
Option A — reqwest crate (rejected)
Full async HTTP client. Adds ~30 dependencies to the dependency tree. Significant compile time increase.
Option B — Manual HTTP POST via tokio::net::TcpStream (selected)
Construct raw HTTP/1.1 POST requests manually over tokio::net::TcpStream. Build request bytes in a Vec<u8> (method, path, headers, body), write to stream, read response.
Decision: Select Option B — HTTP-only POST via raw TcpStream.
Evidence:
- Avoids adding
reqwestas a dependency (justapi-core stays lean) - Only HTTP POST needed (no GET, PUT, DELETE, streaming, cookie handling)
- Slack and PagerDuty webhooks accept POST only
- HTTPS requires
rustlsclient — not implemented; users can proxy through a local HTTP gateway - 3 unit tests for payload builders (Slack, PagerDuty, severity filtering)
ADR-028 — 2026-07-05 — Audit logging: HandlerFn wrapper over Middleware trait
Context: Phase 14 audit logging. Need to log requests to sensitive endpoints with method, path, status, and duration.
Options considered:
Option A — Middleware trait implementation (rejected)
Implement the Middleware trait for AuditLogging. Would run inside the middleware chain, capturing pre-request state.
Option B — HandlerFn wrapper via wrap_handler() (selected)
AuditLogging::wrap_handler() takes a HandlerFn and returns a new HandlerFn that wraps the original. This is designed to be the last wrapper before the middleware chain, ensuring it captures the final status after all middleware processes the request.
Decision: Select Option B — HandlerFn wrapper.
Evidence:
HandlerFnwrapper is simpler than implementing theMiddlewaretrait- Placed at the end of the wrapper chain, captures final response status
- Structured
tracing::info!withaudit=truefield enables log filtering - 2 unit tests covering default and custom audit rules
ADR-029 — 2026-07-05 — Panic recovery: catch_unwind over tokio task abort
Context: Phase 14 panic recovery. Need to prevent panics in user handlers from crashing the server.
Options considered:
Option A — Tokio task abort (rejected)
Set up a task lifecycle hook that detects panics and aborts the task. Limited control over the panic response — tokio's default panic handling just terminates the task.
Option B — std::panic::catch_unwind + FutureExt::catch_unwind (selected)
Wrap the BoxFuture handler with FutureExt::catch_unwind. If the future panics, catch_unwind catches the panic and returns Err(PanicInfo). This is converted to a 500 JSON response: {"error":"Internal server error"}.
Additionally, set a custom panic hook via std::panic::set_hook that logs the panic message and location via tracing::error!(panic=true, ...). The default hook behavior is preserved.
Decision: Select Option B — catch_unwind on the BoxFuture.
Evidence:
FutureExt::catch_unwindwraps the future at the point of execution — no tokio task lifecycle changesCustom panic hook logs stack trace information
500 JSON response ensures the client doesn't hang on a panic
anyhow::Errorfrom handler is also converted to 5001 unit test verifying panic recovery returns a handler function
tokio-uring/monoioevaluation — deferred. epoll viatokiois the default.Free-threaded CPython (PEP 703) maturity for PyO3 — monitor.
Sub-interpreter isolation guarantees — deferred for v1.0.
quinn/ HTTP-3 crate maturity — evaluate at Phase 20.Pydantic bridge performance — if Pydantic bridge is too slow, consider dropping Tier A ASGI shim for Phase 11+ features.
sqlx compile-time checks — may increase compile times. Evaluate trade-off vs runtime query building.
ADR-030 — 2026-07-10 — Native inference engine: Candle (Rust), not torch delegation
Context: JustAPI's 2026 differentiation thesis is "best for model deploying"
with native GPU support. FastAPI delegates all GPU work to torch under the
GIL, which is the bottleneck it can never remove. To beat FastAPI for inference
we must own the GPU execution path in Rust.
Options considered:
- Keep delegating to
torch/transformersvia PyO3 (current state) — rejected:
keeps the GIL bottleneck, adds no native advantage, just repackages FastAPI. - Candle (HuggingFace Rust ML framework: CUDA/ROCm/Metal/CPU backends,
safetensors/GGUF loaders, KV-cache + FlashAttention kernels) — selected. burn(Rust DL framework, backend-agnostic) — strong, but smaller model
coverage and weaker LLM/quantization ecosystem than Candle today.llm/ llama.cpp bindings — excellent for GGUF but narrower (no training,
weaker multimodal/agent tooling); good later as a quantized backend.
Decision: Build a new justapi-inference crate on Candle as the native
GPU inference engine. Python handlers call into it through the existing PyO3
zero-copy (Arrow/DLPack) boundary; heavy compute runs in Rust with no GIL.burn and llama.cpp remain candidate backends behind a trait for Phase 45+.
Evidence:
- 2026 landscape (vLLM/SGLang/TGI/TensorRT-LLM) confirms the engine layer is
what defines GPU efficiency; FastAPI sits strictly below it. - Candle gives Rust-native CUDA/ROCm, KV cache, FlashAttention, and quantization
without leaving the Rust runtime we already control. - Reuses existing
memorycrate (arena/pool) for KV-cache block allocation.
ADR-031 — 2026-07-10 — Two-layer architecture: engine + control plane
Context: Research shows production model serving splits into (a) an
inference engine (vLLM/SGLang/TGI — owns GPU, batching, KV cache) and
(b) a serving control plane (Ray Serve / NVIDIA Dynamo / AIBrix / Ouranos —
owns OpenAI API, routing, autoscaling, registry). JustAPI currently has web
framework + partial control-plane primitives (rate-limit, circuit-breaker,
health, DAG) but no engine layer.
Decision: Implement both layers inside JustAPI:
- Engine (Phase 41-45):
justapi-inference— Candle-backed load/generate,
paged KV cache, continuous-batching scheduler, quantization, multi-LoRA. - Control plane (Phase 44, 46): OpenAI-compatible API, model registry,
KV-aware/LoRA-aware routing, LLM-specific autoscaling, multi-replica.
This avoids a hard dependency on an external engine (unlike Ray Serve, which
orchestrates vLLM but does not implement one).
Evidence: Dynamo/AIBrix/Ouranos all wrap vLLM-class engines; owning the
engine is what lets JustAPI be a single self-contained "deploy a model" binary
(Rust speed + GPU + Python DX) rather than glue code.
ADR-032 — 2026-07-10 — PyPI distribution: single abi3 wheel + manylinux_2_28 build
Context: justapi PyPI name is available (verified 404). The package must
ship one wheel covering CPython 3.11-3.14.
Decision:
- Build a single abi3 wheel (
cp311-abi3) viaabi3-py311 = trueinpyproject.toml+abi3-py311on thepyo3crate feature. One build covers
all supported Pythons. - The publish build MUST run inside a
manylinux_2_28container (maturin
auto-detects the policy). The local Arch host (glibc 2.41) tagsmanylinux_2_34, which PyPI rejects, so local wheels are dev-only. twine checkmust pass before upload; upload viamaturin publishortwine uploadusing aMATURIN_PYPI_TOKEN(not committed).
Evidence: wheel built and verified (twine check PASSED, installs +
imports in a clean venv). Optional extras: pydantic, jinja, full.
ADR-033 — 2026-07-10 — Single Python package source of truth
Context: Two divergent justapi packages existed — the real, complete one
at crates/justapi-py/python/justapi/ (maturin builds/ships from here, has_justapi.so + full app.py) and a stale duplicate at repo-rootpython/justapi/ that shadowed imports and broke test collection.
Decision: crates/justapi-py/python/justapi/ is the single canonical
source. The repo-root python/justapi/ duplicate was removed; its unique files
(tests, grpc_compiler.py) were merged into the canonical dir. Maturin builds
from crates/justapi-py/ via python-source = "python".
Evidence: after consolidation, pytest collects and runs 53 passed / 1
skipped against the installed (editable) package; no import shadowing.
ADR-034 — 2026-07-10 — multer for multipart/form-data parsing
Context: Phase 47 (FastAPI Parity) — multipart file upload. JustAPI needs to
parse multipart/form-data bodies for file upload endpoints (dataset/RAG ingest,
model weight uploads, etc.). The parser must be async, memory-safe, and handle
arbitrary boundary strings.
Options considered:
multer3.1 — battle-tested async parser used by Rocket, Actix, Axum.
AcceptsStream<Item = Result<Bytes, E>>so it works with anyhttp_body::BodyviaBodyDataStream. Has built-in size constraints for
DoS protection. ~88M downloads. Maintained by SergioBenitez (Rocket lead).- Manual parser — hand-roll boundary-aware chunked body reader. Risk of
off-by-one on edge-case boundaries, slower to develop, no streaming support. actix-multipart— tightly coupled to Actix's body type; would require
adapter layer.
Decision: Use multer = "3.1". It integrates directly with hyper 1.x viaBodyDataStream, is already used by the wider Rust web ecosystem, and supports
streaming + size limits out of the box. Added as a dependency of justapi-core
(no feature gate — multipart is not optional for a FastAPI-competitive framework).
Evidence: 7 unit tests covering boundary extraction, text fields, single
file, multiple files, and missing-boundary error. cargo test --workspace passes.
ADR-036 — 2026-07-10 — Disaggregated prefill/decode scheduler
Context: Phase 48 — disaggregated serving. Production LLM servers (vLLM
DistServe, NVIDIA Dynamo) split prefill and decode onto separate GPU pools
because their compute profiles are fundamentally different: prefill is
compute-bound (processes many prompt tokens in parallel) while decode is
memory-bandwidth-bound (generates one token at a time). Pooling them together
wastes GPU cycles (decode waits for prefill to finish).
Options considered:
- Single scheduler with priority scheduling — keep one pool, prioritise
decode over prefill to reduce TTFT jitter. Simpler but cannot fully isolate
prefill-heavy workloads from decode latency. - Two independent schedulers with KV transfer (selected) —
PdScheduler
owns oneSchedulerfor prefill and one for decode. Sequences transfer from
prefill→decode viaScheduler::take_completed_prefill/admit_transferred. Enables independent pool sizing (e.g. prefill poolmax_num_seqs=1, decode poolmax_num_seqs=8).
Decision: Option 2 — PdScheduler wrapping two Scheduler instances.
The prefill scheduler's TransferableSequence struct was moved from inner
(struct inside impl Scheduler) to module scope to satisfy Rust's nested-item
restriction. SchedulerStats given #[derive(Debug, Clone)] so PdStats can
derive them. The KvBlockPool::release flow (→ cached, not free) is the
expected lifecycle — blocks are made available to eviction but stay tracked
until pressure triggers recycling.
Evidence: 5 unit tests: starts idle, single-request prefill→transfer→decode,
independent pool sizing (prefill 1 / decode 8), total_transferred_tokens
accumulation (16+8=24), block in-use verification (prefill releases, decode
allocates). All 92 lib + 10 integration tests pass, clippy + fmt clean.
ADR-037 — 2026-07-10 — Structural LLM benchmark via synthetic GPU-cost model
Context: Phase 48 benchmark vs vLLM/SGLang. No GPU exists in CI, so a literal
wall-clock tokens/sec comparison is impossible. We still need a reproducible,
checkable number proving the disaggregated P/D (ADR-036) and speculative-decoding
(ADR-035) mechanisms transfer to the scheduling metrics (TTFT, ITL, throughput)
that define LLM-serving quality.
Options considered:
- Real GPU run (deferred) — requires
--features real+ CUDA + weights.
Accurate but not runnable in this environment; left as a gate TODO. - Wall-clock of
Engine::generate+ MockModel — rejected:MockModel
produces tokens with no yield, so the unbounded channel buffers the whole
stream and TTFT/ITL collapse to ~0; it measures channel throughput, not
serving dynamics. - Drive the real schedulers over a synthetic GPU-cost model (selected) —
justapi-bench-inferencerunsPdScheduler(disaggregated) andScheduler
(collocated) through the actual prefill→transfer→decode loop, charging each
step a modeled cost. Disaggregated uses parallel virtual clocks (independent
prefill/decode pools); collocated uses one shared clock. Prompt lengths are
varied per request so prefill/decode overlap (the condition that degrades
collocated ITL).
Decision: Option 3. The benchmark is a topology proof: it isolates the
scheduler-level effect of splitting prefill/decode, which is hardware-independent.
Cost model: prefill ∝ prompt tokens/step (compute-bound), decode = fixed step
(memory-bandwidth-bound), matching the vLLM/SGLang assumption. Numbers are
explicitly labeled structural, not GPU throughput.
Evidence: 16 burst requests, prompt 64–256 tok — disaggregated ITL p99 0.20 ms
vs collocated 1.16 ms (5.80x tighter), throughput 70,617 vs 34,634 tok/s (2.04x),
wall time 12.32 vs 25.12 ms. Mirrors the DistServe/Dynamo disaggregated-P/D win.
Recorded in BENCHMARKS.md.
ADR-038 — 2026-07-10 — K8s AI inference gateway plugin
Context: Phase 48 final item — a K8s AI inference gateway: KV-aware +
LoRA-aware routing inside the gateway (the Dynamo / AIBrix behaviour), so
JustAPI can act as a model-serving ingress rather than just an engine library.
Options considered:
- Wire
Server::with_openai_routedonly — already exists (Phase 46) and
routes per-request, but it owns no Kubernetes concepts (readiness, endpoint
DNS, namespace). It is HTTP glue, not a gateway abstraction. - New
InferenceGatewaycomposingControlPlane+Router(selected) —
adds the K8s-specific concerns the raw router lacks: readiness gating,
Kubernetes service-DNS endpoint resolution, namespace scoping, and a
KV-pressure default strategy. Pure decision logic (no network/GPU), matching
the rest of the control plane; a K8s executor fulfils routing decisions and
reports pod readiness/load back.
Decision: Option 2. gateway.rs with InferenceGateway, GatewayConfig
(namespace, service_template supporting {replica}/{model}/{version}/{namespace}, strategy, require_ready), and GatewayDecision (replica +
resolved model + endpoint). Readiness is folded into the router's healthy
flag (standard K8s mapping: not-Ready ⇒ not a routing candidate). Default
strategy LowestKvPressure so the gateway is KV-aware out of the box.
Evidence: 5 unit tests: Kubernetes endpoint resolution, namespace scoping,
readiness gating (not-Ready pod excluded, both drained → no capacity), KV-pressure
default routing to coolest replica, LoRA-aware routing inside the gateway. All
97 lib + 10 integration tests pass, clippy + fmt clean.
ADR-039 — 2026-07-10 — Tree-based speculative decoding (Medusa/EAGLE)
Context: Phase 49 — the speculative-decoding foundation (ADR-035) proved
lossless token streaming with gamma + 1 tokens per step when a draft perfectly
predicts the target. But with an imperfect draft, the single-path draft-target
scheme accepts only ~1 token per step (one bonus target token). Production
systems (Medusa, EAGLE, vLLM's tree speculation) improve this by giving the
draft multiple "guesses" per position — a tree of candidates. ADR-035 explicitly
deferred this ("Tree-based (Medusa/EAGLE): higher acceptance, more complex
verify (tree reduction). Deferred — the draft-target scheme is the foundation
they refine.").
Options considered:
- Draft-target only (rejected for Phase 49): keep single-path draft-target.
Leaves a known capability gap vs vLLM/SGLang — ADR-037's TODO explicitly
called for "Tree-based Medusa/EAGLE verify (higher acceptance than
draft-target)." - Tree-based verify with greedy DFS path (selected):
build_draft_tree
constructs aDraftTreefrombranchcandidates per depth (recursive
top-k extraction from the draft'sforward_logits).verify_treewalks
the tree greedily: at each depth, find a child whose token matches the
target's argmax/sample; accept it and descend; if no match, emit the
target's own token as correction (lossless).speculative_generate_tree
wraps the loop with stop-token checking on accepted tokens (unlike
draft-target, which only checks stop tokens at the bonus phase — tree
speculation must check each accepted token because the draft can propose
stop tokens in the tree). - Full tree verification with scoring (deferred): score all paths and pick
the globally optimal one. More complex, needs tree-attention-style batching
to be efficient; possible future optimization.
Decision:
- New
spec_decode_tree.rsmodule withTreeNode,DraftTree,build_draft_tree,verify_tree,speculative_generate_tree. AcceptanceStats.tree_branchandtree_nodes_verifiedfields added
(backward-compatible: both default to 0 for draft-target runs).top_k_tokenshelper extracted (pure-Rust argmax/top-k from logits).RangeModeltest model: configurable offset + spread for injection of
draft-target alignment scenarios.- For structural (no-GPU) tests:
RangeModel(offset=0, spread=3)as draft,MockModelas target — acceptance rate ≈ 1.0 with branch=3. - Stop tokens checked per-accepted-token in the tree loop (not just at bonus
phase), because the draft tree may contain stop tokens at any depth.
Evidence: 18 unit tests in spec_decode_tree::tests covering:
top_k_tokenssorted order and edge cases (empty, k=0)DraftTree.total_nodesformula (branch=1 ✓, branch=0 ✓, branch=3)- Branch=1 acts identical to draft-target (single path)
- Gamma=0 degenerates to plain decode (lossless)
- Perfect draft (offset=0, branch=3): acceptance rate ≥ 0.95
- Graceful degradation (offset=10, never matches target): acceptance < 0.1,
output identical to plain decode - Higher branch gives ≥ same acceptance as lower branch
verify_treeunit cases: match, no-match, multi-depth match, deep reject- Stop tokens respected (stops on first stop token)
- Empty prompt works (output length == max_tokens)
- All 18 pass, 115 inference tests total.
cargo test --workspace,clippy -D warnings,cargo fmt --checkclean.
What this does NOT yet provide (gated on GPU):
- Wall-clock tokens/sec on real 7B-class models with tree-attention batching
(needs CUDA + weights). - The
SpeculativeModelwrapper for tree speculation (for serving throughEngine::generatewithout callers knowing); draft-target has this, tree
would needTreeSpeculativeModelor a config flag. - Tree-attention batching (verify all tree nodes in one forward pass with a
tree-attention mask, matching Medusa's efficient implementation).
Context: Phase 48 — production hardening for AI. vLLM/SGLang win on decode
throughput via speculative decoding (draft proposes gamma tokens, target
verifies, accept longest agreeing prefix + emit 1 correction). JustAPI must
offer the same to avoid a feature gap vs production LLM servers.
Options considered:
- Tree-based (Medusa/EAGLE): higher acceptance, more complex verify (tree
reduction). Deferred — the draft-target scheme is the foundation they refine. - Draft-target (Chen et al. 2023): standard, simple, correctness-preserving.
Implemented. - Skip / delegate to candle: would leave a hard capability gap.
Decision:
- Add
Model::forward_logits(context) -> Vec<f32>to the trait (single-step
primitive).MockModelreturns a degenerate-but-smooth distribution;RealModelreturns true last-position logits from one forward pass. - New
spec_decodemodule:speculative_generate,sample_token(pure-Rust
temperature/top_p/top_k sampler, seeded viarand0.8),AcceptanceStats,SpeculativeConfig,SpeculativeModel(aModelwrapper servable throughEngine::register_speculative). - Added
rand = "0.8"tojustapi-inference(already used injustapi-core;
minimal, standard). Seeded RNG keeps verification reproducible for tests.
Evidence: 8 unit tests (incl. proof that draft==target yields an identical
token stream to plain decode — speculation is lossless — and acceptance rate
→ 1.0 / gamma+1 tokens per step for a perfect draft) + 2 Engine integration
tests. cargo test --workspace, clippy -D warnings, cargo fmt clean.
ADR-040 — 2026-07-10 — Tree speculation in the serving path (Engine + CLI)
Context: Phase 49 delivered the tree-verify mechanism (spec_decode_tree.rs)
but it was only reachable via the free function speculative_generate_tree. To
be servable like the draft-target scheme (ADR-035's SpeculativeModel +Engine::register_speculative), tree speculation needed an equivalent wrapper
so justapi serve --model can enable it through the OpenAI-compatible API with
zero caller awareness.
Options considered:
Engine::register_speculativeoverload — addbranchparam to the
existing method and branch internally onbranch > 0. Simpler call site but
conflates two distinct verify algorithms in one entry point; harder to read
and test.- New
TreeSpeculativeModel+Engine::register_tree_speculative(selected)
— mirrors theSpeculativeModel/register_speculativeshape exactly, so
draft-target and tree modes are first-class peers. The CLI wires both.
Decision:
spec_decode_tree::TreeSpeculativeModel— aModelwrapper runningspeculative_generate_treewithtarget/draft/gamma/branch/seed, delegatingvocab_size+forward_logitsto the target (same asSpeculativeModel).Engine::register_tree_speculative(name, target, draft, gamma, branch, seed)
— registers the wrapped model undernameso it is served through the normalEngine::generatepath (used by the OpenAI server) transparently.Engine::get(name)— small public accessor used by the CLI to fetch the
registered target before wrapping it (registry was previously private).- CLI
justapi serve: new--gamma(draft-target + tree trigger) and--branch
(tree enable) flags. When either is set, the CLI registers a second mock as the
draft (<model>-draft) and wraps<model>viaregister_tree_speculative
(branch > 0) orregister_speculative(gamma > 0). The log line names the
mode.
Evidence: 2 new wrapper tests (tree_speculative_model_wrapper_matches_target,tree_speculative_model_vocab_and_logits_delegated) — the wrapped model emits the
same token stream as plain target decode and delegates vocab/logits to the target.
End-to-end smoke test: justapi serve --model mymodel --gamma 4 --branch 3
starts with the "tree speculative decoding" log line and serves/v1/models (lists mymodel + mymodel-draft), /v1/chat/completions
(non-streaming: 12 completion tokens) and streaming SSE — all 200. All 117
inference tests pass; clippy -D warnings, cargo fmt --check clean.
What this does NOT yet provide (gated on GPU):
- Wall-clock tokens/sec on real 7B-class models with tree-attention batching
(needs CUDA + weights). The structural acceptance gain (up to 3× at branch=3)
transfers directly; only the per-step batched verification cost changes. - A registered real draft model (today the CLI uses a second
MockModelas
draft — a perfect-draft demo; a smaller/faster real model would be the
production draft).
ADR-041 — 2026-07-10 — Radix tree as the prefix-cache data structure
Context: Phase 50 needed cross-request KV reuse. The existingPrefixCache is a flat hash-map keyed by per-prefix token hashes. It dedupes
identical prefixes but duplicates block-ID references across distinct prefix
entries, so its memory grows super-linearly for nested (chat-history) prompts
and cannot merge partial shared prefixes. A radix tree (SGLang
RadixAttention) stores each block once and merges shared prefixes.
Decision: RadixPrefixCache in radix_cache.rs — a radix tree of
block-aligned nodes. insert splits edges to share common prefixes;lookup returns the longest matching prefix (refreshing LRU timestamps);evict removes LRU leaf subtrees and returns their BlockIds as a free
hint. Benchmark (bench_nested) shows O(N) block-slots for radix vs O(N²)
block references for the flat PrefixCache on a chat-history workload
(radix uses <1% of flat's storage at N=500).
Evidence: 14 unit tests incl. shared_prefix_merges_nodes,three_way_sharing, evict_removes_lru_leaf, and the twobench_nested-derived scaling tests. clippy -D warnings, fmt --check clean.
ADR-042 — 2026-07-10 — Wiring RadixPrefixCache into the Scheduler
Context: RadixPrefixCache (ADR-041) was a standalone module; theScheduler never consulted any prefix cache — finished sequences were simply
dropped, leaking their KV blocks in the pool. The production win of Phase 50
(cross-request prefix KV reuse) required the scheduler to own the cache.
Options considered:
- Caller-driven lookup (keep
NewRequest.cached_blocks/prefix_cached_tokens,
caller fills them) — leaves the scheduler cache-less, duplicates lookup
logic, and the leaked-block bug unfixed. - Scheduler owns the radix cache (selected) — single eviction authority,
O(1) lookup on admission, blocks cached on completion, eviction driven by
the scheduler under pressure.
Decision:
Schedulernow owns aRadixPrefixCacheplus aprefix_refs: HashMap<BlockId, usize>live-reference count.- Admission: if
NewRequest.cached_blocksis empty, the scheduler does its
ownprefix.lookup; matched blocks arereclaimed (reused, no recompute)
and recorded as live-referenced (unpin). A block is only reused when its
live-ref is 0, so two concurrent requests never claim the same cached block. - Completion (
scheduleretain +cancel): finished sequences' blocks are
promoted viacache_completed—prefix.insert(idempotent for shared
prefixes) and thenpool.release+pool.pinso the clock-sweep evictor
cannot recycle them out from under the cache. This also fixes the
finished-sequence block leak. - Eviction: when
Sequence::growfails, the scheduler callsprefix.evict_filter(k, |bs| all live-refs == 0)which returns only
freeable LRU leaf blocks; they areunpined +pool.free_cached(a newKvBlockPoolmethod that returns a cached block to the free list
immediately, no clock-sweep wait). This keeps a single eviction authority and
avoids stale block ids in the tree. pinwas relaxed to allowref_count == 0(cached + pinned), andRadixPrefixCache::evict_filterwas added (skips leaves still referenced by a
live sequence).take_completed_prefillclearsprefix_refsfor transferred
blocks (they leave this pool).
Evidence: 3 new scheduler-integration tests: schedule_radix_reuses_shared_prefix
(full hit, no prefill, 48 tokens saved), schedule_radix_reuses_partial_prefix
(only the unique tail prefilled, computed_tokens == 48), andschedule_radix_evicts_under_pressure (6-block pool, 10 distinct prompts, no
deadlock). All 134 inference tests pass; clippy -D warnings, fmt --check clean.
ADR-043 — 2026-07-10 — Collision-safe content-hash cache key for RadixPrefixCache
Context: ADR-042 wired RadixPrefixCache into the Scheduler. The tree
matches prefixes by exact token comparison, which is already correct, but the
cache key was implicit (the token run itself). If a future change switched
matching to be hash-based for speed, or if the tree were ever corrupted,
a false share could return the wrong KV blocks. We wanted the cache key to be
explicit and collision-resistant.
Decision: every RadixNode now stores token_hash (FNV-1a of its tokens).
insert/ split recompute the hash for each node (including the truncated
edge after a split).lookupaccepts a full-edge match only ifchild.token_hash == hash_tokens(&tokens[..cp]); on divergence it sets acollisionflag and the
caller rejects the match (returnsNone, incrementshash_collisions).- Added
RadixPrefixCache::verify_hashes()(self-check that every node's
stored hash equals its token content) and ahash_collisionsstat.
Evidence: 4 new tests — node_hash_matches_token_content,hashes_consistent_after_split, distinct_prefixes_do_not_collide
(hash_collisions == 0), and lookup_rejects_corrupted_hash (forged
corruption → lookup returns None, hash_collisions == 1). All 138
inference tests pass; clippy -D warnings, fmt --check clean.
Note / caveat: KV-cache reuse is still keyed on exact token ids — two
prompts that are semantically equal but token-distinct correctly do NOT share
blocks, because their KV differs. The hash hardens the key integrity
(collision/corruption safety), not semantic equivalence.
ADR-044 — 2026-07-10 — Prefix-cache observability surface on the Scheduler
Context: With the radix prefix cache wired into the Scheduler (ADR-042)
and its cache key hardened (ADR-043), the reuse win was only observable by
calling RadixPrefixCache::stats() directly. An operator/metrics layer needs
the signal surfaced through the Scheduler's normal stats snapshot.
Decision: SchedulerStats now carries prefix: RadixPrefixCacheStats
(hits / misses / hash_collisions / tokens_saved / nodes) andcached_kv_blocks: usize (resident prefix KV in the pool, viaKvBlockPool::cached). Added Scheduler::prefix_cache_stats() as a direct
accessor. The radix stats already track tokens_saved = tokens not
recomputed thanks to reuse, which is the headline operator metric.
Evidence: schedule_prefix_stats_reflect_reuse asserts that after a shared
prefix is reused, stats().prefix.hits == 1, tokens_saved == 48,hash_collisions == 0, and cached_kv_blocks > 0 once a prefix is resident.
All 139 inference tests pass; clippy -D warnings, fmt --check clean.
ADR-045 — 2026-07-10 — Scheduler serving integration (Phase 51)
Context: Phase 50 wired the RadixPrefixCache into the Scheduler but
the Scheduler was not connected to any HTTP server. The existing OpenAI
handlers (chat_completions_handler, etc.) called Engine::generate()
directly, which delegates to Model::generate() — a black-box prefill+decode
loop that bypasses the scheduler entirely. To get admission control, prefix
reuse, and observability benefits, the scheduler must sit between the HTTP
layer and the model.
Options considered:
- Replace
Model::generate()with a scheduler loop insideEngine—
would require rewriting theModeltrait and breaking speculative decoding.
Too invasive for a single phase. - Per-request scheduler loop — each
generate()spawns its own thread
runningschedule()→forward_logits()→on_step_complete(). Simplest,
but breaks under concurrency: concurrent requests each run their own loop
and double-schedule the same seq (tokens emitted twice). Rejected after
discovering the bug; the fix below subsumes it. - Persistent scheduler background thread (vLLM's
SchedulerLoopmodel) —
one loop thread interleaves ALL in-flight requests through a singleschedule()call. Selected: proper continuous batching, correct under
concurrency.
Decision: Implement SchedulerEngine in a new scheduler_engine.rs
module. A single persistent loop thread (spawned in new()) runs forever,
calling Scheduler::schedule() and routing tokens to per-request mpsc
channels keyed by scheduler seq id. generate() resolves the model, callsScheduler::add_request (which returns the assigned seq id), registers the
channel, and returns the receiver. When a seq leaves the running set the loop
sends a final finish_reason token and closes its channel. Model::forward_logits
is driven per step; greedy argmax sampling is used (temperature/top_p deferred).
Scheduler API changes: add_request now assigns + returns the seq id
immediately (previously assigned lazily inside schedule()), so external
drivers can correlate requests with scheduler events. schedule() buildsSequence::new(req.id) instead of re-assigning. Added running_ids() andrunning_seq_generated() accessors for completion detection. All existing
scheduler tests still pass (seq ids remain 1,2,3… in order).
Key details:
SchedulerEngineholdsArc<Engine>,Arc<Mutex<Scheduler>>, and mapsseq_id → (sender, model, context, max_tokens).- Completion detection runs on EVERY loop iteration (including empty
schedule()cycles) so a finished seq always receives its finish token —
the original single-request design hung here because itcontinued before
the detection block. - Metrics exposed via a
MetricProvidertrait onMetrics:justapi_scheduler_waiting,_running,_prefilling,_back_pressure,_prefix_hits,_prefix_misses,_prefix_tokens_saved,_cached_kv_blocks. - CLI:
justapi serve --model <id> --scheduled [--pool-blocks N] [--max-seqs N].
Evidence: 8 SchedulerEngine tests including batches_concurrent_requests
(2 concurrent) and handles_many_concurrent_requests (8 concurrent, above themax_num_seqs=4 batch limit — exercises admission + interleave). 4
justapi-core HTTP integration tests drive scheduled_chat_completions_handler
/ scheduled_completions_handler and Server::with_openai_scheduled and
assert scheduler metrics appear in /metrics. 147 inference + 259 core tests
pass; clippy -D warnings, cargo fmt --check clean.
ADR-045a — 2026-07-10 — Sampling-parameter plumb in scheduler loop.
The original scheduler loop used greedy argmax only (sample_token →argmax), ignoring temperature, top_k, and top_p fromSamplingParams. This was a real feature gap vs the naiveEngine::generate path, which has always honoured all sampling params.
Change: Replaced sample_token withsample_token_with_params(logits, &SamplingParams) in the scheduler engine.
The new function:
- If
temperature ≈ 0: delegates toargmax(greedy). - Otherwise: scales logits by
1/temperature, applies top-K truncation
(zeroes everything beyond the k-th largest), softmaxes to probabilities,
applies top-P nucleus truncation iftop_p < 1.0, then draws from the
categorical distribution viarand::thread_rng. - Falls back to argmax on degenerate input (all-zero probs after truncation).
seq_params map stores the per-seq SamplingParams (inserted ingenerate(), cleaned up on finish), and get_seq_params helper retrieves
it with a unwrap_or_default fallback.
Evidence: 4 new unit tests (greedy_sampling_is_argmax,sampling_honors_temperature, sampling_top_k_truncates,sampling_uniform_distributes_with_high_temp) verify the sampling logic.
151 inference tests pass (up from 147). Real wall-clock throughput
benchmark added to inference_bench.rs comparing naive vs scheduler-backed
generation on MockModel with 8 concurrent requests. BENCHMARKS.md updated.
ADR-046 — 2026-07-11 — HTTP/3 (QUIC) transport as a parallel server
Context: FastAPI has no first-class HTTP/3 story; adding QUIC to justapi is
a parity/differentiation win and a natural extension of the Rust networking core
(ADR-008: Rust owns I/O and scheduling). The question was how to structure the
H3 server so it reuses the existing HandlerFn dispatch without forking the
large Python-facing handler logic in justapi-py.
Decision: Implement HTTP/3 in justapi-core::server::http3 behind an http3
Cargo feature, using quinn (QUIC) + h3 + h3-quinn (the h3/QUIC bridge).
serve_http3 accepts a MiddlewareChain<Full<Bytes>> plus an optional WasmEngine:
the request body is fully buffered into Bytes before dispatch, then the request
is run through the same middleware chain shape used by HTTP/1.1 (circuit breaker,
coalescer, gateway, WASM preprocessing, then the handler). On the Python side,
make_native_handler / make_test_handler were made generic over the request
body type B: http_body::Body<Data = Bytes> + Send + Sync + Unpin + 'static, so
the same closure serves Incoming (HTTP/1.1) and Full<Bytes> (HTTP/3) — no
duplication of routing/validation/DB/batch/Python-dispatch logic.
JustAPIApp.run spawns serve_http3 in parallel with the HTTP/1.1 server when
enable_http3(cert_pem, key_pem) was called, sharing the same CancellationToken,
building an H3 MiddlewareChain<Full<Bytes>> from the same config as the H1 path.
Trade-offs / limitations:
- Resolved (2026-07-11): the original design served the raw
HandlerFnon H3
becauseMiddlewarewasMiddleware<Incoming>-only. Middleware was made generic
overB(impl<B: Send + 'static> Middleware<B>for all core middleware;
resilience middleware was already generic), soserve_http3now runs the full
chain — H3 applies identical middleware policy to HTTP/1.1. See PLAN.md Phase 53. - We buffer the whole request body for H3 (no streaming request bodies). Acceptable
for the parity target; matches how most H3 servers behave for typical API payloads. - TLS cert/key are PEM strings passed at runtime via
enable_http3; the QUIC
endpoint uses rustls with ALPNh3only (no HTTP/1.1-over-QUIC).
Evidence: cargo test -p justapi-core --features http3 — http3_roundtrip
(unit test, rcgen self-signed cert + h3-quinn client) passes. clippy clean
with the feature. Python test_http3.py confirms the server starts, routes answer
over HTTP/1.1, and the QUIC UDP port is bound. Full pytest suite: 77 passed, 1
skipped under both GIL (3.12) and no-GIL (3.14t) — no regression from the generic
handler refactor.
ADR-0xx — 2026-07-13 — Direct jsonschema + uuid deps in justapi-py for agent-native primitives
Context: Building the v0.2 differentiator (Rust-first per AGENTS.md §2): a
native streaming structured-output validator and an agent session-state store.
Both need (a) JSON-Schema validation of individual streamed values and (b)
opaque, collision-resistant session IDs.
Options considered:
- Reuse
justapi_core::validate::validate_json_schema(re-compiles the schema
per call) — correct but recompiles the schema on every streamed item, which
is wasteful for long LLM token streams. - Add
jsonschema+uuidas direct deps tojustapi-py, with a cached
compiled-Validatormap (keyed by schema string) for the validator — selected.
Decision: Add jsonschema = "0.46" (already a transitive dep via
justapi-core) and uuid = "1" (v4) as direct deps of justapi-py. The streaming
validator caches compiled jsonschema::Validator in a process-wideMutex<HashMap<String, Validator>> so a repeated schema compiles once. Session
IDs are uuid::Uuid::new_v4() hex strings stored in a Mutex<HashMap<String, serde_json::Value>> on the app. Python remains thin glue (schema inference,
handler wrapping, dependency injection); all validation/state lives in Rust.
Evidence: to be filled once cargo test + pytest pass for the new
test_streaming.py / test_session.py suites.
ADR-047 — 2026-07-13 — Hot-path: remove the per-request Python↔Rust boundary
Context: BENCHMARKS.md head-to-head showed justapi ~1.5–2× slower than
Robyn on raw throughput (hello 26,346 vs 40,397 RPS). Root cause was structural,
not tuning: every request crossed the PyO3 boundary with (1) a full PythonRequest dict built in Rust, (2) a Python wrap_result/to_dict round-trip on
the response, and (3) an unconditional set_trace_context Python call. Robyn
avoids these by serializing responses with a cached orjson C-call from Rust and
skipping Request construction for 0-param handlers (call0()).
Options considered:
- Keep the Python boundary; micro-tune
orjsonusage inwrap_result. Rejected —
still pays theto_dict+ Python fn-call overhead on every request. - Make
Responsea Rust#[pyclass]and downcast it directly in Rust. Rejected —
subclassing the pyclass from Python broke: the pyclass#[new]expectedbody: Stringbutrender()returnsbytes, and the Python__init__passed
an unknownbackground=kwarg. Brittle and fight-y with pyo3's subclass/ctor
rules. - Detect
Responsevia a_justapi_responsemarker attribute and read its
fields (status_code,headers,body) directly in Rust — selected. KeepsResponsea thin Python class (AGENTS.md §2: Python owns application glue),
removes theto_dictround-trip, and runsbackground.run()in Rust exactly
wherewrap_resultdid.
Decision (what changed):
handlers.rs::serialize_responsemirrors Robyn'sextract_response_type_fast:Response(marker) → direct field read; dict/list/str/num →orjson.dumps
called from aOnceLock-cached Python lambda; bytes → octet-stream; anything
else falls back to the Pythonwrap_result(preservingdefault=str).call_python_handlerskips building the PythonRequestobject when the route
doesn't need it. The per-handlerneeds_requestflag is computed inapp.py::_wrap_handlerfromsig.parameters,all_deps, and any
(route- or app-level) middleware, and threaded throughrun()→make_native_handler/make_test_handler. DefaultTrue(safe) when the
attribute is missing.- Trace-context propagation is gated behind
JUSTAPI_ENABLE_TRACE(default off)
via aOnceLock-cached check, so the hot path skips the Python call entirely. _native_helper.py::call_handlerreturns the raw result; Rust serializes.
Evidence: Re-run on the same fixture (i5-13600K, oha -z 10s -c 100, release
builds of both) — justapi now beats Robyn: hello-world 60,297 vs 39,103
(×1.54), JSON echo 47,415 vs 36,899 (×1.29), validated JSON 40,080 vs 32,919
(×1.22). Resolved edge-case bug: 0-param handlers behind middleware now correctly
force needs_request=True (empty-dict would have 500'd a middleware that reads
the request). Gates green: cargo test --workspace (0 fail), cargo clippy --workspace --tests -- -D warnings (0 warn; two pre-existing feature-gated
warnings also fixed), cargo fmt --check clean, Python pytest 107 passed / 1
skipped. BENCHMARKS.md head-to-head section updated (old conclusion superseded).
ADR-048 — 2026-07-14 — Schema-backed native Rust fast path (native=True)
Context: ADR-047 removed the per-request Python↔Rust boundary for the
response and the Request build, and justapi now beats Robyn on raw throughput.
But every route still dispatched into a Python handler — the final PyO3 call
could not be eliminated for application logic. Robyn-style "handler runs entirely
in Rust" was still impossible for justapi. The user-chosen next step was the
schema-backed fast path: for routes whose contract is "validate the body against
a schema and return it," there is no application logic to run — the response is
deterministic, so the Python handler is pure overhead.
Options considered:
- Make
native=Truevalidate-then-echo in Rust for any body. Rejected — without
a schema there is nothing to validate against and the body may not be JSON; the
fast path needs a schema to be meaningful and safe. - Validate in Rust, echo body; if
native=Truebut no schema present, fall back to
the normal Python handler (don't panic onNoneschema). Selected.
Decision (what changed):
app.pyroute decoratorspost/put/patch/queryacceptnative: bool = False
and forward it (via**kw→native=) to the Rust_app.<method>call.- Rust
JustAPIAppholds anative: Vec<bool>(parallel tohandlers); every
route method (get/head/options/trace/post/put/patch/delete/query) recordsnative.unwrap_or(false);run()and the test client build anArc<Vec<bool>>
and pass it tomake_native_handler/make_test_handler. call_python_handlergainsnative: bool+schema_json: Option<String>. Whennative && schema_json.is_some(), it callsjustapi_core::validate:: validate_json_schema(body, sj); onOkit returns200 application/jsonwith the
raw request body echoed back; onErrit returns422 application/problem+json
withfield/messageper error. Otherwise execution continues to the Python
handler unchanged.- Guard:
native && schema_json.is_some()—native=Truewithout a schema silently
falls through to the Python path (noNonederef).
Evidence: Release maturin develop on the fixture (i5-13600K, oha -z 10s -c 100,POST {"id":1,"name":"x","price":1.5"}): the native route served 59,666 RPS vs
3,531 RPS for the equivalent Python-handler route (~16.9× faster), exceeding
Robyn's validated-workload number (32,919 RPS) by ~1.8×. New regression testtest_native_fastpath.py (4 cases: valid→200 echo, invalid→422, native-without-
schema→Python fallback, PUT echo) passes. Gates green: cargo clippy -p justapi-py --tests -- -D warnings clean, cargo fmt --check clean. BENCHMARKS.md native
fast-path section added.
ADR-049 — Non-native dispatch deadlocks at high concurrency (GIL/blocking-pool)
Status: Open issue, filed separately from ADR-048. Root-caused (location
known); fix not yet implemented.
Context: While re-benchmarking the ADR-048 native fast path at oha -z 5-6s -c 100, the Python routes were found to hard-stall. This is a separate,
pre-existing defect in the Python dispatch path; it is not caused by the
native optimization (ADR-048) and native routes are immune.
Symptom: At ~100 concurrent connections, every non-native route serves
≈16–20 req/s with all connections aborted/deadlined. At -c 10 the same routes
serve hundreds of thousands of req/s. The stall is nondeterministic in severity
(one /noop run reached 697k req/s; a subsequent /noparam run stalled at 20
req/s for the identical handler class), i.e. a GIL/blocking-pool race that
usually resolves to a full stall under saturation.
Reproduction (minimal): any route, including the simplest:
@app.post("/noparam")
def noparam(): # no params, no body access, needs_request=False
return {"ok": True}
oha -z 5s -c 100 -m POST → ≈20 req/s, 100 aborted. Even /noparam, which never
reaches try_native_fast_path (native=false), never hits the schema block (no
schema), and does not use app/scheme/client/needs_request/http_version (the params added to call_python_handler in ADR-048) — yet it
deadlocks. This proves the ADR-048 change is not the trigger.
Location: crates/justapi-py/src/native/handlers.rs make_native_handler
spawn_blocking block (~line 722):
let nr = tokio::task::spawn_blocking(move || {
Python::attach(|py| { call_python_handler(py, ...) })
}).await;
All non-native requests funnel through tokio::spawn_blocking + Python::attach
(GIL acquisition) on tokio's blocking pool. Under ~100 concurrent requests the
GIL and the blocking pool interact to produce a hard stall — the classic pyo3
"many blocking threads all contending for the GIL while the async runtime needs
those threads to make progress" deadlock class.
Why native routes are immune: try_native_fast_path (handlers.rs ~421)
returns the response with no GIL acquire and no spawn_blocking hop — it
validates via the precompiled CompiledValidator and echoes the body in pure
Rust. Measured 410k–724k req/s at -c 100 (ADR-048 / BENCHMARKS.md). So opting
a route into native=True both maximizes throughput and avoids this deadlock.
Decision (fix direction — NOT yet implemented): Replace the per-requestspawn_blocking + Python::attach pattern with a dedicated GIL thread-pool
(a small, bounded set of threads — sized to num_cpus() — each holding a
persistent GIL / PyGILPool, dispatched via a channel with a oneshot result
future). This decouples Python execution from tokio's blocking pool so the two
never deadlock each other, and removes per-request GIL-acquisition contention.
The async side awaits the oneshot instead of spawn_blocking. Native routes
keep using try_native_fast_path (no change).
Out of scope for ADR-048: the native fast-path optimization is complete and
correct; this deadlock is a separate P0 to schedule as the next phase. It affects
the common (non-native) path, so it blocks production use at realistic
concurrency and should be the immediate next task after ADR-048.
Evidence: isolation benchmark (fixture, oha -z 5s -c 100): /noparam ≈20
req/s (all aborted); /validate_native (native) 410k–724k req/s; /noop once
697k, once ≈20 (nondeterministic stall). Single-request and -c 10 curl work.
ADR-049 — 2026-07-15 — Remove HTTP/3 (QUIC) transport (dead code)
Context: ADR-046 added HTTP/3 behind the http3 Cargo feature
(quinn + h3 + h3-quinn, server::http3::serve_http3,JustAPIApp.enable_http3). During the 2026-07-14 production-readiness audit
(MED#9) it was found that crates/justapi-core/src/server/http3.rs was
untracked and never declared as a module (no mod http3; anywhere), so it
was never compiled and serve_http3 was never called. The app.rs#[cfg(feature = "http3")] blocks reference justapi_core::server::http3::serve_http3,
so enabling the feature would have failed to compile — the "feature" was a
false signal, not working code.
Decision: Remove the dead HTTP/3 transport entirely:
- deleted
crates/justapi-core/src/server/http3.rs; - dropped the
http3feature and thequinn/h3/h3-quinnoptional deps
fromjustapi-core; - removed the
http3feature fromjustapi-py; - deleted the Rust
enable_http3method, the PythonJustAPIApp.enable_http3
wrapper, the.pyistub,test_http3.py, and all#[cfg(feature = "http3")]
wiring inapp.rs.
Rationale: keeping uncompilable, never-exercised code is worse than removing
it (AGENTS §2, "no dead code"; audit MED#9). If HTTP/3 is wanted later it must
be re-added as a fully wired module — declared via mod http3;, and given the
same production hardening the audit applied to the other transports (request
timeout via chain.run + tokio::time::timeout, connection-flood Semaphore,
panic safety) — otherwise it would be a fresh DoS/security gap. The middleware
body-type genericity work from the original phase remains and is still exercised
by the HTTP/1.1 path.
Evidence: cargo check --workspace and cargo clippy --workspace --tests -D warnings clean after removal; git status shows the http3.rs file removed
(it was untracked, so the removal is from the working tree only — it was never
committed). PLAN.md Phase 53 status updated to "reverted — dead code removed".
ADR-050 — 2026-07-15 — Secure-by-default CORS + opt-in security headers
Context: The production-readiness audit (MED#9) flagged two security
defaults. (1) Cors::new()/Default were permissive() → emittedAccess-Control-Allow-Origin: * for every request, and the credential branch
could reflect * with Access-Control-Allow-Credentials: true (an invalid but
leaky combination). (2) SecurityHeaders was only opt-in and always emitted HSTS
even on plaintext.
Decision:
Cors::new()andDefaultnow start with an emptyallow_origins(no
ACAO emitted) — fail-closed. Explicitallow_origin(...)/allow_origins(...)
is required to enable CORS.permissive()still exists for dev.- The
*+allow_credentialsbug is fixed: when the matched origin is*,
credentials are force-disabled and the concrete request origin is reflected
instead of*. SecurityHeadersgainswithout_hsts();Defaultkeeps HSTS but the Pythonenable_secure_headers()applies the non-HSTS default (plaintext server).
HSTS is only added on explicitenable_secure_headers(with_hsts=True).- Security headers are not auto-applied to every app: forcing a CSP
(default-src 'self') by default would break CDN-loaded UIs such as the/docsSwagger UI added in the same audit. They are a one-call opt-in.
Rationale: secure defaults must not leak cross-origin or pin plaintext to
HTTPS; but forcing CSP by default is a breaking change for legitimate apps.
Evidence: cargo test --workspace (CORS unit tests updated to configure an
origin), clippy -D warnings, cargo fmt --check all clean; live server
verified that enable_secure_headers() emits X-Content-Type-Options,X-Frame-Options: DENY, Content-Security-Policy, X-XSS-Protection and
no Strict-Transport-Security.
ADR-051 — 2026-07-15 — Real metrics + genuine readiness for Python apps
Context: The Python app's builtin /metrics returned a staticjustapi_up 1 string and /ready returned a static {"status":"ready"}, so
Kubernetes probes and Prometheus scrapes saw no real signal. The RustServer already collects Metrics and runs a HealthRegistry, but for Python
apps routing is handled by the Python handler, which bypassed those builtins.
Decision:
- The live
MetricsandArc<HealthRegistry>are captured from theServer
just beforeserver.run()(viaslf+Python::attach, because thePyRefMutborrow is!Sendand cannot cross the detached thread) and stored
on the_JustAPIApp. /metricsnow callsMetrics::prometheus()→ real Prometheus exposition
(request counters, status breakdown, latency histogram, bytes, connections)./readynow consults theHealthRegistry: returns 200 with a component
report when healthy/degraded, 503 when any registered check is unhealthy.- Added
JustAPIApp.register_health_check(name, callable)(RustPyHealthCheckwrapping a Python callable, invoked under the GIL) so users can
wire DB/dependency probes into readiness.
Rationale: observability and liveness/readiness are core production
requirements; a static 200 / stub defeats autoscaling and alerting.
Evidence: live server shows /metrics reporting justapi_requests_total
incrementing on real traffic; /ready returns 200 healthy with a component
report, and 503 (with report) when a registered check returns falsy.cargo test --workspace and pytest (minus two pre-existing, unrelated
failures in test_websocket/test_openapi_parity) pass.
ADR-052 — 2026-07-15 — Single error-response contract ({"detail": ...})
Context: The production-readiness audit (MED#9) flagged three inconsistent
error shapes across the codebase: {"error": "..."} (native + core handlers,
resilience, panic, static files), {"detail": ...} (Python HTTPException /RequestValidationError), and an RFC-7807 envelope
({"type","title","status","detail","errors"} with application/problem+json)
emitted by the Rust JSON-Schema validation paths. Clients had no single shape
to parse.
Decision: Adopt {"detail": ...} (FastAPI-compatible) as the single
error envelope for every non-2xx response. The numeric status lives in the HTTP
status line; detail carries the human-readable message. content-type is
always application/json (the application/problem+json RFC-7807 variant is
dropped). Applied across:
justapi_core::error_response(status, detail)/validation_response(detail)
helpers added inlib.rs; all core error sites (server, resilience, panic,
static files) routed through them.justapi-pynative handlers: generic 500 →{"detail":"Internal Server Error"};
all{"error":...}string bodies →{"detail":...}; the RFC-7807 validation
blocks collapsed to{"detail": <message>}.- Python
system.pyerror routes →{"detail":...}. - Sensitive internals are never placed in
detailfor 5xx (matches the secure
generic-500 policy from the prior audit).
Rationale: one parseable contract; detail keeps FastAPI client
compatibility; RFC-7807's extra keys added no consumer value here and broke
uniformity. Evidence: cargo test --workspace (core integration test
updated) and pytest (validation / test-client / native-fastpath tests updated
for detail + application/json) pass. The two remaining pytest failures
(test_websocket, test_openapi_parity) are pre-existing and unrelated
(fail on the prior save-point too).
ADR-053 — 2026-07-15 — Panic model: abort + supervisor restart, not catch_unwind
Context: MED#9 noted that panic = "abort" means one bad request can kill
the process. Two mitigations were considered: (a) switch to panic = "unwind"
catch_unwindaround the handler, or (b) audit/remove hot-path.unwrap().
Decision: Keep panic = "abort" (ADR-048 / gil_pool.rs SAFETY note).
A catch_unwind boundary at the GIL FFI boundary is undefined behaviour
(pyo3 C code holding the GIL cannot be unwound safely) and was measured as a
~3x throughput regression on the GIL path — both documented ingil_pool.rs. The production model is therefore crash-fast + supervisor
restart (Docker/k8s/systemd), which is the correct availability trade for a
Rust HTTP server. The actionable mitigation is the hot-path unwrap audit:
- Fixed a per-request abort:
router.fallback().unwrap()inserver/mod.rs
now returns 404 when no fallback is registered (a 404 would otherwise abort
the whole process). - Reviewed request-path
.unwrap()/.expect()inserver/andnative/; the
remainder are init-time (route registration) or logically-guaranteed (e.g.content_type.unwrap()only reached whenis_multipartis true), so they
cannot be triggered by attacker input. A genuine logic bug still aborts and is
restarted by the supervisor — by design.
Evidence: cargo build --workspace and all gates green; the core 404 path
returns 200/404 normally and no longer panics on unmatched routes.
ADR-054 — 2026-07-16 — Fix WebSocket scope, OpenAPI parity, and SPA frontend mounts
Context: Two pre-existing pytest failures (test_websocket.py:: test_websocket_scope_and_json, test_openapi_parity.py::test_openapi_parity)
were investigated and found to be genuine production-grade framework bugs, not
test issues. A third gap (SPA frontend() serving) was discovered while fixing
the OpenAPI parity test.
Decision / changes:
- WebSocket scope completeness. The Rust WS upgrade path previously passed
only the requestpathto handlers; the query string, headers, and peer
address were discarded, so a Starlette-stylescopeexposed an emptyqueryandNoneclient. TheWsHandlersignature changed fromFn(String, WsRead, WsWrite)toFn(WsConnInfo, WsRead, WsWrite), whereWsConnInfo { path, query_string, headers, client }is built from the
upgradereqbeforehyper::upgrade::on(req)consumes it. The PythonWebSocketscope now reflects the real query/headers/client. The Rust fast
path (native=True) is unaffected. - OpenAPI parity.
build_openapi(Python) emitted the wrong key
(operation_idinstead of OpenAPI'soperationId), silently droppedopenapi_extraextensions, and omitted the default success response when aresponsesmap was supplied. Fixed to emitoperationId, deep-mergeopenapi_extra(handles the JSON-string form stored by route registration),
and always include the success code (200orstatus_code). - SPA frontend mounts.
app.frontend(path, dir, fallback=...)stored aStaticMount { prefix, dir, fallback }butServer::run()discarded theprefixandfallback(only mounteddirat root viawith_static_dir),
so/static/returned 404. CoreServergainedwith_static_mountsand atry_serve_statichelper that honors per-mount prefix + SPA fallback
(unmatched paths under the prefix serve the fallback file). AddedServer::run_on(listener)so callers can supply a pre-bound listener (avoids
an ephemeral-port bind race in tests). Added Rust unit tests forStaticMount::resolveand an integration test for prefix + SPA fallback.
Evidence: Both previously-failing pytest tests now pass; full pytest suite
119 passed / 1 skipped; cargo test --workspace green; cargo clippy --workspace --tests -D warnings clean; cargo fmt --check clean. New Rust
tests: static_files::tests::test_mount_resolve_prefix,test_mount_traversal_blocked_through_prefix, andintegration::test_static_mount_prefix_and_spa_fallback.