FabricFabricPlatform
Platform referenceReference

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.0
dotnet add package FabricOrg.Platform.Abstractions
dotnet add package FabricOrg.Platform

TypeScript 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.NeedsApproval

Low-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 — always needs-approval or rejected.
  • 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.

On this page