Skip to content

fix: check the release record in the call that acts on it - #22

Merged
ausimian merged 1 commit into
release/1.0.0from
issue/13-atomic-gate
Aug 22, 2026
Merged

fix: check the release record in the call that acts on it#22
ausimian merged 1 commit into
release/1.0.0from
issue/13-atomic-gate

Conversation

@ausimian

Copy link
Copy Markdown
Owner

Refs #13. Targets release/1.0.0. Fixes a race in the gate step 3 added.

The race

Step 3 added Castle.upgradable/0, and bin/castle called it as a separate rpc before
unpack and before install. Two rpcs are two moments and possibly two different node
instances: a node can pass the check on a valid in-memory record, restart before the
action's rpc with RELEASES gone, synthesise an empty record, and then perform the action
without rechecking — recreating exactly the silent stale-code failure the gate exists to
prevent.

That was my design error rather than an implementation slip. A gate whose answer can go
stale before the action it guards does not reliably guard anything.

The fix

The check is now part of the operation. Commands.unpack/2 and Commands.install/2 call
it themselves, before :release_handler is touched — same rpc, same node instance, and the
same which_releases() result the action then proceeds on. It removes an rpc from the
shell rather than adding anything.

Gating unpack matters more than it looked

The original reason was "refuse as early as possible". The real one is worse, and is in
OTP's source: do_unpack_release/4 (sasl-4.3:1546-1547) builds its record list from the
in-memory Releases and calls write_releases/3, which (:2570-2580) only maps current
unpacked and otherwise serialises them verbatim.

So an unpack on a synthesised-record node persists the empty-libs record into
RELEASES
. The next boot then consults it successfully — the synthesis path at :1259
is no longer taken — and Castle.make_releases/0 does nothing, because the file exists.
The node is left permanently refused, with the documented remedy, restart, no longer
working.

Letting unpack through does not delay the refusal. It destroys the way out.

commit, remove and releases stay unchecked — now measured

do_make_permanent/2 returns early for an already-permanent release and errors for every
other status; do_remove_release/4 refuses the permanent release outright (:2109-2112);
releases only reads. On a synthesised-record node the record names exactly one release,
so none of them can reach a write_releases/3. Meanwhile gating them could refuse an
upgrade already under way — installed and awaiting commit — which the next restart would
roll back anyway.

Castle.upgradable/0 stays, demoted to a query

The state it reports is otherwise invisible: the file can be present while the record the
node works from was synthesised. Correctness no longer depends on anyone calling it, so
keeping it costs nothing, and #11 owns the documented API surface.

The Castle boundary carries a comment saying it is a diagnostic and must not be put
back in front of the operations. That comment is the durable protection against
reintroducing the race — more useful than deleting the function would be.

One consequence, documented rather than hidden

There is deliberately no second check in Castle.install/1 ahead of materialise/1
that would be the shape just removed, moved in-process. So a node refused for its record
materialises the target's configuration first.

That is only wasted work: materialising writes into the target's version directory,
never to the running system and never to a release record, and it is idempotent. The
refusal still leaves the running system exactly as it was, and still falls before
install_release/1, which is the point of no return AGENTS.md requires everything to
refuse ahead of.

Messages name the operation that did not happen

Cannot install 1.2.3: 1.2.2 is running from a release record OTP built from the
boot script, which names no applications - releases/RELEASES was missing, or
could not be read, when the system booted. …
Restart the system: the release creates the file before it starts.

with Cannot unpack <name> / Cannot install <vsn> / This system cannot be upgraded
supplied per caller.

Tests

6 of 91 fail against 5c5e9e0 — four behavioural, two textual. The behavioural four
are the two refusals and the two ordering assertions: each registers a reply that would
have the handler succeed, then asserts Stub.calls(:unpack_release) == [] /
Stub.calls(:install_release) == [], since the refusal value alone cannot distinguish
"before" from "after".

commit/2 commits without asking passes against 5c5e9e0 by design — it guards the
decision not to gate commit rather than discriminating against the old code, and says so.

Verification

mix precommit green — 91 tests — on Elixir 1.19.5/OTP 28 and again on 1.20.0/OTP 28.

Forecastle follows

Its side is a deletion: the two separate Castle.upgradable() rpcs go, with nothing
replacing them. Landing separately, since its current suite expects them.

upgradable/0 was a gate bin/castle asked for in an rpc of its own,
before the rpc that unpacked or installed. Two rpcs are two moments and
possibly two node instances: a node could answer on the strength of the
record it read at boot, restart before the second call arrived, and
synthesise an empty record on the way back up - and the unpack or the
install would then go ahead on an answer that no longer held, which is
the silent stale-code failure the gate exists to prevent. A check whose
answer can be invalidated between asking and acting is not a check.

So Commands.unpack/2 and Commands.install/2 make it themselves, before
:release_handler is asked for anything. One call, one record, one
decision, and nothing to reintroduce in the shell.

install is checked because that is where the damage is. unpack is
checked because it is the one other operation that writes release
records: do_unpack_release/4 ends in write_releases/3 over the records
the handler holds, so an unpack on such a node puts the synthesised
record into RELEASES, the next boot reads it back, and make_releases/2
does nothing when the file is there - which takes away the restart the
refusal names as the remedy and leaves the system with no way out.
commit, remove and releases stay unchecked, and that is measured rather
than assumed: do_make_permanent/2 returns early for a release that is
already permanent and errors for every other status,
do_remove_release/4 refuses the permanent release outright, and
releases only reads, so none of them can write that record back, while
refusing them could strand a version already installed and waiting to
be committed.

The refusal now names the operation that did not happen rather than
reading as a precondition an operator failed to satisfy: "Cannot
install 1.2.3: 1.2.2 is running from a release record OTP built from
the boot script ...".

upgradable/0 stays, as a query rather than a gate. Nothing has to call
it, but the state it reports is invisible otherwise - the file can be
present while the record the node works from was synthesised - so an
operator needs some way to ask that does not unpack or install
anything. Whether it belongs in the documented API surface is #11's.

The new tests are about ordering, so they are written the way
materialise/2's are: the stub is handed a reply that would have the
operation succeed and the assertion is that it was never asked for it,
because the refusal looks the same whether it came before the mutation
or after. Two more assert which_releases was called in the call that
acted, which no version asking it elsewhere can pass, and commit's
guard is the mirror image - a synthesised record, and which_releases
never called.

Forecastle drops the separate upgradable rpc from bin/castle: nothing
replaces it, because the operations refuse for themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
@ausimian

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 22609f74f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ausimian
ausimian merged commit d72e94e into release/1.0.0 Aug 22, 2026
7 checks passed
@ausimian
ausimian deleted the issue/13-atomic-gate branch August 22, 2026 12:19
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