Skip to main content

Security & Compliance

7 min read

Resolver constraint-binding (`abortOn`)

A resolver fires from a constraint, does async work, then writes a fact in its tail. While it was mid-await, an event changed that fact. The resolver's tail then silently overwrites what the event wrote.

What `abortOn` is NOT

abortOn: protects this resolver from clobbering with stale data. It does not lock, serialize, deduplicate, or prevent other writers from touching the listed facts. Anything else (an event, another resolver, an external mutator) can write to the listed facts at any time. The binding only intercepts writes by this resolver.

Do not use abortOn: as the only safeguard for non-idempotent side effects. A constraint like abortOn: ['payment.amount'] on a charge resolver does NOT prevent a second resolver (or an event-handler retry, or a "retry" button) from also charging the card. Use a coordinator pattern plus an idempotency key for that. abortOn: is optimistic concurrency on the fact write, not the side effect.

Declare the facts the resolver aborts on when changed mid-flight and Directive drops the clobbering write for you:

constraints: {
  mutate: {
    when: (f) => f.status === "mutating",
    require: { type: "EXECUTE_ACTION" },
    abortOn: ["status"], // <-- abort if `status` changes mid-flight
  },
}

Omit abortOn (the default) and every write lands – current behavior.


The bug this fixes

// status: "idle" | "mutating" | "playing" | "left"
resolvers: {
  execute: {
    requirement: "EXECUTE_ACTION",
    resolve: async (_req, ctx) => {
      await mutate();                 // <-- await
      ctx.facts.status = "playing";   // <-- the tail write
    },
  },
}

If a forceLeft event sets status = "left" between the await resolving and the tail running, the tail's status = "playing" clobbers the user's intent.

The naive defense is a manual guard at every mutation site:

await mutate();
if (ctx.facts.status === "mutating") {
  ctx.facts.status = "playing";
}

abortOn replaces that boilerplate – and, unlike a blanket guard, it is precise: it protects only the facts you name.

How it works

abortOn is value-based per-fact compare-and-swap, with one-shot fact-level poisoning. It is not: HTTP If-Match (header-driven, request-level), Postgres row locks (pessimistic), Mongoose __v (whole-document versioning), or RxJS share (multicast). The closest concept is STM's optimistic per-cell retry – but Directive drops the write instead of retrying.

For each listed fact the binding remembers the value the resolver last wrote or started with. A write to a listed fact:

  • lands if the fact still holds that value – nobody else wrote it;
  • is dropped (and ctx.signal is aborted) if the fact's live value differs – an event or another resolver changed it. That fact is then locked dropped for the rest of the invocation.

Writes to any fact not listed always land. The constraint's when() predicate is never consulted by the binding.

The name abortOn: is verb-first by design (the other constraint config keys – when:, require:, priority:, after:, deps: – are noun/preposition-prefixed). The verb is the point: declaring this list says "this resolver aborts on changes to these facts," which is exactly what the runtime enforces.

Before / after

Before – manual guards, easy to miss, and they have to be surgical:

resolve: async (_req, ctx) => {
  try {
    const result = await mutate();
    ctx.facts.lastResult = result;                       // data – keep
    if (ctx.facts.status === "mutating")                 // ❌ forget this and
      ctx.facts.status = "playing";                      //    you clobber `left`
  } catch {
    if (ctx.facts.status === "mutating")
      ctx.facts.status = "rolled-back";
  }
}

After – declare the abort-binding, write plainly:

// constraint: abortOn: ["status"]
resolve: async (_req, ctx) => {
  try {
    const result = await mutate();
    ctx.facts.lastResult = result;   // data – always lands
    ctx.facts.status = "playing";    // abort-bound – dropped if `status` was clobbered
  } catch {
    ctx.facts.status = "rolled-back"; // abort-bound – dropped too
  }
}

lastResult lands either way – the async work succeeded, the data is real. Only the abort-bound status write is clobber-checked. Optionally bail early:

