docs/architecture/l0-l05-architecture-decisions.md.
Full rationale (3-4 agents per question) lives in the engine repo.Decision index
| Q | Decision | Status |
|---|---|---|
| Q1 | No proc macros in L0 · manual impl + macro_rules! | LOCKED |
| Q2 | No nika-stdx; split nika-error → nika-types + nika-error | LOCKED · executed |
| Q3 | Extract nika-catalog-codegen NOW (testable + reusable) | LOCKED |
| Q4 | nika-event: 3-layer split (L0 types + L1 store + L2 export) | LOCKED |
| Q5 | Admission order: schema → codegen → event → binding → pck-manifest | LOCKED |
| Q6 | EventKind = scoped sub-enums (Pattern B, ~22 categories) | LOCKED |
| Q7 | nika-kernel prelude re-export hub (4 lines, 0 new deps) | LOCKED |
| Q8 | nika-transform = standalone L0 crate (rev.2: 2 consumers found) | LOCKED rev.2 |
| Q9 | Timestamp + WallDuration = module in nika-types | LOCKED rev.2 |
| Q10 | Canonical-JSON (RFC 8785) = module in nika-types | LOCKED rev.2 |
| Q11 | Token-streaming cardinality = delta-batching, not 1-event-per-token | LOCKED rev.3 |
| Q12 | Drop ObservabilitySink + add AuditSink (5 sibling channels) | LOCKED rev.3 · executed |
| Q13 | Bridge OTel GenAI semconv via typed GenAiAttrs on Infer | LOCKED rev.3 · executed |
Why these decisions exist
Deep dives
Q1: No proc macros in L0
Q1: No proc macros in L0
syn + quoteproc-macro2(~50 MLOC compile graph). L0 crates must compile in seconds for CI parity testing. Manualimpl Trait for Struct+macro_rules!covers every pattern we’ve needed at L0.
#[builtin_tool]
attribute macro in nika-verb-invoke). L0 stays procmacro-free forever.Impact. nika-types compile time stays < 2s; downstream crates
don’t pay proc-macro cost transitively.Q2: nika-types is the foundation, not nika-stdx
Q2: nika-types is the foundation, not nika-stdx
nika-stdx crate devolves into a kitchen-sink
(anti-pattern #1 from layer-registry). Instead, foundation types live in
nika-types (splitable when cohesion demands), error infra in
nika-error, lookup tables in nika-catalog. Purpose-named, not
kitchen-sink.Executed. nika-types admitted as L0 foundation (no I/O, no async,
Cow-heavy). nika-error re-exports the subset it needs.Q3: Extract nika-catalog-codegen as standalone L0
Q3: Extract nika-catalog-codegen as standalone L0
build.rs. Extract it so:build.rsremains <50 LOC (delegates).- Codegen gets its own unit tests + snapshot tests.
- Community overlays (
nika-catalog-cn) reuse the same codegen.
Q4: nika-event 3-layer split
Q4: nika-event 3-layer split
nika-event-store writes NDJSON / SQLite)
which is separate from export (L2 nika-event-export pushes OTel /
Datadog / Honeycomb). Each layer tested in isolation.Mapping: L0 nika-event (~2.5k LOC) types + sub-enums ·
L1 nika-event-store (~3k LOC, future) · L2 nika-event-export (~2k LOC, future).Q5: Admission order for L0
Q5: Admission order for L0
nika-schemaunblocks the rest (it defines the AST).catalog-codegenindependent (purely build-time).nika-eventneeds types (done) and schema (for correlation IDs).nika-bindingneeds event + transform.nika-pck-manifestis standalone and can run in parallel with any of the above.
Q6: EventKind scoped sub-enums (not mega-enum)
Q6: EventKind scoped sub-enums (not mega-enum)
EventKind enum with 22 variants becomes a
merge-conflict magnet and bloats every consumer’s match arm. Instead,
~22 sub-enums grouped by emission site (ExecEvent, InferEvent,
ProviderEvent, etc.), unified under an Event sum type via the
event_categories! macro.Impact. Each verb crate emits its own sub-enum variants; consumers
match on the category they care about; rest are transparent.Q7: nika-kernel prelude re-export hub
Q7: nika-kernel prelude re-export hub
use nika_types::*; use nika_error::*; use nika_kernel::traits::*; (3 imports every file). Instead,
nika-kernel ships pub mod prelude that re-exports from nika-typesnika-error+ its own traits. Verb crates:use nika_kernel::prelude::*;.
Q8: nika-transform = standalone L0 crate (REVERTED rev.2)
Q8: nika-transform = standalone L0 crate (REVERTED rev.2)
nika-transform into nika-binding (single
consumer).Reversion rationale. Swarm audit found a second consumer:
nika-builtin-* crates need nika-transform filters (json, base64,
shell_quote) for invoke:builtin: parameter processing. Inlining would
force nika-binding dependency on every builtin crate (wrong coupling).Final. nika-transform stays L0, 65 transforms in 7 sub-modules,
two consumers (binding + builtin-*).Q9-Q10: Timestamp + canonical-JSON = modules in nika-types
Q9-Q10: Timestamp + canonical-JSON = modules in nika-types
nika-time, nika-canonical). Audit found each has zero
non-foundation dependencies and tight coupling to core types. They
become modules inside nika-types:nika_types::timestamp:Timestamp+WallDuration(monotonic + RFC3339)nika_types::hash::canonical: RFC 8785 canonical-JSON for hash stability
nika-types gains ~300 LOC, stays well under 15k crate cap.Q11: Token-streaming delta-batching
Q11: Token-streaming delta-batching
InferEvent::Token per LLM token would flood
the event bus (2k tokens/s on fast providers). Instead, batch into
deltas: emit InferEvent::Delta { text, token_count, duration } every
50ms or 32 tokens, whichever first.Impact. Event rate capped at ~20 Hz regardless of provider speed.Q12: 5 sibling sinks (not 1 ObservabilitySink)
Q12: 5 sibling sinks (not 1 ObservabilitySink)
ObservabilitySink god-trait would have
forced every sink implementation to handle 5 concerns at once. Instead,
5 sibling traits live alongside each other:EventSink: structured events (NDJSON, OpenTelemetry)MetricsExporter: Prometheus / StatsD / OTel metricsTracerProvider: OTel traces (W3C TraceContext)AuditSink: compliance audit log (tamper-evident, separate from EventSink)BillingSink: cost accounting ($ per provider call)
Q13: OTel GenAI semconv via typed GenAiAttrs
Q13: OTel GenAI semconv via typed GenAiAttrs
gen_ai.system, gen_ai.request.model, etc.).
Rather than stuff strings into a HashMap<String, String>, expose a
typed GenAiAttrs struct on InferRequest / InferResponse.Impact. Consumers get IDE completion + compile-time safety.
Non-breaking: GenAiAttrs is #[non_exhaustive]. New attrs are additive.