TnsAI
Tutorials

Tutorial: Declarative input/output guardrails

Validate, sanitise, and bound every action's parameters and return value through annotations alone — no per-action if (input.length() > N) throw … plumbing in your Role. The framework's InputGuardrailEnforcer and OutputGuardrailEnforcer enforce the contract at dispatch time, before and after the action body runs.

Prerequisites

The role

Full source lives at tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/UserInputRole.java and is exercised by UserInputRoleGuardrailIntegrationTest. The shape:

@RoleSpec(
    name = "UserInputAgent",
    description = "Validates and sanitises user-supplied text.",
    responsibilities = {
        @Responsibility(
            name = "InputHardening",
            actions = {"summarize", "formatJson", "adminCommand"})
    }
)
@InputGuardrail(
    // Class-level default — inherited by every action below
    maxLength = 16384,
    blockPatterns = {
        "(?i)ignore\\s+previous\\s+instructions",
        "(?i)disregard\\s+the\\s+system\\s+prompt",
        "<script\\b"
    },
    onFailure = FailureAction.REJECT
)
public class UserInputRole extends Role {
    // ... actions below
}

Pattern 1 — Class-level default + per-action inheritance

The @InputGuardrail on the class sets a baseline that every @ActionSpec method inherits unless it declares its own. This is the cleanest way to express "every action this role exposes must reject prompt-injection attempts and oversized payloads."

@ActionSpec(
    type = ActionType.LOCAL,
    description = "Summarize user-provided text in plain language."
)
@OutputGuardrail(maxChars = 2000, onFailure = OutputGuardrail.FailureAction.TRUNCATE)
public String summarize(@Param(name = "text") String text) {
    return "Summary of " + text.substring(0, Math.min(text.length(), 64)) + "…";
}

summarize has no @InputGuardrail of its own, so the framework applies the class default: anything containing (?i)ignore previous instructions is rejected, anything over 16KB is rejected. Output is bounded separately to 2000 chars via @OutputGuardrail; oversize output is silently truncated rather than failing the call.

Pattern 2 — Method-level override + SANITIZE policy

When a specific action needs different rules, declare @InputGuardrail on the method. Method-level wins entirely — no merging. This is deliberate: merging produces surprising behaviour when one annotation tightens what another loosens.

@ActionSpec(
    type = ActionType.LOCAL,
    description = "Format a JSON-shaped payload."
)
@InputGuardrail(
    minLength = 2,
    maxLength = 4096,
    allowPatterns = {"^\\s*[\\{\\[]"},      // must start with { or [
    onFailure = FailureAction.SANITIZE
)
@OutputGuardrail(maxChars = 4096, onFailure = OutputGuardrail.FailureAction.TRUNCATE)
public String formatJson(@Param(name = "json") String json) {
    return json; // pretty-print in real life
}

SANITIZE strips C0 control characters (preserving \t / \n / \r) and truncates to maxLength. The action body still runs — but it sees a cleaned value rather than failing.

Pattern 3 — REVIEW disposition for human-in-loop gating

When you want a violation to route to a human rather than block silently, use onFailure = FailureAction.REVIEW. The thrown GuardrailViolationException carries a Cause.NEEDS_REVIEW marker so callers can route to an approval queue.

@ActionSpec(
    type = ActionType.LOCAL,
    description = "Issue a privileged admin command."
)
@InputGuardrail(
    maxLength = 64,
    allowPatterns = {"^[a-z][a-z0-9_-]{0,62}$"},
    onFailure = FailureAction.REVIEW,
    errorMessage = "Admin commands must be lowercase identifiers; routing to human review."
)
@OutputGuardrail(
    maxChars = 256,
    onFailure = OutputGuardrail.FailureAction.FALLBACK,
    fallback = "(redacted: admin output exceeded budget)"
)
public String adminCommand(@Param(name = "command") String command) {
    return "executed: " + command;
}

A consumer catches ActionExecutionException (the dispatcher wraps GuardrailViolationException as category VALIDATION), inspects the typed cause, and routes to a queue:

try {
    Object result = agent.executeAction("adminCommand", Map.of("command", "REBOOT_NODE"));
} catch (ActionExecutionException ex) {
    if (ex.getCause() instanceof GuardrailViolationException gve
        && gve.guardrailCause() == GuardrailViolationException.Cause.NEEDS_REVIEW) {
        approvalQueue.submit(gve.actionName(), gve.parameterName(), ex.getMessage());
    } else {
        throw ex;
    }
}

Failure dispositions reference

@InputGuardrail.onFailure

ValueWhat happens
REJECTThrows GuardrailViolationException (wrapped as ActionExecutionException(VALIDATION)). Action body never runs.
WARNLogs the violation; passes the original value through unchanged. Useful for telemetry on borderline values.
SANITIZEStrips C0 control chars (preserving \t / \n / \r) and truncates to maxLength. Action body sees the cleaned value.
REVIEWThrows with Cause.NEEDS_REVIEW so callers can route to a human-approval queue.

@OutputGuardrail.onFailure

ValueWhat happens
REJECTThrows GuardrailViolationException (same wrapping as input).
TRUNCATEClips a too-long value to maxChars. Falls back to FALLBACK for too-short values.
FALLBACKReturns the configured fallback string.
RETRYPhase 1: degrades to REJECT with a self-documenting message — the enforcer doesn't have a re-execution callback yet.
REVIEWThrows with Cause.NEEDS_REVIEW.

What's enforced today

Annotation fieldEnforced in Phase 1
minLength / maxLength (input)
minChars / maxChars (output)
blockPatterns (regex denylist)
allowPatterns (regex allowlist)
onFailure disposition
fallback (output)
errorMessage (custom log line)
logFailures
validators / sanitizers (Class[])Phase 2 (needs SPI)
jsonSchemaPhase 2
detectInjection / moderateContent / blockCategoriesPhase 2 (needs content-moderation provider)
maxTokensPhase 2 (needs tokenizer)
maskPII / piiTypesPhase 2 (will plug into #80)
checkHallucinationPhase 2 (LLM-driven)
format per OutputFormat variantPhase 2
RETRY re-executionPhase 2 (needs callback wiring)

The @InputGuardrail and @OutputGuardrail annotations themselves already document every field; this table records what the runtime enforcer does with them today, not what the annotation surface promises.

Where it sits in the pipeline

Inside ActionExecutor.executeInternal:

1. Approval check
2. Security access control + parameter encryption
3. @BeforeAction transformation
4. ActionValidator.validateParameters (basic schema)
5. ActionContract.validateInvariants + preconditions
6. Invariant precondition checks
7. ► InputGuardrailEnforcer.enforce  ← input guardrails
8. Action body execution (LOCAL / WEB_SERVICE / LLM / MCP_TOOL)
9. ► OutputGuardrailEnforcer.enforce ← output guardrails
10. Postcondition + post-execution invariants

Both enforcers translate GuardrailViolationException into ActionExecutionException(VALIDATION) at the dispatcher's edge, so existing consumer error handlers that match on ActionExecutionException continue to work without changes.

Reference

  • @InputGuardrail annotation — tnsai-core/src/main/java/com/tnsai/annotations/InputGuardrail.java
  • @OutputGuardrail annotation — tnsai-core/src/main/java/com/tnsai/annotations/OutputGuardrail.java
  • GuardrailViolationExceptiontnsai-core/src/main/java/com/tnsai/guardrails/GuardrailViolationException.java
  • InputGuardrailEnforcer / OutputGuardrailEnforcertnsai-core/src/main/java/com/tnsai/guardrails/
  • UserInputRoletnsai-integration/src/main/java/com/tnsai/integration/scop/examples/UserInputRole.java
  • UserInputRoleGuardrailIntegrationTesttnsai-integration/src/test/java/com/tnsai/integration/scop/examples/

On this page