Fibric. Docs fibric.io →
v0.9 · preview
Guides

Write guardrail policies

A guardrail policy is the deterministic half of every Fibric operation: the model proposes an ExecutionPlan, and the policy decides what may run. Policies are fail-closed, so anything a rule does not explicitly allow is refused. This guide covers the policy document in full: its anatomy, how rules evaluate, how to scope them by capability, how to validate a policy before it sees a live plan, and three worked examples you can adapt.

i
Two forms, one gate

You will meet policy in two shapes: the YAML document this page centers on, which is what you write, review, and apply, and the kernel's TrustPolicy array, which is what the YAML compiles to and what the deterministic executor evaluates. Both are covered below. The decision model behind them, ALLOW, ALERT, BLOCK, is specified in Trust tiers.

Policy document anatomy

A policy is a plain YAML document: a name, the operator it governs, an ordered list of rules, and a default that is always deny. Each rule grants or refuses one capability, optionally under constraints: a rate limit, a value ceiling, or a single-flight key. The document below is a complete, realistic policy; every field it uses is defined in the table that follows.

policy.yaml
# governs one operator; apply with: fibric policy apply ./policy.yaml
policy: order-risk-guardrails
applies_to: order-risk-watcher

rules:
  # internal notes are low-risk: allow them without constraint
  - allow: conversation.note.write

  # holds are allowed, but capped and serialized per order
  - allow: order.hold
    limit:
      per: hour
      max: 25              # a flood cannot exceed this
    single_flight: order_id  # one in-flight hold per order

  # small refunds run; the ceiling is enforced by the executor
  - allow: refund.issue
    max_value: 250

  # anything above the ceiling stops and waits for a person
  - require_confirmation: refund.issue

  # explicit deny: redundant with the default, kept for the audit trail
  - deny: order.cancel

# the default is always deny; stating it makes review unambiguous
default: deny
require_receipt: true
FieldTypeMeaning
policystringThe policy's name. Referenced by fibric policy validate and recorded on every receipt the policy disposes.
applies_tostringThe operator this document governs. One policy governs one operator; an operator without an applied policy can propose but never act.
ruleslistThe rule list. Each entry names exactly one capability under allow, deny, or require_confirmation, plus optional constraints.
allowstring (capability)Grants the named capability. An allow with constraints grants it only while every constraint passes; a failing constraint blocks the action.
denystring (capability)Refuses the named capability. Because the default is already deny, an explicit deny changes nothing at evaluation time; write one when you want the refusal documented and auditable rather than implied by omission.
require_confirmationstring (capability)Permits the capability only with a human in the loop. A matching action is parked, not run, and disposes as ALERT. Approval executes the original action under its original idempotency key.
limit.perrun | minute | hour | dayThe window a rate limit counts within. per: run caps a single operator run, the form the quickstart uses; the time-based windows cap sustained throughput.
limit.maxintegerThe most actions the rule permits within one window. The count is enforced by the executor, not the model, so a runaway proposal cannot talk its way past it.
single_flightstring (key field)Serializes side effects per entity: at most one in-flight action for each distinct value of the named field. This becomes the action's entity_key. See Single-flight & idempotency.
max_valuenumberA value ceiling. The executor compares it against the action's value field, a refund amount, for example, and blocks anything above it.
defaultdenyWhat happens to an action no rule matches. The only accepted value is deny; the field exists so the fail-closed posture is stated in the document rather than assumed.
require_receiptbooleanRequires an immutable receipt for every disposed action, including blocked ones. Defaults to true; leaving it explicit keeps the guarantee visible in review.

Rule evaluation and default-closed semantics

Only side-effecting actions are gated. Reads pass through without a policy match, which is why a read-only operator such as the one in Build an ops analyst can run under an empty rule list. For every side-effecting action in a proposed plan, evaluation follows three steps, in order:

  1. Match. The evaluator collects every rule whose capability names the action. No matching rule means the action is blocked; the fail-closed refusal is the absence of permission, not the presence of a deny.
  2. Constrain. Every constraint on every matching rule must pass: the rate limit has headroom, the value is at or under max_value, the single-flight lock is available. Any failing constraint blocks the action.
  3. Decide. If any matching rule is a require_confirmation, the action disposes as ALERT and waits for a person. Otherwise it disposes as ALLOW and runs.

Rule order in the document does not create precedence; matching is by capability, and constraints compose. Order the rules for the human reader.

What the YAML compiles to

Applying a policy compiles the document into the kernel's TrustPolicy array, and the three steps above are exactly the kernel's evaluate() function. Each TrustPolicy matches on connector and tool (leaving either undefined matches any), may carry a maxValue cap or a predicate, and declares a decision: ALLOW, ALERT, or BLOCK. The correspondence is direct:

YAMLKernelDisposition
allow: <capability>a policy with decision: 'ALLOW' matching the capability's connector and toolALLOW while constraints pass
require_confirmation: <capability>a matching policy with decision: 'ALERT'ALERT: parked for human approval
max_value: nmaxValue: n on the compiled policy; compared against the action's valueBLOCK when exceeded
deny / default: denyno matching policy: evaluate() returns BLOCK when nothing matchesBLOCK, fail closed

The executor adds one more disposition the policy never produces: DEDUP, returned when an action's idempotency_key has already been applied. Deduplication happens before evaluation, so a retried action never re-consumes a rate limit.

!
An empty policy denies everything

There is no permissive failure mode. A policy with no rules, a policy that fails to parse, or a rule removed by mistake all resolve the same way: the operator stops acting. It never acts without permission. See Governance & trust for why the platform is built this way.

Scoping rules by capability

Rules name capabilities, never vendors. order.hold, conversation.note.write, and refund.issue are capability names; which connector serves each one is decided by the tenant's role bindings, described in Capabilities. This indirection is what keeps a policy stable across infrastructure changes: swap the order system behind the orders role and the policy governing order.hold does not change, because it never mentioned the old vendor.

The same indirection is what makes sandbox-to-production promotion a non-event for policy. The sandbox guide develops an operator against sandbox-orders and promotes it by rebinding the role; the policy file is byte-identical in both environments.

Scope each rule as narrowly as the operation allows:

Validate before anything acts

Two commands let you exercise a policy with zero side effects: one static, one against a real proposal.

Static validation

fibric policy validate checks a policy against an operator without running anything: every capability the operator declares is compared against the rule list, and the command reports what would be allowed, escalated, and refused.

bash
fibric policy apply ./policy.yaml
fibric policy validate order-risk-guardrails --against order-risk-watcher
$ fibric policy validate order-risk-guardrails --against order-risk-watcher conversation.note.write allow unconstrained order.hold allow limit 25/hour · single-flight by order_id refund.issue allow ≤ $250 · above ceiling → confirm order.cancel deny explicit rule orders.read read not gated ✓ valid · no capability the operator declares is left implicit

Dry-running a live proposal

fibric operators run --dry-run goes one step further: the operator senses and proposes for real, and the executor evaluates the plan against the applied policy, but nothing executes. The output shows every proposed action, its would-be disposition, and the idempotency key each step would carry, which is how you confirm dedup behavior before the first real run.

bash
fibric operators run order-risk-watcher --dry-run
$ fibric operators run order-risk-watcher --dry-run sensed 38 open orders reasoned proposed 4 actions (dry run: nothing will execute) would-be dispositions: order.hold SO-11290 ALLOW key=order-risk:SO-11290:hold order.hold SO-11304 ALLOW key=order-risk:SO-11304:hold refund.issue SO-11288 ALERT $410 > $250 → would await approval order.cancel SO-11251 BLOCK denied by rule ✓ dry run complete · 0 side effects · plan written to .fibric/dev/plans/

For local iteration before a deploy exists at all, the fibric dev harness replays recorded envelopes through the same evaluator; that workflow is covered in Testing connectors and the sandbox guide.

Worked examples

A notify-only starter policy

The safest first policy for a new operator grants nothing but notification. The operator senses, reasons, and proposes at full fidelity; the only thing that can reach the world is a message. This is the policy to run for the first week while you read receipts and learn what the operator wants to do.

