---
id: client-feature-gates
title: Cargo feature gates for the client modules
abstract: Put the algod, indexer, and kmd client modules behind additive Cargo features (algod, indexer, kmd), offline core always on, default = the three clients plus a TLS backend so the gating is non-breaking; default-features=false enables slim and wasm32 builds that drop reqwest. The raw openapi_* re-exports are not gated but removed, as the closing act of the hand-named-types migration (north-star D3 / hide-generated-types).
status: accepted
date: 2026-05-22
deciders: []
tags: [build, features, wasm, modularity]
---
# Cargo feature gates for the client modules
## Status
Accepted
## Context
The umbrella `algonaut` crate today pulls in every workspace member
unconditionally. `lib.rs` re-exports the offline crates (`abi`, `core`,
`crypto`, `model`, `transaction`), re-exports the three generated network
clients under raw names (`pub use algonaut_algod as openapi_algod`, plus
`openapi_indexer`, `openapi_kmd`), and exposes the hand-written high-level
modules `algod`, `indexer`, `kmd`, `atomic`, `dryrun`, `simulate`. All of it
compiles for every consumer, whatever they actually use.
### 1. The only features today gate one crate's TLS, inconsistently
The root `Cargo.toml` declares exactly three features:
```toml
[features]
default = ["native"]
native = ["algonaut_kmd/native"]
rustls = ["algonaut_kmd/rustls"]
```
`native`/`rustls` forward only to `algonaut_kmd`. But `algonaut_algod` and
`algonaut_indexer` declare their `reqwest` dependency as
`reqwest = { workspace = true, features = ["json", "multipart", "query"] }`,
and the workspace `reqwest` carries default features — so reqwest's
`default-tls` backend is *always* compiled into the algod and indexer clients
regardless of which feature the consumer picked. (In reqwest 0.13,
`default-tls` resolves to **rustls**, so today algod/indexer are pinned to
rustls while kmd follows the feature — selecting `native` gives you native-tls
for kmd but rustls for algod/indexer, i.e. both stacks linked at once.) The one
feature axis the crate already has is silently ignored by two of its three
clients.
### 2. Offline users still pay for three HTTP clients
A consumer who only builds and signs transactions offline — a CLI that emits
a signed blob, a hardware-wallet companion, a test fixture generator — has no
use for `algod`, `indexer`, or `kmd`, yet links all three `reqwest`-based
clients and their transitive TLS stacks. There is no way to ask for "just the
transaction and signing types."
This bites hardest on `wasm32`. The 0.5 line was specifically reworked so
`wasm32` builds need no C toolchain (`ring` → `ed25519-dalek`), and `lib.rs`
already carries a `cfg(target_arch = "wasm32")` block. The natural wasm
consumer — build a transaction in the browser, hand it to a wallet to
sign — wants the offline core and nothing that drags in a native HTTP/TLS
stack. Today they cannot express that.
### 3. The raw generated clients are still part of the public surface
`pub use algonaut_algod as openapi_algod` (and the indexer/kmd equivalents)
put machine-generated type names on the public API.
[`ideal-type-safe-ergonomic-api`](ideal-type-safe-ergonomic-api.md) item
**D3** and the first cut in
[`hide-generated-types`](hide-generated-types.md) already commit to hiding
these: each client method migrates to a hand-named type in
`algonaut_model::client_types`, and "the `pub use algonaut_algod as
openapi_algod` re-exports in `lib.rs` are removed" once coverage is complete.
That coverage is still early. **56 public client methods** — 35 on algod, 21
on indexer — return a generated type *by name* today
(`RawTransaction200Response`, `PendingTransactionResponse`,
`GetBlockLogs200Response`, `BlockResponse`, `models::Box`, …), against only
**3** wrapped so far (`SuggestedParams`, `NodeStatus`, `Supply`). So the
re-exports are still load-bearing — our own unit-test `World` names ~14
responses through `algonaut::openapi_*` — and the question this ADR settles
is what to *do* with them. The decision (D3 below) is to **remove** them by
finishing the hand-named migration, not to preserve them behind a feature:
gating a generated-type escape hatch the project has already decided to
delete would only entrench it.
### 4. What actually depends on what
Mapping the modules to the generated crates decides which gates are even
possible:
| `core`, `crypto`, `encoding`, `abi`, `abi_sig`, `transaction`, `model` | none — pure offline |
| `error`, `util` | none |
| `algod` module | `algonaut_algod` |
| `indexer` module | `algonaut_indexer` |
| `kmd` module | `algonaut_kmd` |
| `atomic`, `simulate`, `dryrun` | `algonaut_algod::models::*` |
| `openapi_algod` / `_indexer` / `_kmd` re-exports | the matching crate |
Two facts make the split clean. `algonaut_model` does **not** depend on any
client crate, so the hand-named `client_types` (`SuggestedParams`,
`NodeStatus`, …) and the entire offline foundation can stay always-on.
`atomic`, `simulate`, and `dryrun`, however, all import
`algonaut_algod::models` directly (e.g. `PendingTransactionResponse`,
`SimulateRequestTransactionGroup`, `DryrunRequest`) — they are coupled to the
algod crate today and so must ride whatever gate `algonaut_algod` rides.
## Decision
Introduce a small set of **additive** Cargo features on the root crate. The
offline core stays unconditional; each network client and the raw re-export
layer become opt-out toggles. Crucially, `default` keeps **today's full
surface**, so this change compiles every existing consumer unchanged — the
gates are a capability for people who want *less*, not a removal of anything.
### D1 — The feature set
```toml
[features]
# rustls is the default TLS backend: pure-Rust, no system OpenSSL.
default = ["algod", "indexer", "kmd", "rustls"]
# High-level network clients. Each pulls its generated crate and unlocks
# the matching hand-written module(s).
algod = ["dep:algonaut_algod"] # algod + atomic + simulate + dryrun
indexer = ["dep:algonaut_indexer"]
kmd = ["dep:algonaut_kmd"]
# TLS backend, forwarded to *every* enabled client (see D4).
native = ["algonaut_kmd?/native", "algonaut_algod?/native", "algonaut_indexer?/native"]
rustls = ["algonaut_kmd?/rustls", "algonaut_algod?/rustls", "algonaut_indexer?/rustls"]
```
The generated crates become optional dependencies (`optional = true`), pulled
in only by their feature via `dep:`. The `feature?/…` weak-dependency syntax
forwards TLS selection to a client only when that client is also enabled, so
`--no-default-features --features "algod,rustls"` is a coherent rustls-only
algod build.
These are the names a consumer writes. Mapping the examples from the request:
`algod` and `indexer` are literal feature names. The raw-client re-export
(`raw_openapi_clients` in the request — the clients are openapi-generator
output, see [`openapi-client-regeneration`](openapi-client-regeneration.md))
is deliberately **not** a feature here; it is handled by removal, see D3. A
per-workspace-crate `crate`-style gate is considered and rejected in D3.
### D2 — The offline core is always on, gated modules follow their crate
`abi`, `core`, `crypto`, `encoding`, `abi_sig`, `transaction`, `model`,
`error`, and `util` carry no feature gate — they are the floor every build
gets. `#[cfg(feature = "...")]` guards the rest in `lib.rs`:
```rust
#[cfg(feature = "algod")]
pub mod algod;
#[cfg(feature = "indexer")]
pub mod indexer;
#[cfg(feature = "kmd")]
pub mod kmd;
// atomic / simulate / dryrun import algonaut_algod::models today,
// so they ride the algod gate until that coupling is removed (see D3).
#[cfg(feature = "algod")]
pub mod atomic;
#[cfg(feature = "algod")]
pub mod simulate;
#[cfg(feature = "algod")]
pub mod dryrun;
```
### D3 — Remove the raw re-exports by finishing the hand-named migration
The `openapi_*` re-exports are **not** turned into a kept feature; they are
**removed**, once the hand-named migration that makes them unnecessary is
complete. Both halves — wrapping the remaining responses and deleting the
re-exports — are owned by [`hide-generated-types`](hide-generated-types.md) /
north-star **D3**, not by a feature in this ADR.
The reasoning is in the numbers from Context §3: with 56 methods still
returning generated types and only 3 wrapped, neither gating the re-exports
behind a `raw_openapi_clients` feature nor deleting them outright *today*
would hide a generated type. It would only make those 56 return types
**unnameable through `algonaut`** — a caller who must name an un-migrated
response would have to add their own `algonaut_algod` dependency and import
`algonaut_algod::models::…`, re-creating the two-paths problem D3 set out to
kill (north-star item 3), relocated into every user's `Cargo.toml`. A
default-off feature would have the same effect for the default build, just
opt-in-able. So the only move that actually advances the goal is to finish
wrapping, *then* delete.
The sequence:
1. **Finish D3 coverage** (owned by `hide-generated-types`): wrap the
remaining ~53 responses in hand-named `algonaut_model` types, so no public
client method returns a generated type.
2. **Delete the three `pub use algonaut_algod as openapi_*` lines.** The
generated crates stay as optional, *internal-only* dependencies behind the
`algod`/`indexer`/`kmd` gate; nothing re-exports them.
3. The unit-test `World` (which names ~14 responses through
`algonaut::openapi_*`) and any remaining direct `algonaut_algod::models`
uses in `tests/` migrate to the hand-named types in the same effort.
**Transition.** Until step 2 lands, the re-exports remain in the (default)
surface but must ride their client's gate so a slim build does not reference
an uncompiled crate:
```rust
#[cfg(feature = "algod")]
pub use algonaut_algod as openapi_algod;
#[cfg(feature = "indexer")]
pub use algonaut_indexer as openapi_indexer;
#[cfg(feature = "kmd")]
pub use algonaut_kmd as openapi_kmd;
```
They are deleted, not demoted to an opt-in feature — there is no
`raw_openapi_clients`.
Three alternatives were weighed and rejected:
- **Gate-and-keep behind `raw_openapi_clients`** (default-on or default-off).
Rejected: it preserves a generated-type escape hatch the project has decided
to eliminate, and the default-off variant still leaves 56 methods whose
return types cannot be named without the feature. Removal via finishing D3
is the chosen end state, not a permanent gate.
- **A feature per workspace crate** (the literal `crate` example — e.g. a
`crypto` or `transaction` toggle). Rejected: the offline crates are a tight,
cheap, no-network foundation that everything else needs; gating them buys no
meaningful build savings and multiplies the feature-combination matrix CI
must cover. The savings live entirely in the three `reqwest` clients.
- **A separate gate for `atomic`/`simulate`/`dryrun`.** Rejected for now:
these modules import `algonaut_algod::models` directly, so they cannot
compile without the algod crate regardless. Decoupling them (consuming the
hand-named `algonaut_model` types instead of `algonaut_algod::models`, per
D3) is the prerequisite; once that lands, an `atomic`-without-`algod` gate
becomes possible and can get its own follow-up.
### D4 — Fix the TLS leak while we are here
Give `algonaut_algod` and `algonaut_indexer` the same `native`/`rustls`
features `algonaut_kmd` already has, declaring their `reqwest` with
`default-features = false` so the backend is genuinely chosen by the feature
rather than always-on:
```toml
# workspace.dependencies — drop reqwest's default TLS for every client crate.
# (default-features must be set here, not on the inheriting member.)
reqwest = { version = "0.13.3", default-features = false }
# in algonaut_algod / algonaut_indexer — add the TLS-backend axis kmd already
# has, and re-declare the non-TLS former defaults so only TLS is feature-gated.
reqwest = { workspace = true, features = ["json", "multipart", "query", "charset", "http2", "system-proxy"] }
[features]
default = ["rustls"]
native = ["reqwest/native-tls"]
rustls = ["reqwest/rustls"] # reqwest 0.13's feature is `rustls`, not `rustls-tls`
```
After this, the root `native`/`rustls` features forward to all three clients
(D1), and `rustls` produces a genuinely native-tls-free build.
## Consequences
- **The gating is non-breaking by construction.** A consumer who keeps
`default` (or omits the `algonaut` features entirely) keeps the identical
module surface they have today — same modules, same `openapi_*` re-exports
during the transition, same TLS backend (modulo the fix below). The only
consumers who must act for the *gates* are those who opt out with
`default-features = false`, and they opt in to exactly the features they
name. The breaking change in this area is the eventual *removal* of the
`openapi_*` re-exports (D3), which lands with that migration, not with the
gates.
- **Slim and wasm builds become expressible.** `algonaut = { version = "...",
default-features = false }` yields the offline core only — transactions,
signing, ABI, the domain model — with no `reqwest` and no TLS stack. A
browser/wasm signer can build and sign without dragging in a native HTTP
client. This is the headline win.
- **The broken TLS axis is fixed, and the default is now rustls.** The
`native`/`rustls` feature controls all three clients uniformly — umbrella and
leaf-crate defaults agree, so a `--workspace` build does not compile both
stacks. Previously algod/indexer ignored the axis, pinned to reqwest's
`default-tls` (rustls in reqwest 0.13), while kmd used native-tls — so the old
default linked *both*. With `default-features = false` on the shared reqwest
and `default = ["rustls"]`, the default build is **pure-Rust rustls with no
system OpenSSL** (a strict improvement: `main` pulled OpenSSL via kmd), and
`--features rustls` is verifiably native-tls-free. `native` is available
opt-in. The non-TLS former defaults (`http2`, `charset`, `system-proxy`) are
re-declared on algod/indexer so only the TLS backend changes.
- **The raw re-exports are removed, not gated.** This ADR introduces no
escape-hatch feature; the `openapi_*` re-exports are deleted as the closing
act of north-star D3 / [`hide-generated-types`](hide-generated-types.md),
after the remaining ~53 client responses are wrapped in hand-named types.
Finishing that migration is therefore a prerequisite for the final surface.
The client gates (D1/D2/D4) can land first and independently; the re-export
removal lands with D3.
- **CI must cover the feature matrix.** At minimum: `--no-default-features`
(offline core compiles standalone), each client feature in isolation,
`--no-default-features --features "algod,rustls"` (the rustls path), and
`--all-features`. Without these, a `#[cfg]` that references a gated type
from always-on code, or a feature that fails to build alone, regresses
silently. A `cargo hack --feature-powerset --depth 2` check on the root
crate is the cheap insurance.
- **`#[cfg]` discipline is now load-bearing.** Re-exports, the `atomic` /
`simulate` / `dryrun` modules, and any always-on code that touches a gated
type must carry matching `cfg` guards. Examples and integration tests that
hit the network already implicitly need `algod`/`indexer`/`kmd`; those
targets should declare `required-features` so a slim build skips them
cleanly instead of failing to compile.
- **Cost.** Three new feature stanzas, optional-dep plumbing, and the cfg
guards are mechanical and one-time. The ongoing tax is the CI matrix and the
habit of gating new client-touching code — modest, and the kind of thing
`cargo hack` enforces automatically.
- **Non-goals.** This ADR does not gate the offline workspace crates
individually, does not split `atomic`/`simulate`/`dryrun` from `algod`
(blocked on D3 decoupling), and does not change the default build's
behaviour beyond the TLS-backend fix in D4. It introduces the client gates
and the TLS axis; it introduces no escape-hatch feature for the generated
clients — their re-exports are removed by completing
[`hide-generated-types`](hide-generated-types.md) (north-star D3), which
this ADR treats as a prerequisite for the final surface rather than
re-specifying.