Enforcement layers

Every stateful feature has four enforcement layers. Each answers a different question.

Machine — "what could ever happen"

Defines lifecycle structure. The set of valid states and allowed transitions.

// packages/commerce/listings/listing.machine.ts
export const LISTING_TRANSITIONS = [
  { from: "draft", to: "available", allowedBy: ["seller"] },
  { from: "available", to: "draft", allowedBy: ["seller"] },
  { from: "available", to: "reserved", allowedBy: ["system", "buyer"] },
  { from: "reserved", to: "sold", allowedBy: ["system"] },
  { from: "reserved", to: "available", allowedBy: ["system", "admin"] },
];

The machine enforces transition structure:

  • Is pending → paid valid? Yes
  • Is paid → pending valid? No

It does not know about context, actors, or business rules beyond role-level gating.

Policy — "what may happen now"

Contextual business rules that go beyond the transition table. Even if the machine says a transition is valid, a policy can block it.

// packages/commerce/orders/policies/transition-guards.policy.ts

// Sync guard — pure data inspection
export function checkSyncGuard(guard, order) {
  if (guard === "isShipping" && order.fulfillmentMethod !== "shipping") {
    return { ok: false, error: "This event requires shipping fulfillment" };
  }
  return { ok: true };
}

// Async guard — queries the database
export async function checkAsyncGuard(guard, tx, orderId) {
  if (guard === "noOpenDispute") {
    const dispute = await findOpenDispute(tx, orderId);
    if (dispute) return { ok: false, error: "Cannot auto-accept while dispute is open" };
  }
  return { ok: true };
}

Policy enforces business permission/context:

  • Even if awaiting_acceptance → completed is a valid transition, can it happen right now if there's an open dispute? No
  • Even if available → reserved is valid, can a buyer reserve their own item? No
  • Even if payout can move to eligible → paid, should it be blocked because the grace period hasn't passed? Yes

When to use policies vs machine guards

The machine's allowedBy handles simple role gating (seller, buyer, system, admin). Use policies when the rule depends on runtime context beyond the actor's role — order fulfillment method, dispute state, time-based conditions, cross-entity state.

Where policies live

commerce/<module>/policies/. Name files after the real business decision: transition-guards.policy.ts, not CRUD operations like can-complete-order.policy.ts. Orders have multiple completion events with different rules.

Not every module needs policies. Listings doesn't — the machine guards are sufficient. Orders does — fulfillment method and dispute state are contextual.

Query — "can this write happen safely"

Focused DB operations that enforce persistence invariants. The query makes the write safe regardless of what called it.

// packages/commerce/listings/listing.repository.ts
export async function releaseReservedListingsForOrder(tx, orderId) {
  const rows = await tx
    .update(items)
    .set({ status: "available", reservedByOrderId: null, updatedAt: new Date() })
    .where(and(
      eq(items.status, "reserved"),           // status invariant
      eq(items.reservedByOrderId, orderId),   // ownership invariant
    ))
    .returning({ id: items.id });
  return { changed: rows.length > 0 };
}

Query enforces DB correctness:

  • Only update if current status is still reserved
  • Only release if this order owns the reservation
  • Return { changed: false } if invariants don't hold — caller decides what to do

Why returning() not rowCount

Use .returning({ id: table.id }) and check rows.length > 0 instead of result.rowCount. This is driver-confirmed — it works reliably across all Drizzle runtimes.

Workflow — "actually perform the mutation"

Combines all layers into one transactional operation. The workflow is the only thing that runs.

// packages/commerce/listings/listing.service.ts
export async function transitionListing(input) {
  return await db.transaction(async (tx) => {
    // 1. Load (query)
    const item = await getListingForTransition(tx, input.itemId);
    if (!item) return { ok: false, code: "not_found", ... };

    // 2. Machine check
    if (!canTransition(from, input.to, input.triggeredBy))
      return { ok: false, code: "invalid_transition", ... };

    // 3. Status-checked update (query with concurrency guard)
    const { changed } = await updateListingStatus(tx, { ... });
    if (!changed) return { ok: false, code: "concurrent_modification", ... };

    // 4. Append domain event (in same transaction)
    await insertListingEvent(tx, { ... });

    return { ok: true, ... };
  });
}

Workflow enforces the full mutation:

  • Transaction wraps all writes
  • Event append is atomic with status update
  • Concurrency is handled
  • Result type carries enough context for the feature layer to run effects

The full picture

Feature action ("use server")

  ├── auth / session check
  ├── input validation

  ├── Commerce workflow (db.transaction)
  │     ├── Machine check (transition structure)
  │     ├── Policy check (business context)
  │     ├── Query (DB write with invariants)
  │     └── Event append (domain timeline)

  ├── Cache invalidation (updateTag)
  └── Effects (after → analytics, notifications, emails)

Example: payment lifecycle

Machine says

  • pending → paid — valid
  • pending → failed — valid
  • paid → pending — invalid

Policy says

  • Manual confirmation only allowed for admin
  • Refund not allowed if dispute is in review

Query says

UPDATE payments SET status = 'paid'
WHERE id = ? AND status = 'pending'

Workflow does

Load payment → machine check → policy check → DB write → append event → return result

Example: payout release

Machine says

  • blocked → eligible — valid
  • eligible → paid — valid
  • paid → eligible — invalid

Policy says

  • Cannot release if dispute is open
  • Cannot release before delivery + grace period
  • Cannot release if buyer payment is not settled

This is why payouts need policies — the machine only knows the shape, not the business reality.

Query says

Only update if status is still eligible and no concurrent release happened.

Workflow does

Load payout → machine check → policy check (dispute, grace period, settlement) → DB write → append event → return result

Summary

LayerEnforcesQuestion it answers
MachineTransition structureWhat could ever happen?
PolicyBusiness contextWhat may happen now?
QueryDB correctnessCan this write happen safely?
WorkflowFull mutationActually perform the operation