Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR 0004 — Nucleus is a delegation compiler: intent in, minimum authority out

  • Status: accepted (2026-09-08)
  • Applies to: nucleus run --goal, crates/nucleus-task-compiler, portcullis::effect_catalog, portcullis::task_grant; every later surface that asks a person to grant an agent authority

Context

Nucleus’s authority model is sophisticated: a 13-dimension capability lattice, sink scopes, SPIFFE identity, budgets, egress policy, delegation chains, IFC labels, discharges. Until now that model was also the user’s interface. To run an agent safely a person picked a profile by name, or authored a lattice by hand, and read denials as IFC sink scope violation and web_fetch: never. The security architecture was correct and the usability cost of being correct fell on the user.

The rest of the field resolves that cost by widening authority. The common shape in September 2026 is a per-action classifier or a two-knob sandbox (read-only / workspace-write × ask / never), in which a person’s stated boundary is a line in a transcript that is lost when context is compacted, and in which users approve the overwhelming majority of permission prompts because the prompts are ceremony rather than a boundary. Research prototypes go the other way — task-scoped authorization that treats a submitted task as implicitly authorizing exactly the operations its faithful execution requires, and intent certificates that narrow a static tool manifest — but nothing shipped compiles a stated goal into a durable, enforced, minimum grant before execution. That is the open lane, and nucleus already owns the substrate for it: signed certificates whose extensions narrow by meet, mint_child, a weakening-gap calculator, receipts that attest which authority was exercised, and an observer that synthesizes a narrower profile from a trace.

The DX north star this ADR serves:

Minimize the distance between human intent and safely executing that intent through an agent — that is, make least-privilege delegation feel easier than unrestricted execution.

Stated as a quantity, so it can be argued about:

                   successful delegated work
    D  =  ────────────────────────────────────────────────────────────────
          human decisions + configuration + security knowledge + recovery friction

The invariant: D may never improve by widening authority. This is the same shape as ADR 0005’s “ℐ may never be raised by weakening ≼”, and it is what separates this from the field’s answer to the same usability cost. Every term in the denominator has a cheap fix that consists of granting more, and every one of those fixes is forbidden. A DX change that lowers the denominator by raising A_granted has not improved D; it has changed which quantity is being measured.

Two of the denominator’s terms were instrumented from the start:

  • Authority overhead ρ = A_granted / A_observably-required → 1. Usability never improves by granting more.
  • Delegation clicks C(T) = 1 for a new safe task, 0 for a previously approved one. The consequential decision stays intentional; everything else is derived.

C(T) counts decisions on the path where the grant was right the first time. Recovery friction is the term that decides whether a delegation survives being wrong — an agent refused something it needed, and how far it is from there back to working — and it is measured in nucleus-perf agency --recovery-goal. Security knowledge is the term nucleus does not yet measure; the proxy for it is whether a person ever has to read a lattice dimension to get unstuck.

The five moments

D is spent in five places, and every DX decision in this ADR belongs to one:

momentthe questionwhere it is answered
intenthow does a person say what they want?--goal, compiled — decision 1
grantwhat are they agreeing to?five lines, rendered by meaning — decision 3
frictionhow many decisions does it cost?C(T) = 1, then 0 — --save-grant / --grant
explanationwhen it refuses, what do they learn?the escalation proposal — milestone 4
reusedoes the second time cost less than the first?sealed grants, narrowing-only — decision 6

The moments are not independent, and the ordering matters: an explanation that arrives after the run has ended is not an explanation, it is a post-mortem. That distinction is what milestone 7 is about.

