Redouble AI Nucleo documentation
Part 13 · Inside the runtime

Inside the trust boundary

ai.redouble.nucleo.tools.guardrails · nucleo-core

This page is for people working on the runtime itself: where the scope and the guardrails are enforced, the rules of the submission door, and the contract every guardrail is evaluated under. The guide for using them is Scope and the trust boundary, and writing each guardrail kind is Writing guardrails.

Where the pieces live

The sealed kind hierarchy, the scope vocabulary, the reference guardrails and the declaration interfaces live in ai.redouble.nucleo.guardrails; the enforcement engine (GuardrailEnforcer) and the submission door (JobDispatcher) live in ai.redouble.nucleo.harness. This package holds the concrete content checks (SizeCapGuardrail, SchemaConformanceGuardrail, UrlGuardrail, ExfiltrationMarkerGuardrail).

The hierarchy is sealed accordingly: Guardrail permits exactly AuthGuardrail, ContentGuardrail, AdmissionGuardrail, ValidationGuardrail (all in ai.redouble.nucleo.guardrails). Wrong-seat declarations do not compile - the typed returns of the declare methods are the seat rules.

Reference implementations ship in Nucleo, one per rung, doubling as the enforcement test fixtures: PayloadSizeCapGuardrail (content INPUT, parameterized), PIIDetectionGuardrail (content OUTPUT), PrincipalAllowListGuardrail (auth), AgentClassAllowListAdmissionGuardrail (admission), plus TenantScope + TenantScoped as the reference scope axis.

Enforcement: the framework, on every route

Nothing in application or orchestration code runs guardrails. GuardrailEnforcer (in ai.redouble.nucleo.harness), called from the dispatch path, enforces them for every job on every route - thinker-driven, doer-direct, fan-out worker, delegated sub-agent, and whatever transport arrives later. Protection is a property of the tool and the flow, never of the route a call took.

The evaluation contract

A guardrail is a function of exactly two things: validate(target) with the target set before submission, and the immutable JobSnapshot of the gated job (setGatedSnapshot). No live framework object ever crosses into a guardrail: never the Tool, never the JobContext, never an orchestrator. Everything else is the guardrail's own lookup.

All refusals are GuardrailException - correctable by design, so the agent loop can act on the message. The message is the whole semantics: never new exception classes. Capability-wide "never" belongs to admission, where refusal means the tool is not even offered.

Scope enforcement: the submission door

The *Scoped pattern is an identity statement. The examples' CaseAgent carrying a customerId does not merely "have access to" a customer - the instance IS customer C-100's case. The scope machinery's single job is keeping the flow inside its own identity: every id the model writes into a tool input must equal the id the code assigned.

The same *Scoped marker plays two roles on the two sides of the trust boundary. On an input it carries the CLAIM: written by the model, every call - and the field the door judges is the same field the tool acts on, so there is no gap between what was checked and what was executed. On an orchestrator it IS the binding - implementing the marker is the whole declaration.

Enforcement is the submission door in JobDispatcher.dispatch, on every route:

A sub-flow with a genuinely different scope is expressed the only way it can be: trusted code constructing and dispatching a new scoped orchestrator with a new binding, bounded by the auth rung. Permission checks do not belong in scope judgments: "may this principal touch this entity at all" is an AuthGuardrail seated on the tools (PrincipalAllowListGuardrail is the reference shape).