const result = await mutate();
ctx.facts.lastResult = result;
if (ctx.signal.aborted) return; // an abort-bound write was already dropped
ctx.facts.status = "playing";

Choosing the fields

Name the facts the resolver re-asserts in its tail that an event could change out from under it – the "phase" facts (status, phase, mode). This is almost always a single fact.

Do not list the data facts the resolver produces (lastResult, callHistory, winRecord). Those should land regardless – that is the whole point of per-fact binding.

Notes

Resolvers are not cancelled on requirement removal

Normally the engine cancels an in-flight resolver when its requirement goes away (the constraint flipped false). A bound resolver is exempt – it runs to completion so its data writes land; the binding drops only the abort-bound clobber. A bound resolver's ctx.signal is aborted only by a dropped abort-bound write, an explicit cancel(), or a timeout.

Async constraints

Not supported. The binding snapshots the listed facts when the resolver is dispatched; an async constraint awaits between predicate evaluation and dispatch, so an event could move a listed fact before the snapshot is taken. abortOn on an async constraint is ignored (dev-mode warning).

Runtime async detection

A function when that returns a Promise is promoted to async at runtime even if you did not set async: true. That promotion silently disables the abortOn binding for that constraint – the engine logs:

[Directive] constraint '<id>': abortOn binding disabled because when() returned a Promise – convert to a synchronous when, mark the constraint async: true and accept the binding being off, or use a data-form when (always sync).

The fix is one of:

  1. Convert the when to a synchronous predicate (move the async work to a derivation that watches the dependency it would have awaited).
  2. Use a data-form when – data predicates are structurally sync and the binding works.
  3. Mark the constraint async: true explicitly if you genuinely need an async predicate and accept that the binding will be off.

callOne() and out-of-band invocations

No-op. callOne has no source constraint, so there is nothing to bind.

Mixed-source batches

No-op. A batch resolver fed by multiple constraints has no single source; same-source batches are bound normally.

One-shot per fact

Once an abort-bound write is dropped, further writes to that fact are dropped for the rest of the invocation – even if the fact transiently returns to its expected value. Writes to other listed facts are unaffected.

ABA

The check is value-based. If an external writer changes a listed fact and then changes it back to the resolver's expected value before the resolver writes, no clobber is detected – correctly: the external change netted to nothing. A clobber that matters (an event moving a fact to a terminal value and leaving it there) is always caught.

Observing rejected writes

When the binding drops an abort-bound write, Directive emits a resolver.write.rejected observation event with reason: "clobbered" so you can surface the drop in tests, devtools, or production logging. The event is a discriminated union on kind – branch on it before reading the arm-specific fields:

system.observe((e) => {
  if (e.type === "resolver.write.rejected") {
    if (e.kind === "summary") {
      console.warn(
        `[rejected] ${e.resolver}: ${e.dropped} further writes dropped (rate-limited)`,
      );
    } else {
      console.warn(
        `[rejected] ${e.resolver} dropped ${e.fact}: ` +
          `expected=${JSON.stringify(e.expected)} actual=${JSON.stringify(e.actual)}`,
      );
    }
  }
});

The same event is delivered to plugins through the onResolverWriteRejected hook – devtools and the logging plugin handle it by default. The reason field keeps the event backend-neutral; the "summary" arm is the per-resolver suppression summary (emitted once after the per-instance cap of 10).

Renamed from owns: (v1.22.0)

The constraint-binding field was originally named owns:. Reading owns: ['status'] suggested the resolver asserts ownership of status. The runtime enforces the opposite – the resolver yields when status changes. Renamed to abortOn: to reflect what the runtime actually does.

Migration is mechanical: rename owns: [...]abortOn: [...] everywhere in your constraint definitions. The audit event payload (resolver.write.rejected { reason: "clobbered" }) is unchanged – production log queries on "clobbered" keep working. doctor.checkOwns() was renamed to doctor.checkAbortOn() for consistency.

Previous
Test Async Chains

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management