Fibric. Docs fibric.io →
v1.0.0 · stable
Concepts

Trust tiers

Every side-effecting action an operator proposes is scored against your trust policies and receives one of three decisions: ALLOW, ALERT, or BLOCK. This page describes what each decision permits, how the evaluator arrives at one, and how to write policies that put the right actions in the right tier. The evaluator is fail-closed: an action no policy speaks for is refused.

The three decisions

The trust layer exposes exactly one type for its verdicts:

kernel/trust.ts
export type TrustDecision = 'ALLOW' | 'BLOCK' | 'ALERT';

Three decisions, three levels of confidence. Routine, bounded actions run unattended. Actions you want a person to see run under supervision. Everything else is refused. There is no fourth state and no override path around the evaluator.

DecisionWhat it permitsWhen it fires
ALLOW The action runs unattended. The executor invokes the connector, records the outcome, and moves on. No human is involved. At least one policy matches the action, every matching policy's constraints pass, and no matching policy carries the ALERT decision.
ALERT The action runs and is raised for human attention. In the hosted platform (preview), ALERT is the escalation tier: the action is routed to a human approval queue before or alongside execution, depending on the queue's configuration. At least one matching policy carries the ALERT decision and every matching policy's constraints pass.
BLOCK Nothing. The action is vetoed. The plan line fails with the error blocked by trust policy and nothing reaches the connector. No policy matches the action, or a matching policy's maxValue is exceeded, or a matching policy's predicate returns false.
Allow

The unattended tier. Reserve it for actions whose worst case is acceptable without review: bounded holds, idempotent notes, status syncs within allowed transitions.

Alert

The supervised tier. The action proceeds through the same executor, but a human sees it. Refunds, cancellations, and anything customer-visible tend to live here first.

Block

The veto. Also the default: anything you never wrote a policy for lands here automatically. You do not enumerate what is forbidden. You enumerate what is permitted.

Fail-closed by default

The evaluator's first rule is the one that shapes everything else. From the kernel source:

kernel/trust.ts
// Default-CLOSED: a side-effecting action must be explicitly allowed by a matching
// policy whose constraints all pass. No matching policy → BLOCK.
if (matches.length === 0) return 'BLOCK'; // fail closed

The absence of a policy is a veto. A new tool added to a connector, a capability an operator was never granted, a typo in a tool name in a proposed plan: all of these produce BLOCK without any policy author having anticipated them. This is the inversion that makes the system safe to extend. The dangerous state in most automation is the unconfigured one; in Fibric the unconfigured state does nothing.

The same logic governs constraint failures. A policy that matches an action but whose maxValue is exceeded, or whose predicate returns false, does not fall through to some weaker permission. It returns BLOCK immediately. A constraint on any matching policy is a constraint on the action.

i
Blocked is not broken

A BLOCK is a policy decision, not a fault. The action's result carries ok: false and the error string blocked by trust policy, the rest of the plan continues, and the refusal is recorded like any other outcome. See Receipts & audit for the veto trail.

Anatomy of a policy

A trust policy is a small declarative object. Its two selector fields decide which actions it speaks for; its two constraint fields decide whether the action passes; its decision field states the tier the action lands in when it does.

kernel/trust.ts
export interface TrustPolicy {
  connector?: string;   // undefined = any
  tool?: string;        // undefined = any
  maxValue?: number;
  predicate?: (action: PlannedAction, env: EventEnvelope) => boolean;
  decision: TrustDecision;
}
FieldTypeRequiredDescription
connector string Optional The connector this policy applies to, for example magento. Leaving it undefined matches any connector.
tool string Optional The tool this policy applies to, for example orders.hold. Leaving it undefined matches any tool.
maxValue number Optional An upper bound compared against the action's value field, for example a refund amount. An action whose value exceeds maxValue is blocked. An action with no value is treated as 0.
predicate (action, env) => boolean Optional An arbitrary check over the proposed action and the event envelope that triggered the run. Returning false blocks the action. Use it for constraints the selector fields cannot express: business hours, order state, tenant plan.
decision 'ALLOW' | 'BLOCK' | 'ALERT' Required The tier this policy places the action in when it matches and its constraints pass. A policy with decision: 'BLOCK' is an explicit veto that matching cannot upgrade.

Selectors are exact-match. A policy with connector: 'magento' and tool: 'orders.hold' speaks only for that pair. Omitting a selector widens the policy: { tool: 'orders.hold', decision: 'ALERT' } escalates order holds on every connector, and { connector: 'magento', decision: 'ALLOW' } would permit every Magento tool, which is almost never what you want. Prefer narrow policies and let fail-closed handle the rest.

Predicates in practice

A predicate receives both the proposed action and the triggering envelope, so it can express constraints that depend on context rather than on the action alone. The predicate is deterministic code you wrote, evaluated by the executor. The model never sees it, cannot argue with it, and cannot satisfy it by phrasing a proposal differently.

