Skip to main content
·4 min read

Long chains run once

Directive Labs·

Directive's reconciliation loop keeps going until nothing is left to do. Facts change, constraints evaluate, requirements emit, resolvers run, and running a resolver usually changes a fact, so round it goes until the system is quiet.

Four things in that loop were happening without anything saying so. All four are fixed in the current release, and one of them could cost you money.


A long chain no longer re-runs work that finished

If your work naturally chains, this is the one to read. Draining a sixty-item queue. Paging through a cursor. Counting down a retry. Any of those runs more than fifty reconcile passes, and past fifty a guard used to trip and clear the record of which requirements had already been handled.

Everything still outstanding then looked new. Including the ones that had nothing to do with the long chain.

resolvers: {
  charge: {
    requirement: 'CHARGE',
    resolve: async (req, context) => {
      await stripe.charges.create({ amount: req.amount });
      context.facts.charged = true;
    },
  },
}

Put that resolver in a system that also drains a long queue, and the charge could be dispatched again. Measured at nine dispatches for a single order.

The warning that accompanied the guard is compiled out of production builds, so it happened in silence.

Chains of any length now dispatch each requirement exactly once. Nothing you write changes.

<details> <summary>Why the guard was withdrawn rather than repaired</summary>

The ceiling was meant to catch a runaway: a resolver feeding its own constraint, spinning forever. It could not do that job. A resolver that reschedules itself without writing a fact resets the counter every pass, so the actual runaway stayed invisible while ordinary bounded work tripped the ceiling.

Depth is the wrong instrument. The system already knows which constraint produced which requirement and which facts that requirement changed; a repeating pair is the signal a counter cannot see. Until that exists, no ceiling is better than one that fires on safe work and does damage when it fires.

</details>


settle() comes back after you swap a derivation

You can replace a definition on a live system. That is what makes hot reload, rule-level feature flags and live-tuned thresholds possible without a restart.

Replacing a derivation used to leave await system.settle() waiting forever.

const system = createSystem({ module });
system.start();
await system.settle();

system.derive.assign('riskScore', (facts) => facts.amount * 3);

await system.settle();   // now returns

The loop schedules a pass whenever there is something to announce, and its definition of "something" was a fact that changed. Replacing a derivation changes no fact. It changes what a fact means, everything downstream goes stale, and the system knows it, but nothing was scheduled to say so.

The wait came unstuck only if unrelated traffic happened to drag a pass along with it.

Definition changes now schedule a pass when one is owed. If you use derive.assign, derive.register or derive.unregister on a running system, this is worth the upgrade on its own.


Switching an effect off releases what it was reading

Disabling a constraint always dropped the computed values it depended on. Disabling an effect did not, so every value that effect had ever read stayed marked as watched for the life of the system, and the runtime kept doing work on behalf of a reader that was gone.

You could reach this without meaning to: the error boundary disables an effect that throws, so one failure pinned that effect's values permanently.

Both now behave the same way.


A derivation can be named toString

Name a computed value after something on Object.prototype and reading it used to return the inherited built-in function instead of your value.

derive: {
  toString: (facts) => `${facts.count} items`,
}

constraints: {
  show: {
    when: (facts, derived) => Boolean(derived.toString),   // always true
    require: { type: 'RENDER' },
  },
}

A function is truthy, so that gate was unconditionally open, and nothing anywhere raised. Reads are own-property now.


Upgrading

npm install @directive-run/core@latest

No API changes. Worth doing if your reconcile chains run long, if you replace derivations at runtime, or if you rely on the error boundary's disable strategy.


The thread through all four: the runtime did something real and nothing downstream heard about it. A wait that never ends, a guard that fires on the wrong thing, a reader that never leaves, a gate that is always open. None of them produced an error, and none of them produced a wrong answer you could point at, which is exactly why they lasted.


Related

  • Know when your metadata cache went stale system.meta.revision() is an integer you can compare instead of re-walking every definition in the system. Read the contract narrowly, because it is deliberately generous about moving.
  • A cancelled agent is not a failed one race now reports the agents it stopped as cancelled rather than failed, and dag timeouts actually cut work off. If you build dashboards on orchestrator events, this changes what they show.
  • Three of our published rates were wrong We checked every rate table in @directive-run/ai against the providers' own pricing pages and corrected three. Then we gave the tables an expiry date so it cannot happen quietly again.

Directive is free and open source. If this was useful, consider supporting the project.

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