policy.yaml
policy: ship-risk-starter
applies_to: ship-risk

rules:
  - allow: orders.notify
    limit:
      per: hour
      max: 10

default: deny
require_receipt: true

Every proposed hold or refund disposes as BLOCK and is receipted with the proposal attached. The blocked receipts are the point: they show you, with real data, exactly which rules the operator has earned before you grant them.

A value-capped refund policy, in both forms

Refunds are the canonical case for value-aware rules: most are routine, a few deserve a person, and anything the operator invents on its own must never run. In YAML:

policy.yaml
policy: refund-guardrails
applies_to: refund-guard

rules:
  # refunds at or under $250 run unattended
  - allow: refund.issue
    max_value: 250
  # above the ceiling, a person decides
  - require_confirmation: refund.issue

default: deny

And the TrustPolicy array it compiles to, with the capability resolved to its bound connector and tool:

policy/trust.ts
import type { TrustPolicy } from "@fibric/sdk";

// everything not listed here is blocked by omission:
// there is no "allow the rest" rule to forget.
export const policies: TrustPolicy[] = [
  // refunds at or under $250 run without a person in the loop
  {
    connector: "payments",
    tool: "refunds.create",
    maxValue: 250,
    decision: "ALLOW",
  },
  // refunds above $250 stop and page a human instead of running
  {
    connector: "payments",
    tool: "refunds.create",
    predicate: (action) => (action.value ?? 0) > 250,
    decision: "ALERT",
  },
];

Walk three proposals through it. A $180 refund matches the first policy, sits inside the cap, and disposes ALLOW. A $900 refund trips the escalation predicate and disposes ALERT: parked with the operator's reasoning attached, executed only on approval, under its original idempotency key. A $50 account credit through credits.apply matches nothing, so evaluate() returns BLOCK. You never wrote a rule against credits; the refusal is structural. The full walkthrough, including the plan JSON and receipts, is in Guard a refund flow.

A rate-limited hold policy

Rate limits exist for one failure class: the runaway loop. The incident that shaped the kernel was an ungoverned automation that sent 657 messages to a single customer, each retry looking locally reasonable. Two independent guards make that structurally impossible here: the idempotency key collapses repeats of the same action, and the rate limit caps distinct actions per window, so even a loop that varies its payload cannot flood.

policy.yaml
policy: hold-guardrails
applies_to: order-risk-watcher

rules:
  - allow: order.hold
    limit:
      per: hour
      max: 25              # the flood ceiling, enforced by the executor
    single_flight: order_id  # never two holds in flight for one order

default: deny

When the limit is reached, the twenty-sixth hold in the hour disposes BLOCK with the limit named in the receipt. The operator keeps sensing and proposing; the executor resumes allowing when the window rolls. Nothing about the containment depends on the model behaving well.

Common mistakes

SymptomLikely causeFix
Every action disposes BLOCK after a deployThe policy failed to parse or was never applied; a broken policy denies everything rather than falling open.Run fibric policy validate: it reports parse errors and shows the effective grant per capability.
An action you allowed still blocksA constraint on the matching rule is failing: the value exceeds max_value, the rate limit is exhausted, or the single-flight lock is held.Read the receipt; the failing constraint is named. Dispositions and their reasons are always receipted, blocked ones included.
A capability acts that you meant to refuseAnother rule in the same document allows it; explicit deny entries do not override, because refusal is the default and allow is the only thing that changes it.Remove or constrain the allow. Use fibric policy validate to see every rule that speaks for the capability.
Rate limit consumed faster than expectedCounting distinct actions, not proposals: each allowed action in a window counts once, but retries do not, because DEDUP happens before evaluation.Check fibric receipts tail --decision ALLOW for the window; the count there is the count the limiter saw.
An ALERT action ran twice after approvalIt did not: approval executes the original action under its original idempotency key, so a double-approve disposes the second as DEDUP.Confirm in the receipts: one applied, one DEDUP, same key.
Policy references a capability the operator lacksTypo in the capability name, or the connector serving it was unbound from the role.fibric policy validate --against flags rules that match nothing the operator declares; fibric capabilities ls shows what is bound.

Keep going