Summary
st2 hooks install couples two operations that have different blast radii: it publishes this
binary's immutable hook set (additive, content-addressed, host-safe) and it atomically moves the
host-global receipt current.json to select that set. There is no way to ask for only the first
half. Add --no-select: publish and verify this binary's set, leave current.json untouched.
All st2 citations below verified against origin/main at b30bc54c9296e40ea352a5e19444cc268fbc0dc8.
Downstream citations verified against schickling/dotfiles da76bee5fe.
The publication half is already additive and idempotent
install_at ends with exactly the two steps, in order:
// src/hooks.rs:714-716
let dir = write_set(root, &candidate)?;
write_receipt(root, &candidate)?;
verify_installed_at(root)?;
write_set will not touch an existing set directory. If sets/sha256-<hookset> already exists it
re-verifies it byte-for-byte instead of replacing it (src/hooks.rs:636-643), and on the fresh path
it publishes via rename and then verifies (src/hooks.rs:663-671). Set directories are named by
content — sha256-<digest> under sets/ (src/hooks.rs:33-35, :87-90) — so N sets from N
binaries coexist under one root indefinitely. Publishing is a pure addition and re-publishing is a
no-op verification.
The selection half is the host-global mutation, and it refuses
write_receipt renames current.json into place (src/hooks.rs:674-685; RECEIPT_FILE = "current.json", src/hooks.rs:33). Because that receipt is one per host, install_at gates it
through check_replacement, which refuses an equal-version or unorderable transition unless
--replace is passed (src/hooks.rs:594-625):
refusing to replace hook set '{}' with '{}' because {reason}; \
rerun with --replace to intentionally select this binary's exact hook set
So the caller who only wants "make my set available" is forced to either take a global side effect
or pass --replace and take it unconditionally. The CLI exposes nothing else:
HooksCmd::Install { replace, allow_downgrade } (src/main.rs:728-735, dispatch at
src/main.rs:1668-1680).
Every consumer that matters already resolves by content, not by selection
- Rendered seats get the content-addressed path at render time:
env.insert("ST_HOOKS", versioned_hooks_dir()…) (src/materialize.rs:456-458), where
versioned_hooks_dir_at is root.join(expected_manifest().directory) — this binary's set, never
the selected one (src/hooks.rs:258-265).
- The resident supervisor verifies its own set per pass:
.then(crate::hooks::verify_required_set)
(src/run.rs:1820-1823), which is verify_set_contents(root, &expected_manifest())
(src/hooks.rs:570-578) — no receipt read.
- The read-only verb for exactly this already ships:
st2 hooks verify-own
(src/main.rs:739, :1689-1696), distinct from st2 hooks verify, which is the receipt-based
check (src/hooks.rs:546-568).
The selected receipt is therefore load-bearing for hooks verify and for nothing else on the hot
path. A binary can publish its set, render seats against it, and run under it without ever being the
selected one.
What the missing flag costs downstream
Motivating context: https://github.com/schickling/dotfiles/issues/2408 and
https://github.com/schickling/dotfiles/issues/2467.
A Nix-managed host has no additive option, so its activation bundle passes --replace
unconditionally on every activation:
# nixpkgs/home-manager/modules/st2.nix:1512
${st2Bin} hooks install --replace
and then has to defend the global side effect it just asked for by shelling into the running
supervisor's binary — resolved through /proc/$MainPID/exe — to ask whether that other binary can
tolerate losing the selection:
# nixpkgs/home-manager/modules/st2.nix:1499-1509
if systemctl --user is-active --quiet st2.service; then
running_pid="$(systemctl --user show st2.service --property MainPID --value)"
running_st2="$(readlink -f "/proc/$running_pid/exe")"
desired_st2="$(readlink -f ${st2Bin})"
if [ "$running_st2" != "$desired_st2" ] \
&& ! "$running_st2" hooks verify-own >/dev/null 2>&1; then
echo "st2-hooks: running supervisor $running_st2 cannot prove multi-version hook coexistence." >&2
echo "st2-hooks: refusing to change the selected receipt; coordinate the Home Manager version transition." >&2
exit 1
fi
fi
That is a cross-binary reverse guard: the new version interrogates the old, live version before
mutating shared state. It exists only because publication cannot be additive. The ordering hazard it
guards against is real and has taken a host down —
https://github.com/schickling/dotfiles/issues/1612 (st2.service crash-loop, NRestarts reaching
218 in one occurrence) is the receipt-versus-binary skew surfacing as
verify_selected_set's "active hook receipt selects '{}', but this st2 requires '{}'"
(src/hooks.rs:546-556).
Ask
Add --no-select to st2 hooks install:
st2 hooks install --no-select
Behavior: run write_set for this binary's expected_receipt() and verify the published set
(verify_required_set_at), then stop. Do not read current.json, do not run check_replacement,
do not call write_receipt, and do not call verify_installed_at. Exit 0 whether the set was newly
published or already present and byte-exact; fail only if this binary's own set cannot be published
or does not verify. Mutually exclusive with --replace (and with the hidden --allow-downgrade
alias), since --replace only ever relaxes the selection gate that --no-select skips entirely.
Concretely this is install_at minus its receipt branch — the read_receipt/check_replacement
block at src/hooks.rs:696-713 and the write_receipt/verify_installed_at pair at
src/hooks.rs:715-716.
Payoff
- Installing hooks for a new st2 becomes a pure addition, so the activate-before-switch ordering
constraint that produced https://github.com/schickling/dotfiles/issues/1612 stops existing for
hosts that never need to move the selection.
- The cross-binary reverse guard (
st2.nix:1499-1509) becomes deletable: there is no global state to
guard.
--replace narrows to what it actually means — "intentionally move the host's selection" — instead
of being the price of admission for publishing bytes.
- No new state, no new file, no migration: the flag removes work from an existing code path.
Relationship to #223
#223 names the other half of this, under "Two related isolation hazards": the hook receipt is
host-global ambient state, and a scoped invocation does not scope the selection. (At b30bc54,
hooks install no longer takes --catalog at all — HooksCmd::Install carries only replace and
the hidden allow_downgrade, src/main.rs:728-735 — so the flag-is-ignored framing there is stale,
but the receipt-is-global framing is not.)
This ask does not subsume #223. --no-select gives a caller a way to opt out of touching the
global receipt; it does nothing about the receipt being global, or about what a --catalog-scoped
or otherwise isolated invocation should mean when it does want a selection. #223 is the question
of whether selection should be scopeable at all. This is the narrower, purely additive request that
does not require answering it.
Posted on behalf of @schickling
| field |
value |
agent_identity |
dev3.direct.omp.v6c4mkm2 |
session |
dev3.v6c4mkm2 |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.1.2 |
agent_runtime |
OMP 18.1.2 |
tooling_profile |
dotfiles@7534055 |
Summary
st2 hooks installcouples two operations that have different blast radii: it publishes thisbinary's immutable hook set (additive, content-addressed, host-safe) and it atomically moves the
host-global receipt
current.jsonto select that set. There is no way to ask for only the firsthalf. Add
--no-select: publish and verify this binary's set, leavecurrent.jsonuntouched.All st2 citations below verified against
origin/mainatb30bc54c9296e40ea352a5e19444cc268fbc0dc8.Downstream citations verified against
schickling/dotfilesda76bee5fe.The publication half is already additive and idempotent
install_atends with exactly the two steps, in order:write_setwill not touch an existing set directory. Ifsets/sha256-<hookset>already exists itre-verifies it byte-for-byte instead of replacing it (
src/hooks.rs:636-643), and on the fresh pathit publishes via
renameand then verifies (src/hooks.rs:663-671). Set directories are named bycontent —
sha256-<digest>undersets/(src/hooks.rs:33-35,:87-90) — so N sets from Nbinaries coexist under one root indefinitely. Publishing is a pure addition and re-publishing is a
no-op verification.
The selection half is the host-global mutation, and it refuses
write_receiptrenamescurrent.jsoninto place (src/hooks.rs:674-685;RECEIPT_FILE = "current.json",src/hooks.rs:33). Because that receipt is one per host,install_atgates itthrough
check_replacement, which refuses an equal-version or unorderable transition unless--replaceis passed (src/hooks.rs:594-625):So the caller who only wants "make my set available" is forced to either take a global side effect
or pass
--replaceand take it unconditionally. The CLI exposes nothing else:HooksCmd::Install { replace, allow_downgrade }(src/main.rs:728-735, dispatch atsrc/main.rs:1668-1680).Every consumer that matters already resolves by content, not by selection
env.insert("ST_HOOKS", versioned_hooks_dir()…)(src/materialize.rs:456-458), whereversioned_hooks_dir_atisroot.join(expected_manifest().directory)— this binary's set, neverthe selected one (
src/hooks.rs:258-265)..then(crate::hooks::verify_required_set)(
src/run.rs:1820-1823), which isverify_set_contents(root, &expected_manifest())(
src/hooks.rs:570-578) — no receipt read.st2 hooks verify-own(
src/main.rs:739,:1689-1696), distinct fromst2 hooks verify, which is the receipt-basedcheck (
src/hooks.rs:546-568).The selected receipt is therefore load-bearing for
hooks verifyand for nothing else on the hotpath. A binary can publish its set, render seats against it, and run under it without ever being the
selected one.
What the missing flag costs downstream
Motivating context: https://github.com/schickling/dotfiles/issues/2408 and
https://github.com/schickling/dotfiles/issues/2467.
A Nix-managed host has no additive option, so its activation bundle passes
--replaceunconditionally on every activation:
and then has to defend the global side effect it just asked for by shelling into the running
supervisor's binary — resolved through
/proc/$MainPID/exe— to ask whether that other binary cantolerate losing the selection:
That is a cross-binary reverse guard: the new version interrogates the old, live version before
mutating shared state. It exists only because publication cannot be additive. The ordering hazard it
guards against is real and has taken a host down —
https://github.com/schickling/dotfiles/issues/1612 (
st2.servicecrash-loop,NRestartsreaching218 in one occurrence) is the receipt-versus-binary skew surfacing as
verify_selected_set's "active hook receipt selects '{}', but this st2 requires '{}'"(
src/hooks.rs:546-556).Ask
Add
--no-selecttost2 hooks install:Behavior: run
write_setfor this binary'sexpected_receipt()and verify the published set(
verify_required_set_at), then stop. Do not readcurrent.json, do not runcheck_replacement,do not call
write_receipt, and do not callverify_installed_at. Exit 0 whether the set was newlypublished or already present and byte-exact; fail only if this binary's own set cannot be published
or does not verify. Mutually exclusive with
--replace(and with the hidden--allow-downgradealias), since
--replaceonly ever relaxes the selection gate that--no-selectskips entirely.Concretely this is
install_atminus its receipt branch — theread_receipt/check_replacementblock at
src/hooks.rs:696-713and thewrite_receipt/verify_installed_atpair atsrc/hooks.rs:715-716.Payoff
constraint that produced https://github.com/schickling/dotfiles/issues/1612 stops existing for
hosts that never need to move the selection.
st2.nix:1499-1509) becomes deletable: there is no global state toguard.
--replacenarrows to what it actually means — "intentionally move the host's selection" — insteadof being the price of admission for publishing bytes.
Relationship to #223
#223 names the other half of this, under "Two related isolation hazards": the hook receipt is
host-global ambient state, and a scoped invocation does not scope the selection. (At
b30bc54,hooks installno longer takes--catalogat all —HooksCmd::Installcarries onlyreplaceandthe hidden
allow_downgrade,src/main.rs:728-735— so the flag-is-ignored framing there is stale,but the receipt-is-global framing is not.)
This ask does not subsume #223.
--no-selectgives a caller a way to opt out of touching theglobal receipt; it does nothing about the receipt being global, or about what a
--catalog-scopedor otherwise isolated invocation should mean when it does want a selection. #223 is the question
of whether selection should be scopeable at all. This is the narrower, purely additive request that
does not require answering it.
Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile