Skip to content

hooks: add install --no-select so publication is additive without moving the global receipt #459

Description

@schickling-assistant

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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions