ADR-007
Forward-compat invariants with non_exhaustive, new(), and pre-planted hooks
accepted · 2026-04-13 · L0 L0.5 · cites 19
0ADR-007: Forward-compat invariants (`#[non_exhaustive]` + `::new()` + pre-planted kernel hooks) #
Context #
Nika ships on real semver toward a 1.0 launch (ADR-002 · amended D-2026-06-20-N1) with features shipping incrementally over years. Adding a field to a public struct after 1.0 would be a breaking change for any downstream crate using struct literal syntax — and even pre-1.0, churning load-bearing DTOs every minor is costly. InferRequest flows through every provider, verb, and memory call — if Connectome memory fields aren't reserved now, every consumer must be touched when the Connectome lands.
The 16-agent Rust Council (2026-04-13) quantified the ROI: 1.5 days of pre-planting now saves 10 days of migration later (ROI 6.7×). The decision was unanimous.
Decision #
Four interlocking mechanisms make Nika's public API forward-compatible by construction:
1. `#[non_exhaustive]` + `pub fn new()` (Invariant #19) #
Every public struct, enum, and error variant carries #[non_exhaustive]. External code cannot use struct literal syntax (Foo { x: 1 }) — only the constructor (Foo::new(1)). Adding a field post-v0.90 is then non-breaking: existing callers keep calling new() with the old signature; new fields get default values; the struct literally grows without rippling.
Invariant #19 mandates pub fn new() on every #[non_exhaustive] public struct. 15 constructors confirmed across the workspace as of Session 2a. Verified by scripts/ci/check-tests.sh + explicit snapshot tests in nika-catalog.
2. Reserved `Option<T>` fields on `InferRequest` / `InferResponse` #
Future-phase fields land today as None-initialized options:
memory: Option<MemoryDirective>— Cortex, v0.95budget: Option<BudgetDirective>— v0.100replay_seed: Option<u64>— v0.100memory_frames: Vec<MemoryFrameRef>— Cortex, v0.95
When v0.95 lands, the provider impls start populating these fields. No struct shape changes. No downstream migrations.
3. Kernel hooks pre-planted at Phase 0 #
nika-kernel (L0.5) defines these traits now, even though no implementation ships until v0.95/v0.100:
MemoryStore,EmbeddingProvider— Cortex (v0.95)ToolExecutor— agent-v2 parallel tool calling (v0.100)WasmPluginHost— WASM plugins (v0.100)ObservabilitySink— tracing + metrics (v0.100)
At v0.95, a new crate nika-memory-oxigraph implements MemoryStore. Zero modification to the already-admitted kernel.
4. `EventKind` reserved variants + `Extension` escape hatch #
EventKind is #[non_exhaustive] and carries placeholder variants for every planned subsystem: MemoryHit, AgentPlanned, Trace. Plus an Extension { ns: &'static str, name: &'static str, payload: serde_json::Value } variant for community extensions that bypass core without conflicting.
Consequences #
Positive #
- v0.95 Cortex ships by adding
nika-memory-oxigraphimplementing kernel traits — zero modification to v0.90 code. InferRequestfield additions are not breaking changes within v0.x.Extensionvariant gives ecosystem extensions a forward-safe escape hatch.- Downstream users are protected from struct-shape churn during 11+ month Diamond build.
Negative #
- Every public type carries invariant overhead (
#[non_exhaustive]+new()constructor). For internal crates this is ceremony — mitigated by workspace lintunreachable_pub = warnwhich catches over-visibility. - Reserved
Optionfields inInferRequestserialize asNoneuntil v0.95; slight JSON bloat (~5 extra null fields per request). Acceptable. - Future-phase fields require discipline to never accidentally use in pre-phase code. Compiler does not enforce "use only when phase ≥ X."
Neutral #
Extensionvariant payload isserde_json::Value(unstructured). Intentional — extensions define their own schema; core stays neutral.
Evidence #
crates/nika-kernel-ai/src/provider.rslines 196–239 —InferRequestwithmemory: Option<MemoryDirective>,new()constructor, full field listcrates/nika-kernel-ai/src/provider.rslines 291–312 —InferResponsewithmemory_frames: Vec<MemoryFrameRef>crates/nika-kernel-ai/src/memory.rslines 272–305 —MemoryStore+EmbeddingProvidertrait declarations (Cortex hooks)crates/nika-kernel-runtime/src/tool_executor.rs—ToolExecutortrait (agent-v2 hook)docs/architecture/forward-compat-invariants.mdlines 1–100 — the 8 patterns, 5 locked decisions, 10 API design rules, 10 anti-patterns- memory:
POST_AUDIT_REVISIONS.mdDÉCISION 3 — kernel hooks Phase 0 mandatory - Invariant #19 FULL status in
memory: STATE.md(15 constructors, all tested) - Related crates: `nika-error` (error variants #[non_exhaustive]), `nika-kernel` (pre-planted hooks), `nika-catalog` (16 new constructors Session 2a)
Alternatives considered #
Alt A — Add fields as they're needed, breaking-change bump #
Rejected per ROI math (6.7× cost at v0.95).
Alt B — Use builder pattern everywhere (typestate) #
Considered. Rejected: overkill for request/response types that have reasonable defaults. Reserve for kernel-level ceremony like WorkflowBuilder.
Alt C — Versioned struct types (`InferRequestV1` / `InferRequestV2`) #
Rejected: API churn at version boundaries is exactly what the forward-compat discipline (ADR-002 · amended D-2026-06-20-N1 · real semver toward 1.0) is trying to avoid.
Related #
- ADR-002 — real semver toward 1.0 (amended D-2026-06-20-N1 · the release model that demands forward-compat)
- ADR-005 — error hierarchy (error enums also
#[non_exhaustive]) - ADR-006 — kernel ISP traits (where the hooks land)
- ADR-015 —
expect-testinline snapshots (regression coverage when#[non_exhaustive]types grow new variants and rendered output drifts) docs/architecture/forward-compat-invariants.md— full reference
Notes #
Invariant #19 is a gate in the admission protocol (ADR-003, Gate 4 — zero clippy warnings — includes non_exhaustive_omitted_patterns = "deny"). Additions slip is mechanically impossible.
read at v0.107.0 · the decision record ships with the engine