FabricFabricPlatform
Platform referenceDevelopment patterns

Where invariants live

A decision matrix for putting a new rule in the right place — schema, state machine, policy, or handler.

When you have a new rule to enforce, four places will accept it. Picking the right one matters more than people expect — the wrong choice spreads ambiguity, fights other layers, or ends up in three places at once.

The four places

LayerEncodesFailure mode
SchemaShape of the input.validation_failed
PolicyWhether the action is allowed in this context.blocked_by_policy
State machineWhether this transition is legal for this entity right now.Throws before handler runs
HandlerWhat actually happens.failed

Decision matrix

QuestionAnswer →Layer
Is this a constraint on the shape of the input (string, range, enum)?YesSchema
Is this a constraint on the combination of fields independent of any DB state?YesSchema
Does the rule depend on the entity's current state?YesState machine
Is the rule "this verb cannot move this entity from X to Y"?YesState machine
Does the rule depend on another entity, consent, timing, or cross-cutting policy?YesPolicy (data or hybrid)
Does the rule depend on external context the platform doesn't track (vendor health, market hours)?YesPolicy (code)
Is the rule "the work itself"?YesHandler

Concrete examples

"Offer amount must be positive"

Shape constraint. Schema.

const acceptOfferSchema = z.object({
  offerId: z.string(),
  amount: z.number().positive(),
});

"Offer can only be accepted while in presented state"

Constraint on the entity's current state. State machine.

{
  from: "presented",
  to: "accepted",
  causedByAction: "lending.accept_offer",
}

Cross-entity policy. Policy (code).

const creditPullConsentPolicy: PolicyEvaluator = {
  policyId: "lending.credit_pull_consent.v1",
  version: 1,
  evaluate: async (ctx) => {
    const consent = await ctx.db.consentRecord.findFirst({
      where: { partyId: (ctx.parameters as any).partyId, type: "credit_pull" },
      orderBy: { signedAt: "desc" },
    });
    if (!consent) return { policyId: "lending.credit_pull_consent.v1", policyVersion: 1, result: "block", reason: "No consent on file" };
    return { policyId: "lending.credit_pull_consent.v1", policyVersion: 1, result: "pass" };
  },
};

"Action ID must be in the agent credential's scope"

Authorization. Lives at the agent procedure middleware (upstream of invokeAction), not in a policy. The action itself has no way to know which credential called it; that's the middleware's job. See agents within policy.

"Compute the new payment schedule from the new rate and term"

Business logic. Handler.

Anti-patterns

Putting cross-entity rules in the schema

// ❌ Don't
const schema = z.object({
  offerId: z.string(),
}).refine(
  async (input) => {
    const offer = await db.offer.findUnique({ where: { id: input.offerId } });
    return offer?.status === "presented";
  },
);

The schema runs in the handler. By the time it runs, the policy and state machine have already passed. You are duplicating their work and couplings the schema to the DB. Use the state machine.

Putting state-machine rules in policies

// ❌ Don't write a policy that says "block if status != presented"

The state machine already encodes this. A policy version is forever; an extra "status check" policy is dead weight on every audit trail forever.

Putting business logic in policies

A policy returns pass | warn | block. It does not write data, enqueue work, or send notifications. If your "policy" is doing those things, it's a handler — split it.

Putting auth in the handler

By the time the handler runs, all gates have cleared. A handler that re-checks requiredPermissions is a sign that something bypassed the runtime. Find the path that did and close it instead.

See also

On this page