ADR-121
nika-runtime size-cap member split: the run's dataflow descends to nika-dataflow
accepted · 2026-08-25 · L1 L3 · cites 5
0ADR-121: nika-runtime size-cap member split — nika-dataflow #
Context #
nika-runtime measured 14,994 prod LOC against the 15,000 Diamond
invariant — six lines of headroom on the crate every feature lands in.
The wall surfaced through #1171, a written pull request that could not
land: branch and main were each under the cap while their merge
crossed it. Only the merge commit can see that, and no local gate runs on
one.
What made it a decision rather than an inconvenience is how the budget was being paid. #1171 had already compressed three explanatory doc blocks to one-liners in the same diff that added its feature — and still did not fit. A comment is the cheapest line to delete, so it is the first line deleted, which means a maintainability budget gets satisfied by discarding exactly the thing that makes the crate maintainable. The number stays honest while the property it stands for erodes.
nika-runtime is itself the crate that ABSORBED one of these moves: the
production composition descended here from nika-cli at the same wall on
2026-07-22, and the secret-resolution half left for nika-secret on
2026-08-06. The cap is a locked maintainability budget
(nika-invariants.md), not advisory.
These figures are the verdict of the production-LOC gate shipped on current
main. This replacement is bounded to the dataflow admission and does not
claim or carry a separate counter rewrite.
Decision #
Per D-2026-07-09-N1 (a size-cap split is ONE architectural unit in TWO
workspace members), the run's dataflow descends to a new L0 member
crate nika-dataflow. Two questions live there, and they are one question:
- What a task record IS —
TaskStatus·TerminalCause·TaskErrorRecord·TaskRecord, the spec-13 transition law (legal), the failure-cause triage (failure_cause), the Outcome IR (outcome_json), the canonical value rendering (render_value), andTIMEOUT_CODE— the wire code the triage reads. - How a value referencing those records resolves —
Scope,${{ }}island rendering (render·render_json),cel-subset/0.1gate evaluation (eval_when·resolve_expr), andoutput:named jq bindings (eval_binding) — plus the four evaluation error classes asDataflowError.
They descend together because they are not separable: expr projects a
TaskRecord into the CEL object a ${{ tasks.x.output }} island reads,
and renders values back out through record::render_value. A seam between
them would cut one concept in half.
Why this module and not another #
The cut was chosen by coupling, not line count. Measured before the
move, the trio had exactly three intra-crate edges —
crate::errors::RuntimeError, crate::task::TIMEOUT_CODE, and
expr → record (internal to the trio) — against ten-plus for the next
plausible cluster (approval / pause / resume / recover, woven through
task, settle, integrity, agent_events, proof, witness and
stamp). It also needs nothing the runtime owns: no EventSink, no
clock, no compose ladder, no session state. One typed nika-cap policy removes
host environment, diagnostics, and process control; accepted clock spellings
are pure definitions over the one run-start value minted by the runtime at the
execution boundary and forwarded through the executor seam. Halt exceptions
become typed binding errors rather than process exits. The crate is pure — zero
I/O, zero async, zero clock reads — which is why the executor keeps the effects
and this keeps the evaluation.
The seam does not move #
nika-runtime keeps source-compatible TaskRecord, TaskStatus,
TerminalCause, TaskErrorRecord and legal wrappers at their historical
nika_runtime::… paths. Those deliberately preserve the pre-split exhaustive
enum matches and TaskErrorRecord field literals; conversion happens once at
the public RunOutcome boundary. Internally crate::{expr,jq,record} remain
aliases of the canonical dataflow implementation. RuntimeError keeps its four historical
evaluation variants and converts the dataflow-owned errors back into those
exact constructors — public pattern matching and fields remain valid, and
Display, Diagnostic, spec_code() and nika_code() remain byte-identical —
so the wire form a consumer sees
(NIKA-VAR-001 · -002 · -004 · -005 · -006) is byte-identical.
RuntimeError::from_cel still exists and delegates.
The conversion tests stay in nika-runtime, because the compatibility facade
is precisely the risk the descent introduces. Moving them to the new crate
would have tested the dataflow enum and left the public runtime seam unproven.
Layer #
L0, by the registry's mechanical sort: it is pure, synchronous logic with
zero I/O, async, or clock. It has five cohesive sibling inputs — nika-cel ·
nika-tmpl · nika-types · nika-cap · nika-error — so
it uses ADR-027's explicit L0-DEP-FANOUT-EXEMPT policy record. Calling it L1
only to avoid that fanout verdict would confuse an effect layer with pure
evaluation and make the layer declaration less truthful than the code.
Consequences #
nika-runtime14,787 → 14,053 prod LOC under the corrected in-tree gate. The doc comments #1171 compressed can be restored; they were paying rent for a wall that is not theirs.- One more workspace member, under the ADR-037 horizon (50-90 · cap 100 · projected, never a gate · D-2026-07-21-N1).
Scope::workflowandScope::workflow_with_secrets— the empty-namespace TEST constructors — become reachable across the crate boundary and are therefore gated behind atestingfeature thatnika-runtimeenables indev-dependenciesonly. Plainpubwould have blessed a constructor that silently turns a realsecrets.Xinto aNIKA-1702;#[cfg(test)]would have hidden it from the sibling tests that need it.- FCI-002 remains true at the new
nika-dataflowpublic seam:Scope,TaskStatus,TerminalCause, andTaskErrorRecordare non-exhaustive.Scopekeeps its authority fields private and begins only at the explicit value-authority constructor. The historicalnika-runtimefacade remains source-compatible through closed wrappers; it does not leak the new DTOs. - The next crate at the wall is `nika-check` at 14,930 under the same in-tree gate. It descended once already, on 2026-07-21. This ADR does not decide that move; it names it so the next reader does not rediscover it at merge time.
Alternatives considered #
- Compress more prose. Rejected: it is the defect, not the fix. #1171 had already done it once and still did not fit.
- Raise the cap. Rejected without a rule change. The cap is locked in
nika-invariants.md; re-ruling it is a separate, deliberate act with its reason written down — not something a blocked PR does in passing. - Descend approval / resume instead. Rejected on the measurement: ten or more intra-crate edges, and it needs the event lane. It would have dragged half the crate or created a cycle.
- Fix only the counter. Rejected as the whole answer, though it was done alongside. Corrected, the crate is still at 98.6% of the cap.
read at v0.116.2 · the decision record ships with the engine