feat(agent): typed retirement for Nix-owned declarations (closes #473) - #487
Merged
schickling-assistant merged 2 commits intoSep 6, 2026
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Every
st2 agentauthoring verb refuses a declaration carryingmeta { managed-by "nix" }(src/agent_author.rs, four guard sites), including thelifecycle verb:
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, thenst2 agent publish --expect-sha256 <live> --input-sha256 <digest>— which works only becauseagent publishcarries 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 thewriter of these bytes"; it is admitted only when
meta { managed-by "..." }names exactly thatmarker. 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 verbwould 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 Nixprojection;
agent-spec-authoring-marked declarations are edited by st2's own verbs today and mustkeep working. So only
"nix"refuses without an assertion (unchanged), while the exact-match rulefor 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 scopingprevents.
Authority-scoped, not state-scoped. The rule governs who may author, so it applies to all
three desired states. Restricting it to
retiredwould leave the projection able to retire a seatbut 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 publishthe 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, sharingcopy_filtered_catalog+validate_full_catalogwithpublish). A retirement that would leave an active agent descendedfrom 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 whoseh.seatcarries
meta { managed-by "nix" }and supervises nothing: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:
An assertion on an unmarked declaration also refuses:
Merge gate and suites:
Complexity
One new shared helper (
admit_declaration_rewrite, 40 lines) that reusespublish's existingoverlay 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
operator arm still does not, so st2 agent desired-state retired writes a declaration that fails admission, silently making the target un-editable #434 stays open for operator-driven retirement. Widening it
would change refusal behavior for every existing caller of
agent desired-stateand needsst2 agent desired-state retired writes a declaration that fails admission, silently making the target un-editable #434's contract table settled first (which self-bindings a retired agent may keep).
root-count) rather than theownership guard. That is pre-existing catalog policy, not new, but the diagnostic a projection
sees for that case changed.
ST_AGENTremains a trusted-fleet guardrail, not authentication.--managed-byis the sameclass of claim: it proves the caller knows the marker, not that it is the generator. That is the
existing trust model for a same-UID local writer, unchanged by this PR.
Friction & bottlenecks
cargo fmt --all -- --checkin the devShell reports diffs across ~150 untouched files(
crates/agent-spec/**,src/run.rs,src/resync.rs, …): the pinned rustfmt is older than theedition 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 publishstill has no ownership-marker rule, so the guard onagent desired-stateis onlyas 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 publishhas no ownership-marker rule, so any local writer can replace a Nix-owned declaration #486; land it after the projection calls the typed verb.no projection needs it there today.
References
reintroduce it on the asserted arm, and does not fix it on the ordinary arm)
retired-root, the admission rule the asserted arm inherits)archive-after, the step that collects the retired seat afterwards)removed from the source is retired in the live plane (
schickling/dotfiles#2502).CLI the projection should call
Receipt on success:
{"result":"changed"|"unchanged", "identity":..., "desired_state":"retired", "reason":..., "managed_by":"nix"}.unchangedis the safe replay outcome. Refusals keep theirJSON receipt with
code=nix-managed-declaration(no assertion),managed-by-mismatch,managed-by-unmarked,invalid-managed-by, orcandidate-not-admissible. The digest + publishbyte-append leg can be deleted once this lands.
Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile