ADR-006
Layered architecture with kernel ISP atomic traits and trait_variant
accepted · 2026-04-13 · L0 L0.5 L1 L2 L3 L4 L5 · cites 25
0ADR-006: Layered architecture + kernel ISP atomic traits + `trait_variant` #
Context #
The kernel layer defines contracts for all side effects (filesystem, HTTP, shell, memory, embeddings, tool execution, WASM host, observability). Legacy approach was coarse-grained: one large Provider trait, one Fs trait. Consumers that only needed "read a file" had to depend on the entire surface. async_trait was universally used, which boxes every future on the async dispatch path — measurable overhead on the inference hot path.
Rust 1.91 native AFIT (async fn in trait) + trait_variant crate (v0.1) unlocked a better pattern: write the async trait once, let trait_variant::make generate a companion Dyn-suffixed trait for object-safety without boxing on the static path.
Decision #
Layered architecture (strict dep direction):
- Layer · Purpose · Admitted
- L0 · Primitives — error codes, catalog data, pure types · `nika-error`, `nika-catalog`
- L0.5 · Kernel — abstract side-effect traits + request/response types · `nika-kernel`, `nika-kernel-mock`
- L1 · Infrastructure — implementations of kernel traits (fs, http, providers, etc.) · pending Phase 2+
- L2 · Core runtime + DAG execution · pending Phase 2+
- L3 · Subsystems — binding, catalog overlay, policy · pending Phase 3+
- L4 · Applications — CLI, TUI, daemon, serve, LSP, verify tooling · `nika-catalog-verify` (binary)
- L5 · Full product integration · pending
Layer-level ordering is enforced by scripts/ci/check_layers.rs pattern (see ADR-009): layer(dep) ≥ layer(crate) is verified by parsing Cargo.toml deps against a table.
Kernel trait design (L0.5):
- ~20 atomic ISP traits:
FsRead,FsWrite,FsMeta,FsList,HttpGet,HttpPost,ShellRun,ShellCancel,MemoryRemember,MemoryRecall,MemoryForget,ProviderInfer,ProviderStream,ProviderMeta,ProviderEmbed(optional),ProviderVision(optional),ToolExecutor,WasmPluginHost,ObservabilitySink,Clock(sync by design). - Grouped into ~6 super-traits via blanket impls:
Fs,HttpClient,ShellExecutor,MemoryStore,Provider. - All async traits use
#[trait_variant::make(Send)]— native Rust 1.91 AFIT on static path,Dyn-suffixed companion trait for object safety. BTreeMappreferred overHashMapin kernel types for deterministic iteration (tests are reproducible).- Cancel tokens passed as function parameters, not embedded in request structs (keeps structs runtime-agnostic).
Consequences #
Positive #
- A context loader that only reads files imports
FsReadalone — compiler enforces minimum surface. - Zero boxing on the hot inference path (static dispatch via native AFIT).
- Test hermeticity is structural: every I/O path is behind a trait, so
nika-kernel-mocksubstitutes with zero network/disk.nika-kernel99 tests +nika-kernel-mock88 tests exercise this fully. - Adding a new side effect = new atomic trait, no impact on existing ones.
Providermandatory-vs-optional split (ProviderInfer + ProviderStream + ProviderMetamandatory;ProviderEmbed + ProviderVisionoptional) lets lightweight providers (e.g., mock) implement only what they support.
Negative #
trait_variantgenerates a pair of traits per async trait, doubling the documented API surface — ~40 traits visible tocargo docfrom ~20 source definitions.- Caller ergonomics: implementing Provider requires 3 trait impls minimum (
Infer + Stream + Meta). Mitigated by default methods where possible. BTreeMapperf in ultra-hot loops is slightly worse thanFxHashMap; kernel isn't a hot loop, so acceptable.
Neutral #
Clocksync-only is explicit YAGNI. Revisit if proven wrong.
Evidence #
crates/nika-kernel/src/lib.rslines 19–34 — trait hierarchy docstring + re-export listcrates/nika-kernel-ai/src/memory.rslines 272–305 —MemoryRemember,MemoryRecall,MemoryForget+ blanketMemoryStore+EmbeddingProviderwith#[trait_variant::make(Send)]crates/nika-kernel-ai/src/provider.rs—InferRequest/InferResponsewith reservedOption<MemoryDirective>field (see ADR-007)CHANGELOG.mdlines 169–215 —nika-kerneladmission, key decisions listed, commitef8804371- Related crates: `nika-kernel` (L0.5 traits), `nika-kernel-mock` (L0.5 test mock)
Alternatives considered #
Alt A — Coarse-grained traits (`Fs`, `Provider` mega-traits only) #
Rejected: re-creates the legacy problem where consumers over-depend.
Alt B — `async_trait` everywhere (boxed futures) #
Rejected: measured ~100ns per call overhead; multiplied across a DAG with 100+ nodes becomes visible. Native AFIT is free.
Alt C — No super-traits, only atomic traits #
Rejected: 20 atomic trait bounds is unergonomic for consumers that genuinely need most of Fs/Provider. Blanket Fs: FsRead + FsWrite + FsMeta + FsList gives the best of both.
Related #
- ADR-003 — 12-gate admission (kernel admission went through all 12)
- ADR-004 — context-window sizing (kernel + kernel-mock together are 5,080 LOC)
- ADR-007 — forward-compat invariants (kernel pre-plants
MemoryStore,ToolExecutortraits for v0.95 Cortex and v0.100 agent-v2 without touching kernel later) - ADR-012 — typestate runtime (consumes the kernel via
bind_kernel(&dyn Kernel)) - ADR-013 — Loom concurrency verification (validates trait impls of the contracts this ADR defines)
- ADR-014 — sealed kernel traits (locks the trait surface this ADR introduced behind a
Sealedsuper-trait)
Notes #
Rust 1.91 trait_variant is still v0.1.x. Keep version-pinned. Re-evaluate when the crate reaches v1.0 or if stdlib absorbs the pattern.
Amendment 2026-04-16 — Kernel stays monolithic forever #
The original ADR-006 reserved a future split into 4 sibling crates
(nika-kernel-{core,ai,runtime,plugin}) when LOC > 10k OR pub trait count
nika-kernel stays as ONE crate forever, with 5 internal group modules
(io/, ai/, runtime/, plugin/, infra/) that already provide the
logical separation. Real Rust workspaces (rust-analyzer, helix, oxc, biome,
ruff, uv, bevy, tokio) keep their kernel-equivalents monolithic; only
polkadot-sdk splits and that's blockchain-feature-gated. Splitting would force
use nika_kernel_ai::Provider; use nika_kernel_core::Clock; everywhere — worse
DX. The Q7 prelude hub (added 2026-04-16) depends on kernel being whole.
Trigger to revisit (both must hold):
- kernel exceeds 15k LOC, AND
- 2+ active sub-domains being modified independently in same session for 3+ consecutive sessions
Reserved crate names (nika-kernel-core/ai/runtime/plugin) freed.
See ~/.claude/.../memory/feedback_kernel_monolithic_forever.md for the full
rationale + agent evidence.
Status of original split-trigger: superseded by this amendment.
read at v0.107.0 · the decision record ships with the engine