Decision

  1. Intent is compiled, not classified. A goal (nucleus run --goal "fix the failing CI build") is input to a deterministic compiler that derives the effects the task needs from the goal and the repository it is stated in (ecosystem, CI system, git remotes, MCP configs), lowers them to a PermissionLattice, and meets the result with a ceiling profile. The grant is ≤ ceiling by construction and is checked with the same delegate_to a certificate mint uses. The ceiling is the only knob a person can widen.

  2. The unit of authority a person reads is a semantic effect, not a lattice dimension. github/read-ci-logs, shell/run-tests, git/commit are declared as data (crates/portcullis/effects/*.toml, plus a repository’s .nucleus/effects/), each with a title, a risk grade, what it lowers to (operations, sinks, hosts) and how it is recognised (MCP tool names, command prefixes, HTTP method+host+path). The host→meaning table lives in the catalog, not in comments beside an allowlist. Plugins improve DX and security together by contributing effects: plugin quality is semantic compression of authority.

  3. The grant is an object, and it is rendered by meaning. A TaskGrant records the goal (prompt playback), can, cannot (proposed but clipped by the ceiling, with the reason), limits, the lattice, a risk summary from the uninhabitable-state analysis, and provenance (which rules fired, which proposers were consulted, the repository-context digest). It renders as five lines — Goal / Can / Cannot / Limits / Risk — with progressive disclosure below them: the 13-dimension grid, then the per-dimension weakening requests and their cost. Same object, three depths.

  4. Effect proposal is deterministic and explainable, with a validated seam for more. The built-in proposer is a rule table over goal phrases × repository context; every rule that fires is named in the grant. An orchestrator may add an EffectProposer as a child process (JSON in, JSON out). Its output is validated against the catalog and meet-clamped like everything else, so a proposer can only narrow or starve a goal, never widen it. Nucleus links no LLM SDK; the compiler crate is offline by construction and CI enforces that.

  5. Fail closed at every edge. A goal nothing recognises is an error that names the remedy (--effects, --profile); it never falls back to a permissive profile. An effect the ceiling clips is reported in cannot, never silently granted or silently dropped. Without a TTY, --goal refuses to run unless --yes names the decision; an unattended run does not acquire authority by default.

  6. Improvement is narrowing-only. The loop this ADR opens is goal → effects → minimum authority → risk delta → execute → receipts → narrower reusable grant. Later milestones seal the grant into a certificate (a durable intent, not a transcript line), turn every denial into a structured escalation proposal bounded by the same ceiling, attribute receipts to effects to compute ρ, and offer a profile with unused authority removed. None of those steps may introduce a path that widens authority outside POST /v1/escalate and the existing approval counters.

  7. A denial is told to the agent as well as to the person. The escalation proposal — what was attempted, why it was stopped, the least authority that would have allowed it, the new risk that adds, and the command that grants exactly that — goes to both audiences, in band, on the refusal itself.

    This widens what an agent is told, and the justification is its trust position: the agent is inside the boundary and holds its own certificate, so it can enumerate its own grant regardless. Telling it what it is missing reveals nothing it could not compute, and withholding it only guarantees that a recoverable refusal is spent thrashing.

    This is deliberately asymmetric with the token endpoint (#2756), and the two must not be harmonised. There, the caller is a remote workload whose authority is still being decided; a refusal that named the scope it fell short of would be an oracle for probing the ceiling, so it names nothing. The rule is not “explain more” or “explain less” — it is that a refusal may enumerate authority to a principal already inside the boundary, and may not to one still outside it. Anyone reading only one of these two decisions will conclude the other is a bug.

Consequences

  • nucleus run --goal is the primary interface; --profile and PodSpec YAML remain the expert path. --dry-run shows the grant and stops; --explain technical | policy-trace deepens it; --save-grant writes it.
  • The 13-dimension verified core does not change. Effects are catalog data now and extensions keys on the certificate later, following the tool_surface pattern.
  • Enforcement of a semantic effect was, through milestone 5, the lattice it lowers to plus the host list plus the command prefixes it vouches for. From milestone 6 a pod whose certificate carries the effect dimension is also bounded per effect at the egress boundary (method + host + path on web_fetch and credentialed egress) and at the MCP boundary (tool names), so github/read-ci-logs cannot be spent on opening a pull request. Shell commands remain bounded by the command lattice.
  • Two metrics become product surfaces: ρ (authority granted ÷ authority used, from receipts) and C(T) (authorization decisions per task). Recovery friction joins them as a third, measured in the agency harness rather than at runtime, because it is a property of the loop and not of a single run.
  • Effect packs are the lever on both halves at once. A pack that names one effect covering what a person actually meant lowers ρ and removes a decision; a pack full of broad effects raises ρ while looking like better DX. Plugin quality is semantic compression of authority, and the two ways to measure a pack — ρ over its effects, and how often granting one of them requires a second decision — are the ways to tell the two apart.
  • A new CI gate, “The task compiler is offline by construction”, is probed by the gate-of-gates like every other script gate.

Milestones

#DeliversStatus
1Effect catalog, TaskGrant + renderer, nucleus-task-compiler, nucleus run --goal preview and single confirmation, offline gate#2675
2effect/ certificate keys (effect_surface), SealedTaskGrant (grant + signed certificate, binding keys), nucleus run --save-grant / --grant, `nucleus grant sealshow` (C(T)=0)
3Trace → effect attribution (grant_usage), ρ over dimensions and effects, post-run usage lines and “save a narrower profile”, nucleus observe --grant --narrow --save, user profiles in ~/.config/nucleus/profiles (never wider than a canonical name)this PR
4EscalationProposal (escalation_proposal): attempt, reason, minimum effect and raised dimensions, risk delta, scopes (always / this run), outside-ceiling and repair outcomes; denials_in_trace; post-run proposals; nucleus grant propose|widen. Carriage inside the denial payloads themselves lands in milestone 7this PR
5AuthoritySummary (authority_metrics, feature-free): ρ over dimensions, C(T) = confirmations + approvals, decision counts; in ExitReport.authority, the MCP session_summary, and the run’s closing line; PodSpec.metadata.task_grant_idthis PR
6Per-effect enforcement from the certificate’s effect/ keys: EffectCatalog::admits_http / admits_tool; the tool-proxy refuses a web_fetch or credentialed-egress request no granted effect vouches for (method + host + path); mcp-guard blocks tools no granted effect names; --goal / --grant runs hand the sealed certificate to the proxy in local modethis PR
7The explanation arrives in time to be used: DenyReason gets one rendering for all nineteen variants and every surface calls it; a refusal carries its escalation proposal in band (ApiError::Refused, ErrorBody.proposal, Error::AccessDenied.proposal); the risk line says what the combination permits rather than counting legs; action.yml takes a goal:; recovery friction becomes a measured row#2758, #2762, #2763, #2764, #2765