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: β
Resolved. The dedicated GIL thread-pool (crates/justapi-py/src/gil_pool.rs)
replaced the per-request tokio::spawn_blocking + Python::attach pattern on
both non-native dispatch sites (handlers.rs calls gil_pool::run_python). The
fix was implemented in a later session (WIP save-point f47c554); this ADR's
status line was left "open" and is corrected here.
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.
ADR-056 β 2026-07-16 β Rust-native route compiler (skip the GIL on common routes)
Context: JustAPI already beats Robyn on raw throughput (ADR-048/055,
head-to-head in BENCHMARKS.md: native fast path ~21β25Γ Robyn, plain Python
path ~5.4Γ Robyn). But the only route that currently runs entirely in Rust is
the validate-and-echo native fast path (try_native_fast_path,
handlers.rs:449): it validates the body against a JSON schema and returns the
bytes unchanged. That covers a single shape β "accept + validate + echo" β and
cannot transform, query a DB, branch, or set custom status/headers. Every other
route falls back to the Python GIL path (~120k req/s here), which is GIL-capped.
The explicit goal ("faster than every existing framework, perfectly"): the only
way to be faster than Python-in-hot-path frameworks (Robyn/Flask/FastAPI/Sanic)
and match pure-Rust frameworks (Actix/Axum) is to serve routes in Rust by
default and make Python the exception (only for logic with no Rust
primitive). Raw echo can never beat Actix; the win is "Actix-class speed for
every route shape expressible in Rust primitives, with Python DX for the rest."
Design β a route compiler, not "make everything native":
A route is eligible for the Rust path iff every step of its behavior has a
Rust implementation in justapi_core. If eligible, it compiles to aHandler::Custom (Rust closure, zero Python/GIL) via the existingHandler::Custom mechanism (already used by OpenAI/DB routes). If not eligible,
it falls back to the Python GIL path β never silently wrong. This mirrors the
today's native=True+Schema rule (schema present β Rust echo; absent β Python
fallback), generalized to a set of primitives.
Rust-primitive set (all already exist in justapi_core):
- Body validation against JSON schema β
validate::CompiledValidatorβ - Query/param validation β
try_native_fast_path_queryβ - SQL execute/select/insert/update/delete β
db/(AnyPool,Select/Insert/Update/Deletebuilders,TransactionHandle) β (needs a row-fetch-as-JSON
method added toAnyPool; see below) - Serialization to JSON response β
serializeβ - Middleware already in Rust:
rate_limit,resilience(circuit breaker),
CORS, auth/JWT,middlewarechain β
Eligibility examples (promote to Rust iff all true):
POST /users+ bodySchema+native="crud_insert"(table, columns) β
validate βINSERT ... RETURNINGβ return row JSON. βGET /users+query_schema+native="crud_select"β validate query βSELECTβ return rows. β- Route with a Python
def handlerbody that does arithmetic/string work /
calls an LLM SDK / third-party lib β β falls back to Python.
Correctness contract (the real engineering risk): a Rust-native route must
behave identically to the equivalent Python route β same status codes, error
envelope {"detail": ...}, transaction semantics (auto-begin/commit on writes,
rollback on error), NULL handling, and header/content-type. Add a test that
runs the same route in both modes and diffs responses. Silent semantic drift is
the failure mode to prevent.
Phased plan:
- Step B (shipped): Rust-native
POSTCRUD insert β validate body βINSERT ... RETURNING *(viaAnyPool::query_with_params, injection-safe
bound params) β return the row as 200 JSON. Exposed asapp.post(path, crud_table=..., crud_columns=...)on the Python side,
compiling toHandler::Custom. Reusesdb/,validate,serialize. - Step C (shipped): Unified
crud_dispatch_byteshandler switches on the
HTTP method:POSTβinsert,GETβselect,PUT/PATCHβupdate,DELETEβdelete, using oneCrudSpec { op, table, columns, id_column }per
route. All ops run entirely in Rust with no GIL/Python hop. Exposed on the
Python side asapp.post/get/put/patch/delete(path, crud_table=..., crud_columns=...).id_columnis hardcoded"id"(no Python exposure yet).
Correctness locked byintegration::test_crud_all_handler+ end-to-end smoke. - Step D: Compile-time eligibility checker promoting routes automatically
when all steps are Rust-backed; honest benchmark ledger vs Robyn and
Actix/Axum on the same fixture; expose pool-size/WAL tuning fromapp.set_databaseand benchmark on Postgres. - Step D: Compile-time eligibility checker promoting routes automatically
when all steps are Rust-backed; honest benchmark ledger vs Robyn and
Actix/Axum on the same fixture.
Why not a WASM/user-Rust handler instead: wasm.rs exists but user-authored
WASM handlers shift the DX burden to the user and add a runtime; the
declarative-primitive compiler keeps Python DX while executing in Rust. WASM
stays an option for truly custom logic.
Evidence (Step B, recorded in BENCHMARKS.md, 2026-07-16): On a file-backed
SQLite fixture, the Rust-native POST /items (crud_table/crud_columns) is
~2.5Γ faster than the equivalent Python route at -c1 (6.8k vs 2.7k RPS, 2.2ms
vs 6.4ms p99) β the GIL-avoidance win is real. Under load (-c10/-c100) both routes
collapse toward SQLite's single-writer ceiling (~5β6k RPS); Rust's tail latency is
worse there only because the default 10-connection pool queues checkouts, while the
Python path opens a fresh connection per request. So the bottleneck under concurrency
is the DB + pool size, not the runtime β the architecture is correct and faster at
the single-flight level. Remaining work (Step D): raise/expose max_connections,
enable WAL, and benchmark on Postgres where the GIL-avoidance advantage compounds
(no single-writer lock). Gates met: cargo test --workspace (incl.
integration::test_crud_insert_handler), cargo clippy --workspace --tests -D warnings,
pytest green; Rust integration test asserts the Rust-native route returns the same row
JSON + 422 envelope as the Python contract.
Evidence (Step C, recorded in BENCHMARKS.md, 2026-07-16): All four CRUD ops
now serve entirely in Rust via crud_dispatch_bytes. On an in-memory SQLite
fixture (max_connections=1, no fsync), INSERT β 12.9k RPS @c1 and SELECT-by-id
β 18.1k RPS @c1 (β 21.7k @c10 β reads avoid the write lock). UPDATE/DELETE are
correct in isolation (core integration test + end-to-end smoke both pass) and
bounded by the same SQLite single-writer lock as INSERT. The Python API is
app.post/get/put/patch/delete(path, crud_table=..., crud_columns=...); a route
registered this way never invokes a Python handler. Database(init_sql=...) was
added so apps can bootstrap schema at startup. Gates met: cargo test --workspace --features db (incl. integration::test_crud_all_handler), cargo clippy --workspace --tests -D warnings, cargo fmt --check, pytest green.
Evidence (Step D, recorded in BENCHMARKS.md, 2026-07-16): Benchmarked the
Rust-native CRUD path against a real hosted Postgres (Aiven, TLS, 20-conn
pool). All four verbs verified end-to-end; throughput scales ~linearly with
pool concurrency (~9β~84 RPS INSERT from -c1β-c20) and is bounded by the
~110 ms cloud round-trip, not by JustAPI β confirming the Step D prediction
(no SQLite single-writer ceiling, GIL-avoidance lets the pool saturate).
Delivered the Step D tuning knobs: Database(max_connections=β¦, init_sql=β¦, pragmas=β¦) + app.set_database(db, init_sql=, pragmas=, wal=); SQLite
journal_mode=WAL applied per connection via after_connect; Postgres TLS
enabled via sqlx tls-rustls. crud_dispatch_bytes now emits driver-correct
placeholders ($N Postgres / ? SQLite-MySQL) via placeholder_gen. Gates
met: cargo test --workspace --features db, cargo clippy -D warnings,
cargo fmt --check, pytest green. Remaining (post-Step D): a Rust-backed
Python DB API (app.query/app.execute over AnyPool) so custom handlers
can run arbitrary SQL without losing the GIL-avoidance win.
Evidence (P1 β Python DB API over AnyPool, 2026-07-16): Shipped the
follow-up predicted at the end of Step D: a Rust-owned DbPool bridge exposed
to Python handlers so arbitrary SQL runs entirely in Rust with bound
parameters (injection-safe, no string interpolation) and the GIL released
during the round-trip (py.detach in the run() entrypoint; per-call the
AnyPool future runs on the tokio runtime handle captured at startup, outside
any Python token). API: app.db.query(sql, params=None),
app.db.execute(sql, params=None), app.db.insert(table, data, columns=None)
(returns the RETURNING * row), app.db.transaction([(sql, params), β¦])
(commits atomically; returns the last statement's rows or {"rows_affected":N}),
app.db.health(). app.db resolves to the DbPool only after app.run()
(db_pool field is set inside run() on the resolved AnyPool; before that it
is None). Verified end-to-end against the Aiven Postgres fixture from
benchmarks/smoke_dbpool.py: INSERT with $1/$2 binds, SELECT returning row
dicts, RETURNING insert, and a 2-statement transaction committing (alpha's
qty went 10β11 across the UPDATE+SELECT) all succeeded. This keeps the
AGENTS.md Β§2 / ADR-056 thesis: the DB layer is Rust-owned and the Python GIL is
never held during a query. app.db is accessed from handlers as app.db
(Python JustAPIApp.db property β self._app.db_pool()); the Python-side
config is app.set_database(Database(url), init_sql=..., pragmas=..., wal=...)
(note: app.database = ... is a no-op β the setter is set_database).
Gates met: cargo test --workspace --features db, cargo clippy -p justapi-core --tests --features db -D warnings, cargo clippy -p justapi-py --tests -D warnings, cargo fmt --check, pytest green, and the Aiven
end-to-end smoke.
ADR-055 β 2026-07-16 β Configurable max request body size (413 on overflow)
Context: The request body cap was a hardcoded 50 * 1024 * 1024 (50 MiB)
buried at four read sites (core execute_handler Echo path; Pythonmake_native_handler native-fast-path and the Python-handler path; plus theserve() default-routes path). It was invisible to operators and could not be
tightened for endpoints that only expect small payloads β a memory-exhaustion
DoS exposure on untrusted ingress.
Decision / changes:
- Single source of truth. Added `const DEFAULT_MAX_BODY_SIZE = 50 * 1024
- 1024
inserver/mod.rs. Every read site now derives its cap from a threadedmax_body_size: usize` value rather than a literal.
- 1024
- Builder surface.
Server::with_max_body_size(bytes)(Rust) andJustAPIApp.run(addr, max_body_size=...)(Python, default 50 MiB) plumb the
cap throughmake_handlerβexecute_handler(core) andmake_native_handler
β both body reads (Python).serve()'s default-routes path uses the default. - Correct status code. Previously an oversized body surfaced as a generic
400/500/404. Thehttp_body_util::Limitedlength-limit error now maps
to413 Payload Too Largewith{"detail":"payload too large"}on every
affected path (core Echo, Python native fast path, Python handler path). - Test handler (
make_test_handler, used by the async test client) takes
the same parameter; the test-client call site passes the default.
Evidence: New Rust integration test integration::test_max_body_size_enforced
(binds limit=1024, asserts a 512-byte echo returns 200 and a 1025-byte body
returns 413). New pytest suite test_max_body_size.py (test_max_body_size_enforced
test_max_body_size_default_accepts_large) passes against a real server.cargo test --workspace,cargo clippy --workspace --tests -D warnings, andcargo fmt --checkall clean.
Note: crates/justapi-py/test_graphql.py::test_graphql fails with 404 β the
Python package does not yet expose a graphql() builder, so /graphql is never
registered. This is a pre-existing gap unrelated to this change (the core has aHandler::GraphQL, but no Python binding wires it up). Tracked separately.
ADR-056 β 2026-07-17 β Cross-engine ? placeholder normalization + justapi create <app>
Context: The DbPool public API (query_with_params / execute_with_params
/ query_stream / transaction) was documented as injection-safe with bound? parameters, but Postgres's driver (sqlx::Any + Postgres) requires positional$1, $2, β¦ placeholders. Passing ? to Postgres produced syntax error at or near ")". SQLite and MySQL accept ? natively. Without normalization, the same
Python code could not run unmodified across all three engines β defeating the
"pick any engine at scaffold time" promise of the CLI.
Decision / changes:
AnyPool::normalize_sql(sql)(crates/justapi-core/src/db/pool.rs): rewrites
each?β$Nwhenself.kind == DbKind::Postgres; returns the SQL unchanged
for SQLite/MySQL. Applied inquery_with,execute_with,query_stream, andtransaction_with_isolation. Callers (Pythondb.query/db.execute/db.query_stream/db.transaction) continue to pass?everywhere.justapi create <app>CLI (crates/justapi-cli/src/main.rs): newCommands::Create { name, output, db, db_url }.--db {sqlite,postgres,mysql}
picks the scaffolded backend (default sqlite);--db-urloverrides the URL.resolve_scaffold_dbmaps the choice to a default URL;scaffold_project
writes a full project (app/main.py wired toDatabase, migrations/, static/,
.env, README, requirements.txt, Dockerfile, .gitignore).Newnow delegates
toscaffold_projectwith sqlite default, removing ~90 lines of duplicated
inline logic.
Evidence: benchmarks/smoke_dbpool.py extended to cover query_stream,DbParam.bytes round-trip ({"$bytes":...} wire marker β Postgres BYTEA), andtransaction. Run against live Aiven Postgres β all endpoints return 200
("DBPOOL SMOKE OK"). Verified justapi create scaffolds correct main.py for
postgres, mysql, and sqlite. cargo test --workspace --features db green;cargo clippy --workspace --tests --features justapi-core/db -D warnings clean;cargo fmt --check clean.
ADR-057 β 2026-07-17 β Eager/early DB pool connect removes app.db is None footgun
Context: app.db returned None until app.run() had started, because the
pool was only resolved inside the server entrypoint's detached async block.
Application code that wanted to touch the DB before serving β a migration step,
a REPL, or a test that exercises app.db.query(...) without binding a socket β
had no way to get a live DbPool. This was a recurring footgun.
Decision / changes:
JustAPIApp::connect_database(py)(crates/justapi-py/src/native/app.rs):
resolves the configuredDatabaseinto anAnyPoolimmediately. Idempotent
(returns early ifdb_poolalready set). The DB round-trip runs with the GIL
released on a dedicated tokio runtime that is kept alive on the app
(db_runtime: Option<Runtime>) so theDbPool'sHandlestays valid for the
whole process. Bootstrapinit_sqland the optional background health-check
loop are applied here.- Lazy-by-default on the Python side:
JustAPIApp.dbproperty now callsconnect_database()on first access (swallowing only the "not configured"
case) and returns the resolvedDbPool. Soapp.dbworks both before and
afterrun(). Added an explicitapp.connect_database()for callers who want
to surface connect errors eagerly. run()reuses the same path: it now resolves the pool viaconnect_databasebefore the detached loop (guarded bydb_pool.is_none()),
then hands the innerAnyPoolto the Rust-native CRUD handler viaDbPool::as_any_pool(). No double-connect, no second runtime for the server
case.
Evidence: New benchmarks/smoke_dbpool_prerun.py asserts app.db is not None
and runs SELECT 1, a DELETE/INSERT, and a SELECT before any socket is
bound; then serves a route that queries the same pool. Against live Aiven
Postgres: "FOOTGUN FIX VERIFIED". cargo clippy -p justapi-py --tests -D warnings
clean; cargo test --workspace --features db green.
ADR-058 β 2026-07-17 β Track routing/gateway tests; fix streaming-error abort in test client
Context: Two artifacts were flagged "untracked" in PLAN.md: test_routing.py
(valid alias / multi-method / router-inclusion tests) and test_gateway.json
(orphaned gateway config fixture). Separately, the full pytest run hit a hardFatal Python error: Aborted from panic = "abort": the in-processTestClient mock server (justapi-core/src/testing.rs) panic!s on any
hyper connection error, and a validated stream that aborts mid-flight (e.g.stream_json rejecting an invalid item via send_error) surfaces that as a
body-stream error β aborting the entire test process.
Decision / changes:
- Track the tests.
test_routing.py(2 tests, pass) is now committed.test_gateway.jsonmoved into the package test dir and exercised by a newtest_gateway.pythat assertsenable_gateway()parses the config and
registers proxy routes without needing live upstreams (1 test, pass). - Test-client body-stream errors must not abort.
testing.rsline ~90
changed frompanic!(...)totracing::debug!(...)whenserve_connection
returns an error. A streamed body that fails (validation abort) is expected
application behavior, not a harness fault; underpanic = "abort"it killed
pytest. Now logged and the connection closes gracefully.
Evidence: pytest crates/justapi-py/python/justapi/ β 120 passed, 1 skipped
(previously aborted). cargo test --workspace --features db green; clippy-D warnings clean; cargo fmt --check clean.
ADR-059 β 2026-07-17 β Request.json()/body()/form() must not require an event loop
Context: A framework audit (background tasks, routes, request handling) found
that a sync handler accessing request.json() / request.body() /request.form() raised RuntimeError: no running event loop. Root cause:Request::json/body/form in crates/justapi-py/src/request.rs returned their
value wrapped in pyo3_async_runtimes::tokio::future_into_py(...), i.e. an
awaitable coroutine. Under the real server the sync handler runs on the daemon
event-loop thread (so it happened to work), but the JustAPITestClient path
(make_test_handler) runs sync handlers on a plain worker thread with no
running loop β so request.json() crashed every sync handler that parsed a body
through the test client. body()/form() had the same latent defect.
Decision / change: Make json(), body(), and form() pure synchronous
returns (they are CPU-only parses β no I/O). receive/close/is_disconnected
remain async (legitimate ASGI coroutines). Updated the two await request.*
call sites (system.py tools_call_handler, test_request_parity.py) to call
them without await. Added test_sync_handler_body_json_no_event_loop to lock
the regression in.
Evidence: New repro + test confirm request.json() works in a sync handler
via JustAPITestClient (previously RuntimeError: no running event loop). Full
suite 120 passed / 1 skipped; cargo test --workspace --features db, clippy-D warnings, cargo fmt --check all green.
Other audit observations (not changed): (1) Registering two GET routes on
the same path raises ValueError ("route conflict") from the matchit router β
FastAPI/Starlette let the last win; this is a deliberate hard-fail, documented
here. (2) Trailing-slash mismatch is not normalized: a route declared /trail/
returns 404 for /trail. Consistent with the hard-fail philosophy; left as-is.
ADR-060 β 2026-07-17 β Native Rust cron/interval scheduler (UTC, in-memory)
Context: A framework audit showed JustAPI lacked a built-in scheduler, forcing
users to bolt on APScheduler/celery for periodic work β a gap vs. frameworks
that ship one. The runtime's thesis (AGENTS.md Β§2) mandates scheduling/worker
pools live in Rust, not Python. We implemented a native scheduler that reuses the
existing Rust background-task worker pool (crates/justapi-py/src/background.rssubmit_py_task), so periodic jobs cost nothing extra on the worker side and
stay on the hot Rust path.
Decision / change:
- New
crates/justapi-py/src/scheduler.rs: process-wideScheduler(oneOnceLock<Arc<SchedulerInner>>), a non-blockingtick_looprunning on the
shared tokioHandlepolling every 250 ms. EachJobcarries acronSchedule(6-field, UTC) or aDurationinterval.compute_next
(pure, no GIL β unit-tested) advances past missed ticks. On fire, the job is
enqueued onto the worker pool viasubmit_py_taskandstats.fired/failed
are updated; failures are caught (job id +repr(func)logged, not panicked β
underpanic = "abort"a user callback must never take the runtime down). - Deps: added
cron = "0.15"+chronotocrates/justapi-py/Cargo.toml.
Justification:cronis the de-facto Rust standard cron parser (MIT, 0 deps
beyondchrono), battle-tested; re-implementing 6-field cron math with
timezone handling by hand would be more code and more bug surface.chrono
ships theDateTime<Utc>we need for UTC-aligned fire math. This satisfies
AGENTS.md Β§4 (new dep justified in DECISIONS). - Pyclass
Scheduler(python/justapi/__init__.py):schedule(cron, func, *a, **kw),every(secs, func, *a, **kw)(return job id),start(),stop(),stats(),jobs(),remove(id). Invalid cron raisesValueErrorat
registration (untrusted-spec parse handled up front, not in the tick loop). app.py:run()callsScheduler().start();maybe_start_if_jobs()starts
the loop only if jobs are registered; addedapp.schedule(...)/app.every(...)
convenience delegating to the shared instance.- v1 scope (deliberate): UTC-only, in-memory (no persistence/cron-file, no
timezone table, no catch-up replay across restarts). Documented so users don't
assume durability. The purecompute_nextunit tests lock the math without a
Python runtime, avoiding thecdyliblink issue.
Evidence: cargo test -p justapi-py scheduler β 6 unit tests (cron future,
minute-boundary 0 * * * * *, interval first-fire + advance + skip-past). Pythontest_scheduler.py β 2 passed: interval + */2 * * * * * cron both fire within
3.6 s, invalid cron raises, stats() reflects fired, remove() works. Manual
probe: fired == ['every','cron',...] count>=4, stats={jobs:2,fired:4,...}.
Full gates green (cargo test --workspace --features db, clippy -D warnings,cargo fmt --check).
ADR-061 β 2026-07-17 β Multi-worker prefork supervisor (justapi serve --workers N)
Context: JustAPI ran a single-process server (one accept loop on onetokio runtime), capping throughput at one core for accept + handler dispatch
and offering no process-isolation fault tolerance. Users expect a production
ASGI/WSGI-style multi-worker model (uvicorn --workers, gunicorn). The feature
list calls for "Multi-worker runtime", "Process management", "Graceful
shutdown", and (later) "Auto-scaling workers".
Decision / change:
- New
crates/justapi-cli/src/workers.rs: a prefork supervisor. The parent
binds theTcpListeneronce (bind_listener), clearsFD_CLOEXECon the
socket fd (modern std sets it; without clearing, the fd is closed across the
childexecβ "IO Safety violation: owned file descriptor already closed"
abort), then spawnsNchildren re-exec'ing the same binary with a hidden--worker-fd <fd>flag. Each worker recovers the socket vialistener_from_fdand serves on it β true OS-process isolation, noSO_REUSEPORTport races, all workers share one kernel socket backlog. - Process management: a persistent
JoinSetof worker waits + a parallelpids[]vector. On unexpected exit (non-shutdown) the worker is restarted
(verified:kill -KILLa worker β supervisor respawns it, fleet stays at N). - Graceful shutdown: SIGTERM/SIGINT cancel a
CancellationToken; the
supervisor forwards SIGTERM to every live worker and waits up todrain_timeout(default 5s) for in-flight requests to drain before forcibly
reaping with SIGKILL (verified: parent SIGTERM β both workers stop accept
loop, drain 0 connections, exit 0, tree fully reaped). - Refactored the previously-duplicated reload/non-reload server-build into
build_server()+build_engines()helpers so single-process, reload, and
worker paths share identical wiring (static/compression/TLS/inference). --workers > 1is incompatible with--reload(hot reload restarts the whole
tree); the supervisor warns and ignores--reloadin that case.--workersis
default 1 (unchanged behavior). Addedlibc = "0.2"to the workspace forfcntl/kill.- Also fixed a pre-existing, unrelated bug gated behind the
tlsfeature: amethodvariable referenced in the TLS request-timeout branch was out of
scope (server/mod.rs:1848); capturedlet method = req.method().clone();
before the timeout consumesreq. (Thetlsfeature still has separate
move-error breakage out of scope for this ADR.)
Evidence: cargo test -p justapi-cli workers β 2 unit tests
(make_spawn_argv carries fd+args; bind_listener actually binds an ephemeral
port). Manual: justapi serve --addr 127.0.0.1:8099 --workers 2 β parent + 2
workers all "Listening on β¦:8099"; curl / served (404, no route β expected);kill -TERM parent β both workers drain + exit 0, tree reaped; kill -KILL a
worker β respawned. Gates green: cargo test --workspace --features db, clippy-D warnings, cargo fmt --check. (Windows/macOS: --worker-fd path is Unix-
gated; non-unix falls back to single-process with a clear error β auto-scaling
left for a follow-up.)
ADR-062 β 2026-07-17 β Unix domain socket listener support (justapi serve --unix <path>)
Context: The feature list calls for "Unix domain socket support" for local
deployments (no TCP port; socket-permission-gated access; easy systemd socket
activation and nginx upstreams). Server only accepted a TCP SocketAddr.
Decision / change:
crates/justapi-core/src/server/mod.rs: introduced aListenAddrenum
(Tcp(SocketAddr)|Unix(PathBuf)) withFrom<SocketAddr>, andServer::newnow takesimpl Into<ListenAddr>.Server::run()branches on
TCP vs Unix; newrun_on_uds(UnixListener)accepts an already-bound socket.
Per-connection logic (serve_connection) was extracted out ofserve_http
and is shared by the TCP and Unix accept loops (serve_unix/serve_http).bind_unix_listener(path)removes a stale socket file on bind and spawns aUnixSocketGuardtask that removes the file when the process's runtime
terminates. TLS over UDS is not supported (serve_on_uds_tlsbails with a
clear error) β documented limitation.crates/justapi-cli/src/main.rs: added a--unix <path>flag (takes
precedence over--addr).build_servernow takes&ListenAddr; all four
paths (single-process, reload, worker--worker-fd, parent prefork) branch on
UDS vs TCP. In prefork, the parent binds the UDS once viaworkers::bind_unix_listener(clearsFD_CLOEXEC, keeps the listener alive
in the parent for the supervisor's lifetime) and hands the fd to each worker,
which recovers it withworkers::listener_from_unix_fd.crates/justapi-cli/src/workers.rs: addedbind_unix_listener(sync,
non-CLOEXEC) mirroringbind_listener, andlistener_from_unix_fd(recovers a
tokioUnixListenerfrom an inherited fd, setting non-blocking first to avoid
the tokio "Registering a blocking socket" panic).
Evidence: New #[cfg(test)] test_unix_socket_serves_http binds a UDS,
serves a trivial handler, connects via tokio::net::UnixStream, and asserts a200 {"uds":true} HTTP response (exercises serve_unix β serve_connection,
262 core tests in the workspace run). Manual: justapi serve --unix /tmp/j.sock
β "Listening on /tmp/j.sock (plain HTTP/1.1, unix)"; curl --unix-socket /tmp/j.sock http://localhost/ β 404 (no route). Prefork: justapi serve --unix /tmp/j.sock --workers 2 β parent + 2 workers, curl --unix-socket served; SIGTERM to parent
β both workers drain + exit 0 ("worker exited during shutdown", "all workers
exited cleanly"), tree reaped. Gates green: cargo test --workspace --features justapi-core/db, clippy -D warnings, cargo fmt --check. (UDS is
Unix-only; on non-unix --unix errors clearly.)
ADR-063 β 2026-07-17 β Route-lookup cache (Router::resolve memoization)
Context: Router::at does a HashMap lookup + a matchit radix traversal on
every request, then (for non-matches) a second scan across all methods to
distinguish NotFound from MethodNotAllowed. At high RPS this is pure,
repeatable work for stable URLs. The feature list calls for a "route cache".
Decision / change:
crates/justapi-core/src/router.rs: addedRouteResolution<T> { handler, params }
(owned, no borrow into the router) andRouter::resolve(&self, method, path)
which memoizes lookups through a boundedRouteCache<T>(Mutex<HashMap>+
FIFOordervec for eviction; default capacity 1024, always-on).- Caching policy (safety): only stable outcomes are cached β static-route
hits (no path params) and definitiveNotFounds. Param routes andMethodNotAllowedare never cached, because they require a fresh full match
(param extraction / cross-method scan) each request and could otherwise
return stale data. The cache is per-Router(Arc<RouteCache>soRouter
staysClone); negative entries avoid re-scanning on repeat misses. Router::new()keeps an always-on cache;without_cache()andwith_cache_capacity(0)disable it (useful for tests/benchmarks).
- Caching policy (safety): only stable outcomes are cached β static-route
crates/justapi-core/src/server/mod.rs:execute_handlernow takes an ownedHandler+params: Vec<(String,String)>(replacing the borrowedMatch),
eliminating the router borrow on the hot path and lettingmake_handlercallrouter.resolve(...)and forward owned params toParamsEcho/custom handlers
via request extensions. The existingMatch/atAPI is retained for tests andgateway.rs(no behavior change there).
Evidence: 4 new router unit tests (test_resolve_static_hit_and_cache,test_resolve_not_found_is_cached, test_resolve_param_route_bypasses_cache,test_without_cache_still_resolves). Full suite green (266 core tests). clippy-D warnings + fmt clean. The cache is hit on every repeat request to a static
route / miss, removing the matchit traversal and the cross-method MethodNotAllowed
scan (which the pre-existing benchmark test still bounds at <100ns release / <1Β΅s
debug). No change to routing semantics: param routes, method-not-allowed, and
fallback all resolve identically.
Trade-off: small fixed memory per cached (method, path) key (bounded by
capacity, FIFO-evicted). No new dependency.
ADR-064 β 2026-07-17 β XML request/response support (justapi_core::xml)
Context: The feature list calls for XML/SOAP support. JSON is the dominant
wire format, but enterprise/legacy clients (SOAP, many internal services) speak
XML. JustAPI needed first-class XML as a content type alongside JSON, with the
parsing/sizing living in Rust (justapi-core) per ADR-008.
Decision / change:
- New
crates/justapi-core/src/xml.rs(new depquick-xml = "0.36",serializefeature β high-perf XML reader/writer, justified below). Provides:- Responses:
xml_response(status, &str)(raw string βapplication/xml) andXmlResponse<T: Serialize>(typed, mirrorsserialize::JsonResponse) emittingapplication/xmlviaquick_xml::se::to_string_with_root.json_to_xml(root, &Value)serializes
aserde_json::Valueto XML. - Requests:
xml_to_json(bytes) -> Valueconverts anapplication/xml/text/xmlbody into aserde_json::Value, so the rest of the pipeline (which
speaks JSONValue) consumes XML input transparently. Implemented as a small
event-driven converter overquick_xml::reader::Reader(NOT its serdeDeserializerβ that cannot represent open JSONValues). Semantics: nested
elements β nested objects; repeated sibling tags β arrays; text β string
(coerced to number when it parses); attributes β@namekeys; a leaf element
with only text collapses to the scalar (<id>1</id>β{"id": 1}); mixed
text+children lives under#text. - Content negotiation:
Format { Json, Xml },negotiate(content_type, accept), andrespond(status, root, &Value, format)produce a JSON or XML
response from oneValue.
- Responses:
- Request wiring (Python path): in
crates/justapi-py/src/native/handlers.rs,
after the body is read the requestContent-Typeis checked; anapplication/xml/text/xmlbody is converted to JSON viaxml_to_jsonand
re-serialized to canonical JSON bytes, so Python handlers receive a uniform
JSON-shaped body regardless of the wire format (invalid XML β 400 with detail).
Routing/validation/CRUD paths are untouched. lib.rsexports thexmlmodule.
Why quick-xml: it is the de-facto high-performance Rust XML crate (zero-copy
reader, serde integration) and avoids hand-rolling a parser. The alternative
(writing our own XML reader) is more code and more bug surface for no benefit.
Only the serialize feature is enabled; no transitive heavy deps.
Evidence: 8 new xml::tests covering raw/typed XML responses, status+headers,json_to_xml, xml_to_json (nested + arrays), negotiate (all branches), and
content-negotiated respond. Full workspace suite green (274 core tests). clippy-D warnings + fmt clean. Limitation documented: XML attributes survive only
as @name keys; namespaces/comments/DTDs are ignored by the converter.
ADR-065 β 2026-07-18 β Load-based worker auto-scaling (justapi serve --scale)
Context: The feature list calls for "Auto-scaling workers". The prefork
supervisor (ADR-061) runs a fixed N workers; operators had to edit --workers
by hand as load changed. We need the fleet to grow under load and shrink when
idle, with true OS-process isolation preserved.
Decision / change:
crates/justapi-cli/src/workers.rs:LoadProbetrait (Send + Sync,fn sample() -> f64returning normalized
load, 0.0 = idle, 1.0 = saturated).SystemLoadProbe(unix) reads the
1-minute load average vialibc::getloadavgnormalized byavailable_parallelism;NoProbe(0.0) is the no-policy fallback.ScalingPolicy { min_workers, max_workers, low, high, cooldown, step }and a
puredecide_scale(current, load, &policy) -> usize(scale up bystep
whenload >= high, down bystepwhenload <= low, else hold, clamped to[min, max]). This is the testable core β no processes, no OS.supervisenow takesOption<(ScalingPolicy, Arc<dyn LoadProbe>)>. Atokio::time::intervalre-samples load each tick; when the cooldown has
elapsed anddecide_scalediffers from the live active count, it scales:
up spawns into free/reused slots (next_free_slot); down gracefullySIGTERMs live, non-stopping workers and records them in astoppingHashSetso the restart-on-death path does NOT respawn them. The drain logic
ignoresstoppingslots when checking "all exited".
crates/justapi-cli/src/main.rs: new flags--scale,--min-workers,--max-workers,--scale-low,--scale-high,--scale-cooldown. Prefork now
also triggers when--scaleis set withmax > 1(so a fleet can start atmin_workers == 1and grow). Bounds capped at 256. When--scaleis set
without explicit--min/--max, min =--workers, max =workers * 2
(min+1 floor). Builds the policy +SystemLoadProbeand passes it tosupervise. Thetlsfeature still excluded (separate breakage).
Evidence: 7 new workers::tests: decide_scale holds-in-band / up-at-high /
down-at-low / clamps-to-bounds / respects-step, plus next_free_slot reuse+grow.
Full suite green (274 core + 8 workers tests). Manual: --scale --workers 1 --max-workers 3 --scale-high 0.0 β supervisor starts at 1 worker then scales up
to 3 (log: "scaled up load=0.08 workers=2/3"); --workers 3 --min-workers 1 --max-workers 3 --scale-low 1.0 β scales 3β2β1 with "scaled-down worker exited"
(graceful, not restarted). SIGTERM in both cases drains the tree to exit 0.
Clippy -D warnings + fmt clean. No new dependency (reuses libc,available_parallelism).
ADR-066 β 2026-07-18 β Fix pre-existing tls feature breakage in justapi-core
Context: cargo build -p justapi-core --features tls failed to compile
before any of ADR-060..065 touched it β a pre-existing defect inserve_with_tls (the TCP+TLS accept loop in crates/justapi-core/src/server/mod.rs),
not caused by the worker/UDS/XML work. The function param static_mounts: Vec<StaticMount> was captured by the per-request service_fn(move |req| ...)
closure (an Fn, invoked for every request) and referenced by move, producingE0507 ("cannot move out of static_mounts, a captured variable in an Fn
closure") and E0382 ("use of moved value: static_mounts in previous
iteration of the loop"). A second latent clippy lint (if let Some(_) = shutdown) in the same function also broke -D warnings.
Decision / change:
- Mirror the already-correct pattern in non-TLS
serve_connection:- Clone
static_mountsonce per loop iteration from the function param (alongsidechain/static_dir/metrics) so the value is re-acquired each iteration and
never moved out of the per-iteration scope. - Inside the
service_fnclosure,let static_mounts = static_mounts.clone();
so theFnclosure borrows its own copy to.clone()per request instead of
moving aVecout of a captured variable.
- Clone
- Replace
if let Some(_) = shutdownwithif shutdown.is_some()to satisfyclippy::redundant_pattern_matchingunder-D warnings.
Evidence: cargo build -p justapi-core --features tls now compiles.cargo test -p justapi-core --features "tls,db" green (274 core + 8 xml + 15
wasm + 1 tracer). cargo clippy --workspace --tests --features "justapi-core/db,justapi-core/tls" -- -D warnings clean; cargo fmt --check
clean. No new dependency, no public API change (internal function only).
ADR-067 β 2026-07-18 β Freeze all AI/inference work until release 2.0.8
Context: We spent effort on the Phase 52 GPU Benchmark Gate (real-model
inference via justapi-inference/justapi-bench, CUDA PTX mismatch, Needle
26M encoder-decoder weights downloaded to ~/needle-bucket). The user has
decided to skip all AI / inference / LLM work for now and defer it to the
next release, 2.0.8. This includes the CUDA driver upgrade (NVIDIA 610.43.03
was available and matched the running kernel 7.1.3-2-cachyos but was
deliberately NOT applied), the real-GPU benchmark run, and any Needle
integration.
Decision:
- No AI/inference features will be touched until release 2.0.8.
- The CUDA driver stays at 580.173.02 (CUDA 13.0 runtime) and the toolkit at
13.3.1 β the PTX mismatch is intentionally left unresolved for now. - The Phase 52 GPU Benchmark Gate remains π΄ blocked; its exit criteria
(real weights on real GPU, tokens/sec + TTFT/ITL) are deferred to 2.0.8. - The Needle weights in
~/needle-bucketare kept as reference material only;
no encoder-decoder loader will be added tojustapi-inferenceyet (it
currently supports Llama-family GGUF/safetensors only). justapi-gpu-benchMockModel (CPU) run is recorded as non-gate plumbing
validation only (see PLAN.md Phase 52 update, commita6c8cac).
Evidence: User directive 2026-07-18 ("skip this AI stuff, work it on next
release 2.0.8, until then don't touch AI stuff"). PLAN.md Phase 52 updated to
reflect the opt-out and committed (a6c8cac). No code change in this ADR.
ADR-068 β 2026-07-18 β Fix DB-backed handler deadlock + WAL hang (D2/D3/D4)
Context: Building demo_shop/ (23-route Olist SQLite e-commerce API) on the
framework surfaced four defects in the Python-handler + SQLite path. Two were
genuine framework bugs, one was a misdiagnosis, one was a usage note.
- D2 β
set_database(wal=True)hung the pool connect forever on the 112 MB
Olist file. Reproduced deterministically (30s+ no output). - D3 β DB-backed handler ~2500Γ slower: ~900 ms/req (15 req/s) for a trivial
COUNT(*), vs ~1.8 ms raw query. - D4 β concurrent async load deadlocked:
bench_async.py -c 32hung (90s+
timeout); only the no-DB path survived concurrency. - D1 β
app.run()"never binds" with a DB: on re-test this was RETRACTED β
the server always bound; the failure was the D4 deadlock on the probing
DB route, misread as "server down" (seedemo_shop/README.mdFinding #3).
Root cause (D2): justapi-core/src/db/pool.rs after_connect ranPRAGMA journal_mode=WAL with conn.execute and no busy_timeout. WAL
requires an exclusive lock; during pool warm-up several connections open
concurrently and the second opener blocks on the WAL lock with no timeout β
infinite wait.
Root cause (D3/D4): crates/justapi-py/src/native/app.rs::connect_database
built a tokio::runtime::Runtime::new() (current-thread) runtime and stored
its handle for DbPool::query/execute to block_on. A current-thread runtime's
handle can only be driven from the owning thread; calling block_on on it from a
foreign thread (a GIL-pool worker or the server-runtime thread) silently
deadlocks or serializes catastrophically β producing ~900 ms/req at concurrency=1
(D3) and a full hang at concurrency>1 (D4).
Decision:
- D2 fix: in
justapi-core/src/db/pool.rs, for SQLite always prependPRAGMA busy_timeout=5000to the pragma list and run every pragma (includingjournal_mode=WAL) viafetch_optionalinstead ofexecute, ignoring the
returned row. - D3/D4 fix: in
crates/justapi-py/src/native/app.rs::connect_database, build a
multi-threaded runtime βtokio::runtime::Builder::new_multi_thread() .worker_threads(4).enable_all().build()β and use itsHandle(which is safe
toblock_onfrom any thread, as it temporarily drives the runtime on the
caller's thread) for the bootstrap block_on and forDbPool's queries. - D1: no code change β confirmed server binds; the report was a misread of D4.
Evidence:
wal=Trueconnect on 112 MB Olist file: 0.00 s (was 30s+ hang)./productsat concurrency=1: p50 2β4 ms (was 910 ms) β ~250Γ faster.oha -z 12s -c 64 /products?size=20: 3,706 successful concurrent requests,
p50 188 ms, p99 367 ms, 0 deadlocks (was a full hang at -c 32). The 188 ms
p50 at -c 64 is GIL-pool saturation across 64 parallel Python handlers β a
Python-GIL effect, not a DB-path defect.demo_shop/test_app.py: 23/23 routes pass via AsyncTestClient.cargo test --workspace --features justapi-core/dbgreen;cargo clippy --workspace --tests --features justapi-core/db -- -D warningsclean;cargo fmt --checkclean. No new dependency; internal change only.
ADR-069 β 2026-07-18 β Two framework serialization/payload gotchas (found building demo_shop MVP)
Context: Rebuilt demo_shop/ as a proper MVP shop (real data model, full
CRUD, cartβcheckoutβorderβpaymentβreview flow, validation via justapi.Schema).
Two framework behaviors surfaced that break naive handlers and were worked
around in demo_shop/app.py:
Response bodies containing a top-level
"status"key are emitted as an
empty body. A handler returning{"status": "ok", "products": N}yieldsHTTP 200withcontent-length: 0and no body;{"products": N}serializes
correctly. Reproduced with minimal handlers:{"status":"ok"}β empty,{"products":1}β fine,{"status":"ok","products":1}β empty. The
framework appears to treat a JSON object with astatuskey as a
status-response envelope and mishandles it. Workaround: order state is
exposed asorder_status(SQL column staysstatus); health useshealth.
This is a framework bug and should be filed/fixed (response
serialization path).body_schema=delivers the validated payload to the handler as rawbytes, not an instantiated Schema object. A handler declared asdef h(request, payload: ProductIn)receivespayloadbeing abytes
object (sopayload.idβAttributeError: 'bytes' object has no attribute 'id'). Workaround: handlers parserequest.json()and validate
via a localvalidate(schema_cls, data)helper that uses the Schema's
generated JSON Schema (Schema._build_schema()). TheSchemaclasses still
drive OpenAPI docs throughbody_schema=. This is a framework bug
(PyO3/dispatch binding for validated bodies) and should be filed/fixed.
Decision: Work around both in the app layer for now (no framework code
change beyond adding PATCH to JustAPITestClient/TestClient so the test
suite can drive PATCH routes). Document them in demo_shop/README.md as
framework gotchas to fix.
Evidence: demo_shop/test_app.py β 29/29 assertions pass viajustapi.testing.AsyncTestClient (catalog, CRUD, atomic checkout decrementing
stock, order status lifecycle, review validation). The two behaviors were
isolated with minimal repro handlers. cargo test --workspace --features justapi-core/db green; cargo clippy --workspace --tests --features justapi-core/db -- -D warnings clean; cargo fmt --check clean.
ADR-070 β 2026-07-18 β Framework had no usable logging from app.run()
Context: Building demo_shop/ surfaced that the framework emitted no
logs at all when launched from Python. justapi-core already had a fulltracing-based subsystem (tracing_setup.rs: init_logging, LoggingConfig,
JSON/text, rolling file, OTLP) and the server (server/mod.rs) firedtracing::info!/error! events plus an info_span!("http.request", β¦) per
request β but nothing ever initialized a tracing subscriber on the Pythonapp.run() path. The init_tracing/init_logging functions existed in core
but were unreachable from PyO3, and the default LoggingConfig shipped withotel_exporter: Some(OtelExporter::Stdout), which would dump raw OTLP span
JSON to stdout.
Options considered:
- Leave logging as a Rust-only feature, require users to wire
tracing
themselves β rejected: a web framework that is silent on startup, request
completion, and errors is not production-grade; the user explicitly flagged
this ("there is no logging system in this framework"). - Auto-install a default subscriber inside
app.run()+ expose opt-in config
from Python β selected.
Decision:
justapi-core/src/tracing_setup.rs: addedinit_default_if_unset()β callsinit_logging(LoggingConfig::default())only iftracing::dispatcher::has_been_set()is false (so a user-installed subscriber
is never clobbered). ChangedLoggingConfig::default()tootel_exporter: Noneso the default is clean textβstdout, not an OTLP stdout span dump.justapi-py/src/native/app.rs::runnow callsinit_default_if_unset()before
serving β the framework logs automatically the moment an app runs.justapi-py/src/logging.rs(new): thin PyO3 glue exposinginit_logging,init_json_logging,init_file_logging,init_otlp_tracing,shutdown_tracing(registered inlib.rs). Re-exported on thejustapi
package and viajustapi.logging.justapi-core/src/server/mod.rs: added a structured access-loginfo!
event on every completed (non-timeout) request βhttp.method,http.path,http.status_code,latency_msβ so each request is visible. Startup now
prints a clear banner:π JustAPI serving on http://<ip>:<port>plus a
"endpoints live at β¦" line, so the bound IP/port is always visible.justapi-core/src/tracing_setup.rs: the default Text format was
reworked into a clean, colored CLI style β compactHH:MM:SStimestamp
(customUptimeTimer, no nanos/date), ANSI-colored level (green INFO /
yellow WARN / red ERROR / cyan DEBUG / magenta TRACE), dimmed module target,
and thread/file/line noise disabled. ANSI is on for stdout, off for file
appenders. TheJsonformat stays structured for collectors.
Evidence: python -m shop --port 8111 now prints:
17:28:10 INFO justapi::gil_pool: GIL pool initialized: mode=GilBased β¦
17:28:10 INFO justapi_core::server: π JustAPI serving on http://127.0.0.1:8111
17:28:10 INFO justapi_core::server: ready β endpoints live at http://127.0.0.1:8111/
17:28:15 INFO justapi_core::server: request completed http.method=GET http.path=/products http.status_code=200 latency_ms="8.70"
Levels are ANSI-colored, timestamp is short, and the listening IP:port is
explicit. cargo test -p justapi-core --features justapi-core/db, cargo clippy, cargo fmt --check all green; demo_shop/test_app.py 29/29 anddemo_shop/test_lookup.py 19/19.
ADR-071 β 2026-07-18 β Scalar API Reference as the default home-page docs
Context: The framework already served Swagger UI (/docs) and ReDoc
(/redoc) from /openapi.json, but the home page / was unclaimed (404 /
static fallback) and Scalar β the modern, fast API-reference UI at
https://scalar.com/ β was not available. The user asked for Scalar to be wired
in properly and for the home page to open the docs.
Decision: In crates/justapi-py/python/justapi/app.py:
- Added
_builtin_scalar(app)β the official Scalar CDN embed
(@scalar/api-reference,Scalar.createApiReference('#app', { url: '/openapi.json', theme: 'default', layout: 'modern' })). - Registered
GET /scalarβ Scalar, andGET /(root) β Scalar so the
home page opens the interactive reference. /docs(Swagger UI) and/redoc(ReDoc) remain, all reading the live/openapi.jsongenerated from registered routes (build_openapi).
Evidence: python -m shop --port 8112 β GET / returns 200 text/html
(564 B) embedding Scalar.createApiReference; GET /scalar and GET /openapi.json (title "JustAPIApp", 20 shop paths) verified. cargo fmt,cargo clippy -D warnings clean; demo_shop/test_app.py 29/29,test_lookup.py 19/19.
ADR-072 β 2026-07-18 β Fixed body_schema= validation (ADR-069 root cause) + auto-slug
Context: ADR-069 documented two framework behaviors as "work around in the
app, file as bugs". The first β body_schema= delivering the validated payload
as raw bytes / failing to instantiate the Schema β was actually a crash,
not a silent bytes delivery: validate_body in _native_helper.py called the
Schema class directly (CategoryIn(body_dict)), raisingTypeError: CategoryIn() takes no arguments on every POST. The app papered over
it with a manual validate() helper, but the framework still logged the spurious
error on every write request. The user hit {"detail":"missing required field: slug"} noise and a TypeError traceback on POST /categories.
Root cause #2 (latent): justapi.Schema.__init_subclass__ read rawcls.__annotations__. With from __future__ import annotations (used byshop/schemas.py), annotations are stringized, so every field resolved tostr β numeric fields (price: float, stock: int) generated "type": "string" in the JSON Schema. Once real validation was switched on, every
numeric body was rejected ("12.5 is not of type string").
Decision (framework fixes in crates/justapi-py):
_native_helper.validate_bodynow detects ajustapi.Schemasubclass (via_schema_json) and validates through the Rustvalidate_valueengine β the
same path as the native fast route β returning clean error strings (e.g."name_pt" is a required property). No moreTypeErrornoise.justapi.Schema.__init_subclass__resolves annotations withtyping.get_type_hints(cls)so PEP-563 stringized annotations and forward
refs map to real types; numeric fields now generate correct JSON Schema types.
App fix (demo_shop/shop): CategoryIn.slug is now Optional[str] andcreate_category auto-generates a slug via slugify(name_pt) when absent, soPOST /categories only requires name_pt. The 409 message reports the actual
computed slug.
Evidence: POST /categories with only name_pt β 200 (slug auto-created);
duplicate β 409 category slug 'β¦' already exists; missing name_pt β 422"name_pt" is a required property (no TypeError). POST /products withprice:12.5, stock:10, category_id:1 β 200 (numerics validated correctly).cargo fmt, cargo clippy -D warnings clean; demo_shop/test_app.py 29/29,test_lookup.py 19/19.
ADR-073 β 2026-07-20 β DB-startup + response-correctness fixes (P0.1/P0.2/P0.3)
Context: The 2026-07-20 production audit (PRODUCTION_PLAN.md) found three
shipping blockers on the core request path:
- P0.1 (BUG-2a):
normalize_db_url(pool.rs) only stripped thesqlite://
prefix verbatim, sosqlite:///rel.dbβsqlite:/rel.db(absolute at
filesystem root) andsqlite:////absβsqlite://abs(mangled). Everyapp.set_database(...)+app.run()crashed withSQLITE_CANTOPEN(code 14)
on any path.demo_shopreported it as a deadlock. - P0.2 (BUG-2b / D3): SQLite PRAGMAs were opt-in β the
busy_timeout+WAL block only ranif (DbKind::Sqlite, Some(pragmas)). The
Pythonset_databasedefault ispragmas=None, so a fresh pool ran stock
defaults (rollback journal +busy_timeout=0) β constantSQLITE_BUSYunder
the 10-connection default pool β ~15 req/s / ~900 ms p50 in the stress test. - P0.3 (BUG-1):
serialize_response(handlers.rs) treated ANY dict with a"status"key as a legacy{"status","body"}envelope. A normal payload like{"status":"ok","products":5}got its body silently emptied (200 + empty body).
Decision:
normalize_db_urlrewritten as a slash-count matcher following the
SQLAlchemy 3/4-slash convention:sqlite:///x(3 slashes) = relative
(joined to cwd, becausesqlite:relbare-relative is not reliably resolved by
sqlx's Any driver),sqlite:////abs(4 slashes) = absolute,sqlite:///abs
(absolute passed by the Python bridge assqlite://{abs_path}) = absolute,sqlite://:memory:/sqlite://./xpreserved. Relative/cwd-joined names are
emitted as absolutesqlite:/abs/pathso sqlx opens them.- In
connect(), for file-backed SQLite (not:memory:) the parent directory
is created and the DB file is pre-created before opening β sqlx's SQLite Any
driver does not auto-create the file for this URL form (code 14). Verified by
direct sqlx connect failing without this, succeeding with a pre-created file. - SQLite pragmas are now unconditional:
busy_timeout=5000+journal_mode=WAL+synchronous=NORMALapplied for every SQLite pool;
callerpragmasare appended after and may override. serialize_responsenow only enters the legacy-envelope branch when the dict
carries a"body"key or an explicit__response__: truesentinel. A plain
data dict with a"status"field falls through to normal JSON serialization.
Evidence:
cargo test -p justapi-core --features db db::pool: 8 passed incl. newtest_normalize_db_url_sqlite_paths(relative/absolute/:memory/./x/postgres)
andtest_sqlite_connect_relative_and_absolute_file(both connect + SELECT 1).- Live repro:
app.set_database("sqlite:///abs/path.db")+app.run()now
serves/pingand{"status":"ok",...}returns intact (previously crashed /
empty body). demo_shopfull e-commerce script (test_app.py): 29 passed, 0 failed
against a seeded SQLite DB β D1/D3/D4 all closed at the app level.cargo test --workspace --features db: all suites green;cargo clippy --testsclean (one manual-prefix-strip warning fixed viastrip_prefix).
ADR-073 addendum β P1.1: body_schema routes receive a parsed dict
Context (PRODUCTION_PLAN.md P1.1 / BUG-3): handlers registered with ajustapi.Schema body_schema should receive the already-validated, parsed
body object, not raw bytes they must json.loads() again. ADR-072 fixed the
crash (Schema instantiation as a class), but the handler still re-parsed bytes.
Decision:
crates/justapi-py/src/native/handlers.rsnow parses the JSON body once on
the fast path (via a new_native_helper.parse_body) only when the schema
is ajustapi.Schemasubclass (detected by a_schema_jsonattribute β so
legacy callable validators keeprequest["body"]as raw bytes and existingjson.loads(request["body"])handlers keep working). The parsed object is
attached to theRequest(request.rsnewparsed_bodycache).Request.json(),Request["body"], and the newRequest.validated_body
getter return the cached parsed object for schema routes.request["body"]
keeps raw bytes for non-schema / legacy-callable routes.- The P0.3 envelope rule (
handlers.rsserialize_response+_native_helper. wrap_result) was refined so a status-only dict ({"status": 204}or{"status": 200, "headers": [...]}) is still treated as a response envelope
(sets the status code, empty body), while a dict with"status"plus other
keys ({"status": "ok", "products": 5}) is always serialized as normal JSON.
This preserves the commonreturn {"status": 204}idiom without re-introducing
BUG-1. The explicit{"__response__": true, ...}sentinel still wins.
Evidence:
- New
test_body_schema_parsed.py::test_body_schema_delivers_parsed_dict
(pytest-asyncio) asserts aSchema-registered handler receivesdictfrom
bothrequest.json()andrequest["body"]. test_validation.py(legacy callable schema doingjson.loads(request["body"]))
still passes 200/201/422 β legacy path unchanged.test_responses.py/test_testing.py/test_test_client.pyconfirm{"status": 204}β 204 and{"status": "ok", "products": 5}β intact body.- Full Rust workspace + clippy clean. Remaining pytest failures are the known
P1.3 missing-dev-deps (pydantic/jinja2/websockets) +test_scheduler
flake, unrelated to these changes.
ADR-074 β 2026-07-20 β Real DB-backed CRUD benchmark + Python-handler write-path defect
Context: PRODUCTION_PLAN.md P2.1 required an honest "real life" DB-backed CRUD
benchmark vs FastAPI+SQLAlchemy and Robyn (the micro echo/validate benchmarks
overstate production reality ~1000Γ on DB paths). Added benchmarks/crud_justapi.py
(JustAPI Python-handler + Rust-native CRUD), crud_fastapi.py (FastAPI +
SQLAlchemy async), crud_robyn.py (Robyn + sqlite3), and bench_one.py (drivesoha --output-format json and reports success rate). Ran oha -c 10 -z 5s
against a SQLite file (WAL, busy_timeout=5000, 10-conn pool) on the standard
hardware fixture (i5-13600K, 20 threads, CachyOS).
Findings (appended to BENCHMARKS.md):
SELECT (reads) are solid and framework-differentiated: JustAPI Rust-native
177,709 RPS > Robyn 30,602 > JustAPI Python-handler 27,551 > FastAPI+SQLAlchemy
1,606. JustAPI Python-handler reads are ~17Γ faster than FastAPI+SQLAlchemy on
this workload (SQLAlchemy async-session/ORM overhead dominates).Writes on single-file SQLite are SQLite-bound for every framework
(SQLite serializes writers); no framework sustains more than hundreds of
writes/s on one file. So writes are not a fair framework comparison on this
fixture.Blocking defect in JustAPI's Python-handler write path (P2.2): at
-c 10
the same server reports INSERT 362,143 RPS, UPDATE 14.6 RPS, DELETE 4.2 RPS
β internally inconsistent and physically impossible for SQLite. Repro at-c 50against the Python-handler server: of ~50 concurrent INSERTs only 1β2
rows persist (the rest silently vanish, successRate still 100% because the
client sees no error β the request'sblock_onnever returns a response and
the connection is closed by the client first).Root cause (revised):
crates/justapi-py/src/database.rsquery()/execute()ran the DB future viapy.detach(|| rt.block_on(fut))wherertis the pool's dedicated multi-threaded tokio runtime. CallingHandle::block_onfrom the server's dispatch thread deadlocks that runtime:
the connection-pool acquire never resolves (sqlx logsacquired connection ... after 9.99s, i.e. it hitbusy_timeout), so every
subsequent write blocks until timeout and no row is committed. Reads happened
to slip through before the pool exhausted, which is why only writes collapsed
and why the defect looked "INSERT-specific". It is in fact a
blocking-runtime re-entrancy bug on the write path under concurrency.Fix:
run_blocking()now releases the GIL withpy.detach(preserving
read throughput) but instead ofrt.block_onit doesrt.spawn(fut)and
blocks the caller on anmpscchannel β the future runs to completion on the
DB runtime's own worker threads and commits; the caller just waits, with no
re-entrant runtime driving. Verified: 100 concurrent INSERTs via the test
client all persist (was ~1β2 before), and a direct 100-threadapp.db.executeburst persists 200/200 with zero errors. SELECT throughput
is unchanged vs the old path (~5k RPS on the single-worker GIL-pool server
fixture; the 27k previously recorded was a different multi-worker run).
Decision:
- Publish the SELECT numbers and the honest SQLite-write caveat in BENCHMARKS.md.
- P2.2 is resolved: the Python-handler write path now awaits + commits
deterministically, guarded bytest_db_concurrent.py
(test_concurrent_inserts_all_persistandtest_concurrent_writes_via_test_client). The Rust-native CRUD path
(crud_table/crud_columns) remains the fastest write route. - Track the fix as P2.2 (done) in PRODUCTION_PLAN.md and PLAN.md.
Evidence: benchmarks/run_crud_bench.sh (and the isolated bench_one.py
runs) on the fixture; raw oha JSON captured successRate=100% with the
INSERT/UPDATE/DELETE RPS spread above. Read numbers reproduced across runs; the
write-path inconsistency reproduced at -c 10 and -c 20 (c=1 INSERT was a
healthy 117 RPS, confirming the collapse is concurrency-induced).
ADR-075 β 2026-07-24 β Untrusted-input fuzzing target and JSON Schema security justification
Context: PRODUCTION_PLAN.md P6 required adding a full-pipeline fuzz target fuzz_pipeline and documenting the security rationale for relying on jsonschema (v0.46) for body validation.
Decision:
- Created
fuzz/fuzz_targets/fuzz_pipeline.rstargeting:- Untrusted byte validation via
justapi_core::validate::validate_json_schemaand precompiledCompiledValidator - URL query parameter deserialization via
parse_query - Path resolution and route parameter extraction via
justapi_core::router::Router
- Untrusted byte validation via
- Security justification for
jsonschemacrate:jsonschemais a pure-Rust, memory-safe JSON Schema engine maintained by PyO3 core contributors.- It executes zero unsafe memory operations during validation and is extensively fuzzed upstream.
- Precompiled validators (
CompiledValidator) ensure schemas are validated and compiled once per route, avoiding unbounded memory allocation or regex stack overflow under malformed input payloads.
Evidence: cargo check --bin fuzz_pipeline builds cleanly in fuzz/.
ADR-076 β 2026-07-25 β ORJSON as optional Rust-level JSON serializer
Context: Project listed "ORJSON as optional JSON serializer" as a remaining gap. The Python orjson library (v3.11) was already imported on a best-effort basis in both _native_helper.py (try/except) and the Rust fast_dumps() path (__import__('orjson') with json.dumps fallback). The gap was that this integration was informal β no feature flag, no hard requirement path, no pyproject.toml extra.
Options considered:
- Add
orjsonas a Rust crate dependency (via crates.io) β rejected: no standaloneorjsonRust crate exists;orjsonis a Python package built with PyO3/maturin, not published on crates.io. - Create a Rust-level
orjsonfeature that panics if the Python module is missing β selected forjustapi-py. The feature makesfast_dumps()hard-requireorjsonat first call instead of silently falling back tojson.dumps. - Add
orjsontopyproject.tomlextras β selected.orjson>=3.10added as[orjson]extra and included in[full]. - No change (status quo) β rejected: the integration should be documented, testable, and discoverable by users.
Decision:
- Added
orjson = []feature tojustapi-core(marker only, no dependencies) andorjson = ["justapi-core/orjson"]tojustapi-py. fast_dumps()inhandlers.rsuses#[cfg(feature = "orjson")]to hard-requirepy.import("orjson")with apanic!on failure; without the feature it falls back tojson.dumpsas before.- Added
orjson>=3.10topyproject.tomlas[project.optional-dependencies] orjsonextra and included infull. - Added 7 tests in
test_orjson.pycovering compact-JSON output,default=strfallback, and end-to-end response serialization viaAsyncTestClient.
Evidence: cargo check -p justapi-py --features orjson passes; cargo check (default/no-orjson) passes; pytest 159 passed / 1 skipped; cargo test --workspace all pass.
ADR-077 β 2026-08-06 β Drop ASGI compatibility; fully native Rust pipeline
Context: PROMPT.md Section 2 and ADR-007/013 mandated a Tier A ASGI
compatibility shim (run unmodified FastAPI/Starlette apps on JustAPI's Rust
runtime). That shim was never shipped in the maintained codebase. The public
docs (README, docs_site) still claimed "an ASGI shim for compatibility with the
Starlette middleware ecosystem", app.mount() accepting arbitrary ASGI apps,httpx.ASGITransport(app=app) test clients, and an asgi_app= kwarg onapp.run(). Investigation confirmed these are not implemented:
mount()raisesValueErrorfor anything that isn't astrorAPIRouter.run()has noasgi_appkwarg.- No ASGI receiver/sender/scope shim exists anywhere in
justapi-py/justapi-coreRust sources. dependency_overrides(documented) does not exist.
Options considered:
- Implement a real ASGI layer (Tier A) β rejected. ASGI is a dict-based,
dynamically-typed protocol that would drag the typed zero-copy Rust pipeline
(route β schema validation β Rust serialization β wire) back through a
Python interface on every request. It taxes the fastest path for the benefit
of running legacy Starlette apps β the wrong target audience. We already
ship the native equivalents of every middleware ASGI would import (CORS,
JWT, rate-limit, security headers, compression, WS, SSE). - Keep the ASGI claims but defer the work β rejected. The docs are a false
promise; claims without substance erode trust faster than a missing feature. - Remove ASGI compatibility entirely and own the native stack β selected.
Decision:
- JustAPI is a fully native runtime. No ASGI/Starlette dependency, no ASGI shim.
- Removed the false claims from README, docs_site (advanced-middleware,
async-tests, sub-applications, static-files, migration-guide, glossary,
external-links), AGENTS.md architecture diagram, and PLAN.md (marked Phase 10
Tier A as historical/deprecated; updated the phase-table row and the
"native-for-performance, ASGI-for-compatibility" principle). - Corrected the real APIs in docs: native middleware is
@app.middleware("http")(request, call_next)callables and theenable_secure_headers/add_cors/set_jwt_auth/enable_request_coalescingnative bridges;
testing usesjustapi.testing.AsyncTestClient, nothttpx.ASGITransport;
sub-apps useAPIRouter+mount(); static files useapp.frontend().
- Integration with legacy ASGI apps is delegated to a path-routing reverse proxy.
Evidence: rg -l ASGI inventory across repo; reads of app.py (mount,add_middleware, run, dependency_overrides), testing.py,test_client.rs; factual ASGI references (Granian/uvicorn comparisons in
README/HANDBOOK/BENCHMARKS, historical DECISIONS ADRs, benchmark harness) are
kept β they describe the stack JustAPI intentionally does NOT use.
ADR-078 β 2026-08-06 β QUERY method (RFC 10008) testability + honest CRUD benchmark re-run
Context: Two items from the "make JustAPI production-grade" drive:
- The HTTP QUERY method (RFC 10008) had a complete route chain (Python
app.query()β PyO3native::JustAPIApp::queryβ Rust router keyed onjustapi_core::query_method()) and correct RFC enforcement (QUERY MUST carry
a Content-Type, rejected with 400), but there was no way to test it: the
test clients only exposed GET/POST/PUT/PATCH/DELETE. - The DB-backed CRUD benchmark ledger (2026-07-26) needed re-verification on
current code before trusting its "beat FastAPI" claims.
Decision:
- Added
TestClient::query/query_with(core),JustAPITestClient.query/query_with(PyO3), andAsyncTestClient.query(defaults toContent-Type: application/json, since RFC 10008 requires it). Tests: 3
Python (roundtrip, wrong-route 405, missing-Content-Type 400) + 1 Rust. - Re-ran
benchmarks/run_crud_bench.shwith a release wheel and appended
the results to BENCHMARKS.md. JustAPI native SELECT = 181k RPS (Γ125 vs
FastAPI, up from Γ108). UPDATE 31.9k / DELETE 37.7k still lead. - Honest finding (recorded, not hidden): the Python-handler path now
measures 16.5k SELECT and concurrent writes collapse to ~6 RPS at-c 20
(147 at-c 1). Root cause: the GIL-pool backpressure fix (2026-07-25)
throttles the single Python worker instead of dropping requests when its
bounded channel is full. This is a correctness/throughput tradeoff β all
writes commit; it is not a durability bug. The Rust-native path is unaffected.
The 2026-07-26 107k Python-handler SELECT predates that fix and is not
reproducible on current code. - Methodology rule added: benchmarks MUST use the release wheel; a debug
maturin developbuild measures ~4Γ slower and is not comparable.
Evidence: benchmarks/run_crud_bench.sh output (2026-08-06), BENCHMARKS.md
append, testing.rs/test_client.rs/testing.py QUERY additions, tests intest_testing.py and crates/justapi-core/src/testing.rs.
ADR-079 β 2026-08-06 β HTTP/3 (QUIC) transport: feature-gated module, not a TCP-path rewrite
Context: "Make JustAPI better than any existing Python framework" β HTTP/3
is the one transport no Python web framework ships (all are TCP-based
HTTP/1.1/2). QUIC gives connection multiplexing without head-of-line blocking,
0-RTT resumption, and built-in TLS 1.3.
Options considered:
quiche(Cloudflare) β fastest per interop-runner, but BoringSSL-only,
low-level event loop, no hyper compatibility. Rejected.s2n-quic+s2n-quic-h3(AWS) β production-grade, buts2n-quic-h3is
a separate lower-level integration and MSRV 1.88 (project is on 1.97, OK).h3+h3-quinn(hyperium) β selected. hyper's own HTTP/3 layer;
tokio-native (matches the project's tokio+hyper+rustls stack); h3-quinn
reuses quinn 0.11 + rustls 0.23 (already in the tree);h3is 0.0.x but
hyperium-owned and used by reqwest/salvo.
Decision:
- New
http3feature flag onjustapi-core(h3,h3-quinn,quinn
optional deps; pulls intls). - New
crates/justapi-core/src/http3.rs:Http3Config(PEM cert/key),Http3Handlerbridge type (mapshyper::Request<Full<Bytes>>β(status, headers, body)),quic_server_config(),serve_http3()
(UDP bind β quinn endpoint β h3 server connection loop β per-request
task),collect_body(),bridge_request(),chain_to_http3_handler(). - End-to-end test
tests/http3_test.rs: self-signed cert via rcgen, real
QUIC handshake, request round-trip through h3-quinn client. Passes. - Seam documented, not hidden: the core
make_handler/execute_handler
andHandler::Customare typed tohyper::body::Incoming, so the full
Python-native pipeline (GIL pool, native fast path, schema validation) is
NOT yet wired to HTTP/3 βchain_to_http3_handlertakes aMiddlewareChain<Full<Bytes>>, andmake_test_handler<B>in justapi-py is
already body-generic, so the wiring is a mechanical follow-up (make core
handler generic overB). Follow-up phase "HTTP/3 native pipeline".
Evidence: http3.rs (module), tests/http3_test.rs (passing e2e),http3 feature in Cargo.toml, serve_http3 log line "HTTP/3 listening on
udp://β¦".
ADR-080 β 2026-08-06 β Remove request-scoped auto-transaction: write-path pool-saturation collapse
Context: The honest re-benchmark (2026-08-06) surfaced a real defect: the
Python-handler DB write path measured 150 RPS at -c 10 but collapsed to
~3 RPS at -c 11 (with 503/500 "pool timed out while waiting for an open
connection" at exactly request_acquire_timeout). Root-caused by
measurement, not theory:
make_native_handler/make_test_handlerbegan a request-scopedpool.begin_request()transaction on the async runtime for every
POST/PUT/DELETE (handlers.rs), before dispatching to the single GIL
worker.- The transaction held a pool connection while the request waited in the
GIL queue; the handler's ownapp.db.querythen acquired a second
connection (run_blockingβ pool acquire). The tx was never passed to the
handler β it provided zero atomicity for handler work. - Result: N concurrent write requests need 2N connections on an
N-connection pool β immediate saturation at N+1 concurrency. Verified:
raisingrequest_acquire_timeoutto 30s did NOT fix it (still ~3 RPS);
reads (SELECT) at c=16 stayed at 21k RPS β the collapse was write-specific.
Options considered:
- Thread the transaction into the handler's
app.dbso the handler's queries
run inside it (one connection per request) β correct but a large refactor
(tx plumbed through theDbPoolpyclass +Request). - Remove the auto-transaction β selected. The pool's own
request_acquire_timeoutalready produces the fast-503 backpressure the
tx provided; multi-statement atomicity is explicit viaapp.db.transaction(). The tx was dead weight that doubled connection
usage per write.
Decision:
- Removed the auto-transaction begin/commit/rollback from both
make_native_handlerandmake_test_handler(handlers.rs). Writes now use
exactly one pool connection (the handler's ownapp.db.query). - Extended
db_error_response(justapi-core/lib.rs): SQLITE_BUSY/LOCKED
(codes 5/6/517/518/261) now map to 503 Retry-After instead of a generic
500 β a concurrent write crunch surfaces as retryable backpressure, not a
5s stall + 500. - Reworked
test_db_saturated.pyto saturate via an external SQLite write
lock (the old premise β a Python handler sleeping while holding a pool
connection β is architecturally impossible now: the GIL worker serializes
handlers so they never race for the pool). Addedtest_concurrent_writes_small_pool_no_saturation_collapse(2-conn pool,
50 concurrent writers, all must 200 + persist).
Measured after fix (release wheel, same fixture): Python-handler INSERT
flat at 142β162 RPS from c=1 through c=50, 100% success, zero timeouts
(before: 160 RPS at c=10 β 2.8 RPS at c=11). SELECT unchanged (19.5k @ c=20).
Writes durable: 604 rows persisted from a 4s burst @ c=20.
Evidence: BENCHMARKS.md 2026-08-06 re-run section (pre-fix numbers),
handlers.rs diff, lib.rs db_error_response, test_db_saturated.py +
test_db_concurrent.py, live oha measurements above.
ADR-081 β 2026-08-06 β Fork-safe GIL pool (fixes test_circuit_breaker flake + prefork hangs)
Context: The full pytest suite had exactly one persistent flake:test_circuit_breaker.py failed with 504 timeouts when run after any test that
initialized the GIL pool (passes in isolation). Root cause, proven by
reproduction: the pool was a OnceLock<GilPool>; fork(2) copies the
parent's address space, so a forked child inherited an initialized pool whose
worker threads do not exist in the child. Every run_python send went into
a channel no one read β the child's server blocked forever β 504s. This is not
a test-only issue: any user app that forks after the pool is warm (prefork
servers, multiprocessing, Celery-style workers) would hang every
Python-handler request in the child.
Decision:
POOLchanged fromOnceLock<GilPool>toMutex<Option<GilPool>>plus aPOOL_PID: AtomicU32.run_python(andinit_pool) checksPOOL_PID != current_pid; on mismatch (forked child or fresh process) the
pool is rebuilt on the child's own threads. Same-process calls pay one
atomic load + one short mutex lock per request (dispatch already held the
lock only to clone senders; the atomic is uncontended in the common path).GilPool.nextis nowArc<AtomicUsize>(cloneable counter for round-robin).- Regression guards:
test_dependency_injection.py::test_gil_pool_survives_fork
(warm parent pool β fork β child serves 200) and the previously-flakytest_circuit_breaker.pynow passes in the full suite.
Measured: full pytest suite 169 passed / 1 skipped / 0 failed (was 167
- 1 flake). No GIL-path throughput change (uncontended atomic + lock in the
same-process fast path).
Evidence: gil_pool.rs diff, the two regression tests, full-suite run.
ADR-082 β 2026-08-06 β HTTP/3 native pipeline: Python handlers serve over QUIC
Context: ADR-079 shipped the HTTP/3 transport (quinn + h3) with a bridge
handler but noted a seam: the core make_handler/execute_handler were typed
to hyper::body::Incoming, so the Python-native pipeline (GIL pool, native
fast path, schema validation, DI) could not serve over QUIC. This closes that
seam.
Decision:
- The Python binding's
make_native_handler<B>was already body-generic
(B: Body), so no core refactor was needed.JustAPIApp::run()now builds
a secondMiddlewareChain<Full<Bytes>>from the same app state (same route
table, handlers, schemas, batchers, DB pool) when HTTP/3 is enabled, wraps
it withchain_to_http3_handler, and spawnsserve_http3on the same
address over UDP alongside the TCP server. - New public API:
app.enable_http3(cert_path, key_path)(PEM TLS files,
required β QUIC uses TLS 1.3). RaisesNotImplementedErroron builds
without thehttp3feature.justapi-pygained anhttp3feature
passthrough tojustapi-core/http3. - Sharing: the TCP and QUIC chains share the same
Arcstate and the same
PythonRequest.apphandle; per-transport handler closures are built once
at startup.
Verified end-to-end (tests/http3_test.rs::http3_python_native_pipeline,
real QUIC client): a Python app with enable_http3 serves a @app.get
handler over HTTP/3 through the full native pipeline β response body{"handler":"python","route":"/native"}. TCP and UDP listeners both bind the
same port.
Evidence: app.rs diff (http3 bridge + spawn), enable_http3 in app.py,
passing e2e test, dual-listener smoke (TCP 200 + UDP bound, both logged).
ADR-083 β 2026-08-06 β Async handlers no longer block the GIL worker (14Γ async throughput)
Context: Benchmarked async Python handlers against the sync path and found
a hard ceiling: a handler doing await asyncio.sleep(0.001) served ~800 RPS
regardless of concurrency (c=8 and c=100 both ~808 RPS). Root cause: the
single GIL worker called future.result() on therun_coroutine_threadsafe future, blocking the worker for the coroutine's
full duration. With one worker, async requests serialized one-at-a-time β
while Granian (which runs async handlers directly on its event loop whereawait yields) interleaved thousands. This is the main reason Granian's
async-handler throughput beat ours.
Decision:
- New
NativeBody::Async(Py<PyAny>)variant.call_python_handlerdetects an
awaitable result and returns the future WITHOUT calling.result(),
freeing the GIL worker immediately. - New
resolve_async_response()awaits the future on atokio::task::spawn_blockingthread (the future's internal wait releases
the GIL), then converts the result with the samehandle_ok_result/handle_py_errorlogic the sync path uses (streaming responses included). - Extracted
handle_ok_result()andhandle_py_error()fromcall_python_handlerso both sync and async paths share one
response-handling pipeline. - Both
make_native_handlerandmake_test_handlerresolveAsyncbodies.
Measured (release wheel, same fixture): async 1ms-sleep handler @ c=100:
820 β 11,758 RPS (14Γ). Regression testtest_async_handler_interleaves_concurrent_requests: 20Γ50ms requests finish
in 0.08s (serialized would be 1.0s). Full pytest suite 170 passed.
Remaining gap (documented, not hidden): the async path still has 3 thread
hops (GIL worker β loop thread β spawn_blocking resolution) vs Granian's
direct event-loop dispatch, capping light async handlers at ~12k RPS on this
fixture. Closing that requires running async handlers directly on the loop
(identified at registration via is_async) β a follow-up.
Evidence: handlers.rs diff (NativeBody::Async, resolve_async_response,
handle_ok_result/handle_py_error), the regression test, live oha measurements.
ADR-084 β 2026-08-06 β Free-threaded CPython support (3.13t/3.14t)
Context: The framework's throughput ceiling on GIL-locked CPython is the
GIL itself (~119k RPS trivial handlers; CPU-bound handlers serialize to ~190
RPS). Free-threaded CPython (3.13t/3.14t, Py_GIL_DISABLED) removes the GIL;
the GIL pool already had a GilFree mode but it was never reachable β the
runtime sys._is_gil_enabled read through pyo3's import returned true on
free-threaded interpreters (limited-API artifact), so the pool always ranGilBased, workers=1.
Decision:
- Mode detection now uses the compile-time
Py_GIL_DISABLEDcfg (set by
pyo3-build-config / emitted by a newjustapi-py/build.rsthat probes$PYO3_PYTHON), not the unreliable runtime read. GIL-locked builds keep the
runtime fallback. - Free-threaded wheels build WITHOUT abi3 (
--no-default-featuresβ the
limited API cannot be free-threaded):maturin build --interpreter python3.14t --no-default-features --features mail,http3,orjson. requires-pythonraised to>=3.12(per the free-threaded target).__init__.pyimports the compiled_justapicore FIRST β submodules thatfrom ._justapi import ...resolved inconsistently when imported before the
core on free-threaded builds.- CI wheels matrix +
scripts/publish.shbuild the 3.14t wheel.
Measured (release wheels, same fixture):
- Trivial handler: GIL-locked 3.13 = ~119k RPS; 3.14t = ~12-23k RPS
(per-object atomics cost more on trivial Python work β expected). - CPU-bound handler: GIL-locked = 191 RPS; 3.14t = 2,372 RPS at c=20 β
12Γ faster and scales with cores (the GIL ceiling is gone). - 168/170 pytest pass on the 3.14t wheel (2 env-only failures: fixture path,
multiprocessing spawn).
Evidence: gil_pool.rs cfg detection, build.rs, pyproject requires-python,
wheels.yml matrix, live benchmarks (ft9/ft11/ft_cpu logs).
ADR-085 β 2026-08-06 β Server runs on a multi-threaded tokio runtime (was single-threaded)
Context: Release-stability audit found the HTTP server was running ontokio::runtime::Runtime::new() β a current-thread runtime β so the
entire server (accept loop, every connection, TLS, streaming) ran on ONE
thread. Confirmed empirically: ls /proc/<pid>/task | wc -l = 1 during load.
Throughput survived (119k trivial handlers) but the server had no I/O
parallelism β fragile under load spikes and a hard ceiling for connection-
heavy workloads.
Decision: app.rs run() now builds a multi-threaded runtime
(Builder::new_multi_thread().enable_all()), worker count =available_parallelism() (override: JUSTAPI_SERVER_THREADS), matching the
DB pool's multi-threaded runtime (ADR-068). No API change.
Measured (release wheel, same fixture): server now runs on 24 threads
(20 cores + extras). Sync handler: 118-119k RPS (unchanged). High-concurrency
stability: c=500 sync = 117k RPS, 100% success, zero errors/panics;
c=200 async = 4.9k, 100% success. Full gates green (fmt/clippy/workspace
15 suites/pytest 171).
Evidence: app.rs run() diff, thread-count probe, c=500 load run, gate runs.