predicate example
// holds are unattended only for orders sensed from the commerce source;
// anything an operator inferred from a support conversation gets reviewed
{
  connector: 'magento',
  tool: 'orders.hold',
  maxValue: 500,
  predicate: (action, env) => env.source === 'magento',
  decision: 'ALLOW',
}

Remember the failure mode from step 2 of the evaluation: a predicate that returns false blocks the action outright, on every matching policy. If you want a failed contextual check to escalate rather than refuse, express it as two policies — a narrow ALLOW whose predicate captures the safe case, and a broad ALERT with no predicate as the fallback tier.

How a plan is scored

The evaluator runs per PlannedAction, not per plan. An operator returns an ExecutionPlan, an ordered list of proposed actions, and the executor walks that list one action at a time, calling evaluate() for each side-effecting line. A plan is not atomic. Each action gets its own disposition, and one blocked line does not block its neighbors.

This is deliberate. A plan that proposes a hold, a customer note, and a refund can have the hold and note run unattended while the refund is escalated. The tier is a property of the action, because risk is a property of the action.

Each proposed action carries the fields the evaluator and the executor need:

FieldTypeDescription
connectorstringThe connector the action targets, matched against policy selectors.
toolstringThe tool to invoke, matched against policy selectors.
argsRecord<string, unknown>The arguments the connector receives if the action runs.
valuenumber, optionalThe monetary or magnitude value of the action, for example a refund amount, compared against maxValue policies.
entity_keystringThe single-flight key. Side effects are serialized per entity. See Single-flight & idempotency.
idempotency_keystringThe dedup key. The same logical side effect applies at most once. See Single-flight & idempotency.

The evaluation itself is three steps, in order:

  1. Collect every policy whose connector and tool selectors match the action. If none match, return BLOCK.
  2. For each matching policy, check its constraints. If any policy's maxValue is exceeded or its predicate returns false, return BLOCK.
  3. Otherwise, return ALERT if any matching policy's decision is ALERT; else return ALLOW.

Note the asymmetry in step 3: ALERT wins over ALLOW when both match. Adding an escalation policy always tightens the tier; it can never loosen one. Layering a broad ALERT over a narrow ALLOW is a safe way to put a whole family of actions under supervision without rewriting the narrow policy.

Worked example: an order hold

Consider a workspace running an order-risk operator against a Magento store. The policy set permits unattended holds up to a value cap and escalates refunds for approval. Nothing else is mentioned, which means nothing else is permitted.

policies.ts
const policies: TrustPolicy[] = [
  { connector: 'magento', tool: 'orders.hold',   maxValue: 500, decision: 'ALLOW' },
  { connector: 'magento', tool: 'orders.refund',                decision: 'ALERT' },
];

Now walk three proposed actions through the evaluator:

Proposed actionMatching policyConstraintsDecision
orders.hold on a $180 order (value: 180) The orders.hold policy 180 ≤ 500, passes ALLOW — the hold is placed unattended.
orders.refund for $95 (value: 95) The orders.refund policy No constraints on the policy ALERT — the refund is escalated for human approval (preview queue behavior in the hosted platform).
orders.cancel None Not reached BLOCK — no matching policy, fail closed. The line fails with blocked by trust policy.

Two more cases worth holding in mind with the same policy set. An orders.hold proposed at value: 820 matches the hold policy but exceeds its maxValue, so it is blocked rather than escalated; if you want over-cap holds reviewed instead of refused, add a second hold policy with decision: 'ALERT' and no cap. And an orders.hold proposed against a different connector matches nothing, because the policy names magento explicitly.

Escalation to human approval

ALERT is the tier that keeps a person in the loop without stopping the operation. In the kernel's reference implementation, an ALERT action executes and its disposition is recorded as ALERT on the result, so downstream systems can route it for attention. In the hosted platform (preview), the escalation is first-class: an ALERT action is placed in a human approval queue, and workspace configuration determines whether the action waits for approval or runs immediately with the review happening alongside.

Either way, the receipt records the decision. An approved escalation, a pending one, and an unattended ALLOW all leave the same auditable record of what was proposed, which policy decided it, and what happened. See Receipts & audit.

!
Start supervised, earn unattended

A practical rollout pattern: put every new operator's side effects under ALERT first. Once the approval queue shows a stretch of proposals you would have approved anyway, move the narrow, bounded ones to ALLOW. The policy change is one line; the confidence behind it is a receipt trail.

Relationship to reads

Trust policy gates side effects only. When the executor runs an action, it first asks the connector registry whether the tool is side-effecting. A read — orders.read, conversations.read, metrics.read — bypasses policy entirely and bypasses idempotency too: it runs immediately and its result carries the decision ALLOW. Only tools declared sideEffecting in their connector definition are scored.

This is why an operator can sense freely and still be tightly governed on the act side. Reading the world is how the operator forms a picture; changing the world is what the tiers exist for. What data a read can reach is governed separately, by capability grants and tenancy, not by trust policy.

Keep going