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:
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.
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),
- Enumerate what can be enumerated. A free-text
reasoninvites the model to invent one; an enum forces a decision review can reason about. - Bound every number. Amounts, quantities, and durations get explicit ranges here, and value ceilings again in policy via
maxValue. Two independent layers is the intended design. - Reject unknown keys. Zod's default strip is acceptable;
strict()is better for actions, because a surplus argument is a sign the proposal came from a stale contract.
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.
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 state | Correct behavior | Why |
|---|---|---|
| Already in the target state | Return success with changed: false | The intent is satisfied. Failing here turns retries into noise and breaks idempotent re-runs. |
| Moved to a state where the action is wrong | Throw with a stable, specific reason | The receipt records why; the operator can sense the new state and re-plan. |
| Ambiguous or unreadable | Throw; never guess | Fail-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:
| Obligation | What it means in code |
|---|---|
| Re-execution with the same args must converge, not compound | Holding a held order succeeds with changed: false. Posting the same note twice must not produce two notes. |
| Forward the key where the vendor supports it | The 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 convergent | Prefer 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 key | The 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. |
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.
{
"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" }
}
- Include what changed and from what:
changed,previous_status, the created record's id. These are the fields undo tooling and audits read. - Never include credential material or whole vendor responses. Receipts are long-lived and widely readable within the tenant.
- Keep the shape stable across patch versions. The receipt
resultis an interface; renaming its fields is a breaking change like any other.
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 class | Signal | Platform behavior |
|---|---|---|
| Invalid arguments | input throws | No lock, no policy consult, no key burned. Receipt with the validation message. |
| Precondition failed | handler throws a stable, specific error | Receipt ok: false; the operator can sense and re-plan. Not retried blindly. |
| Vendor 429 / 5xx / timeout | let ctx.http's error propagate | The runtime already retried within policy; the propagated error marks the action failed-retryable. |
| Vendor 4xx (semantic rejection) | throw with the vendor's reason attached | Failed, not retried; the reason is in the receipt for a human. |
| Partial success in a multi-step handler | throw, and say what completed | The 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:
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:
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
- Single-flight & idempotency: the kernel primitives this contract plugs into.
- Defining operators: how the proposing side constructs actions and keys.
- Receipts & audit: the lifecycle of what your handler returns.
- Testing connectors: asserting on proposed plans without executing them.