Tutorial: Declarative input/output guardrails
Validate, sanitise, and bound every action's parameters and return value through annotations alone, with 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."
)
@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(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, and 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, with 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(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(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 and 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, because 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/