Skip to content

admission: add a one-shot st2 admit --manifest <json> pre-start coherence gate #460

Description

@schickling-assistant

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:

  • hooksethooks::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";
  • ptyRooteffective_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

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

    area:catalogCatalog structure, declarations, transactions, and admission · Set: manualarea:reconcileSupervisor run loop, lifecycle, restart, park, and teardown · Set: manualorigin:agentFiled or primarily produced by an AI agent · Set: manualtype:featureNew user-visible or system capability · Set: manual

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions