Skip to content

feat(agent): typed retirement for Nix-owned declarations (closes #473) - #487

Merged
schickling-assistant merged 2 commits into
mainfrom
schickling-assistant/2026-09-06-nix-managed-retire-verb
Sep 6, 2026
Merged

feat(agent): typed retirement for Nix-owned declarations (closes #473)#487
schickling-assistant merged 2 commits into
mainfrom
schickling-assistant/2026-09-06-nix-managed-retire-verb

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Problem

Every st2 agent authoring verb refuses a declaration carrying
meta { managed-by "nix" } (src/agent_author.rs, four guard sites), including the
lifecycle verb:

$ st2 agent desired-state <id> retired --reason "no longer declared"
Error: agent "<id>" is Nix-owned; edit its Nix source instead of <catalog>/agents/<host>/<id>/agent.kdl

That guard is right for a human or an agent hand-editing a projected seat, and wrong for the
projection itself. A projection has exactly one transition its own source cannot express: the
source change being projected is the seat's removal, so "edit its Nix source instead" names an
edit the operator already made.

Because there is no way to say "I am the projection", the shipped Nix activation leg reaches the
typed transition by byte surgery instead: stage the live declaration bytes plus one appended
desired-state "retired" reason="..." line, st2 agent digest, then
st2 agent publish --expect-sha256 <live> --input-sha256 <digest> — which works only because
agent publish carries no ownership guard at all.

Goal

The projection retires a seat it stopped declaring through a typed verb, with the ownership marker
as the authority, and the byte-append workaround can be deleted.

Decisions

The marker is the authority, asserted by the caller. --managed-by <marker> says "I am the
writer of these bytes"; it is admitted only when meta { managed-by "..." } names exactly that
marker. Mismatch, an unmarked subject, an unresolvable multi-marker declaration, and an empty or
padded assertion all refuse before any write. A caller wrong about who owns the bytes is wrong
about the edit. Chosen over catalog retire-undeclared --keep <ids…> because the desired-set verb
would need its own selection, diffing, and batch-refusal semantics to reach the same one-seat
transition the existing span-bounded editor already performs.

The unasserted refusal stays scoped to "nix". R25's guard is specifically about the Nix
projection; agent-spec-authoring-marked declarations are edited by st2's own verbs today and must
keep working. So only "nix" refuses without an assertion (unchanged), while the exact-match rule
for an assertion is generic over markers. A first pass that refused every marker broke
desired_state_authoring_refuses_a_stale_source, which is exactly the regression that scoping
prevents.

Authority-scoped, not state-scoped. The rule governs who may author, so it applies to all
three desired states. Restricting it to retired would leave the projection able to retire a seat
but not reverse it.

Only the lifecycle verb. Presentation, address, stream, and Resource authoring keep the
unconditional refusal: none of them projects a source its generator cannot itself rewrite, so none
of them has the transition that motivates the authority.

The asserted arm carries publish's admission. It stands in for the compare-and-swap
agent publish the leg performs today, so it inherits that path's gate as well as its transaction:
the complete prospective catalog is validated in a shadow overlay before anything is committed
(agent_publish::admit_declaration_rewrite, sharing copy_filtered_catalog +
validate_full_catalog with publish). A retirement that would leave an active agent descended
from a retired root refuses (retired-root) rather than landing bytes the next pass rejects
(#434). Without this the typed verb would be strictly weaker than the workaround it replaces.

Verification

Real binary from the merge gate (nix build .#st2), against a two-seat catalog whose h.seat
carries meta { managed-by "nix" } and supervises nothing:

$ st2 agent desired-state h.seat retired --reason "nix: no longer declared"
before  Error: agent "h.seat" is Nix-owned; edit its Nix source instead of <path>
after   Error: agent "h.seat" is Nix-owned; edit its Nix source instead of <path>,
               or pass --managed-by "nix" if you are that projection

$ ... --managed-by agent-spec-authoring
Error: --managed-by "agent-spec-authoring" does not own agent "h.seat": <path> declares owner "nix"

$ ... --managed-by nix
h.seat desired-state retired reason="nix: no longer declared" managed-by="nix" (changed)

$ ... --managed-by nix --json          # replay
{"result":"unchanged","identity":"h.seat","desired_state":"retired","reason":"nix: no longer declared","managed_by":"nix"}

The declaration differs from the projected bytes by exactly one line:

   agent "seat" {
     host "h"
     supervisor "h.root"
     meta { managed-by "nix" }
     command "true"
+    desired-state "retired" reason="nix: no longer declared"
   }

Admission, on a nix-marked root that still heads an active worker — refused, bytes untouched:

$ st2 agent desired-state h.root retired --reason "..." --managed-by nix
Error: candidate fails full-catalog validation: catalog fails full validation:
. [root-count]: host 'h' must declare exactly one root agent; found 0
agents/h/worker/agent.kdl [retired-root]: supervisor chain from 'h.worker' terminates at retired
  root 'h.root'; active agents must descend from a counted root
--- bytes unchanged: yes

An assertion on an unmarked declaration also refuses:

Error: --managed-by "nix" claims agent "h.root", whose declaration <path> carries no
       `meta { managed-by }` marker

Merge gate and suites:

nix build .#st2 --no-link -L                → exit 0
  test agent_author::tests::marker_matched_lifecycle_authority_is_exact_and_source_preserving ... ok
  test agent_author::tests::marker_matched_retirement_refuses_a_candidate_admission_would_reject ... ok
  test result: ok. 741 passed; 0 failed; 1 ignored

cargo test --test agent_desired_state        → ok. 8 passed; 0 failed
  test cli_managed_by_authority_retires_a_projected_seat_and_refuses_every_inexact_claim ... ok
cargo test --test agent_address              → ok. 12 passed; 0 failed
cargo test --test agent_resource             → ok. 10 passed; 0 failed
cargo test --test invariants --test event_e2e → ok. 2 passed / ok. 38 passed; 0 failed

axe vrs check --profile strict docs/vrs
  104 errors — byte-identical set to the same check on the merge-base (diff empty); no new findings

Complexity

One new shared helper (admit_declaration_rewrite, 40 lines) that reuses publish's existing
overlay construction and validation rather than duplicating either. Everything else is one
parameter threaded through an existing call chain and one guard site turned from a boolean into a
three-way decision.

Concerns

Friction & bottlenecks

  • cargo fmt --all -- --check in the devShell reports diffs across ~150 untouched files
    (crates/agent-spec/**, src/run.rs, src/resync.rs, …): the pinned rustfmt is older than the
    edition style the committed code uses. Formatting is therefore not usable as a local signal in
    this repo, and it is not part of the Nix gate either. Logged, not filed.

Follow-ups

  • agent publish still has no ownership-marker rule, so the guard on agent desired-state is only
    as strong as the weakest write path onto the same bytes. Deliberately not in this PR: the only
    shipped caller of that path is the activation leg this verb replaces, and it publishes without
    an assertion — enforcing the rule in the same release would break activation before the leg can
    switch to the verb. Tracked in agent publish has no ownership-marker rule, so any local writer can replace a Nix-owned declaration #486; land it after the projection calls the typed verb.
  • Marker-matched authority is not extended to presentation, address, stream, or Resource authoring;
    no projection needs it there today.

References

CLI the projection should call

st2 agent desired-state <host>.<identity> retired \
  --managed-by nix \
  --reason "nix: no longer declared" \
  --json

Receipt on success: {"result":"changed"|"unchanged", "identity":..., "desired_state":"retired", "reason":..., "managed_by":"nix"}. unchanged is the safe replay outcome. Refusals keep their
JSON receipt with code = nix-managed-declaration (no assertion), managed-by-mismatch,
managed-by-unmarked, invalid-managed-by, or candidate-not-admissible. The digest + publish
byte-append leg can be deleted once this lands.

Posted on behalf of @schickling
field value
agent_identity dev3.direct.omp.43sz6ujq
session dev3.43sz6ujq
agent_persona generalist
agent_supervisor unavailable
agent_tool OMP
agent_tool_version 18.1.7
agent_runtime OMP 18.1.7
tooling_profile dotfiles@39a19af

Every `st2 agent` authoring verb refused a declaration carrying
`meta { managed-by "nix" }`, including `agent desired-state`. That guard is
right for a human or an agent hand-editing a projected seat, and wrong for the
projection itself: a projection has exactly one transition its own source
cannot express, because the source change being projected is the seat's
removal. "Edit its Nix source instead" names an edit the operator already made.

`st2 agent desired-state <id> <state> --managed-by <marker>` makes the marker
the authority. The caller asserts who owns the declaration; the verb admits
only when `meta { managed-by "..." }` names exactly that marker. A mismatched
marker, an unmarked subject, an unresolvable multi-marker declaration, and an
empty or padded assertion all refuse before any write. Without the assertion a
marked declaration refuses as before, and presentation, address, stream, and
Resource authoring keep the unconditional refusal — none of them projects a
source its generator cannot itself rewrite.

The asserted arm stands in for the compare-and-swap `agent publish` the
projection performs today (dotfiles Q27), so it carries that path's admission
as well as its transaction: the complete prospective catalog is validated in a
shadow overlay before anything is committed. A retirement that would leave an
active agent descended from a retired root refuses instead of landing bytes the
next pass rejects (#434). The receipt records the confirmed marker.

Closes #473.

agent-identity: dev3.direct.omp.43sz6ujq
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.1.7
agent-runtime: OMP 18.1.7
tooling-profile: dotfiles@39a19af
The first pass refused unasserted authoring on any `managed-by` marker, which
widened R25 beyond the Nix projection and broke lifecycle authoring on
`agent-spec-authoring`-marked declarations (caught by
`desired_state_authoring_refuses_a_stale_source`). Only `"nix"` refuses without
an assertion, as before; the exact-match rule for an assertion stays generic.

agent-identity: dev3.direct.omp.43sz6ujq
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.1.7
agent-runtime: OMP 18.1.7
tooling-profile: dotfiles@39a19af
@schickling-assistant
schickling-assistant merged commit 03b7cdb into main Sep 6, 2026
5 checks passed
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.

The Nix projection cannot author lifecycle on its own declarations: every agent authoring verb refuses managed-by "nix"

1 participant