ADR-122
One access plan is the execution authority: resolved once, executed as resolved
accepted · 2026-09-03 · L1.5 L2 L3 L4 · cites 4
0ADR-122: one access plan is the execution authority #
Context #
A run has to answer one question before its first task: which path
reaches each model on this machine — a provider API with a key, a
local server, the mock, or a subscription seat driven through an agentic
CLI. Until this decision the question was answered by five independent
resolutions on one nika run path, and none of them was what the
dispatcher read:
- surface · resolved by · consumed by execution
- the `--dry-run` preview · `access_plan_map` over the report's models, pin only · no
- the boot manifest (`access_plan` field) · `access_plan_map` again, in the composer · no
- the human announce · `resolve_access` per model, after composition · no
- `check --json` `access_plan` rows · `resolve_access` per model, no pin · no
- the admission gate · `access_pin_refusal`, pin only · only under a pin
The seat itself was gated on the spelling of --access: without a
typed pin the runtime discarded the ready seat it had just computed and
the task dialed the provider API with whatever key was in the
environment. Measured on the shipped 0.116.2 (census B of the one-door
refactor pack): the announce said codex, the run dialed OpenAI with a
dead key and failed inside the task; --model mock/echo announced the
file's model; a pinned seat was priced on the API lane. Five answers to
one question cannot stay equal, and the one that mattered was not asked.
Decision #
The access plan is resolved once per execution attempt and then executed as resolved. Nothing on the run path resolves access a second time.
- One resolver, one value.
nika_providers::resolve_execution_planfolds the run's needs (ModelNeed= each effective model with the verbs that read it,--modelalready applied) with this machine's probe rows and the--accesspin into a frozenExecutionAccessPlan: oneLaneVerdictper static model (admitted with itsAccessPlanand candidate count, or refused with every witness), the pin, the ONE seat the run holds, and the pin refusal when the pin cannot be honored.nika_cli_host::access::resolve_planis the single composition of needs + probes for the CLI; it runs once inrun_admitted_contextand once per answered gate leg. - The runtime executes the plan. The composer attaches it
(
AuthorizedRuntime::with_access_plan); the seat is built fromplan.seat— never from the pin's spelling and never from « a seat exists »; the admission belt refuses from the plan (nika_runtime::plan_refusal: an unsatisfied pin isNIKA-1801, a lane with no ready path isNIKA-1800, before the first task and with the witnesses); eachinfer:/agent:task routes by its lane (Runtime::seat_for(model)) — a pinned seat serves every model, a resolved seat serves only its harness lanes, and an agent whose lane is a provider path runs the native loop even while a seat is attached for another lane. - Everything else is a projection. The announce, the
--dry-runpreview (text and JSON),check --json'saccess_planrows,nika explain's access section, the boot manifest'saccess_pinandaccess_planstamps, and the task terminal'saccess·access_id·billing·providerfields all read the plan. They can render it; they cannot disagree with it. - Eligibility is part of resolution. A harness candidate is
eligible for a lane only when the seat can drive every verb that
reads the model: an ACP-only seat (
claude-code,gemini-cli, …) never serves aninfer:lane; only an infer-grade seat (codex) does. A second harness candidate is ineligible once another seat holds the run (one seat per run). A pinned seat's billing class isunknownuntil the adapter's own surface attests it — never a fabricated included-quota.
Consequences #
- A model with no ready path now refuses before task 1 on the
environment exit, naming the model and every rejected candidate,
instead of failing inside the task with a provider error after work
may have started. This is a behavior change for keyless runs of cloud
models; the refusal teaches the fix (
nika doctor, the key's variable, or--model). - The sovereign order (
local < mock < harness < oauth < api) now actually routes: a ready seat wins over a present API key, unpinned. nika_harness::seat_from_pinis deleted;first_ready_infer_harnessand the pin-onlyaccess_pin_refusalremain only as the planless embedder's path (a runtime composed without a plan keeps the old admission law).- Proof:
crates/nika-cli/tests/access_plan_e2e.rsdrives the real binary with a scriptedcodexonPATHbeside a dead OpenAI key aimed at a closed loopback port — the seat serves, the key is never dialed, the announce andcheck --jsonname the path the terminal frame records,--access apinever borrows the seat,--model mock/echoannounces nothing, and no path refuses with exit 3 before any frame.nika_providers::plancarries the unit law (verb eligibility, one seat, pin refusal, templated models unjudged).
Follow-ups #
- Delivered in wave 1b: the resume judges the recorded lanes against
the live plan (a moved lane refuses unless
--accessnames it), the lane joins the resume identity,nika serveresolves and attaches the plan throughServiceExecutionOptions, ARM inherits through the CLI door, andnika_service_execution::accessis the one resolver every door reads. - Wave 2: the layered
checkverdicts (VALID · ACCESS READY · CAPACITY FIT · RUN READY) read the same plan.
Amendment · closing audit, 2026-09-04 #
Static model resolution, thinking and known capacity are now judged at
ExecutionService admission for the root and every captured workflow,
before an execution identity is minted. The root's envelope override is
applied for that judgment; explicit task models and child workflow models
retain their own authority. AuthorizedRuntime rechecks the actual
override before its prologue, so admitting under one model does not permit
running under another invalid model. The judges remain the provider and
checker functions, not copies in CLI, Serve or the SDK.
A child workflow is a distinct composition over captured bytes. It carries the root's explicit access pin and probe facts, but resolves its own model needs into its own frozen plan before child composition. Copying the root's lane map would omit child-only models; losing the pin would permit a different access path. Child boot fields and execution both read the child plan. The CLI boot-field helper delegates to the service projection.
AuthorizedRuntime cannot execute without an attached frozen plan, even
for a workflow whose plan has no model lanes. ServiceExecutionDriver:: execute derives an omitted plan from its captured facts and effective
model; direct builder callers must attach theirs. The generic low-level
Runtime remains an injected execution substrate, not a second authorized
service door. The model-less gate judges the effective envelope too: a
legal override supplies a missing default but never replaces a task model.
The predicate is owned by nika-runtime::first_modelless_task and
re-exported by service access. The oracle evaluates it alongside all
static lanes: an admitted model cannot hide another task's missing model,
and a seat cannot clear a refused lane or pin.
The closure-wide static check does not claim to predict dynamically chosen models or future remote availability. Tests cover known capacity refusal before events, explicit task-model precedence, child-plan propagation and redaction of nested settlement errors. Provider calls and harness behavior require their separate executable fixtures.
See One Door proof boundaries for the distinction between source tests, same-job replay preservation, cross-run comparison and published-artifact provenance.
read at v0.118.7 · the decision record ships with the engine