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
- Installation
- A Role with at least one
@ActionSpecmethod
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
| Value | What happens |
|---|---|
REJECT | Throws GuardrailViolationException (wrapped as ActionExecutionException(VALIDATION)). Action body never runs. |
WARN | Logs the violation; passes the original value through unchanged. Useful for telemetry on borderline values. |
SANITIZE | Strips C0 control chars (preserving \t / \n / \r) and truncates to maxLength. Action body sees the cleaned value. |
REVIEW | Throws with Cause.NEEDS_REVIEW so callers can route to a human-approval queue. |
@OutputGuardrail.onFailure
| Value | What happens |
|---|---|
REJECT | Throws GuardrailViolationException (same wrapping as input). |
TRUNCATE | Clips a too-long value to maxChars. Falls back to FALLBACK for too-short values. |
FALLBACK | Returns the configured fallback string. |
RETRY | Phase 1: degrades to REJECT with a self-documenting message — the enforcer doesn't have a re-execution callback yet. |
REVIEW | Throws with Cause.NEEDS_REVIEW. |
What's enforced today
| Annotation field | Enforced 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) |
jsonSchema | Phase 2 |
detectInjection / moderateContent / blockCategories | Phase 2 (needs content-moderation provider) |
maxTokens | Phase 2 (needs tokenizer) |
maskPII / piiTypes | Phase 2 (will plug into #80) |
checkHallucination | Phase 2 (LLM-driven) |
format per OutputFormat variant | Phase 2 |
RETRY re-execution | Phase 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 invariantsBoth 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
@InputGuardrailannotation —tnsai-core/src/main/java/com/tnsai/annotations/InputGuardrail.java@OutputGuardrailannotation —tnsai-core/src/main/java/com/tnsai/annotations/OutputGuardrail.javaGuardrailViolationException—tnsai-core/src/main/java/com/tnsai/guardrails/GuardrailViolationException.javaInputGuardrailEnforcer/OutputGuardrailEnforcer—tnsai-core/src/main/java/com/tnsai/guardrails/UserInputRole—tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/UserInputRole.javaUserInputRoleGuardrailIntegrationTest—tnsai-integration/src/test/java/com/tnsai/integration/scop/examples/