Getting Started
•24 min read
Changelog
What changed in each release, and what you need to do about it.
Core and the framework adapters share one version. @directive-run/query, el, mcp, lint, timeline, optimistic, mutator, and scaffold version independently.
Full per-package detail lives in each package's CHANGELOG.md on npm and GitHub. This page carries the changes that affect how you write code.
1.33.0
Upgrading from 1.32.x
One thing to check: counts from assertFactChanges() and getFactsHistory() go up, because they now include writes made inside a batch – which is most of them – and each module's own init writes. A test asserting an exact count needs a new number. The old one was missing whatever the system did through a batch.
The testing utilities and the worker adapter see batched writes
These were the last two consumers watching only the unbatched plugin hook, and nearly every write a running system makes is batched: event handlers, effects, resolvers before their first await, the opening state, and every history navigation.
assertFactChanges was under-reporting. A fact that changed four times was recorded as having changed once, so an assertion that a value did not change passed for a value that did – inside the tooling written to catch that.
The worker adapter was letting the main thread drift. FACT_CHANGED is the only path a fact value has across the thread boundary; there is no wholesale sync behind it. So a worker-backed application missed every write an event handler made. Derived values were not gated the same way, so the mirror could be told a computed value had changed while never being told the fact it is computed from had – two numbers on screen contradicting each other, both delivered by a channel that looked healthy.
Three things came with recording those writes, each already solved elsewhere in the codebase:
- The worker posts one message per run of writes to a key rather than one per write, matching how the observation stream coalesces. A handler writing one key five hundred times in a batch sends one message.
- A test system keeps the most recent 10,000 fact changes, configurable with
maxFactsHistory, and says so when it drops any. assertFactChangesandassertFactSeton a namespaced system accept either the short name or the namespaced one.
See Testing and Web Workers.
Writing a fact into a worker system works (1.33.1)
SET_FACT and SET_FACTS threw for every worker system, so the main thread could not write to a worker at all – workerClient.setFact() and .setFacts() were both dead.
A worker always builds a namespaced system, whose top-level facts object exposes a namespace per module and correctly refuses a flat module::fact assignment, since that name belongs to a module rather than to the system. It was being assigned flat anyway, so the proxy rejected it.
Writes now go through the module that owns the fact, and setFacts applies them in one batch so facts that belong together arrive together. A key naming no module reports that rather than being dropped – these arrive from the far side of a thread boundary, where a typo has nothing else to announce it.
1.32.0
Upgrading from 1.31.x
Expect more audit entries, and one type-level break.
Ledger rows go up roughly fourfold – on a workload of a hundred event dispatches touching three facts each, entries go from 102 to 405 – because writes made inside a batch were not recorded before and now are. A system's opening state appears too. The default in-memory sink holds 10,000 entries and rotates about four times sooner; truncation markers share that capacity, so ask for about twice what you intend to keep. Anything asserting ledger row counts will see different numbers.
The break: origin is required on FactChange and on the fact.change member of ObservationEvent. Reading either is unaffected. Code that constructs one – in practice plugin test fixtures and synthetic timelines – needs the property added.
A write made inside system.batch() reaches system.observe()
It did not before. The bridge behind observe() implemented the single-write plugin hook and not the batched one, so the audit ledger, and anything else observing, recorded an unwrapped write and missed the identical wrapped one.
That is not a corner of the API. Event handlers, effects, resolvers before their first await, initialFacts, hydrate and every history navigation write through a batch – so most of the writes a running system makes were arriving on the path that recorded nothing. Wrapping a write in a batch was enough to keep it out of the record entirely, while the plain write beside it was captured in full.
Each key gets one entry per batch, carrying the value it held before and the value it holds after – not one entry per write. A body that writes one key in a loop produces one entry. The cost, if you are auditing rather than debugging, is that a value a fact held only inside a batch is not recorded. If you need every intermediate value, do not batch those writes.
Every fact change carries where it came from
ledger.query({ kind: "fact.change", origin: "authored" });
"authored" when your program made the write, "restore" when a history navigation replayed it, "hydrate" when stored state was loaded in through hydrate, initialFacts or system.restore.
It is stamped against each write as it is made rather than read from a flag when the batch is reported, because a batch can hold writes of more than one origin and one label taken at the end describes neither. Select on it in the query rather than filtering the result – query() stops at limit before your filter runs.
origin says how a write arrived, not whether to trust it. "authored" means only that the write did not come through a replay or a hydration door.
The audit ledger holds up better under someone trying to make it lie
A rotated buffer no longer reports itself tampered with. Once a bounded sink fills it drops its oldest entries – ordinary operation – but verify() began every walk at the genesis hash, so the first link failed the moment the head rotated out and a healthy ledger returned valid: false for the rest of its life. It now starts from the surviving window and reports windowStartSeq.
A sink that refuses an entry no longer breaks the chain behind it, and a new onWriteError option names the entry that did not land. verify() also reports missingSeqs for a gap it closed over, unmarkedTombstoneSeqs for an erasure tombstone it cannot vouch for, and marksChecked for whether it could check those at all – a ledger reloaded from an export cannot be, and says so rather than guessing.
Entries stamp schemaVersion: 2. Entries written under 1 still verify, because the version is part of what each entry is hashed over, and they answer origin: "authored" to a query, since replayed writes could not be recorded under that schema.
Timeline replay skips frames your program did not author
@directive-run/timeline marks non-authored frames in rendered output and no longer re-dispatches them during replayTimeline – a timeline containing an undo used to replay as two mutations where the user made one. toMutate counts authored frames only. Timelines recorded before this release replay unchanged. Its peer range on core moves to ^1.32.0, since it now reads origin.
See Audit Ledger and Timeline.
1.31.0
Upgrading from 1.30.x
Nothing to do. Both changes are additive – a source with no gate behaves exactly as before, and a module that already declares schema.derivations is unaffected.
A subscription whose lifecycle is a fact
A source attached at start() and tore down at stop(). That is right for a stream whose subscription is a constant of the system's life, and wrong for one that should come and go with the system's own state.
A source can now declare a gate:
sources: {
roundChannel: {
key: (facts) => (facts.roundId ? `round:${facts.roundId}` : null),
attach: (publish, reportError, ctx) => subscribeTo(ctx.key, publish),
},
}
null detaches. A string attaches under that identity. A changed string tears the old subscription down before attaching the new one. active: (facts) => boolean is the on/off shorthand, and gateLingerMs adds hysteresis for a gate whose value flickers.
The gate runs on the post-commit plane, so it reads settled facts and never a value mid-write. It is a pure fact read: on a history restore the key is re-derived but no transport re-attaches, so time travel replays your state and not your network.
Three things this closes, each measured on a real migration rather than imagined. Channels that stayed subscribed after a user left, so the client kept receiving other people's traffic for a session it had left – filtering on arrival does not help, the bytes had already landed. Duplicate and missed rows from the overlap between an initial load and a stream with no backfill. And a terminal event published during a load window, clobbered back by the still-running loader.
A gate that throws, returns undefined, or returns anything other than a string or null is treated as detached and reported with phase: "gate". It fails closed, because a gate that cannot answer must not leave a data channel open.
Full design in RFC 0012. See Sources for the guide.
schema.derivations is optional again, as its documentation always said
Omitting the section made every derivation's expected return type resolve to never, so nothing a derivation returned would type-check:
Type 'boolean' is not assignable to type 'never'.
The runtime always inferred these; only the types refused. Declaring the section still constrains each return type exactly, so nothing changes for modules that already declare it.
A gated source whose attach fails now retries (1.31.1)
When a gate opened and attach threw – a transport briefly unavailable at the moment a fact changed – the key was recorded as attached even though nothing was. The next evaluation saw no change and did nothing, so the source stayed dark until the key happened to move again.
lastKey now records what is attached rather than what was intended, so a failed attach leaves the gate open and the next reconcile tries again. Retries back off, 250ms doubling to a 30 second ceiling, so a transport that is simply down is not re-attached on every reconcile of a busy system. A gate that moves to a new key starts a fresh subscription immediately rather than waiting out the old backoff.
A batch is announced once (1.31.2)
Two ways it was announced twice, or not at all.
A listener that opened a batch during a flush saw the outer batch's changes still sitting in the buffer and reported them again, because flush() cleared after the notify phase rather than before. Anything reconstructing state from onFactsBatch – a replica, a persistence layer, an audit trail – received duplicates carrying pre-write values.
And a plugin that wrote in response to a batch could silence every derivation notification for the life of the process. onFactsBatch is broadcast before the batch's derivation hold is released, so writing there opens a nested batch inside that window; the engine kept a single release closure, so the nested hold overwrote the outer one and the count never returned to zero. watch, subscribe, and every framework hook built on them stopped firing. Derived values still read correctly on demand and nothing threw, so the symptom looked like a bug in whatever renders. Holds are tracked per batch now, and unwound to the depth the batch opened at even when it throws.
Installing the audit ledger no longer freezes application state (1.31.3)
Entries are frozen so a consumer cannot mutate a payload in place and forge the chain, but the freeze was applied to whatever it was handed – and what it was handed was the application's own fact value. Recording a change froze that object, and reading a nested property afterwards threw a proxy invariant error. The ledger takes its own copy first, which is also the stronger guarantee: a value mutated after it was recorded no longer changes what the record says.
An exported ledger verifies again, too. The chain is hashed over a stable stringification that encodes a present-but-undefined key and JSON.stringify drops it, so an entry carrying one – which the first write of any fact does – hashed one way live and another after export. Anyone exporting the trail and checking it was told it had been altered, by the tool whose job is to answer that question.
1.30.0
Upgrading from 1.29.x
This one has source edits. The metadata surface was renamed with no aliases, and two methods were removed. Nothing else in the release needs a change.
Ask what one definition carries, instead of caching what everything carries
system.meta.byTag("pii") decides what gets redacted before a value reaches a model, a log, or a hash-chained audit ledger. Answering it walks every definition in the system, so all three consumers cached the answer – and every defect this area has had was a cache built once and never rebuilt.
// O(1) for a fact. Nothing to invalidate, nothing to poll.
system.meta.carriesTag("fact", key, "pii");
// Replaces revision(). Fires for dynamic register/assign/unregister too.
system.meta.subscribe(["pii"], rebuild, { immediate: true });
// Narrow the walk when you only want one kind.
system.meta.byTag("pii", { kind: "fact" });
carriesTag returns boolean | undefined. undefined means the runtime could not answer, and it is deliberately not false – a redactor reading "I could not look" as "nothing to redact" is the failure this surface exists to prevent. Default it to the safe side.
Renamed, no aliases
| Before | After |
|---|---|
MetaMatch.type | kind, typed DefinitionKind |
via?: "inherited" | tagOrigin: "authored" | "inherited", always present |
meta: { inheritsTags: false } | meta: { tagBoundary: true } |
byCategory(...) | removed |
revision() | removed |
Note the polarity flip on the boundary flag. It is named for the mechanism on purpose: it stops tag propagation and asserts nothing about the value, and a name promising the value was scrubbed would be a guarantee the runtime does not make.
Fixed along the way
Plugins are told about a write after the graph is invalidated. A plugin asking what a value carries during onFactSet used to get an answer from before the write it was reacting to. The batched path already worked the right way round, so the two disagreed with each other.
A throwing subscriber no longer aborts the write. system.subscribe and system.watch callbacks are now isolated per listener, the way plugin hooks already were.
A fact's tags cannot be taken back. Schema types are frozen, registerKeys refuses to re-declare an existing key, and tags must be a plain array of strings – an Array subclass could override includes and answer differently on each call.
The audit ledger and the clobber-loop detector stopped going stale. Both refreshed their pii sets from a hook registerModule does not emit, so a module registered after start put raw values into a sink that cannot be edited afterwards. Both now ask per lookup, and both resolve a dotted clause path to the fact that carries the tag.
The personal-data guardrail screens hydrated state regardless of where it sits in the plugin list.
New: guardrail.coverage
guardrail.blocked fires on a match, so a screen covering nothing and a screen with nothing to report were indistinguishable – both silent. The new event reports what a guardrail covers, on start and whenever the answer moves, carrying a digest of the covered keys rather than their names.
1.29.0
Upgrading from 1.28.x
Nothing requires a source edit. An effect's second parameter was renamed, but parameter names are positional in TypeScript – a module written against the old name compiles and behaves identically. Read the rate-table notes if you bill against them: three published rates were wrong.
An effect's second parameter is prevFacts (1.29.4)
It always held the previous facts – the same shape as the first parameter – but only one of the two said so:
run: (facts, prev, derived) => { … } // prev what? value? state? result?
run: (facts, prevFacts, derived) => { … }
Nothing to change on your side. This moves hover text, the emitted type declarations, and every generated example – the module directive init writes, the scaffolded module body, the knowledge files, and the plugin skills.
There is deliberately no prevDerived. The runtime snapshots the previous facts and nothing else, because derivations are computed from facts – a previous derived value would have to be recomputed from prevFacts, which the callback already has.
A constraint's when() gets no rename, because it never had the parameter: it has no previous-facts snapshot at all. The $changed registration error now says that in those words rather than borrowing a name from a different callback.
See Effects.
Every rate table carries the date it was checked (1.29.0)
ANTHROPIC_PRICING_AS_OF, OPENAI_PRICING_AS_OF, GEMINI_PRICING_AS_OF and OLLAMA_PRICING_AS_OF ship beside their tables in @directive-run/ai.
import {
ANTHROPIC_PRICING,
ANTHROPIC_PRICING_AS_OF,
} from "@directive-run/ai/anthropic";
const daysOld = (Date.now() - Date.parse(ANTHROPIC_PRICING_AS_OF)) / 86_400_000;
if (daysOld > 90) {
// Re-check against the provider before trusting a bill to these.
}
A rate that moves is the quietest thing that can go wrong here: nothing throws, nothing is missing, no shape changes, and every cost the package reports drifts by a constant factor in the same direction for every caller. The date is the value a program can act on. A table whose date is more than ninety days old now fails its test.
Three published rates were wrong (1.29.2)
The dates above asserted these tables had been checked. They had not. Corrected against each provider's own pricing page:
| Model | Was | Published |
|---|---|---|
claude-sonnet-5 | $3 / $15 | $2 / $10 |
o3 | $10 / $40 | $2 / $8 |
gemini-2.5-flash | $0.15 / $0.60 | $0.30 / $2.50 |
gemini-2.5-flash was the dangerous direction – it under-charged, so a budget built on it had quietly stopped stopping anything.
OpenAI's table gained all fourteen of the gpt-5 family plus o1, o1-pro and o3-pro; Gemini gained the 3.x line and gemini-2.5-flash-lite. Cached-input rates are populated for OpenAI and Gemini, with cacheWrite deliberately unset – neither provider charges for cache writes.
gemini-2.0-flash and gemini-2.0-flash-lite were shut down by the provider on 2026-06-01 and are removed. Naming one now raises an error naming the model rather than returning a price for something you cannot call.
The Gemini runners defaulted to a shut-down model (1.29.3)
createGeminiRunner and createGeminiStreamingRunner used gemini-2.0-flash when no model was named – an unmakeable call for two and a half months. Nothing caught it: the default is only reached when a caller names no model, and the failure arrives from the provider, so it read as a network problem.
import { DEFAULT_GEMINI_MODEL } from "@directive-run/ai/gemini";
DEFAULT_GEMINI_MODEL; // "gemini-2.5-flash"
A test now requires every adapter's default to be a model its own rate table prices.
The personal-data screen no longer latches open (1.29.1)
Two fixes to factPIIGuardrail, both worth an upgrade if you rely on it.
The rebuild added in 1.28.0 emptied the live key list and marked itself current before asking which keys to screen – so a failed or empty lookup left the screen holding nothing with the marker already advanced, and every later write took the "already current" shortcut. It now builds to the side and swaps in only on success.
Separately, a value the copier refuses no longer switches the screen off for everything beside it. { email, ssn, retry: () => {} } used to commit both the address and the number in the clear, because "could not copy" reported the same as "scanned and clean". Refused members are dropped and the rest is scanned.
Smaller
- The reconcile depth ceiling made reachable in 1.28.0 is dormant again. It turned out to be reachable by ordinary bounded work – a sixty-item queue drain, cursor pagination, a backoff counter – and tripping it re-dispatched resolvers that had already finished. Chains of any length now run once.
@directive-run/scaffold@0.2.3emitsprevFactsin the module it generates.
1.28.0
Upgrading from 1.27.x
Nothing requires a source edit. If you have tests asserting that a timed-out DAG node completes, or that a race loser appears as an error, those assertions described the test double and now describe the runtime.
derive.assign no longer leaves settle() waiting
Replacing a derivation definition records an invalidation, but the reconcile tail only scheduled a pass when a fact had changed – and replacing a derivation changes no fact key.
system.derive.assign("riskScore", (facts) => facts.amount * 3);
await system.settle(); // used to hang; now returns
The invalidation sat undelivered with no pass in which to deliver it, and the wait came unstuck only if unrelated traffic happened to schedule one. Definition changes now schedule a pass when one is owed.
New: system.meta.revision()
An integer that moves whenever the set meta.byTag() and meta.byCategory() search can have changed. Both walk every definition in the system, so anything consulting them per-operation caches the answer – and had no way to learn the answer had gone stale short of re-walking.
let tagged = system.meta.byTag("pii");
let seenAt = system.meta.revision();
function currentTags() {
const now = system.meta.revision();
if (now !== seenAt) {
// one integer compare
tagged = system.meta.byTag("pii");
seenAt = now;
}
return tagged;
}
Only equality is meaningful. The starting value, the step size, and whether it moves for a change that does not affect your tag are all unspecified. A rebuild you did not need is harmless; a rebuild you skipped is not, so the number is deliberately generous about moving. Treat it as a same-instance, same-process signal: do not persist it and do not compare across systems.
See Definition Meta.
factPIIGuardrail screens facts that arrive after it starts
It built its set of pii-tagged fact keys once, on init. A module registered later brought its own tagged facts, and a write to one took the same early return an untagged key takes – no scan, no redaction, nothing reported, because not on the list and scanned and clean leave the same trace. The set now rebuilds when the system's metadata changes, using revision() above.
Test doubles honour AbortSignal (1.28.1)
createMockAgentRunner's configured delay was a bare timer nothing could interrupt, and the signal was never read – so a test written against it passed whether or not abort worked.
const mock = createMockAgentRunner({
responses: { slow: { output: "never arrives", delay: 5000 } },
});
const controller = new AbortController();
const call = mock.run(agent, "go", { signal: controller.signal });
setTimeout(() => controller.abort(), 10);
await expect(call).rejects.toThrow(); // and now it does
Aborting before the call starts or while it is in flight rejects; an answer already in hand is not thrown away because the signal fired afterwards.
Two real defects fell out. A cancelled loser in race is no longer reported as a failure – errored agents were excluded from the cancellation set, so race_cancelled fired only when a loser had ignored the signal and finished normally, which is exactly when nothing was cancelled. And dag node and graph timeouts are verified for the first time; both tests had asserted that a timed-out node reached "completed".
If you build dashboards on orchestrator events, cancellations that used to look like failures will now look like cancellations.
Smaller
- Disabling an effect now releases the derivations it read. Disabling a constraint always dropped its dependency set; an effect did not, so every derivation it had read stayed watched for the life of the system. The error boundary's disable strategy reaches this, so an effect that threw once pinned its derivations permanently.
- A derivation may be named after a member of
Object.prototype.toString,valueOfandhasOwnPropertyresolved to the inherited builtin instead of the derivation's value, so a constraint gated on one was unconditionally truthy with no error anywhere. - The runaway-reconcile guard's counter resets when the system reaches quiet rather than at the end of every pass, which is the state that distinguishes a circular chain from a busy system.
@directive-run/eldeclares^1.15.0for core rather than^1.0.0. It imports two types that did not exist before 1.15.0, so the old range let a package manager report the peer as met while the consumer's typecheck broke.- Correcting the 1.27.1 note. That release reported roughly 29 to 18 microseconds per reconcile. The measurement is real but was taken only on the shape where the change wins – a deep derivation chain behind narrow readers. On wide readers it was a 12% to 20% regression, and where nothing is watched a 4% to 23% regression for no benefit. The second case is now guarded; the first is a real trade, stated rather than implied.
1.27.0
Upgrading from 1.26.x or earlier
Nothing requires a source edit. Two results grew, and both are worth a look if you act on them programmatically – see Tag inheritance below.
Tags travel down the derivation graph
system.meta.byTag("pii") now also reports derivations that read a tagged fact, so a computed value carrying PII no longer reads as untagged:
system.meta.byTag("pii");
// [ { type: "fact", id: "email" },
// { type: "derivation", id: "domain", via: "inherited" } ]
MetaMatch gained via, which is "inherited" on a derivation matched this way and absent when the tag was authored on the definition itself. system.meta.derivation(id) exposes what a derivation picked up as inheritedTags, and meta: { inheritsTags: false } marks a derivation where the claim stops holding.
Two results grew. Anything acting on byTag() – a redactor, an audit filter, a compliance sweep – now covers derivations it did not before. And system.meta.derivation(id) returns an object where it previously returned undefined for a derivation with no authored meta, so if (system.meta.derivation(id)) no longer means "did anyone annotate this". Compare against ?.label or ?.tags instead.
See Definition Meta.
An effect reads its own writes through derived
Write a fact and read a derivation of it later in the same effect body, and you now get the value that follows from the write:
run: (facts, prev, derived) => {
facts.quantity = 5;
derived.subtotal; // reflects quantity === 5
}
facts always read back immediately; derived did not, because an effect body runs inside a batch and invalidation waited for the flush. A constraint's when() is not batched, so the identical two lines already worked there. Invalidation is now eager per write and only the notification still waits, so listeners fire at exactly the moment they did before.
See Effects → Reading your own writes.
Reaching through system.derive from inside a module warns
system.derive is the single-module accessor. In a createSystem({ modules }) system it holds module names, so the same read returns undefined, the gate goes falsy, and the constraint silently never fires. Development builds now warn, naming the module that owns the derivation and both correct routes.
Production builds are unaffected, and the warning fires once per name per system.
Smaller
settle()no longer resolves while a derivation invalidation is still undelivered.system.inspect()gainedpendingInvalidationsandobservedDerivations.createModulerejects a fact key or derivation ID containing U+001F, which collided with the separator used internally to namespace dependency entries. No identifier written in normal source contains it.
The watched set shrinks again (1.27.1)
A derivation joined the set of values watched from outside the graph the moment a constraint or an effect read it, and left only when the derivation itself was destroyed. So a constraint that read a value once, behind a flag that was briefly true, kept that value watched for the life of the system.
That set is the bound the per-reconcile invalidation walk is measured against, so every stale entry made the walk both broader and less able to stop early. It is rebuilt at the end of each reconcile now, from the dependency sets constraints and effects already keep and already replace wholesale each time they run. No reference count, no delta to track, and nothing to drift.
Measured on a graph of forty gated constraints over a thirty-deep chain, with every gate opened once and then closed: the watched count settles at zero instead of forty, and a reconcile takes about 18 microseconds instead of about 29.
1.26.0
Constraints and effects receive derived
A constraint's when() is now (facts, derived) and an effect's run() is now (facts, prev, derived). Derivation bodies have always been (facts, derived); these now match.
constraints: {
offerShipping: {
when: (facts, derived) =>
facts.checkoutOpen && derived.qualifiesForFreeShipping,
require: { type: "OFFER_FREE_SHIPPING" },
},
},
The parameter is additive – a callback that ignores it behaves exactly as before, so no existing code needs to change. What it replaces is reaching back through system.derive from inside a module, which breaks silently once that module is composed into a multi-module system.
A read through derived registers a dependency on the auto-tracked path: a synchronous body, no explicit deps, reading before any await. It does not in three cases – deps declared, async: true on a constraint, or a read after an await.
An explicit deps entry may now name a derivation. It could not before in either direction: the runtime matched deps only against fact keys, and the type refused the correct code anyway.
A derivation dependency wakes on possible movement; a fact dependency wakes on a change. Writing a fact its current value is not a change and does not run the effect. A derivation has no value to compare at the moment its inputs move, so it wakes its dependent whenever the facts underneath it move – whether or not the derived value moved with them.
An effect whose deps name only derivations does not run at startup, where one naming a fact does. Startup announces the fact keys init wrote, and a derivation is not among them. If the effect establishes something that must exist from the start, name a fact it reads as well, or do the setup in init.
See Constraints and Effects.
Earlier releases
For releases before 1.26.0, see the per-package changelogs on GitHub:

