Governance
Felix applies five governance layers to every tool call: policies, limits, guardrails, llm_judge, approvals. They compose at build time and run on every invocation. A federated PolicyBundle overlays manifest-declared policies and approvals.
Composition
Section titled “Composition”buildAgent step 10 (src/manifests/builder.ts):
const merged = mergeWithManifest(manifest.spec.policies, manifest.spec.approvals);
if (merged.policies.length) tools = applyPolicies(tools, merged.policies, manifestId);if (anyLimit(manifest.spec.limits)) tools = applyLimits(tools, manifest.spec.limits, manifestId);if (guardrailsEnabled(guardrails)) tools = applyGuardrails(tools, guardrails, manifestId);if (judgesEnabled(guardrails)) tools = applyJudges(tools, guardrails, manifestId);if (merged.approvals.length) tools = applyApprovals(tools, merged.approvals, manifestId);Each wrapper rewrites the tools array, returning new Tool objects whose executor delegates to the inner one. The wrappers use wrapExecutor(inner.executor, ...) from src/tools/executor.ts, which preserves the inner transport label (local / mcp / a2a / container / queue / sandbox / browser) so audit and observability can still report the true transport after composition. The order of application determines the runtime stack:
model call -> tool dispatch -> Approvals (gate) -> LLM Judge (post-call score) -> Guardrails (filter) -> Limits (cap) -> Policies (scope-check) -> inner toolRead top-to-bottom for the call direction; bottom-to-top for which layer was applied first at build time.
Deny-output contract
Section titled “Deny-output contract”Every wrapper returns its deny via denyOutput(content, source) from src/tools/types.ts — they never throw. The model sees the content string in the tool result and can adapt: retry with different args, try a different tool, or surface the limitation to the user. Throwing would abort the loop and lose context.
The structured marker (metadata[WRAPPER_DENY_FLAG] = true + metadata.source) lets outer wrappers detect inner denies via isWrapperDeny(output). The guardrails output filter uses this to short-circuit — without it, a policy/approval/limit deny string would get re-scanned and possibly redacted.
When writing a new wrapper that does post-call work (output filtering, scoring, transformation), check isWrapperDeny(out) first and pass the deny through verbatim.
Fatal tool errors
Section titled “Fatal tool errors”Tools may declare fatal: true when constructing via defineTool({ ..., fatal: true }) or defineToolWithExecutor({ ..., fatal: true }). By default, an exception thrown from tool.executor.execute(...) is stringified as [tool error] <msg> and fed back to the model so it can recover. With fatal: true, the react loop terminates immediately with the tool error message as final — the model never sees the failure.
Use sparingly. Reasonable cases:
- Hard quota exhaustion that won’t recover within the run.
- Security violations the model should not be allowed to retry around.
- Configuration errors that mean the tool is unusable for the entire request.
Recoverable conditions (a flaky API call, a transient lookup miss, malformed args) should stay non-fatal so the model can adapt.
Cancellation via ctx.signal
Section titled “Cancellation via ctx.signal”The per-request LimitState.abortController fires when the wall-clock cap elapses or the request is torn down. Patterns inject signal into ToolInvocationCtx; the executor receives it and tool authors should pass it through to outbound work:
defineTool({ name: 'fetch_thing', description: '...', args: z.object({ url: z.string() }), async handler({ url }, ctx) { const resp = await fetch(url, { signal: ctx?.signal }); return await resp.text(); },});Without signal, the wall-clock check only blocks the next tool call — a long-running fetch already in-flight runs to completion. With it, AbortError surfaces and the tool returns the catch-block output (e.g. [cancelled] ...). The built-in non-local executors — A2AExecutor (src/a2a/client.ts), McpExecutor (src/mcp/client.ts), and ContainerExecutor (src/tools/container-executor.ts) — already plumb the signal through to their outbound fetches; custom tools and custom executors that fetch external services should match the pattern.
Policies
Section titled “Policies”Source: src/policy/wrap.ts, src/policy/models.ts.
A policy declares required scopes on a set of tools:
policies: - id: write-paths description: Writes need explicit grant required_scopes: ["data:write"] tools: ["create_record", "update_record"]When a wrapped tool is invoked, the wrapper reads principal.scopes from the AsyncLocalStorage context and AND-checks every applicable policy:
principalScopes = ctx.auth.principal.scopes (Set<string>)missing = policy.required_scopes.filter(s => !principalScopes.has(s))if missing.length: emit policy_decision audit (denied), return deny stringWhen a tool appears in multiple policies, all must pass.
Audit event:
{ "event_type": "policy_decision", "status": "denied", "payload": { "policy_id": "...", "tool": "...", "missing_scopes": [...], "outcome": "denied" } }Counter: orchestrator_policy_decisions { outcome, policy_id, manifest_id }.
Limits
Section titled “Limits”Source: src/limits/wrap.ts, src/limits/models.ts, src/limits/state.ts.
Manifest declares per-run caps:
limits: max_tool_calls: 40 # ceiling: 200 max_wall_clock_seconds: 120 # ceiling: 600 max_peer_hops: 2 # ceiling: 5 max_input_tokens: 100000 # ceiling: 1_000_000 max_output_tokens: 8000 # ceiling: 100_000Absolute ceilings (ABSOLUTE_LIMITS in src/limits/models.ts) apply even if the schema is bypassed:
| Cap | Ceiling |
|---|---|
max_tool_calls |
200 |
max_wall_clock_seconds |
600 |
max_peer_hops |
5 |
max_input_tokens |
1,000,000 |
max_output_tokens |
100,000 |
recursion_limit |
50 |
max_turns |
20 |
null in the manifest means “no manifest-level cap”; the absolute ceiling still applies.
LimitState lives on RequestContext.limitState (installed by auth middleware, torn down via disposeLimitState in finally):
{ toolCalls: 0, peerHops: 0, startedAt: Date.now(), auditCount: 0, auditTruncatedEmitted: false, abortController: new AbortController(), // fires on wall-clock breach or teardown wallClockTimerId?: setTimeout(...), // armed on first wrapped call tokens: { input: 0, output: 0 }, // accumulated by patterns/model.ts:recordUsage}Tool wrappers read it through currentLimitState() (src/limits/state.ts). Because it’s AsyncLocalStorage-bound, no parameter threading is needed and tool authors cannot tamper with it.
Check order
Section titled “Check order”Per invocation, before incrementing:
- Wall clock:
(Date.now() - state.startedAt) / 1000 > max_wall_clock_seconds - Tool calls:
state.toolCalls >= max_tool_calls - Peer hops:
(inner.isPeer || inner.name.startsWith('peer_')) && state.peerHops >= max_peer_hops
On any breach: return denyOutput(...) (content [limit exceeded] <name> cap of <cap> reached at tool '<tool>'), emit limit_exceeded audit event, do not increment.
On pass: increment toolCalls (always) and peerHops (peer tools only). Inject state.abortController.signal into ctx.signal so the tool sees cancellation if wall-clock fires mid-call.
Counter: orchestrator_limit_breaches { limit, manifest_id }.
Token caps
Section titled “Token caps”Token caps live on the same Limits block but are not checked by the per-tool wrapper — tools don’t accrue tokens, model calls do. Patterns run two checks immediately before each model.chat / model.streamChat:
checkPreflightTokenBudget(model, messages, tools, limits, manifestId)— gated bylimits.precountandmax_input_tokensand only effective when the route implementscountTokens. Anthropic’s free/v1/messages/count_tokensprojects the next call’s input; ifstate.tokens.input + projected >= max_input_tokens, the call is denied before any paid request is made. Count failures are swallowed (the post-call check picks up the slack).checkTokenBudget(limits, manifestId)— compares the cumulativestate.tokens.input/state.tokens.outputagainstmax_input_tokens/max_output_tokens.
Call sites in src/limits/wrap.ts:
react.ts/deep.ts— before every loop iteration’s model call (bothinvokeandstreamEvents).router.ts— before the classifier call (falls back to first sub-agent if budget blown).parallel.ts— before the aggregator call (returns the deny as the final message).
recordUsage(result, { manifestId, modelId }) in src/patterns/model.ts accumulates result.usage onto state.tokens.input / state.tokens.output. Cache reads and cache creations still occupy the request’s input context window, so they count against max_input_tokens — state.tokens.input += input + cache_creation + cache_read. The OpenAI client subtracts cached_tokens from prompt_tokens before reporting usage.input, matching the Anthropic shape so cached tokens don’t double-count. Per-kind counters (orchestrator_tokens { manifest_id, model, kind ∈ input | output | cache_creation | cache_read }) capture the cost split for observability. Sub-agents share the parent’s LimitState, so token spend in a fan-out aggregates across everyone.
Wall-clock abort timer
Section titled “Wall-clock abort timer”armWallClockAbort(state, limits, manifestId) (src/limits/wrap.ts) is idempotent — only the first wrapped call per request schedules the timer. When it fires, state.abortController.abort(...) cancels in-flight fetch calls that have ctx.signal plumbed through. Without the timer, a long-running tool past the budget would run to completion; only the next call would see the breach.
disposeLimitState in src/context.ts is the cleanup hook — clears the timer, fires abort if it hasn’t already. Auth middleware calls it in finally; cron + queue handlers do the same in apps/api/src/index.ts.
Audit truncation
Section titled “Audit truncation”limitState.auditCount tracks audit events per request; cap is 200. On hit, one audit_truncated event with payload: { reason, cap } is emitted (auditTruncatedEmitted flips), and further events are silently dropped. Defends against runaway loops bloating the queue.
Guardrails
Section titled “Guardrails”Source: src/guardrails/wrap.ts, src/guardrails/pipeline.ts, src/guardrails/models.ts.
guardrails: providers: [pii] # currently: "pii", "bedrock" (placeholder) block_on_match: false # true -> deny; false -> redact and continue targets: [input, output] # which side(s) of the call to filterPII redactor
Section titled “PII redactor”Four regex patterns (src/guardrails/pipeline.ts:21-38):
| Name | Pattern |
|---|---|
email |
[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,} |
ssn |
\b\d{3}-\d{2}-\d{4}\b |
phone |
\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b |
credit_card |
\b(?:\d[ -]*?){13,16}\b |
Hits are replaced with [REDACTED:<name>]. For each hit, an audit Match is recorded with a fingerprint only — SHA-256 of the matched text truncated to the first 8 hex bytes. The raw value never goes to audit.
Bedrock placeholder
Section titled “Bedrock placeholder”bedrockFilter returns the input unchanged. Reserved for a future AI Gateway content policy hook; including it in providers: [bedrock] is a no-op today.
LLM Judge
Section titled “LLM Judge”Source: src/guardrails/judge-wrap.ts, src/guardrails/models.ts.
guardrails: judges: - name: relevance threshold: 0.7 # default 0.7; pass floor model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast' # optional; this is the default criteria: | response should be on-topic and directly answer the tool input target_tools: ['fetch_page', 'page_links'] # optional; empty = all toolsComposed after guardrails (applyJudges(tools, guardrails, manifestId)) so judges score a result that has already been redacted/filtered. Each applicable judge calls env.AI.run(rule.model, { messages: [...] }) once per matching tool call (native binding — no AI Gateway hop) with the tool name, args (sliced to 500 chars), output (sliced to 2000 chars), and the criteria string. The judge model is expected to return JSON {"score": <float>, "reasoning": "<text>"}.
Judges only run on a clean inner result — outputs already flagged via isWrapperDeny (a deny from policy/limits/guardrails/approvals) or carrying a tool-error code are passed through untouched. Applicable judges run in order; the first below-threshold judge short-circuits to a deny.
Per-judge behavior:
- Every score emits a
judge_scoreaudit event withstatus: 'pass' | 'fail'andpayload: { judge, tool, transport, score, threshold, reasoning }(reasoningtruncated to 500 chars). There is nosourcefield on the payload. - When
score < thresholdthe wrapper returnsdenyOutput('[judge denied] tool '<name>' failed judge '<judge>' (score <s> < <threshold>): <reasoning>', 'guardrails')— the deny is sourced as'guardrails', and the model sees the deny and adapts (the inner result is dropped). Below-threshold always denies; there is no score-only mode.
Failure handling: a missing env.AI binding returns null → the judge is skipped (treated as a pass) and orchestrator_judge_skipped { reason, judge, manifest_id } increments. A model-call error or an unparseable reply, however, yields passed: false with score 0, so the result is denied (a model error increments orchestrator_judge_error { judge, manifest_id }); only the absent-binding case fails open.
Counter: orchestrator_judge_scores { judge, tool, verdict, manifest_id } (verdict ∈ pass | fail).
The reflect pattern also emits judge_score audit events, but tags them with payload.source: 'reflect' (src/patterns/reflect.ts) so an operator can distinguish a reflect verifier score from a guardrails-judge score — the guardrails judge omits source entirely.
Wrap behavior
Section titled “Wrap behavior”For each invocation:
if targets.includes('input'): for each string arg: run all providers if matches: audit guardrail_block { surface: 'input', matches } if block_on_match: return deny string else: replace arg with filtered
invoke inner toolif out is string and targets.includes('output'): run all providers on out if matches: audit guardrail_block { surface: 'output', matches } if block_on_match: return deny string else: replace string with filteredCounter: orchestrator_guardrail_blocks { surface, manifest_id }.
Approvals
Section titled “Approvals”Source: src/approvals/wrap.ts, src/approvals/store.ts, src/approvals/approvals-do.ts, src/approvals/models.ts.
approvals: - id: production-writes description: All writes require approval tools: ["create_record", "update_record"]First invocation
Section titled “First invocation”- Parse args through the tool’s Zod schema (rejects unknown keys, normalizes order).
- Compute deterministic call signature:
callSignature = SHA-256(`${manifestId}|${toolName}|${canonicalize(args)}`)
canonicalizesorts keys before stringifying so semantically-equivalent args hash the same. findBySignature(env, tenantId, manifestId, toolName, callSignature)— D1 lookup using the unique indexuq_approval_signature.- On miss:
INSERTanapprovalsrow withstatus='pending',args_json = redactSecrets(args). Emitapproval_requestaudit (status: pending). Return deny string to the model:[approval required] tool '<name>' requires human approval. approval_id=<uuid>. Retry later with the same arguments.
Decision
Section titled “Decision”POST /approvals/:id/decide routes through ApprovalsDO (src/api/approvals.ts):
- Pre-check tenant ownership in D1 (return 404 if not owned; avoid locking the DO on a probe).
approvalsDoStub(env, tenantId, id).fetch('https://do/decide', { ... })— the DO usesblockConcurrencyWhileso concurrent decisions serialize.- The DO calls back into
decideRequest()which updates the D1 row. - Emit
approval_decisionaudit (status: approvedorstatus: denied).
The DO is a critical section, not the system of record. D1 is the source of truth.
When the model retries with the same args:
- Signature hashes to the same value.
- Lookup returns the existing row.
- If
status='approved': emitapproval_decision(approved), useedited_args_jsonif set otherwise originalargs, call through to the inner tool. - If
status='denied': emitapproval_decision(denied), return deny string withdecision_note. - If
status='pending': same deny string as first invocation. The unique index guarantees no duplicate row is inserted.
Counters:
orchestrator_approval_requests { manifest_id }orchestrator_approval_decisions { outcome, manifest_id }
Federation
Section titled “Federation”Source: src/policy/bundle.ts, src/policy/federation-do.ts.
A central authority ships a signed PolicyBundle to R2:
{ "version": "2026.05.13-01", "issuer": "felix-federation", "policies": [{ "id": "...", "required_scopes": [...], "tools": [...] }], "approvals": [...], "signature": "<base64 Ed25519>"}Lifecycle
Section titled “Lifecycle”- Authoring — author bundle JSON, sign deterministically (key-sorted JSON with
signatureremoved) with the Ed25519 private key whose public key is inPOLICY_BUNDLE_PUBKEY. - Upload —
wrangler r2 object putto the bucket atPOLICY_BUNDLE_KEY. - Refresh — every 10 minutes the worker cron fires
federationStub(env).fetch('https://do/refresh'). TheFederationDOpulls from R2, verifies the signature, and updates its cached bundle andrefreshedAt. - Verification —
crypto.subtle.verify({ name: 'Ed25519' }, key, sig, target)against the JSON-with-signature-removed key-sorted form.- Staging/production: signature mismatch keeps the previous active bundle in place; an unsigned bundle is rejected.
- Development: signature failures log a warning and load anyway.
- Process-local cache —
syncFederationCache(env)populates an in-isolate variable read bygetActiveBundle()on the hot path so the DO isn’t hit per request.
Merge semantics
Section titled “Merge semantics”mergeWithManifest(manifest.policies, manifest.approvals) (src/policy/bundle.ts):
- Manifest’s policies and approvals are loaded first into a
Mapby id. - Bundle policies are loaded second; bundle wins on id collision — a central revocation cannot be silently disabled by a manifest authoring a policy with the same id.
- Bundle-side approvals have a passthrough (
z.unknown()) shape today, so they are not cross-merged with manifest approvals — only the manifest’s approval rules survive into the wrapped tool stack.
Wrapping summary
Section titled “Wrapping summary”| Layer | Reads | Decision | On fail | Audit | Counter |
|---|---|---|---|---|---|
| Policies | principal.scopes |
scope AND-check | deny string | policy_decision |
policy_decisions |
| Limits | LimitState |
wall_clock / tool_calls / peer_hops / tokens | deny string | limit_exceeded |
limit_breaches |
| Guardrails | filter providers | regex hits / placeholder | deny or redact | guardrail_block |
guardrail_blocks |
| LLM Judge | env.AI criteria score |
score < threshold | deny string | judge_score |
judge_scores |
| Approvals | D1 status + call signature | pending/approved/denied | deny string | approval_request, approval_decision |
approval_* |
Every wrapper’s audit row and counter row carry the inner tool’s transport (local / mcp / a2a / container / queue / sandbox / browser) as a payload field / label, so an operator can slice “policy denies by transport” or “approval requests for container tools.” The transport label survives wrapping because each wrapper uses wrapExecutor(inner.executor, ...) (see src/tools/executor.ts), which preserves the inner transport on the new outer executor — so inner.executor.transport is always available at audit/counter time.