Authoring a new scope axis

  1. A record implementing Scope (the examples' record CustomerScope(String customerId) implements Scope).
  2. A marker extending Scoped with the id getter and a default scope() returning the record (CustomerScoped.getCustomerId()).
  3. Every input that carries the claim implements the marker; the orchestrator that owns the flow implements the same marker.

Nothing else: no guardrail class, no registration, no per-tool wiring - any submission in the flow's subtree carrying the marker is judged.

An axis that refines another (a line item within an invoice) is an inheritance chain, because this is Java: the scope becomes a plain immutable class extending the parent scope (records cannot extend), and the marker extends the parent marker overriding scope(). Each level's matches adds its own fields on top of super.matches, guarding its own cast with instanceof so it skips itself for a candidate that does not reach its depth. Scope.pass then judges any two related claims on the axes both carry - an invoice-bound guard pins a line-item claim's invoice axis, a line-item-bound guard pins a plain invoice claim, and two sibling refinements pin each other's shared parent axis - with no extra machinery (the region scope in ScopeValueTest, refined by a city scope that is itself refined by a block scope and by a sibling district scope, is the worked example). CompositeScope is only for a carrier claiming several UNRELATED axes at once.

What the runtime records about a refusal

A guardrail is a job: the enforcer submits it and blocks on the handle, so a refusal travels the ordinary job lifecycle. The agent sees it - GuardrailException is correctable, so the loop hands the message to the LLM as an observation it can act on. Observers see it - the guard job's failure reaches the message bus like any other, which is how a deployment forwards refusals to a SIEM or audit table (see Why observability has this shape); routing policy is the deployment's observer, not framework behavior. The runtime writes the gating relationship onto the guard job's metadata - AbstractGuardrail.OBS_GATES_JOB_ID names the gated job, OBS_GATES_PHASE the rung (AUTH / ADMISSION / CONTENT_INPUT / CONTENT_OUTPUT / VALIDATION) - so a deployment's recorder can lift them into columns of its own and query guardrail rows against the jobs they gated. Scope refusals travel the same exception surface but spawn no guardrail job: the door refuses before the child exists.

The validation seat

An OUTPUT content guard on a thinker can only refuse delivery: it runs at the dispatch door after the thinker's execute() has returned, and the thinker released its conversation before that. Whoever called the thinker gets the failure, and the model that wrote the answer, the one holding every fact needed to fix it, never hears about it. A validation guard runs at the other seat: the final-answer branch of a goal-directed thinker (SingleObjectiveThinker, and AbstractToollessThinker), after artifact refs are resolved and before the answer is streamed, while the conversation is still held.

The use case that shaped it: a thinker runs a long flow and produces a record; a guard checks every field against what it knows; on a discrepancy the thinker, not its caller, is told what is wrong and fixes it with everything it already has in context.

Type hierarchy

Guardrail<T> (sealed, Nucleo) -- validate(T), setTarget, targetType(), setGatedSnapshot
    |
    +-> AuthGuardrail<T>                             seat: tools
    +-> ContentGuardrail<T>   (+ direction())        seat: tools (INPUT and OUTPUT)
    +-> AdmissionGuardrail    (+ init(AdmissionContext))  seat: tools + ToolHub rules
    +-> ValidationGuardrail<T>                       seat: the goal-directed thinker's final answer

AbstractGuardrail<T> (Nucleo)          -- job plumbing; implements no branch; carries the
    |                                     scope-guard seat a guard uses only when it declares ScopeAuthority
    +-> AbstractContentGuardrail<T>    (Nucleo)
    +-> AbstractAuthGuardrail<T>       (Nucleo)
    +-> AbstractAdmissionGuardrail     (Nucleo)
    +-> AbstractValidationGuardrail<T> (Nucleo)

Scope / Scoped / CompositeScope / ScopeGuard (nucleo.guardrails) -- the scope vocabulary,
judged at the submission door, guard jobs included, no guardrail jobs spawned for it

Declaration seats: GuardedExecution (implemented by every Tool) carries the three tool-side declare methods; AbstractThinker carries declareValidationGuardrails(), consulted by SingleObjectiveThinker and AbstractToollessThinker; ScopeAuthority (extended by Orchestrator, declarable by a guardrail) carries the scope-guard field and marks the only classes allowed to submit from inside a job.

Thread safety

ComponentThread Safety
GuardrailEnforcerThread-safe (stateless entry points)
ScopeGuardImmutable
Guardrail instancesSingle-threaded (one instance per validation)
^