Frontier Engineering
← Reliability & Guardrails

Input and Output Guardrail Chain

Also known as: safety filters, content moderation pipeline, pre- and post-checks

Run ordered, independently-failing checks before the model sees input and before the user sees output.

9 min read

The problem

Prompt instructions are not enforcement. A system prompt saying "never discuss competitors" is a strong suggestion that a determined user can talk around, and it provides nothing at all for the failure modes the model cannot self-police: PII leaking into logs, a jailbreak succeeding, an answer that is on-policy but factually ungrounded. Meanwhile teams that do add checks bolt them on as a single tangled function nobody can safely modify.

The pattern

Make guardrails a chain of small, independent, individually-testable checks running outside the model. Input guardrails run before generation and can block, redact, or annotate. Output guardrails run after generation and can block, rewrite, or regenerate. Each check declares its own severity and action, the chain short-circuits on a blocking failure, and every decision is logged.

Use when

  • The assistant is user-facing and inputs are adversarial or unpredictable
  • Policy, regulatory, or brand constraints must be enforced rather than requested
  • PII or secrets can plausibly appear in inputs or outputs
  • You need an auditable record of why a specific request was refused

Avoid when

  • Internal tools with trusted users, where friction outweighs the risk
  • Latency budgets that cannot absorb extra checks — though cheap checks can run in parallel
  • The check would be so imprecise that false positives cost more than the harm prevented

Tradeoffs

  • GainEnforcement that does not depend on the model cooperating with its own prompt

    CostLatency per check, and false positives that block legitimate requests

  • GainIndependent checks are individually testable and safely modifiable

    CostA chain to orchestrate, order, and monitor as its own subsystem

  • GainDecision logs give you an audit trail and a false-positive dataset

    CostEvery check is a new thing to tune, and tuning is never finished


The distinction that matters: a system prompt asks the model to behave, a guardrail makes misbehaviour not reach anyone. Both belong in a production system, and only one of them is enforcement.

Order by cost and short-circuit — there is no value in running a classifier after a regex already rejected.

A check is a small, independent unit

Give every check the same interface so the chain is uniform and each check can be tested in isolation. Cheap deterministic checks — regex, denylists, length limits — go first because they are nearly free. Model-based checks go last and only run if the cheap ones passed.

class Action(StrEnum):
    ALLOW = "allow"
    REDACT = "redact"        # continue with modified content
    REGENERATE = "regenerate"  # output only: try again with feedback
    BLOCK = "block"


class CheckResult(BaseModel):
    action: Action
    reason: str
    modified: str | None = None


class Check(Protocol):
    name: str
    cost: Literal["free", "cheap", "model"]

    async def __call__(self, text: str, ctx: Context) -> CheckResult: ...


INPUT_CHECKS = [
    LengthLimit(max_chars=20_000),        # free
    PIIRedactor(),                        # cheap: regex + validators
    KnownJailbreakPatterns(),             # cheap
    PromptInjectionClassifier(),          # model
]

OUTPUT_CHECKS = [
    SecretScanner(),                      # free: never leak a key we generated
    PIIRedactor(),                        # cheap
    CompetitorMentionPolicy(),            # cheap
    GroundednessCheck(),                  # model: is this supported by retrieved context
]

Run the chain, short-circuit on block

Order by cost and run same-cost checks in parallel. Short-circuit as soon as something blocks — there is no value in running a model-based classifier after a regex has already decided to reject. Log every result, not just failures, because the pass rate of each check is what tells you whether it still does anything.

async def run_chain(text: str, checks: list[Check], ctx: Context) -> ChainOutcome:
    current, applied = text, []

    for tier in ("free", "cheap", "model"):
        tier_checks = [c for c in checks if c.cost == tier]
        if not tier_checks:
            continue

        results = await asyncio.gather(*(c(current, ctx) for c in tier_checks))

        for check, r in zip(tier_checks, results):
            audit.record(ctx.request_id, check.name, r.action, r.reason)
            if r.action is Action.BLOCK:
                return ChainOutcome(blocked=True, by=check.name, reason=r.reason)
            if r.action is Action.REGENERATE:
                return ChainOutcome(regenerate=True, by=check.name, feedback=r.reason)
            if r.action is Action.REDACT and r.modified is not None:
                current = r.modified
                applied.append(check.name)

    return ChainOutcome(text=current, redactions=applied)
warning

Guardrails must fail closed on the paths that matter. If the moderation service times out, a system that defaults to allow has no guardrail at all precisely when it is under load. Decide fail-open or fail-closed per check, deliberately, and write it down.

Output checks and the regenerate path

Output guardrails have an option input guardrails do not: regeneration. When groundedness fails, feeding the failure back and generating once more often produces an acceptable answer — which is a better outcome than a blanket refusal. Bound it at one retry, and refuse cleanly if the second attempt also fails.

async def generate_guarded(prompt: str, ctx: Context) -> Reply:
    inbound = await run_chain(ctx.user_input, INPUT_CHECKS, ctx)
    if inbound.blocked:
        return Reply.refusal(user_message(inbound.by), audit_id=ctx.request_id)

    for attempt in range(2):
        draft = await model(prompt, user_input=inbound.text)
        outbound = await run_chain(draft, OUTPUT_CHECKS, ctx)

        if outbound.blocked:
            return Reply.refusal(user_message(outbound.by), audit_id=ctx.request_id)
        if outbound.regenerate and attempt == 0:
            prompt = prompt + f"\n\nYour previous attempt failed: {outbound.feedback}. Fix it."
            continue
        if outbound.regenerate:
            return Reply.refusal("I couldn't produce a well-grounded answer.", audit_id=ctx.request_id)

        return Reply.ok(outbound.text, redactions=outbound.redactions)
  • Checks are small, uniform, independently testable units — never one tangled function
  • Order by cost; parallelize within a tier; short-circuit on block
  • Decide fail-open versus fail-closed per check, explicitly
  • Prefer regenerate over refuse on output failures, bounded at one retry
  • Log every decision — pass rates tell you which checks have stopped earning their latency
tip

Give every refusal an audit id the user can quote. It turns "the bot won't let me" into a support ticket you can actually resolve, and the resulting log is the best false-positive dataset you will get.