Fibric. Docs fibric.io →
v0.9 ยท preview
Build

Exposing actions

An action is a tool with sideEffecting: true: a capability that changes the world, reachable only through a validated ExecutionPlan disposed by the deterministic executor. This page is the contract for authors of those tools: the input schema, the preconditions a handler should check, the idempotency obligations it must honor, what lands in the receipt, how to signal errors so the platform can act on them, and two worked examples.

The tool() surface

The whole authoring surface for an action, from @fibric/connector-sdk:

@fibric/connector-sdk — ToolDef
export interface ToolDef {
  // minimal validator seam (Zod in production); returns the validated args
  input?: (args: unknown) => Record<string, unknown>;
  sideEffecting?: boolean; // true: routes through the deterministic executor + TrustPolicy
  handler: (ctx: ConnectorCtx, args: Record<string, unknown>) => Promise<unknown> | unknown;
}

export function tool(def: ToolDef): ToolDef;

Before your handler runs, the executor has already done its sequence: waited on the entity_key single-flight lock, checked the idempotency_key against the seen set, and evaluated the trust policy default-closed. By the time control reaches your code, the action is allowed, deduplicated, and alone on its entity. Your remaining job is to make one honest call and report honestly.

Input schema

The input function receives the raw args from the plan and must return the validated shape or throw. For an action, the arguments were produced by a model, which changes the posture: validate values, not only shapes, because a structurally perfect argument can still be operationally wrong.

value-aware validation
import { z } from 'zod';

const HoldArgs = z.object({
  order_id: z.string().regex(/^SO-\d+$/),
  reason:   z.enum(['promise_risk', 'payment_review', 'address_mismatch']),
  note:     z.string().max(500).optional(),
});

input: (args) => HoldArgs.parse(args),

A validation failure is cheap and early: the action fails before the policy is consulted, no lock is taken, and no idempotency key is burned. The failure still produces a receipt, so a plan that keeps proposing malformed arguments is visible in the audit trail.

Preconditions

Validation checks the arguments; preconditions check the world. An action handler should confirm the state it is about to change is the state the proposal assumed, because time passed between the operator's sensing and the executor's disposing.

check state, then act
handler: async (ctx, args) => {
  // precondition: read before write, against the same system
  const order = await getOrder(ctx, args.order_id as string);

  if (order.status === 'holded') {
    // already in the target state: succeed without acting (see idempotency)
    return { held: true, changed: false, status: order.status };
  }
  if (order.status === 'complete' || order.status === 'canceled') {
    // the world moved; acting now would be wrong. Fail with a stable reason.
    throw new PreconditionFailed(`order ${args.order_id} is ${order.status}; cannot hold`);
  }

  await setStatus(ctx, args.order_id as string, 'holded', args.note as string | undefined);
  return { held: true, changed: true, previous_status: order.status };
},

Three dispositions cover every precondition outcome, and choosing the right one is part of the contract:

World stateCorrect behaviorWhy
Already in the target stateReturn success with changed: falseThe intent is satisfied. Failing here turns retries into noise and breaks idempotent re-runs.
Moved to a state where the action is wrongThrow with a stable, specific reasonThe receipt records why; the operator can sense the new state and re-plan.
Ambiguous or unreadableThrow; never guessFail-closed applies inside handlers too. An unverifiable write is a refused write.

The idempotency contract

The executor dedupes on the plan's idempotency_key: a key it has seen succeed returns DEDUP without calling you. But the key is recorded only after your handler returns successfully, which leaves one window a handler must own: your call succeeds against the vendor, and the process fails before the success is recorded. The retry will call you again. The contract a tool must honor, precisely stated:

ObligationWhat it means in code
Re-execution with the same args must converge, not compoundHolding a held order succeeds with changed: false. Posting the same note twice must not produce two notes.
Forward the key where the vendor supports itThe runtime exposes the plan's key to the handler's HTTP profile; Stripe-style APIs accept it as an idempotency header, which extends dedup into the vendor's system and closes the window completely.
Where the vendor has no idempotency support, make the write naturally convergentPrefer set-state calls (status = holded) over apply-delta calls (increment, append). For unavoidable appends, tag the created record with the key and check for the tag first.
Never invent your own idempotency keyThe proposing operator constructs it (operator:entity:action); the executor dedupes on it. A handler that substitutes its own key silently disables the platform's dedup.
!
At-least-once at the edges, exactly-once in effect

The platform guarantees your handler is not called twice for a key it recorded. The vendor edge is yours: pass the key through, or make the write convergent. A tool that does neither is the one place duplicate side effects can still enter the world, and review treats it accordingly.

What lands in the receipt

Every disposition writes a receipt: the action as proposed, the decision, and your handler's outcome. The handler's return value is stored as result, which makes its shape part of your public contract. Return the facts an auditor or a downstream operator needs, and nothing bulky.

a receipt, as read back
{
  "action": {
    "connector": "cn-magento",
    "tool": "order.hold",
    "args": { "order_id": "SO-11290", "reason": "promise_risk" },
    "entity_key": "order:SO-11290",
    "idempotency_key": "order-risk:order:SO-11290:hold"
  },
  "decision": "ALLOW",          // 'ALLOW' | 'BLOCK' | 'ALERT' | 'DEDUP'
  "ok": true,
  "result": { "held": true, "changed": true, "previous_status": "processing" }
}

Error signaling

Throw honestly and specifically; the message becomes the receipt's error and the idempotency key is not recorded, so a retry remains possible. What you must not do is mask failure: returning { ok: false } instead of throwing records a success, burns the key, and makes the failed write unretryable.

Failure classSignalPlatform behavior
Invalid argumentsinput throwsNo lock, no policy consult, no key burned. Receipt with the validation message.
Precondition failedhandler throws a stable, specific errorReceipt ok: false; the operator can sense and re-plan. Not retried blindly.
Vendor 429 / 5xx / timeoutlet ctx.http's error propagateThe runtime already retried within policy; the propagated error marks the action failed-retryable.
Vendor 4xx (semantic rejection)throw with the vendor's reason attachedFailed, not retried; the reason is in the receipt for a human.
Partial success in a multi-step handlerthrow, and say what completedThe receipt records the partial state. Better: keep handlers to one call so this class cannot exist.

Example: post a note

An append-style action against a helpdesk without vendor idempotency support, made convergent by tagging:

note.write
import { tool } from '@fibric/connector-sdk';
import { z } from 'zod';

const NoteArgs = z.object({
  conversation_id: z.string().min(1),
  body: z.string().min(1).max(4000),
}).strict();

export const noteWrite = tool({
  sideEffecting: true,
  input: (a) => NoteArgs.parse(a),
  handler: async (ctx, args) => {
    // convergence for an append: the runtime exposes the plan's idempotency key
    // to the HTTP profile; we also tag the note so a re-run can find it.
    const existing = await findNoteByTag(ctx, args.conversation_id as string);
    if (existing) return { note_id: existing.id, changed: false };

    const note = await createNote(ctx, {
      conversation_id: args.conversation_id,
      body: args.body,                     // the tag rides in metadata, not the body
    });
    ctx.log('note posted', { conversation: args.conversation_id, note: note.id });
    return { note_id: note.id, changed: true };
  },
});

Example: update an order

A set-state action with a value ceiling shared between validator and policy. The value field on the planned action is what maxValue policies evaluate, so an operator proposing this tool sets it to the refund amount:

order.refund
const RefundArgs = z.object({
  order_id: z.string().regex(/^SO-\d+$/),
  amount:   z.number().positive().max(500),          // hard ceiling in the connector
  reason:   z.enum(['damaged', 'late', 'goodwill']),
}).strict();

export const orderRefund = tool({
  sideEffecting: true,
  input: (a) => RefundArgs.parse(a),
  handler: async (ctx, args) => {
    const order = await getOrder(ctx, args.order_id as string);

    if ((args.amount as number) > order.remaining_refundable) {
      throw new Error(
        `refund ${args.amount} exceeds remaining refundable ${order.remaining_refundable}`);
    }

    // set-state semantics: the vendor call is keyed on (order, plan idempotency key),
    // so a crash-retry converges on the vendor side as well.
    const refund = await createRefund(ctx, args);
    return { refund_id: refund.id, amount: args.amount, changed: true };
  },
});

// and in the tenant's policy, the second, independent ceiling:
// { connector: 'cn-magento', tool: 'order.refund', decision: 'ALLOW', maxValue: 100 }

Note the layering: the validator caps at 500 (the connector's structural maximum), the handler caps at the order's remaining refundable (the world's maximum), and the tenant's policy caps at 100 (the business's appetite). Any layer alone would be enough to stop a bad proposal; all three fail independently.

Keep going