Agent HITL
Human-in-the-loop policy registry and evaluator for agent actions.
The @fabricorg/platform/agent-hitl module decides how an agent's proposed action is routed: auto-execute, needs-approval, escalate, or rejected. The platform supplies the registry and types; each vertical supplies the actual policy evaluator.
Install
pnpm add @fabricorg/platform@^0.9.0 @fabricorg/platform-host@^0.7.0dotnet add package FabricOrg.Platform.Abstractions
dotnet add package FabricOrg.PlatformTypeScript host integration
@fabricorg/platform-host 0.7 can enforce this contract in the canonical mutation runtime.
Inject the vertical evaluator at boot; the host calls it only for agent actors, after schema validation
and before ordinary policies.
import { createGovernedActionHost } from "@fabricorg/platform-host";
const host = createGovernedActionHost({
store,
authorization,
hitlEvaluator: evaluateAgentActionPolicy,
hitlPolicyVersion: "lending-rules.v3",
});needs-approval and escalate decisions persist their route, risk tier, reason, and ruleset version
before the invocation is parked. Resume through the host so authorization and the approval decision
remain part of the same durable mutation record:
await host.resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, {
approved: true,
approverId: reviewer.id,
approverType: "natural_person",
reason: "Reviewed and approved",
});Do not call an action handler or adapter directly from an approval signal. Parked rows are intentionally
not worker-claimable; resumeApprovedInvocation() atomically records the decision and acquires the
execution lease before continuing. Host 0.7 then runs authorizeExecution with
executionReason: "approval_resume" against the original actor and schema-parsed durable parameters.
Approval does not preserve authority that has since been revoked.
Types
export type AgentActionRoute = "auto-execute" | "needs-approval" | "escalate" | "rejected";
export type AgentActionRiskTier = "low" | "medium" | "high";
export interface AgentActionPolicyContext {
actionId: string;
actorId: string;
actorType: string;
tenantId: string;
spaceId: string;
parameters: Record<string, unknown>;
confidence?: number;
riskTier?: AgentActionRiskTier;
agentSessionId?: string;
agentRunId?: string;
}
export interface AgentActionPolicyDecision {
route: AgentActionRoute;
riskTier: AgentActionRiskTier;
reason: string;
}
export type AgentActionPolicyEvaluator = (
context: AgentActionPolicyContext,
) => AgentActionPolicyDecision | Promise<AgentActionPolicyDecision>;using FabricOrg.Platform.Actions;
using FabricOrg.Platform.Policies;
// AgentActionRoute is an abstract discriminated union
public abstract class AgentActionRoute
{
public sealed class AutoExecute : AgentActionRoute { }
public sealed class NeedsApproval : AgentActionRoute { }
public sealed class Escalated : AgentActionRoute { }
public sealed class Rejected : AgentActionRoute { }
}
public enum AgentActionRiskTier { Low, Medium, High }
public sealed class AgentActionPolicyContext
{
public ActionId ActionId { get; }
public string ActorId { get; }
public ActorType ActorType { get; }
public string TenantId { get; }
public string SpaceId { get; }
public IReadOnlyDictionary<string, object?> Parameters { get; }
public double? Confidence { get; }
public AgentActionRiskTier? RiskTier { get; }
public string? AgentSessionId { get; }
public string? AgentRunId { get; }
}
public sealed class AgentActionPolicyDecision
{
public AgentActionRoute Route { get; }
public AgentActionRiskTier RiskTier { get; }
public string Reason { get; }
}
public delegate Task<AgentActionPolicyDecision> AgentActionPolicyEvaluator(
AgentActionPolicyContext context, CancellationToken ct);Registry
Register one evaluator per action ID. The registry throws if you try to register a different evaluator for the same action (fail-closed). Re-registering the same evaluator is idempotent.
import { AgentActionPolicyRegistry } from "@fabricorg/platform/agent-hitl";
const registry = new AgentActionPolicyRegistry();
registry.register({
actionId: "orders.send_quote",
evaluator: (ctx) => {
const { amount } = ctx.parameters as { amount: number };
if (amount > 50_000) {
return {
route: "needs-approval",
riskTier: "high",
reason: "Amount exceeds $50k threshold",
};
}
return { route: "auto-execute", riskTier: "low", reason: "Under threshold" };
},
});
const def = registry.resolve("orders.send_quote");
const decision = await def!.evaluator({
actionId: "orders.send_quote",
actorId: "agent-1",
actorType: "agent",
tenantId: "tenant-1",
spaceId: "space-1",
parameters: { amount: 75_000 },
});
// decision.route === "needs-approval"using FabricOrg.Platform.Policies;
using FabricOrg.Platform.Registries;
var registry = new AgentActionPolicyRegistry();
registry.Register(new AgentActionPolicyDefinition(
new ActionId("orders", "send_quote"),
async (ctx, ct) =>
{
var amount = (double)(ctx.Parameters["amount"] ?? 0);
if (amount > 50_000)
{
return new AgentActionPolicyDecision(
new AgentActionRoute.NeedsApproval(),
AgentActionRiskTier.High,
"Amount exceeds $50k threshold");
}
return new AgentActionPolicyDecision(
new AgentActionRoute.AutoExecute(),
AgentActionRiskTier.Low,
"Under threshold");
}));
var def = registry.Resolve(new ActionId("orders", "send_quote"));
var decision = await def!.Evaluator(new AgentActionPolicyContext(
new ActionId("orders", "send_quote"),
"agent-1",
ActorType.Agent,
"tenant-1",
"space-1",
new Dictionary<string, object?> { ["amount"] = 75_000 }), CancellationToken.None);
// decision.Route is AgentActionRoute.NeedsApprovalLow-level bridge pattern
Code that is not using the governed host can expose a singleton pointer that verticals wire up at boot. Hosted applications should prefer the runtime integration above because a bridge alone does not persist decisions or enforce approval lifecycle transitions.
// platform layer — vertical-agnostic
import { type AgentActionPolicyEvaluator } from "@fabricorg/platform/agent-hitl";
let registered: AgentActionPolicyEvaluator | null = null;
export function registerAgentActionPolicy(evaluator: AgentActionPolicyEvaluator): void {
registered = evaluator;
}
export async function evaluateRegisteredAgentActionPolicy(
input: Partial<AgentActionPolicyContext> & Pick<AgentActionPolicyContext, "actionId">,
): Promise<AgentActionPolicyDecision> {
if (registered) {
return await registered({
actorId: "system",
actorType: "system",
tenantId: "system",
spaceId: "system",
parameters: {},
...input,
});
}
return {
route: "auto-execute",
riskTier: "low",
reason: "No agent action policy registered; defaulting to auto-execute.",
};
}// platform layer — vertical-agnostic
using FabricOrg.Platform.Policies;
public static class AgentActionPolicyBridge
{
private static AgentActionPolicyEvaluator? _registered;
public static void Register(AgentActionPolicyEvaluator evaluator)
=> _registered = evaluator;
public static async Task<AgentActionPolicyDecision> EvaluateAsync(
AgentActionPolicyContext context,
CancellationToken ct = default)
{
if (_registered is not null)
return await _registered(context, ct);
return new AgentActionPolicyDecision(
new AgentActionRoute.AutoExecute(),
AgentActionRiskTier.Low,
"No agent action policy registered; defaulting to auto-execute.");
}
}Vertical evaluator example
A real lending evaluator might look like this:
export function evaluateAgentActionPolicy(
context: AgentActionPolicyContext,
): AgentActionPolicyDecision {
const { actionId, confidence } = context;
// Blacklist — underwriting actions are never delegable
if (FORBIDDEN_ACTION_IDS.has(actionId)) {
return { route: "rejected", riskTier: "high", reason: "Never delegable to agents." };
}
switch (actionId) {
case "lending.update_data_from_borrower_response": {
if (confidence == null) return { route: "needs-approval", riskTier: "medium", reason: "Missing confidence" };
if (confidence >= 0.85) return { route: "auto-execute", riskTier: "low", reason: `confidence=${confidence}` };
if (confidence >= 0.6) return { route: "needs-approval", riskTier: "medium", reason: "Low confidence" };
return { route: "escalate", riskTier: "high", reason: "Unreliable extraction" };
}
case "lending.satisfy_stipulation":
return { route: "needs-approval", riskTier: "high", reason: "Financial impact" };
default:
return { route: "rejected", riskTier: "high", reason: "Not in whitelist" };
}
}public static AgentActionPolicyDecision EvaluateAgentActionPolicy(AgentActionPolicyContext ctx)
{
var actionId = ctx.ActionId.ToString();
var confidence = ctx.Confidence;
// Blacklist — underwriting actions are never delegable
if (ForbiddenActionIds.Contains(actionId))
{
return new AgentActionPolicyDecision(
new AgentActionRoute.Rejected(),
AgentActionRiskTier.High,
"Never delegable to agents.");
}
switch (actionId)
{
case "lending.update_data_from_borrower_response":
if (confidence is null)
return new AgentActionPolicyDecision(
new AgentActionRoute.NeedsApproval(),
AgentActionRiskTier.Medium,
"Missing confidence");
if (confidence >= 0.85)
return new AgentActionPolicyDecision(
new AgentActionRoute.AutoExecute(),
AgentActionRiskTier.Low,
$"confidence={confidence}");
if (confidence >= 0.6)
return new AgentActionPolicyDecision(
new AgentActionRoute.NeedsApproval(),
AgentActionRiskTier.Medium,
"Low confidence");
return new AgentActionPolicyDecision(
new AgentActionRoute.Escalated(),
AgentActionRiskTier.High,
"Unreliable extraction");
case "lending.satisfy_stipulation":
return new AgentActionPolicyDecision(
new AgentActionRoute.NeedsApproval(),
AgentActionRiskTier.High,
"Financial impact");
default:
return new AgentActionPolicyDecision(
new AgentActionRoute.Rejected(),
AgentActionRiskTier.High,
"Not in whitelist");
}
}Best practices
- Whitelist model. Any action not explicitly listed should return
rejected. This prevents new actions from silently becoming auto-executable. - Confidence-gate inferred data. Actions that consume LLM-extracted data should gate on a confidence threshold.
- Never auto-allow financial mutations.
satisfy_stipulation,create_offer,record_funding— alwaysneeds-approvalorrejected. - Keep the evaluator pure. No DB calls, no side effects. The evaluator receives context and returns a decision.
- Register at boot. Call
registerAgentActionPolicy(evaluator)in your module initialization so the platform endpoint can dispatch through it.