Problem
st2 up has no startup admission. There is no point at which st2 checks, once, that the world it is about to supervise matches the world its caller believes it deployed — so every supervised deployment has to build that gate itself, outside st2, out of values that are necessarily stale by the time the unit starts.
All citations below verified against origin/main at b30bc54c9296e40ea352a5e19444cc268fbc0dc8.
Why the current shape forces the caller into it
up_loop_until (src/run.rs:2951) goes straight to work:
- compiles a task context (
src/run.rs:2960);
- per pass, takes the shared catalog-authoring lock (
src/run.rs:2987), reads the declared profile catalog (src/run.rs:3003), publishes the machine-local stream owner binding (src/run.rs:3007), and reconciles (src/run.rs:3026).
The only contract checks are inside the pass, and they are deliberately degrading rather than refusing: the lifecycle-hook gate computes hook_error (src/run.rs:1805-1828), reports "...materialization deferred" (src/run.rs:1829-1832), and then filters the affected seats out of materializable_specs while the pass proceeds for everyone else (src/run.rs:1834-1841). That is the right behavior for a resident supervisor. It is not a gate.
So a supervised deployment writes its own. In our case that gate is 188 lines of ExecStartPre shell — preflightText, nixpkgs/home-manager/modules/st2.nix:1312-1499, wired at :2773 (verified against schickling/dotfiles origin/main at 1a878e5) — with twelve exit 1 refusal sites covering: an inherited PTY_ROOT outranking catalog.kdl, the declared pty root, ST_HOOKS surviving into the process env, PATH resolving to the pinned pty, the PTY_ROOT byte budget, XDG_RUNTIME_DIR isolation, seat-roster derivability, live duplicates in foreign pty roots, a verified hook set for this binary, runtime-carrier admission, and declared-spec presence.
The decisive problem is not the line count. It is that every value that gate compares against is frozen at its build time, not resolved at start time. That is the mechanism of https://github.com/schickling/dotfiles/issues/2467: a stamp naming one generation's closure while the profile selects another, with status still printing [ok]. The gate agreed with itself and was wrong about the world. Motivating context for the pair of asks: https://github.com/schickling/dotfiles/issues/2408 and https://github.com/schickling/dotfiles/issues/2467.
Why a separate verb, not up --admit
This is deliberately not a mode of up.
#337 wants the resident supervisor to DEGRADE rather than exit when it meets an incomplete-apply catalog: declarations stay blocked, the state plane keeps running. A startup refusal living inside the same process pulls directly against that — it makes the supervisor's first pass fatal again, in a different guard.
A separate one-shot verb has the right failure semantics for both:
Refusal and degradation want different processes, not different flags on one.
Proposed surface
st2 admit --manifest <json>
Reads a small caller-authored contract, verifies each field against live state exactly once, prints one named mismatch per line, and exits non-zero if any field mismatches. No writes, no reconcile, no supervisor.
Suggested initial fields:
{
"hookset": "<hookset id this binary must carry>",
"catalogRootSha256": "<digest the caller deployed>",
"ptyRoot": "/abs/path",
"runtimeCarrier": "<carrier the catalog must resolve>"
}
Semantics that matter more than the field list:
- Every field is optional; every field present is checked. The caller states only what it actually knows, and adding a field later does not change the verb.
- Resolved at start time, never at the caller's build time. The whole point is that the comparison happens in the process that is about to hand off to
up.
- One line per mismatch, named field first, so the failure is greppable from a unit log rather than a wall of shell
echos.
- Exit code is the contract. 0 = admitted, non-zero = at least one named mismatch.
This is wiring, not new semantics
st2 already computes every proposed field:
hookset — hooks::hookset_id() (src/hooks.rs:107-110), the stable content identity of the binary's embedded hook set;
catalogRootSha256 — the digest machinery behind st2 catalog digest (src/main.rs:512-520), "the authoritative digest bound by catalog apply --input-sha256";
ptyRoot — effective_pty_root (src/run.rs:418-428), including the documented ambient-wins precedence that the caller most needs asserted;
runtimeCarrier — resolution the reconcile path already performs.
What is missing is a single non-mutating entry point that evaluates them together and one receipt shape for the result.
Nearest existing issues
No open or closed issue proposes a pre-start coherence verb (searched admit, manifest, preflight, admission, coherence, plus the full issue list).
Note on st2 service install
Upstream's own unit installer has the same frozen-pointer hazard. ServiceSpec.exe is "Absolute path to the st2 binary (env::current_exe() at install time)" (src/service.rs:34), program_arguments bakes it into ExecStart (src/service.rs:84-97), and install resolves it via env::current_exe() (src/service.rs:116). A unit installed from one closure keeps naming that closure's binary after the profile moves on — the same class of failure as https://github.com/schickling/dotfiles/issues/2467, reproduced by st2's own installer.
An admit verb is what lets a generation-invariant unit gate itself: ExecStart runs the binary from the current closure, and that binary's first act is to check the current closure's manifest against live state.
Payoff
The caller's gate collapses to: run the binary from the closure, with the closure's manifest. Concretely deletable downstream — the hand-written ExecStartPre comparisons for hookset, catalog digest, pty root, and runtime carrier, along with the build-time stamps they exist to compare against. What remains in the caller's preflight is only what is genuinely host-policy and outside st2's knowledge (foreign-root duplicate detection, XDG_RUNTIME_DIR shape).
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 |
Problem
st2 uphas no startup admission. There is no point at which st2 checks, once, that the world it is about to supervise matches the world its caller believes it deployed — so every supervised deployment has to build that gate itself, outside st2, out of values that are necessarily stale by the time the unit starts.All citations below verified against
origin/mainatb30bc54c9296e40ea352a5e19444cc268fbc0dc8.Why the current shape forces the caller into it
up_loop_until(src/run.rs:2951) goes straight to work:src/run.rs:2960);src/run.rs:2987), reads the declared profile catalog (src/run.rs:3003), publishes the machine-local stream owner binding (src/run.rs:3007), and reconciles (src/run.rs:3026).The only contract checks are inside the pass, and they are deliberately degrading rather than refusing: the lifecycle-hook gate computes
hook_error(src/run.rs:1805-1828), reports"...materialization deferred"(src/run.rs:1829-1832), and then filters the affected seats out ofmaterializable_specswhile the pass proceeds for everyone else (src/run.rs:1834-1841). That is the right behavior for a resident supervisor. It is not a gate.So a supervised deployment writes its own. In our case that gate is 188 lines of
ExecStartPreshell —preflightText,nixpkgs/home-manager/modules/st2.nix:1312-1499, wired at:2773(verified againstschickling/dotfilesorigin/mainat1a878e5) — with twelveexit 1refusal sites covering: an inheritedPTY_ROOToutrankingcatalog.kdl, the declared pty root,ST_HOOKSsurviving into the process env,PATHresolving to the pinnedpty, thePTY_ROOTbyte budget,XDG_RUNTIME_DIRisolation, seat-roster derivability, live duplicates in foreign pty roots, a verified hook set for this binary, runtime-carrier admission, and declared-spec presence.The decisive problem is not the line count. It is that every value that gate compares against is frozen at its build time, not resolved at start time. That is the mechanism of https://github.com/schickling/dotfiles/issues/2467: a stamp naming one generation's closure while the profile selects another, with
statusstill printing[ok]. The gate agreed with itself and was wrong about the world. Motivating context for the pair of asks: https://github.com/schickling/dotfiles/issues/2408 and https://github.com/schickling/dotfiles/issues/2467.Why a separate verb, not
up --admitThis is deliberately not a mode of
up.#337 wants the resident supervisor to DEGRADE rather than exit when it meets an incomplete-apply catalog: declarations stay blocked, the state plane keeps running. A startup refusal living inside the same process pulls directly against that — it makes the supervisor's first pass fatal again, in a different guard.
A separate one-shot verb has the right failure semantics for both:
ExecStartPre, an activation step, a deploy step);Refusal and degradation want different processes, not different flags on one.
Proposed surface
st2 admit --manifest <json>Reads a small caller-authored contract, verifies each field against live state exactly once, prints one named mismatch per line, and exits non-zero if any field mismatches. No writes, no reconcile, no supervisor.
Suggested initial fields:
{ "hookset": "<hookset id this binary must carry>", "catalogRootSha256": "<digest the caller deployed>", "ptyRoot": "/abs/path", "runtimeCarrier": "<carrier the catalog must resolve>" }Semantics that matter more than the field list:
up.echos.This is wiring, not new semantics
st2 already computes every proposed field:
hookset—hooks::hookset_id()(src/hooks.rs:107-110), the stable content identity of the binary's embedded hook set;catalogRootSha256— the digest machinery behindst2 catalog digest(src/main.rs:512-520), "the authoritative digest bound bycatalog apply --input-sha256";ptyRoot—effective_pty_root(src/run.rs:418-428), including the documented ambient-wins precedence that the caller most needs asserted;runtimeCarrier— resolution the reconcile path already performs.What is missing is a single non-mutating entry point that evaluates them together and one receipt shape for the result.
Nearest existing issues
st2 up --once#53 — a true no-write dry run forst2 up --once. Adjacent but different: Add a true no-write dry run forst2 up --once#53 answers what would st2 do, over the whole reconcile plan.admitanswers is the world the one I deployed, over a handful of named fields, cheaply enough to sit in front of every start. Add a true no-write dry run forst2 up --once#53 does not give a caller a stable contract to assert against.admitis the complement, not a competitor.No open or closed issue proposes a pre-start coherence verb (searched
admit,manifest,preflight,admission,coherence, plus the full issue list).Note on
st2 service installUpstream's own unit installer has the same frozen-pointer hazard.
ServiceSpec.exeis "Absolute path to thest2binary (env::current_exe()at install time)" (src/service.rs:34),program_argumentsbakes it intoExecStart(src/service.rs:84-97), andinstallresolves it viaenv::current_exe()(src/service.rs:116). A unit installed from one closure keeps naming that closure's binary after the profile moves on — the same class of failure as https://github.com/schickling/dotfiles/issues/2467, reproduced by st2's own installer.An
admitverb is what lets a generation-invariant unit gate itself:ExecStartruns the binary from the current closure, and that binary's first act is to check the current closure's manifest against live state.Payoff
The caller's gate collapses to: run the binary from the closure, with the closure's manifest. Concretely deletable downstream — the hand-written
ExecStartPrecomparisons for hookset, catalog digest, pty root, and runtime carrier, along with the build-time stamps they exist to compare against. What remains in the caller's preflight is only what is genuinely host-policy and outside st2's knowledge (foreign-root duplicate detection,XDG_RUNTIME_DIRshape).Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile