# 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](../start/installation.md)
- A Role with at least one `@ActionSpec` method

## 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:

```java
@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."

```java
@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`; 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.

```java
@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.

```java
@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:

```java
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](https://github.com/TnsAI-Framework/TnsAI/issues/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 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`
- `GuardrailViolationException` — `tnsai-core/src/main/java/com/tnsai/guardrails/GuardrailViolationException.java`
- `InputGuardrailEnforcer` / `OutputGuardrailEnforcer` — `tnsai-core/src/main/java/com/tnsai/guardrails/`
- `UserInputRole` — `tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/UserInputRole.java`
- `UserInputRoleGuardrailIntegrationTest` — `tnsai-integration/src/test/java/com/tnsai/integration/scop/examples/`
