regent
Optimistic, message-driven state engine: a journal of sealed facts folded into keyed stores and units, traversed by one ordered queue of regents. Pure Dart.
Regent in 60 seconds
In standard state-management terms:
- Msg — an event/action: an immutable fact you
dispatch. - Store / Unit — a reducer with its state: a
Storefolds messages into a keyed collection, aUnitinto a single value. - Guard — middleware as a pure function: it can drop, rewrite, or fan out a message before the rows below it see it.
- Mint — a guard deriving a new message from one it judged (run as its own round, re-judged from the top; never journaled — it re-derives on replay).
- Ledger / journal — the runtime plus its event log: the journal keeps
every dispatched message; reads and streams come from
at(position). - Regent — any row in the ordered queue a message walks: a store, a unit, or a guard.
import 'package:regent/regent.dart';
sealed class CartMsg extends Msg { const CartMsg(); }
final class ItemAdded extends CartMsg {
const ItemAdded(this.productId);
final String productId;
}
final class Cart extends Unit<List<String>, CartMsg> {
const Cart() : super(const []);
@override
List<String> reduce(List<String> items, CartMsg msg) => switch (msg) {
ItemAdded(:final productId) => [...items, productId],
};
}
const cart = Cart();
void main() {
final ledger = Ledger.root(cart);
ledger.dispatch(const ItemAdded('sku-42'));
print(ledger.at(cart).state); // [sku-42]
}
The rest of this document uses these terms with their full meanings.
The queue of regents
Dispatch a Msg; it enters the journal (the complete, ungated record) and
walks the QUEUE — an ordered list of REGENTS:
- A store row is a pure READER standing at its place: it folds what
passes (
Store.reduceover a keyed collection,Unit.reduceover one value) and can never touch the message. What it sees is whatever survived the guards above its row. - A guard row is a pure JUDGE of the flow: it folds nothing and holds no
state. Its verdict is a set of LAUNCHES targeting the only two indices
that preserve the theorem no row ever sees a message that skipped a
guard above it:
.forward(msg)continues THIS round below (pass, drop via{}, rewrite, fan out);.mint(msg)DERIVES a new fact as its own round from index 0, after this round completes — re-judged by every guard, never journaled (it re-derives on replay), required to commute with its siblings. AVetois the boolean specialization (pass or drop).
One order, two opposite relationships to it: moving a store changes what IT sees; moving a guard changes what EVERYONE below it sees. The record always keeps the original fact — guards shape the admitted feed, never the record.
Two doors
The app is a const VALUE — a Regency of rows in traversal order plus the
merge edges (each projection carries its own endpoints). Reader rows get
NAMES — const globals the consumer owns (const canonicalization makes the
global and any equal construction ONE instance, so the name IS the row):
const catalogCovered = CatalogCovered();
const localCatalog = LocalCatalog();
const catalog = Catalog();
const app = Regency({
catalogCovered,
CachedCatalogGate(), // set order is the queue — placement is protection
localCatalog,
catalog,
}, merges: {LocalCatalogSupports()});
final ledger = Ledger.root(app); // splices rows, wires merges
Regencies nest (a segment splices at its position) and a plain regent is a
one-row graph: Ledger.root(const NavUnit()). A FEATURE whose rows are
provably self-contained travels as one named graft — its merge edges ride
along and resolve before the root's own:
final class WishlistFeature extends Regency {
const WishlistFeature()
: super(const {WishlistCap(), wishlist},
merges: const {LocalWishlistSupports()});
}
Grouping is splice-in-place and reads stay FLAT — read(wishlist) never
knows the grouping exists, so a graft can never change what a row means,
only where the set is written down. The ledger then has exactly TWO doors:
dispatch(msg)— state a fact.at(position)— stand at a position, typed by the spec instance:at(catalog)is the store's live memory,at(viewer)the unit's,at(const CachedCatalogGate())the guard's story (GuardEvents: judged input + verdict —dropped,forwarded,minted),at(.entry)the complete pre-judgment RECORD,at(.exit)the admitted feed (at(.exit).msgs<OrderPlaced>()— effects tap here, so nothing fires on a dropped message).
On every handle, PLURAL members are streams (msgs<T>(), states,
statesBefore, events — all derived from the one atomic events, so
nothing races the fold) and SINGULAR members are values now (state for a
unit, entities/[id]/ids for a store — merge-resolved; folded is the
unmerged fold truth on both, what guards judge through and what replay
snapshots).
Guards read the world only through read(catalog) — the ledger's own
folded state by REGENT IDENTITY (the const global or an equal construction
name the same row). With canon's generator the graph is annotated
(@canon const app = Regency(...)) and each row CLASS gains a read
extension, so the same globals answer everywhere: read(catalog) in a
judge, catalog.of(context) in a build, catalog.entities now — the
generator never invents a name.
Optimism
Optimism is ROWS, never memory machinery — a store's memory holds nothing but its fold. The write dock: a side store holds the pending prediction as honest state (base has no arm for it), a merge edge applies it at read, a guard settles it against echoes by STATE COMPARISON, and a deadline EFFECT dispatches a timeout fact the guard judges like any other. Pending, settled, in-flight, covered — every status a UI could render is a row, so everything replays and confirm/revert/amend orders are statable as laws.
Beyond the fold
- Events — each store emits one post-fold event per delivered family
message (
msg,before,after, changed keys): effects observe cause and consequence atomically, so they can never race the fold. Sugar:transitions(),entering(state),on<M>(). - In-flight as a row — a request fact folds its key in, the answering facts (success or failure) fold it out; presence = loading, read with the same surface as any state. A guard reading it drops duplicate asks; a scope-entry FACT judged by a gate replaces every fetch-on-entry bridge. No machinery, no sidecar.
- Merges — read-time edges, never copied state: a unit's state answers a
keyed surface at its own
Identifiableid (merge), or a whole store lends its rows to another's reads through a projection (mergeStore) — the shadow-store pattern: a disk cache folds into its own store and supports the main store's reads until the authority covers.
The one clock, and why there is no effects runner
Regent has exactly ONE clock: the dispatch stream. Nothing else is allowed
to mark time — not a Duration parameter in the state tier, not a wall-clock
read in a fold. So the first thing every arrival from an effects-as-values
framework asks for — a runner with restartable / latest / debounce
modes — regent deliberately does not ship. Not for lack of ambition: the
modes have no work left to do here.
The reason is that the wire is already a queue of facts, not a Future per
call. A network ask isn't a promise you cancel; it's a fact you send. You
cannot unsend it — you can only ignore its answer, and ignoring is a
judgment, which is pure by nature. So every mode collapses into citizens:
| runner mode | regent |
|---|---|
restartable / latest |
a CURRENT-intent row + a staleness veto |
droppable |
the in-flight row + its gate |
sequential |
the outbox — an ordered queue already |
Search-as-you-type, whole (the wire correlation is the trick — the answer echoes its question, so staleness is judgeable in a pure fold):
final class Search extends Unit<String?, SearchMsg> { // the CURRENT intent
const Search() : super(null);
@override
String? reduce(String? q, SearchMsg msg) => switch (msg) {
SearchQueryMsg(:final query) => query, // newest intent wins
SearchResultsMsg() => q,
};
}
/// An answer to a question that is no longer current is dropped for every
/// row below — the whole "cancel the stale request" story, as a judgment.
final class StaleSearchGate extends Veto<SearchResultsMsg> {
const StaleSearchGate();
@override
bool block(SearchResultsMsg msg, ReadStore read) => msg.query != read(search);
}
The effect that remains is a TRANSLATOR — fact in, I/O, fact out. No state, no branches, and its worst possible sin (a stale or duplicate answer) is eaten by the veto above: the edge may be racy, the ledger cannot be.
ledger.at(.exit).msgs<SearchQueryMsg>().listen((msg) async {
try {
dispatch(SearchResultsMsg(
query: msg.query, products: await api.searchProducts(msg.query)));
} on ApiException {
dispatch(SearchFailedMsg(query: msg.query)); // failure is a fact too
}
});
And when a consumer genuinely wants to wait? The timer is a translator
like any other edge: wall-time in, ONE fact out — after(d, msg) — and what
the elapsed time MEANS is judged in the queue against the present (an epoch
or a state check on the due-fact; a settle tick for a superseded epoch is a
stale fact like any other). A timer nobody cancels is harmless when its tick
is judged. Cancelling is then a cost optimization, never a correctness
requirement — and replay() never arms a timer at all, so a law test proves
the whole debounce by dispatching the due-facts, with zero real waiting.
What irreducibly stays at the edge: the sites that HOLD a cancellable resource — upload bytes, platform streams, the socket itself. There the decision is still a fold (the saga says cancelled; the connection unit says offline) and the edge merely releases the resource. That is a small, stable, hand-written set — which is why effects stay one honest file.
The edge spends; the queue decides.
Message conventions
The structure prevents most failure modes; message taxonomy discipline prevents the rest. These are the rules the engine can't enforce for you:
- Messages are facts, not calls. Name an inbound message for the fact it
states (
ProductLoaded,UsernameTaken), an outbound one for the intent it declares. A message never names its handler. - Semantic outcomes, never generic errors.
UsernameTaken, notError("username taken")— an expected outcome is a message the reducer and UI handle like any other fact. - One sealed family per entity concern. The family (
ProductMsg,CartMsg) is exactly what one store reduces —sealed, so the reduce is exhaustively matched and a new variant is a compile error until every store answers it. NO row reduces the rootMsg— a row whose facts cross families (a shadow, a dock, an in-flight unit) declares a sealed GROUP its factsimplements(a family base may join a group wholesale), so even a shadow's delegation arm is typed:final UserMsg m => const Users().reduce(rows, m). - Guards are pure. A guard reads the world only through
read— never dispatches, never touches the world. Placement is semantics: declare guards above the rows they protect. - The locality axiom. Every regent invocation is a pure function of (current state, message) — never of why the cursor arrived, what round it is, or what minted what. STORES TRANSFORM STATE AND NOTHING ELSE; GUARDS ENQUEUE CURSORS (at 0 or x+1) AND NOTHING ELSE. History reaches the future only through state, so replay totality is a theorem, provenance is invisible (if causation matters, it goes ON the fact), and every regent is table-testable with (state, msg) pairs — judgments are values.
- Mints derive, never sequence. A legitimate mint is a fact the fold already implies, restatable as a law about state ("whenever X folds, Y exists"). Sequencing over time belongs to effects; a mint chain past the depth budget throws — a design diagnosis, not a runtime hazard.
Store keys are gradually typed
A store's key type may be the raw codec type or the id's generated extension type — both are always valid, and they are runtime-identical (extension types erase):
final class Products extends Store<String, Product, ProductMsg> { … } // day one
final class Products extends Store<ProductId, Product, ProductMsg> { … } // hardened
Write String before the first generation exists (nothing else compiles
yet); tighten to ProductId whenever you like — or never. The typed key buys
exactly one thing: nominal protection on the store's key axis
(products[someUserId] stops compiling). Everything else — verbs, entity
fields, derived reads — is typed independently and works the same either way.