cabaxiom
Declare the state you want. The loop reads the world, finds the gap and closes it. Then it looks again to prove the gap is gone.
A reconciliation kernel with no domain baked in and nothing to install alongside it. You describe each concern as a Step that reads its own drift and knows how to close it. The kernel resolves the order and runs the steps, then reads reality back. A clean result is verified, not assumed. Pure standard library, zero runtime dependencies.
Quickstart
import asyncio
from cabaxiom import Reconciler, Step
class CreateConfig(Step):
... # assess() reads the gap, apply() closes it
class InstallHooks(Step):
expects = (CreateConfig,)
class VerifyHooks(Step):
expects = (InstallHooks,)
asyncio.run(Reconciler([
CreateConfig(),
InstallHooks(),
VerifyHooks(),
]).converge())
That is the whole surface. Steps declare what they depend on, the Reconciler resolves the order, applies what is missing and re-probes to prove the result. Everything else in this README - dispatchers, retries, cancellation, the reactive watch() loop - is an optional strategy plugged into exactly this loop.
See it work
This is examples/git_repo_state.py, printed verbatim. It takes a throwaway git repository from empty to fully configured, in the right order and then proves the result held.
Throwaway repository: /tmp/reconciler-git-rf6bona1
Plan (what converge would do, in resolved order):
- user.name: want 'Reconciler Bot', have unset
- user.email: want 'bot@example.test', have unset
- commit.gpgsign: want 'false', have unset
- HEAD: repository has no commits yet
- branch:feature/login: local branch is missing
First converge:
applied this run:
+ user.name: set to 'Reconciler Bot'
+ user.email: set to 'bot@example.test'
+ commit.gpgsign: set to 'false'
+ HEAD: created the initial commit
+ branch:feature/login: created local branch
residual: empty -> desired state verified by re-probe
Second converge (should be a clean no-op):
applied this run: nothing
residual: empty -> desired state verified by re-probe
Direct git inspection:
user.name = 'Reconciler Bot'
HEAD = 13fde42
branch = feature/login
Three things in that output are the entire idea.
The plan came out ordered. The steps were handed in scrambled. A branch cannot exist before there is a commit and a commit needs user.name and user.email first. Nobody wrote that sequence. The kernel read the dependency graph and produced it - config, then the initial commit, then the branch.
The first converge did the work that was missing and nothing else. Every line under "applied this run" is a real mutation of a real repository. Then the kernel probed a second time and found nothing left. That empty residual isn't bookkeeping. The loop went back to git after acting and confirmed reality now matches intent.
The second converge did nothing and that is the whole point. Running the identical reconcile again applied zero changes and still verified clean. A no-op is not a wasted pass. It's the property that makes every other pass safe.
What reconciliation actually is
Most code that touches the world is a script - a fixed run of imperative steps that assumes it starts from a known place. Run it twice and it breaks or worse, it quietly does the wrong thing. Reconciliation drops that model. You keep a picture of the desired state and repeatedly drag the actual state toward it through a short feedback loop:
+------------------------------------------------+
| |
v |
+---------+ +----------+ +---------+ |
| WATCH | ----> | COMPARE | ----> | ACT | -----+
| read | | actual | | close |
| actual | | vs | | the |
| state | | desired | | gap |
+---------+ +----------+ +---------+
|
v
the gap here
is called DRIFT
WATCH reads what is true right now, not what you last left behind. COMPARE holds that against what you declared and computes the difference. That difference is the drift - the itemized gap between reality and intent. ACT applies just enough to close it. Then the loop runs again from the top.
A few properties fall straight out of this shape and they are what separate a reconciler from a setup script.
Drift is expected, not exceptional. Someone hand-edits a file. A branch gets deleted, a replica dies, config drifts during an incident. A reconciler does not try to prevent any of that. It assumes the world will wander off and treats every deviation as something to fix next pass. It does not raise an alarm.
It is level-triggered, not edge-triggered. A trigger does not mean "handle this one event". It means "re-check the whole state against desired, now". You never process a single delta. You ask the same question from scratch, every time. That is why you can miss a signal, double-fire a trigger or run on a plain timer and the answer stays correct.
That forces idempotency, which is the reward. Because the same unit of work may run any number of times, doing it twice has to land exactly where doing it once did. A pass that finds nothing to change and does nothing is a first-class outcome, the no-op from the second converge above. A system built this way self-heals. It converges toward desired over repeated cycles no matter how it was knocked off course.
If you have used a Kubernetes controller, you have already met this loop. It runs the same idea on your own Steps. For intuition, it is a thermostat that keeps re-reading the room instead of firing the furnace once. Or an immune system that patrols instead of firing once and going quiet. Reconciliation is what resilience gets built on, precisely because everything drifts eventually.
cabaxiom is that principle and nothing else, boiled down to a small kernel with no domain vocabulary. It has no idea what a file or a git repo is. You teach it one Step at a time.
The building blocks
Step - a unit of desired state
You subclass Step and answer one question. What is the gap between the world and what I want? You report that gap as drift and you know how to close it.
from cabaxiom import Step
class Config(Step):
def __init__(self, key: str, want: str) -> None:
self.key = key
self.want = want
def assess(self, inputs):
have = read_config(self.key) # WATCH
if have == self.want: # COMPARE
return self.verified() # no gap, no drift
return self.drifted(f"want {self.want!r}, have {have!r}")
def apply(self, inputs):
write_config(self.key, self.want) # ACT
return self.changed(f"set to {self.want!r}")
Four named constructors carry every answer a hook can give. verified() and drifted(...) for the read, unchanged() and changed(...) for the write - and a bare message is named after the step itself, so a declaration that already knows what it is called is never asked twice. They are methods on Step, which is why the import above is one name long. Nothing here has to meet the records that carry the answer.
assess() is WATCH plus COMPARE in one probe and it never mutates. It answers what it found beside the artifacts it produced for its dependents and finding nothing IS the verified answer. apply() is ACT and it must be idempotent - it hands back what it changed and its own artifacts, with unchanged() for a clean no-op. The kernel calls both. You never write the loop. Those artifacts are the DATA plane and a step may put it under contract - produces = frozenset({"dsn"}) names the keys it writes, consumes = frozenset({"dsn"}) the keys it reads and a consumer whose key no prerequisite of it produces is refused at construction. Declaring a key creates no ordering - a step may well come after another for reasons that carry no payload - it only closes the hole where a missing key arrives as an absent entry your hook cannot tell from a real one. The git example at the top is three steps of exactly this shape.
The kernel reads only two fields out of your drift, through the Drift protocol - a name and a message. That is the entire contract. DriftItem(name, message) is the ready-made implementation and the constructors above mint one for you whenever you pass a bare message. Hand them your own carrier instead wherever the subject is not the step and it passes through untouched - because the kernel reads nothing else, your domain stays entirely yours.
expects - declare dependencies, get ordering for free
A step names what must run before it with one class attribute:
class InitialCommit(Step):
expects = (GitConfig,)
class LocalBranch(Step):
expects = (InitialCommit,)
Hand the reconciler these in any order and it sorts them into dependency waves. That is what produced the correct plan in the opening output. A dependency cycle raises ValueError at construction and names the steps it could not place. A dependency on a step outside the set you passed is ignored, so a subset still reconciles cleanly.
expects relates kinds. Every instance of a named class runs first. For a dependency between two instances of one class - Directory("a") before Directory("a/b"), which expects cannot express - set self.uses = (other_step, ...) on the instance. And when the arrow is easier to write from the other end - a step that must run before a class it cannot edit - declare prepares = (Other,), the same edge written the other way around. The kernel resolves all of them together, over one instance-keyed graph.
expects, prepares, wants and uses are soft. A dependency absent from the run is trusted, not demanded. Each has a hard twin - requires, mandates, demands and needs - that orders exactly the same way but raises ValueError at resolution when the named class, provider or instance is missing, for the prerequisite a targeted or filtered run must never drop by accident. Orthogonally, a step can carry opaque tags (a frozenset, class default or per instance) - an environment, a risk class, a subsystem - that the WithTag / WithoutTag scopes select on, the axis-orthogonal counterpart to Only / Skip.
Every declaration above orders two steps. One runs, then the other. Sometimes what you need is the opposite - two steps that must never run at the same time, in either order, with any gap between them. A migration and a backfill against one database, two jobs against one rate-limited API, anything holding a single build directory. Name the resource in contends and the kernel seats them apart:
class Migrate(Step):
contends = frozenset({"database"})
class Backfill(Step):
contends = frozenset({"database"}) # never in flight beside Migrate. Either order is fine.
This is not an edge and that is the whole point. Writing Backfill.expects = (Migrate,) would also stop the overlap and it would do it by declaring a dependency that does not exist - so foresee(Migrate) would name Backfill a casualty of a step it never needed, Enclosure would see a boundary that is not there and every drawing would show the arrow. contends moves the seating and leaves the derivation alone. explain().arrows stays empty, because nothing was declared about order.
The run shape honours it the way its own rule requires and it works out which way to move without being told. Parallel pushes contenders into different waves, a Chains arrangement pulls them into one chain (a chain is serial, so that is where they stop overlapping) and Serial never had a problem. A seating that puts two contenders where they would overlap is refused at construction, so a custom Arranger is checked rather than trusted.
Because there is no arrow to read, explain() carries the reason separately - keyed by resource, since that is what a reader arrives asking about:
explanation = reconciler.explain()
explanation.groups # (('Migrate', 'Archive'), ('Backfill',))
explanation.arrows # () - nothing was declared about order
explanation.contention # (('database', ('Migrate', 'Backfill')),
# ('tape', ('Archive', 'Backfill')))
That last line is why Backfill is alone in the second wave. It contends with Migrate over the database and with Archive over the tape and those two share the first. A resource only one step names constrains nothing and is left out.
The drawing tells the two apart. A dependency keeps its arrowhead, contention is an undirected dotted link, because putting a head on it would draw exactly the dependency that was so carefully not declared:
n0 -->|after| n2 # Migrate runs before Backfill
n0 -.-|database| n1 # Migrate and Archive merely exclude each other
Reconciler - the engine
import asyncio
from cabaxiom import Reconciler
reconciler = Reconciler([LocalBranch(...), GitConfig(...), InitialCommit(...)])
drift = asyncio.run(reconciler.assess()) # the ordered gap, no changes made
residual = asyncio.run(reconciler.converge()) # WATCH -> COMPARE -> ACT -> re-probe
if not residual:
print("verified clean")
converge() returns a Residual, a list[Drift] of whatever gap outlived the run. Empty means the kernel acted, probed again and confirmed reality now matches intent. The changes made along the way live on a separate channel, residual.applied, which is what the examples print under "applied this run". Keeping the two apart means "what I fixed" never blurs into "what is still wrong".
watch() - the reactive standing converge
converge() is one turn of the crank. Reconciler.watch() turns it whenever the world moves, which is what a long-lived reconciler does. It is an async generator. A step exposes its own watch() that yields when its world may have changed - a file event, a queue message, a webhook - and the engine answers each wake with a full converge, yielding the residual.
async for residual in reconciler.watch(): # one converge per wake, lazily
log_gap(residual)
if judge.send(residual): # stop when your own condition is met
break
A wake carries no payload and is never trusted as a report - the re-assessment that follows is the only truth, so a spurious wake costs one clean pass and a missed one is caught by the next. The default watch() on a step yields nothing and exits, so a step with no event source is a plain one-shot concern. A domain that genuinely wants a timer wakes on its own clock inside watch().
watch() runs until the caller breaks out of the loop - the standing run is yours to stop. A Settle is the reusable judge for that. Prime one with judge = Stable(3).watch(); next(judge), then judge.send(residual) yields True once the condition holds. Clean stops on the first clean pass, while Stable(passes) waits for that many consecutive clean passes before it calls the world settled, so a resource that flaps clean then dirty again does not fool it.
Choosing behavior
Every axis of behavior is a small object you swap. The defaults resolve to Kahn, Parallel, Once and no cancellation. Pass nothing and you get all four.
| Axis | The question it answers | Default | Alternatives |
|---|---|---|---|
| Scope | Which of the handed steps take part? | Scope (everything) |
Only(StepC, ...) (the targets plus their transitive dependencies), Skip(StepB, ...) (all but the named, no cascade); WithTag(...) / WithoutTag(...) select by Step.tags instead of by type |
| Ordering | Given the dependency graph (expects / requires kinds, prepares / mandates reversed kinds, wants / demands capabilities against provides, uses / needs instances - each pair soft and hard), in what order do steps run? |
Kahn (dependency waves) |
DFS (flat post-order), Priority(key=...) (best-first frontier over a key), Components (split into independent chains) |
Run mode (mode=) |
What shape does the run take and how does it actually run? | Parallel (fan each wave onto the event loop with asyncio.gather, awaiting coroutine steps concurrently) |
Supervised (polls the cancellation mid-wave so a Deadline bounds even a hung apply()), Serial (one step at a time in resolved order - no concurrency anywhere, for a run you need reproducible above all), Flow (barrier-free: a step starts the moment its OWN prerequisites settle rather than when its whole wave does, so one slow step stops holding up everything behind it on a wide, uneven declaration), ThreadDispatcher (runs a blocking apply() on a worker thread through asyncio.to_thread, the bridge for a synchronous domain), RemoteDispatcher (ships each unit of work to a worker in another process - an injected transport carries the pickled (verb, step, inputs) envelope and RemoteDispatcher.work() is the worker half), ChaosDispatcher (injects faults to exercise your own failure semantics) |
| Error policy | When a step fails, stop or push on? | OnError.FailFast |
OnError.BestEffort (finish the group, collect failures as step failed drift and BLOCK the failed step's transitive dependents - each reported as step blocked: prerequisite X failed, naming every failure it stands on in declaration order so the sentence does not move with the Ordering instead of converging against state nobody put there); OnError.Halt (stop like FailFast, report like BestEffort: the failure is recorded, in-flight steps settle, every unrun step reports step skipped and the run returns its record - applied channel intact - instead of raising) |
| Retries | How many tries does one step's write get? | one try | Retry(attempts, backoff=...) guards each apply() and prune(), spending paced attempts (Fixed / Exponential / Linear / Jitter) before a failure reaches the error policy; subclass BaseRetry and implement retryable(step, attempt, error) to retry only some failures or to give a step its own share of the budget |
| Convergence | How many apply-then-probe passes per converge? | Once (single pass) |
Fixpoint(max_passes=...) (repeat until the residual stops changing by value, starts repeating a prior state (an oscillation) or a ceiling is hit), optionally paced by a backoff (Fixed, Exponential, Linear (a fixed step up to a cap) or Jitter, which scatters any backoff's pause to break a thundering herd) that retries an unchanged pass instead of stopping, so drift waiting on external state still clears; Shrinking(max_passes=...) instead loops only while the residual keeps getting SMALLER, stopping the moment a pass buys no ground - for a strictly reducible world rather than one that settles by churning |
| Cancellation | When should a run abort cooperatively between steps? | Cancellation (never aborts) |
Deadline(seconds) (wall-clock budget), Flag (manual switch), the composites AnyOf / AllOf / Majority that nest into a tree or Quorum(..., rule=...) with Some / Every / Most or a Rule of your own |
| Observability | How do you trace a run without instrumenting every step? | none (silent) | an Observer with pass hooks (began / acted / remained, plus pruned for teardowns) and live unit hooks (dispatching as each unit's payload is mounted, stepped per settled step, produced attributing each clean step's artifacts, retried per spent attempt), composed with Chorus or recorded as data (a Pass per pass) with Journal |
Every axis is an open base you can subclass and each carries a seam to implement and inject (BaseNamed, BaseTagged, BaseLooping, BaseRetry, BaseDispatcher, BaseFan). You do not extend a reconciler, you inject into it. The full cut is in the architecture notes (Ordering, Dispatcher, Convergence, Backoff, Cancellation, Settle, Observer, Scope, BaseRetry - whose seam retryable(step, attempt, error) says which failures earn another try). A run mode PAIRS two things and vouches for the pair. An Arranger turns an Ordering into the partition to walk (Waves, Chains, the serial-safe Walk or a shape of your own), a worker walks it and RunMode.resolve() builds the shape and then checks that this worker can safely walk the partition that actually came back - at construction, before anything touches your world. You compose your own with RunMode(MyShape(), MyWorker()) and the worker walks its groups through a Ledger (the record of one run - blocked / admit gate, record classifies, outcome closes), inheriting the kernel's entire failure semantics - the Cancelled cut-through, FailFast, BestEffort with dependent blocking, the live stepped hook - without re-deriving a line of it. The shared bases are public and named Base*, following BaseException - BaseDispatcher behind the workers, BaseFan behind the arrangers, BaseLooping behind the looping convergences Fixpoint and Shrinking, BaseRetry behind the shipped retry-everything Retry and BaseNamed / BaseTagged behind the scopes.
import asyncio
from cabaxiom import Reconciler, Parallel, Fixpoint, Deadline
reconciler = Reconciler(
steps,
mode=Parallel(),
convergence=Fixpoint(max_passes=10),
cancellation=Deadline(seconds=30),
)
residual = asyncio.run(reconciler.converge())
Async native - the one engine on your event loop
The kernel is async to the bone. There is no sync twin. Every verb that touches a step is a coroutine - converge(), prune() and the read assess() - so a step whose apply() or assess() is a coroutine closes its gap over I/O without blocking the coroutines it shares the loop with and a plain sync apply() still runs inline. Parallel fans each dependency wave with asyncio.gather and bars between waves, fanning the re-probe that proves a converge exactly as it fans the writes. It checks the cancellation only between waves, so Supervised is its polling sibling. It consults the cancellation while a wave is in flight and cancels the outstanding coroutines when it fires, which is what lets a Deadline bound an apply() that hangs instead of blocking on it forever. A synchronous, blocking domain rides the same engine through ThreadDispatcher, which runs each blocking apply() on a worker thread via asyncio.to_thread - and a domain with workers on other machines rides it through RemoteDispatcher, whose injected transport carries each (verb, step, inputs) envelope out and whose books never leave the originals. Two sentences an operator needs before putting anything irreversible behind that wire. The default Retry(1) is at-most-once per cycle, while any larger budget over a wire is an opt-in at-least-once - a reply lost on the way home cannot be told apart from an envelope lost on the way out, so retry re-ships and the write lands again, which an idempotent apply() absorbs and anything else answers by declining a distinguishable lost-reply error in retryable(). And artifacts carry POINTERS, not payloads. RemoteDispatcher(transport, limit=...) puts a byte ceiling on both directions, refusing an oversized envelope before it crosses and an oversized reply as the non-retryable Skew it is, because the data plane moves the data and the wire moves the words. For a continuous loop, Reconciler.watch() drives a converge per wake, taking the same pluggable settle= stop condition (Clean by default, Stable(k) for k consecutive clean passes).
More than converge
A Reconciler reads and writes state through a handful of verbs. Every one but explain() fans across the steps in resolved order, reversed for the teardown.
assess()fans every step's ownassess()across the run and folds the answers into oneReading. The reading is the deviations in run order, the channel converge re-probes for its proof. It carries the other three channels beside them -plan(what a converge would do),advisory(findings about a step already in desired state, kept out of deviation so advice never dirties a proof) andfootprint(what a teardown would remove).explain()returns the resolved run structure itself - the waves the dispatcher will walk (labelled per instance, so two steps of one kind read apart), the resolved dependency arrows behind them straight off the run's Graph - every edge kind, capabilities included - and the declarations that produced each arrow. It reads the same resolution every other verb uses, so it shows the actual run.converge()applies, then re-probes, returning theResidual.prune()is the reverse-order teardown itself, verifying the same way a converge does.watch()is the reactive standing converge, a converge per wake from the steps' own event sources.
Proving idempotence
Idempotence is the whole promise. Converge and a second converge on the settled world changes nothing. Idempotence proves that instead of trusting it. It wraps a Reconciler, converges to desired state, converges once more and returns a Proof.
import asyncio
from cabaxiom import Idempotence
proof = asyncio.run(Idempotence(reconciler).prove())
assert proof.stable # the second converge was a clean no-op
proof.churned is what that second converge's apply() reported doing - empty on an idempotent reconciler and the tell of an apply() that keeps working even when assess() reads clean - which the residual alone hides, being empty whenever the reading is clean. proof.residual is what was still wrong after it, empty only when the reconciler actually reached desired state, so a Proof says which of the two failures it found.
Proving reversibility
converge() builds and prune() tears down - but nothing yet proved the tear-down returned the world to what the run first observed. Reversibility mints that certificate. It holds the opening drift reading as a value, converges, prunes, reads again and returns a Roundtrip.
import asyncio
from cabaxiom import Reversibility
roundtrip = asyncio.run(Reversibility(reconciler).prove())
assert roundtrip.reversible # arrived, erased its footprint, world reads as first observed
The three channels are named for the promises they guard and hold the counter-evidence - built (the converge never arrived), erased (the prune reported survivors) and restored (the closing reading differs from the opening one). The last is the trick no bookkeeping-based destroy can pull off. It compares against the OPENING reading, never against emptiness, so a prune that claims clean while its artifact still stands is convicted and a run that tore down a world somebody else had built is refused honestly. For ephemeral test environments, sandboxes and canaries, the proof that "undo" is not a lie.
Isolating a failure
When a large stack churns or will not settle, the question is always the same. Which steps, exactly? Isolation answers it by delta-debugging the declaration itself - it shrinks the step set to the smallest dependency-closed subset that still shows the failure and returns an Indictment naming it.
import asyncio
from cabaxiom import Isolation, Stall
indictment = asyncio.run(Isolation(steps, charge=Stall()).isolate())
if indictment:
print("accused:", ", ".join(indictment.accused)) # the group the failure lives in
print("dismissed:", ", ".join(indictment.dismissed)) # dropped while the failure persisted
print("evidence:", indictment.evidence) # the accused set's own Proof, re-runnable
Every candidate subset travels with everything it stands on (the Graph keeps it dependency-closed), so every trial is a legal, runnable world and never a resolution error. The charge picks the failure to hunt - Churn (an apply() that keeps working under a clean drift, the default) or Stall (a residual that never clears) and a custom Charge reads the trial's Proof however your failure looks. Trials are real runs - point an isolation at a rehearsal world, exactly as you would Idempotence.prove(), whose protocol each trial runs.
Besieging a declaration
Isolation holds the world still and shrinks the DECLARATION, asking which step breaks the stack. A Siege is its twin on the other axis. It holds the declaration still and shrinks the FAULT, asking which failure the stack cannot take. It lays a strike on every step, then lifts them one at a time while the failure keeps showing and returns a Breach naming the smallest fault set your declaration cannot absorb.
from cabaxiom import Siege, Stall
breach = await Siege(steps, charge=Stall()).lay()
if breach:
print("cannot take:", ", ".join(breach.breached)) # the smallest fault set that still breaks it
print("absorbed:", ", ".join(breach.survived)) # the failures the declaration shrugged off
else:
print("converged under every fault the siege could lay")
A falsy Breach is the stronger result and the one worth asserting in a suite. The strikes are NAMED rather than rolled (Targeted), which is what makes a fault set a value a hunt can halve, re-run and compare - a seeded Sabotage makes a random chaos run repeatable, which is what you want while HUNTING for a failure and a Siege is what you reach for once you have one and want it minimal. Read a Breach as a reproducer and never as a root cause. Where several faults each break the run on their own they are all equally minimal and the lift order decides which one the shrink keeps. Trials are real runs, so lay a siege against a rehearsal world.
Nesting whole stacks
The Step surface and the reconciler surface align verb for verb and Enclave closes the loop. A whole reconciler - its own ordering, executor, convergence, retries - joins a parent run as ONE step.
import asyncio
from cabaxiom import Enclave, Idempotence, Parallel, Reconciler
database = Reconciler(db_steps, mode=Parallel()) # a whole stack...
application = Reconciler(app_steps) # ...and another
class DatabaseStack(Enclave): ...
class ApplicationStack(Enclave):
expects = (DatabaseStack,) # ordered like any step
top = Reconciler((ApplicationStack(application), DatabaseStack(database)))
assert asyncio.run(Idempotence(top).prove()) # one Proof certifies the whole tree
Reconciliation is fractal. Stacks nest, one top-level converge() returns one residual and what changed inside an enclave rides the parent's applied channel while what is still wrong surfaces through the parent's re-probe - so "empty means verified" stays true across every level.
An Enclave holds a stack you already built. A Fanout builds one per pass, over a world it lists in
the moment, which is how a declaration naming one concern answers for however many things exist today.
from cabaxiom import Fanout, Reconciler
class SweepBuckets(Fanout):
async def explore(self, inputs): # the listing goes in a verb, never a constructor
return Reconciler([Clean(name) for name in await bucket_names()])
Each discovered thing is its own step, so it is its own finding under its own name, it spends its own
Retry and only what is still wrong stays in the residual. A bucket that appeared since the last pass
joins the next one with no edit to anything - the pass lists the world again. The inner graph is rebuilt
WHOLE rather than mutated, so every pass holds a complete immutable derivation and Idempotence.prove()
on the parent still certifies the tree. The three verbs are final, because the wiring has one rule that
reads as success when you get it wrong. apply() hands up the child's applied channel and never its
residual. The cost is stated plainly - a fan asks the world instead of remembering it, so every pass lists
it twice, once for the write and once for the re-probe that proves the write.
Proving the nesting is airtight
Fractal nesting has one hazard and it is silent. Each reconciler derives its structure over its OWN steps, so a step inside an enclave that declares expects=(Network,) while the Network lives in the parent gets a dead end - an absent name is trusted to the world, nothing raises - and the parent orders the enclave only by the enclave's own declarations. The inner step can genuinely run first and neither level's explain() can see it, because every read is scoped to one reconciler by design.
from cabaxiom import Enclosure
severance = Enclosure(top).prove()
assert severance.airtight # no declared ordering dies at a boundary
for cut in severance.severed:
print(f"{cut.leaner} leans on {cut.resolver}, so order {cut.boundary} after {cut.against}")
Enclosure walks the tree and, for every name a step declares, finds the steps that would answer it at OTHER levels - every one, not just the names that matched nothing, because a name answered locally AND a level up is exactly as unguarded on the second one. For each pair it asks the deepest common level whether the resolver's representative already precedes the leaner's. Ordered means the intent survives, since an enclave's apply() is the whole inner converge. severance.inverted is the sharper subset, where the common level orders the pair the wrong way round.
It is pure structure. Nothing runs, no world is touched and it works over sync and async trees alike. Read severed as a suspicion, not an error - an enclave may deliberately trust the outer world to converge on its own schedule - but an empty one is a proof the nesting holds.
Moving between declarations
A step removed from the desired set just stops being mentioned - and whatever it built would leak forever, since nothing prunes what is no longer declared. Transition closes that gap statelessly. The caller still holds the previous declaration, so the difference is computable.
import asyncio
from cabaxiom import Transition
cutover = asyncio.run(Transition(previous_steps, desired_steps).converge())
assert cutover.clean # the leavers are gone AND the new declaration converged
It tears down exactly the steps present before but absent now (the leavers, in their own reverse dependency order, self-verifying like every prune), then converges the desired set - teardown first, so a leaver's port, path or name is free before the new world claims it. The returned Cutover carries both proofs - pruned (the leavers' surviving residue) and residual (the converge outcome).
Foreseeing a failure
explain() says what the run WILL do. foresee() says what it would lose if one step failed, which is the question you actually have at 3am, asked before anything runs.
casualty = reconciler.foresee(network)
print(casualty.blocked) # under BestEffort: everything that would report "step blocked"
print(casualty.skipped) # under Halt: everything in a later group, guaranteed not to start
print(casualty.settling) # under Halt: its own group, indeterminate by shape
print(casualty.starves) # capabilities it is the only present provider of
The forecast is not a simulation. blocked is the Graph's fallout() walk and the Ledger blames a real failure by running that same walk, so the two cannot disagree. Halt is reported in two channels on purpose, because Halt gates by run POSITION rather than by edges. A strictly later group can never start, while the failure's own group depends on the shape - a fanning executor admitted it whole so it settles, a serial walk skips only what it had not reached. Folding those into one number would be a promise the kernel does not make.
starves is a severity note rather than a further casualty. Everything wanting a capability already depends on its provider, so it is already in blocked. What starvation adds is what a FUTURE declaration would lose. A demands on a capability nobody else provides raises at resolution and a wants simply goes quiet.
Drawing a run
Mermaid and Dot render an Explanation as source text. Both are handed a run that is already resolved and can reach nothing else, so a rendering bug makes an ugly picture and never a wrong run. Contention is drawn without an arrowhead, since it says neither step may run beside the other while taking no view on which of them goes first.
from cabaxiom import Mermaid
print(Mermaid()(reconciler.explain()))
flowchart TD
subgraph g0["wave 1"]
Postgres[Postgres]
Cache[Cache]
end
subgraph g1["wave 2"]
Api[Api]
end
Cache -.->|expects| Api
Postgres -->|demands 'database'| Api
A dashed arrow is a soft declaration, so the picture shows at a glance which edges the run would still be correct without.
Examples
The examples/ directory holds four runnable, self-contained programs. Each builds a real throwaway resource, converges it, ends with an empty residual and cleans up on the way out. Run any of them with python examples/<name>.py.
Checking a declaration before you run it
Transition moves the world. Succession reads the same two declarations and tells you what the move does to the STRUCTURE, before anything runs - the only member of the proof family that touches nothing at all, so it belongs in a pre-commit hook or a review, not a sandbox.
from cabaxiom import Succession
rift = Succession(previous_steps, desired_steps).compare()
if rift.torn: # a soft edge lost its whole match set
for shift in rift.quieted:
print(f"{shift.step.__class__.__name__}.{shift.slot} no longer resolves")
The finding it exists for is the silent one. When the only step providing "database" leaves, a survivor's wants does not fail - it goes quiet and the run simply stops waiting for something. requires and demands are already loud (demand() raises the moment membership is final), so quieted is precisely the class nothing else reports and it is the only channel that votes. A Rift is truthy exactly when torn.
The other channels read the same delta in the other directions - materialised (a name that matched nothing now matches something), widened and narrowed (a fan-in that grew or thinned but still resolves) and standing (every name this declaration leans on the world to have already satisfied). That makes the one read answer three questions:
Succession(previous, desired).compare().quieted # what a cutover would silence
Succession(ours, (*ours, *theirs)).compare() # what merging two declarations creates
Succession((), mine).compare().standing # what my declaration assumes the world provides
Survivors are matched by Step.paired, the one home Transition reads to pick its leavers, so a pre-flight cannot disagree with the move it precedes. A step is recognised by the ident it calls itself where it has one and by object identity where it does not, which is what makes a declaration rebuilt from data comparable at all while leaving every older declaration matching exactly as it did. When not one step is recognised and both sides hold kinds in common, rift.estranged names those kinds and the Rift is torn, because the empty verdict that shape used to produce read as a clean bill over a declaration whose every edge was in fact remade.
Walking a path of declarations
A staged rollout is just several declarations of one world at growing amplitude - and Itinerary expresses the whole path as a value, walked one proof-gated Transition per hop.
import asyncio
from cabaxiom import Itinerary
itinerary = Itinerary([(), baseline, canary, fleet]) # start from () to build from scratch
passage = asyncio.run(itinerary.converge())
if not passage:
print(f"stands at waypoint {passage.reached}") # proven, not assumed
rollback = asyncio.run(reversed(itinerary).converge()) # the same value, walked back
The walk advances only on a clean Cutover, so the world never leaves a waypoint it cannot prove it reached. Re-running the same itinerary resumes for free - settled hops no-op through the level-triggered loop - and reversed(itinerary) is the rollback with zero extra declarations, because the waypoints already contain every intermediate world. Waypoints share step instances for the steps that persist between them (identity is the kernel's dependency addressing) and the empty declaration () marks the path's far ends. Start from it to build from scratch, end at it for a full teardown.
Batteries - declare a dev environment
A setup script that breaks on its second run is dead. cabaxiom.setup ships ready-made Steps for the most common desired state a developer declares - directories, files, symlinked dotfiles, a line in a shell profile, a key in a JSON config - so you get value without writing a single drift/apply yourself.
import asyncio
from cabaxiom import Idempotence, Reconciler
from cabaxiom.setup import Directory, File, Symlink, LineInFile, JsonKey
class AppDir(Directory):
def __init__(self): super().__init__("~/.config/myapp")
class Config(File):
expects = (AppDir,) # ordered after the directory that holds it
def __init__(self): super().__init__("~/.config/myapp/config.toml", '[core]\nmode = "fast"\n')
class Editor(JsonKey):
def __init__(self): super().__init__("~/.config/Code/settings.json", ("editor", "tabSize"), 4)
setup = (AppDir(), Config(), Editor(), LineInFile("~/.gitignore", "*.pyc"))
asyncio.run(Reconciler(setup).converge()) # brings the machine to desired state
assert asyncio.run(Idempotence(Reconciler(setup)).prove()) # PROVES a second run is a clean no-op
Every battery is a Step, so a declared setup converges to the machine you want, self-verifies by re-probing, proves idempotent under Idempotence (not trusted, proven), self-heals when the world drifts and reverses through prune - and it is never reckless. Directory prunes only when empty, JsonKey removes its key but not a file it shares, so a teardown never deletes what it did not fully own.
The batteries - Directory, File, Symlink, LineInFile, JsonKey. They are opt-in - import cabaxiom stays the pristine, zero-dependency kernel and the batteries arrive only when you from cabaxiom.setup import .... See examples/dev_setup.py for the full arc - declare, converge, prove, drift, self-heal, reverse.
Fleet standards - prove your repos conform
Stop asking "is every repo compliant?" and prove it. cabaxiom.repository reconciles one repository or a whole fleet to an organisation standard and the residual reads as a compliance report. An empty residual PROVES every repo conforms, a non-empty one NAMES the repos that resist.
import asyncio
from cabaxiom import Reconciler
from cabaxiom.repository import GitHub, RepoSetting
gh = GitHub(token)
standard = [RepoSetting(gh, repo, "has_issues", True) for repo in ("acme/api", "acme/web", "acme/cli")]
report = asyncio.run(Reconciler(standard).assess()) # read-only, names the repos out of standard
residual = asyncio.run(Reconciler(standard).converge()) # enforce it across the fleet
assert not residual # empty means the fleet conforms, PROVEN by re-probe
Batteries - declare a warehouse (cabaxiom.data)
The data batteries take a PORT, never a driver. Any PEP-249 connection the domain's own client minted -
Snowflake, Postgres, DuckDB or the standard library's sqlite3, which is what the whole suite proves
them on. Schema and Table converge and tear down honestly (a table holding rows is LEFT STANDING and
named, never dropped), Loaded is the idempotent incremental load with the watermark as its judge,
Fresh and Quality are pure sensors - criticals dirty the proof, advisories never do - and Lineage
is data lineage as a trace - an Observer on the kernel's own produced() seam, one JSONL line per clean
producer, with not one step instrumented. Identifiers are vetted, never quoted - the vet is the
injection guard. DbtRun reads the run state dbt itself records (target/run_results.json) and runs dbt build
through stdlib subprocess only when the reading is dirty - the command is the seam, so no dbt in the
dependencies and the suite rehearses it offline. The full arc, proofs included, runs in
examples/data_warehouse.py.
The GitHub client is stdlib urllib only - no requests, no PyGithub - and its transport is injectable, so a compliance run rehearses against a fake in-memory GitHub with no token and no network, exactly the way the kernel rehearses everything. The self-verifying loop is the point. apply patches a repo but the RE-READ is the judge, so a setting the API accepted yet did not change (a locked or archived repo) is caught and named, never called done on the strength of the API's word. When a repo carries many standards and one will not hold, Isolation delta-debugs them down to the single blocker and names it (examples/repo_isolation.py) and file standards - a required LICENSE, a gitignore entry - reuse the cabaxiom.setup batteries on a checked-out path. See examples/repo_standards.py.
Examples
The examples/ directory holds runnable, self-contained programs. Each builds a real throwaway resource (or an in-memory one), converges it, ends with an empty residual and cleans up on the way out. Run any of them with python examples/<name>.py.
Start here: the three below are the grounding trio (a real git repo, a real filesystem tree, a parallel service graph). Then reach for the feature tours: async_service_mesh.py (concurrent bring-up on the event loop, plus Supervised bounding a hung dependency with a Deadline), parallel_fixpoint.py (ThreadDispatcher fanning a blocking domain across worker threads, settled by Fixpoint), controller_watch.py (the reactive watch() loop with Clean vs Stable stop conditions on a flapping service), fractal_idempotence.py (Idempotence.prove() catching a churn a single converge cannot, then Enclave nesting whole stacks and proving the tree), scoped_rollout.py (Only / Skip / WithTag / WithoutTag selection and a hard requires), resilient_run.py (BestEffort blocking a failure's dependents, Retry recovering the transient and a live Observer + Journal), blue_green_transition.py (Transition pruning what left the declaration and converging what joined), convergence_shapes.py (the convergence axis end to end - Shrinking vs Fixpoint on a churning world, Linear backoff pacing a stall and a custom convergence built on the BaseLooping seam) and fanout_reduce.py (the Fanout base - one step lists the world, declares a child reconciler over what it found and reduces through namespaced keys, the expansion the kernel refuses to make mid-run and supports honestly outside it).
dev_setup.py - the batteries, proven
A development environment declared with cabaxiom.setup, then converged, certified idempotent, drifted, self-healed and torn down - the whole beachhead arc against a throwaway temp home, safe to run again and again.
repo_standards.py - the fleet, proven
An organisation standard reconciled across a fleet of repositories against a fake in-memory GitHub - the compliance report that names who resists, the enforcement and the empty residual that proves the fleet conforms.
repo_isolation.py - which standard will not hold
One repository, several standards, one of them structurally unsatisfiable. Isolation delta-debugs the standards down to the single blocker and mints an Indictment naming it, instead of leaving you to read a wall of residual.
blast_radius.py - the forecast, checked against the run
foresee() predicts what a failing migration would cost, the script then runs the fleet for real and diffs the prediction against the residual. They match exactly, because both read the one fallout() walk. It also shows why Halt is reported in two channels. A serial walk and a fanning one skip different sets and the difference is precisely the group the forecast called indeterminate.
silent_edges.py - the failures that never fail
One ordinary refactor - extracting a database into its own stack - passes Transition, Idempotence and explain() without a mark, while silently dropping both edges that ordered the api and the worker after it. Succession names what the change took away, Enclosure names what the nesting voids right now and one fix satisfies both. Neither touches the world.
git_repo_state.py - the flagship
Drives a real throwaway git repository through plain subprocess calls, over the chain GitConfig -> InitialCommit -> LocalBranch. The steps go in scrambled and Kahn resolves the order. Every assess() is a genuine read, every apply() a genuine mutation. Read it first. Its full output is at the top of this README.
filesystem_layout.py
Brings a temp directory tree to a desired layout with Directory and TextFile steps, ordered so directories land before the files inside them. Then it tampers with a file behind the reconciler's back and re-converges. This is the clearest look at drift as something you recover from.
Tampering: overwrite config/app.toml with the wrong content
drift now sees 1 problem(s):
! /tmp/reconciler-fs-0gafhd5o/config/app.toml: content does not match desired
Converge again to self-heal:
applied this run:
+ /tmp/reconciler-fs-0gafhd5o/config/app.toml: wrote desired content
residual: empty -> layout verified by re-probe
parallel_fixpoint.py
Provisions a service dependency graph with ThreadDispatcher, so each dependency wave fans across worker threads and settles a multi-pass replica scale-up with Fixpoint convergence.
Resolved waves (Kahn):
wave 0: Network
wave 1: Cache, Database
wave 2: AppServers
Converge (ThreadDispatcher, Fixpoint convergence):
applied this run: 6 change(s)
+ Network: provisioned
+ Cache: provisioned
+ Database: provisioned
+ AppServers: scaled up to 1 replica(s)
+ AppServers: scaled up to 2 replica(s)
+ AppServers: scaled up to 3 replica(s)
residual: empty -> whole stack verified by re-probe
Install
pip install cabaxiom
Nothing else is pulled in. The kernel leans on the standard library alone and everything you need is re-exported from the top-level package.
When not to reach for this
The re-probe is the entire guarantee, only as honest as your assess(). If a step cannot observe the thing it changed, converge() can't tell a real fix from a no-op. An empty residual then means only that assess() reported nothing. Write assess() to read the world, never to echo what apply() intended.
A few more boundaries, stated plainly:
- Reconciliation earns its keep when a system will drift and you want it to keep correcting itself. If all you need is a one-shot transformation that runs once and is never checked again, a plain function is simpler and you should write that instead. The value here is the loop.
- It does not poll or schedule on its own.
watch()sleeps until a step announces its world moved throughStep.watch(), so the clock belongs to the world rather than to a timer. - It's not a state store. It keeps no history and no desired-state document. Each step owns its own notion of desired and observed.
- The engine is asyncio to the bone. A blocking or CPU-bound
apply()belongs onThreadDispatcher, which relocates the call to a thread, while everything else stays on the caller's loop.
Development
The kernel's internal composition - who holds whom and how a converge runs - is mapped in docs/architecture.md.
pip install -e ".[dev]"
ruff check .
pytest --cov=cabaxiom --cov-report=term-missing
The suite is written on the standard-library unittest framework with subtests and runs under pytest with coverage, currently at 100% across ordering, execution, convergence, cancellation, planning and pruning. CI lints with Ruff and runs the full suite on CPython 3.12 through 3.14, on every push to master and every pull request. fail-fast is off, so a break on one interpreter does not hide the others.
Status
The kernel is feature-complete. Every axis the design set out to cover - scoping, ordering, execution (serial, threaded and async), convergence, backoff, retry, cancellation, the control loop, idempotence proofs, fractal nesting and stateless cutover - is in and exercised at 100% coverage. The surface is settled. From here the work is hardening and the Semantic Versioning promise, not new features. The next and last milestone is the 1.0.0 freeze.
This is where the building stops.
License
Released under the MIT License. See LICENSE.
Metadata
Release files for cabaxiom 0.6.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cabaxiom-0.6.1.tar.gz | 496.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cabaxiom-0.6.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 718.6 kB
Release files / cabaxiom-0.6.1.tar.gz
| Download URL | cabaxiom-0.6.1.tar.gz |
|---|---|
| Size | 496.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
67b4221b7846cf0362ef50085c87ffb0d033ef016d3b78be1e31e79aa8c4b6b6
|
|
BLAKE2b-256 checksum How to use checksums |
8a8c2845eb9d884cf6d3c5ee61573ae762dbf79e1e605dc456dbefe151cef063
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / cabaxiom-0.6.1-py3-none-any.whl
| Download URL | cabaxiom-0.6.1-py3-none-any.whl |
|---|---|
| Size | 221.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c1b0157866b87f9109d6cc679d5e91aa93438889340a7016dda3362215de830e
|
|
BLAKE2b-256 checksum How to use checksums |
d9910b7dd689ad4248312cf04ae3b9c4b0461e9c423086c391805fedd82dd3cf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|