Skip to content

Add Belay.collapse_journal/1: bounded journals, model-checked first - #10

Open
b-erdem wants to merge 2 commits into
checkpoint-determinismfrom
nested-steps
Open

b-erdem wants to merge 2 commits into
checkpoint-determinismfrom
nested-steps

Conversation

@b-erdem

@b-erdem b-erdem commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Stacked on #9. The nested-step design, done spec-first.

The problem, and why the obvious fix is dangerous

Nothing bounded journal growth. A job that loops for weeks accumulates a row per step, every attempt reads all of them, and steps/2 grows with the job. map_children only helps for fan-out, not a long sequential run.

Deleting rows is the obvious fix and the obvious way to corrupt a job. Steps memoize by name: a missing row makes step/4 see :none and run the body again. For a step that charged a card, that is the worst failure this library has.

The rule

Two facts make the safe set decidable:

  1. a step's body is the only place its nested steps are requested;
  2. a journaled step returns its memo without running its body.

So every descendant of a journaled step is unreachable, and nothing else is. collapse_journal/1 drops exactly that set, transitively.

Modelled before implemented

verify/README.md says to model protocol changes before writing them, so I did that first.

verify/nested_collapse/NestedCollapse.tla explores the complete state graph — 24 distinct states, depth 8, zero violations — for NoStepRunsTwice and RemovedOnlyUnderPresentAncestor.

The mutant is the part worth reading. It drops the ordering guard so children may be collapsed before the parent's journal write commits — which is the natural implementation if you collapse eagerly while the body unwinds. Checked against NoStepRunsTwice alone, so what it proves is the consequence rather than a restatement of the rule:

Error: Invariant NoStepRunsTwice is violated.
State 2: <Collapse>   \* children removed while outer is still uncommitted
State 3: <Exec>  execCount = [i1 |-> 1, ...]
State 4: <Exec>  execCount = [i1 |-> 2, ...]   \* the body ran twice

Four states from Init to a repeated side effect. Both are wired into verify/check.sh, which now proves five mutants fail rather than four.

Implementation

A dynamic step-scope stack in the runner records each step's parent, so a helper function called from inside a body nests correctly with no API change. Storage keeps it in a new nullable belay_steps.parent; both adapters compute the full descendant closure before deleting, so removing a child cannot hide its own children from the sweep.

Migration — the first since 1.0

This is why it opens a 2.2.0 section rather than joining 2.1.0, which advertises no migration.

Migration 4 adds one nullable column. Additive, so 2.1.x nodes keep working against a migrated database and it is rollback-safe. The contract major stays 1 — postgres_schema_test now asserts [[4, 1]] and says why.

Two properties I tested explicitly

Budgets are untouched. Spend accumulates on the job row rather than summing step rows, so dropping rows cannot move it. There is a test for exactly that, because it would be a quiet and nasty regression.

A retry after collapse does not re-run a collapsed body. Attempt 1 collapses then raises; attempt 2 must get outer from the journal and never re-enter its body. That is the model's invariant, checked against the real engine.

What it costs

History. Collapsed steps leave steps/2 and any mix belay.replay fixture. That is a real trade and the docs say so in all three places.

It also inherits replay's determinism assumption: if a later attempt asks for a nested step name outside its parent, that row is gone and the body runs again. mix belay.replay --check-determinism from #9 exists to find precisely that, and all three doc sites point at it.

213 tests on Memory, 231 on PostgreSQL, both green; format and docs clean; full formal suite green. No version bump.

Nothing bounded journal growth. A job that loops for weeks accumulates a row
per step, every attempt reads all of them, and steps/2 grows with the job.
map_children only helps for fan-out, not a long sequential run.

Deleting rows is the obvious fix and the obvious way to corrupt a job: steps
memoize by name, so a missing row makes step/4 run the body again. For a step
that charged a card, that is the worst failure this library has.

Two facts make the safe set decidable. A step's body is the only place its
nested steps are requested, and a journaled step returns its memo without
running its body. So every descendant of a journaled step is unreachable, and
nothing else is. collapse_journal/1 drops exactly that set, transitively.

Modelled before implementing, per verify/README.md. NestedCollapse.tla checks
the complete state graph (24 states, depth 8) for NoStepRunsTwice and
RemovedOnlyUnderPresentAncestor. The mutant drops the ordering guard — collapse
before the parent commits, which is the natural implementation if you collapse
eagerly while the body unwinds — and reaches a twice-run body in four states.
Both are wired into verify/check.sh, which now proves five mutants fail.

Implementation: a dynamic step-scope stack in the runner records each step's
parent, so a helper called from inside a body nests correctly with no API
change. Storage keeps it in a new nullable belay_steps.parent column, and both
adapters compute the full descendant closure before deleting, so removing a
child cannot hide its own children from the sweep.

Migration 4, the first since 1.0. Additive and nullable: 2.1.x nodes keep
working against a migrated database, so it is rollback-safe. The contract major
stays 1, which is what postgres_schema_test now asserts.

Spend accumulates on the job row rather than summing step rows, so budgets are
untouched — there is a test for that specifically. What is lost is history:
collapsed steps leave steps/2 and any replay fixture.

Collapse inherits replay's determinism assumption, and the docs say so in all
three places, pointing at mix belay.replay --check-determinism.

213 tests on Memory, 231 on PostgreSQL.
@b-erdem

b-erdem commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Extra verification, since this stack touches Runner.step/4 in five places.

verify/traces.exs drives the real engine through the interesting scenarios and emits byte-stable JSONL. I regenerated all of them on top of this branch and diffed against the committed traces:

IDENTICAL

So the cumulative runner changes across #6–#10 — duplicate detection, the request log, the step-scope stack, parent recording, version checks — do not move observable engine behaviour on any recorded scenario. Since both the traces and spec/Spec.tla are unchanged, Spec.TraceMatch necessarily still holds; there was nothing new for it to check.

I did try to run the optional ATTEST_BIN trace-check end to end and could not: the attest escript on this machine fails to load with corrupt atom table, an OTP mismatch in that project rather than anything here. CI does not set ATTEST_BIN, so this was never a gate — noting it only so the claim above is understood as "traces unchanged," not "attest re-ran and passed."

Full formal suite with the model checker: both canonical models verify, five mutants produce their expected counterexamples.

ATTEST_BIN is an escript and runs under whatever escript is first on PATH.
Attest pins a newer Erlang than Belay builds against, so loading its beams on
the older VM fails with 'corrupt atom table' — which reads as a corrupt binary
and is not one. It cost me a wrong diagnosis; the README now names it.

Also corrects the mutant count (five, not four) and records that CI does not
set ATTEST_BIN, so trace admission is a local step.
@b-erdem

b-erdem commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Correcting my previous comment: attest is not broken, and the trace-check now runs.

attest is an escript, so it runs under whatever escript is first on PATH. It pins Erlang 28.4.1; the active runtime in this repo is OTP 26. Loading beams built by a newer VM on an older one fails with corrupt atom table, which reads as a corrupt binary and is not one. Given its own runtime it works fine.

Full suite, end to end, on this branch:

AttemptFence      — no error
NestedCollapse    — no error (24 distinct states, depth 8)

PreFixAttemptFence   : expected counterexample (StaleAttemptCannotCommit violated)
PreFixBudget         : expected counterexample (NoExecutionPastBudget violated)
PreFixCancelClear    : expected counterexample (CancelRequestSurvivesUntilTerminal violated)
PreFixRetry          : expected counterexample (TerminalStatesAreFinal violated)
PreFixCollapseOrder  : expected counterexample (NoStepRunsTwice violated)

budget_crash_window.jsonl : admitted (12/12 events)
budget_exact.jsonl        : admitted (7/7)
cancel_across_crash.jsonl : admitted (11/11)
cancel_mid_run.jsonl      : admitted (6/6)
happy.jsonl               : admitted (7/7)
retry_memoized.jsonl       : admitted (9/9)
workflow_settle.jsonl     : admitted (8/8)
verdict: admitted

So all 60 recorded events of the real engine still satisfy Spec.TraceMatch after this stack, which is a stronger statement than the byte-stability check in my earlier comment — that showed the traces did not change, this shows they are still admitted by the spec.

Pushed a verify/README.md note with the PATH= invocation, since the failure mode is misleading enough that I misdiagnosed it once. Also corrected the mutant count there (five now, not four) and recorded that CI does not set ATTEST_BIN, so trace admission is a local step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant