Skip to content

Commit 1d993ea

Browse files
fix(agent): publish honours the managed-by ownership marker (closes #486)
`agent publish` rewrites a whole declaration, so it was the widest st2 write path onto the very bytes `agent desired-state --managed-by` guards (#473): any local writer could replace a Nix-owned declaration wholesale. The incumbent's ownership marker now governs the replacement, through the same authority and the same refusal codes the lifecycle verb uses. Creation and a byte-identical republication replace no owned bytes and stay open, and the unasserted refusal stays scoped to `nix` so publishers of other markers adopt the assertion on their own schedule. 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
1 parent 7d6a3c4 commit 1d993ea

9 files changed

Lines changed: 377 additions & 24 deletions

File tree

INVARIANTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ materialization, messaging, DING, or presence must preserve them.
4343
| **Pass progress is independent of publication** | A reconcile pass completes whether or not resync publication is making progress. Publication runs on its own thread, so a pass's per-seat watch-set handshakes never wait for a catalog lock, a stream lock, or a refused publication. One publication is outstanding per subscription at a time, and a queued publication is dropped when its subscription is deactivated or removed by a refresh, so nothing is published to a seat the pass has already decided receives no events. | `src/run.rs::reconcile_pass_completes_while_a_resync_publication_is_blocked`; `src/resync.rs::a_flush_never_hands_off_a_subscription_whose_publication_is_outstanding`; `src/resync.rs::deactivation_drops_only_that_recipients_queued_publication`; `src/resync.rs::a_refresh_drops_a_queued_publication_for_a_subscription_it_removed` |
4444
| **Terminal refusals are classified, not retried** | A refused resync publication is classified by what could admit it later. A recipient that is declared but not running parks its reservation: it is attempted once, captures and schedules nothing while parked, and re-arms with its exact reserved bytes when a refresh carries that recipient again. An ambiguous recipient, a foreign owning host, and an undeclared stream are permanently refused: the reservation is dropped and the carrier baseline advances so the same transition is not re-captured. Everything else, including an absent declaration, stays retryable. | `src/resync.rs::a_not_running_recipient_parks_its_reservation_and_is_attempted_once`; `src/resync.rs::a_parked_reservation_re_arms_and_replays_when_its_recipient_runs_again`; `src/resync.rs::a_permanently_refused_reservation_is_dropped_and_not_recaptured`; `src/resync.rs::refusals_are_classified_by_what_could_admit_them_later` |
4545
| **Unbindable session sockets fail at admission** | Host-scoped validation rejects a pty task whose session socket path would exceed the portable 104-byte `sun_path` bound, because `pty` refuses that bind and the task can then never spawn. The bound is derived from the pty root resolved for the selected host rather than a fixed identity length, only that host's pty tasks are judged against it, and the diagnostic names the resolved path and the byte overage. A park whose cause is that same unbindable path does not advise `st2 unpark`, which would relaunch into the identical failure. | `tests/validate.rs::an_unbindable_session_socket_path_is_rejected_at_admission`; `tests/validate.rs::another_hosts_long_identity_is_not_judged_against_this_hosts_pty_root`; `tests/validate.rs::a_long_exec_task_id_is_not_a_socket_path_issue`; `src/run.rs::session_socket_overage_is_derived_from_the_resolved_root`; `tests/run.rs::a_structurally_unrecoverable_park_does_not_advise_unpark` |
46-
| **Marker-matched lifecycle authority** | A declaration carrying `meta { managed-by "nix" }` is authorable only by a caller asserting exactly the marker that declaration carries, and only through the lifecycle verb: a mismatched marker, an unmarked subject, an unresolvable multi-marker declaration, and an empty or padded assertion each refuse before any write, while presentation, address, stream, and Resource authoring refuse a Nix-owned declaration outright. A marker-matched edit rewrites nothing but the lifecycle line and is admitted through the same full-catalog gate as a compare-and-swap publication of the same bytes, so it cannot commit a declaration the catalog would reject. | `src/agent_author.rs::marker_matched_lifecycle_authority_is_exact_and_source_preserving`; `src/agent_author.rs::marker_matched_retirement_refuses_a_candidate_admission_would_reject`; `tests/agent_desired_state.rs::cli_managed_by_authority_retires_a_projected_seat_and_refuses_every_inexact_claim` |
46+
| **Marker-matched declaration authority** | Every st2 write path that rewrites a declaration carrying `meta { managed-by "nix" }` admits it only from a caller asserting exactly the marker that declaration carries: the lifecycle verb and the compare-and-swap `agent publish` both refuse a mismatched marker, an unmarked subject, an unresolvable multi-marker declaration, and an empty or padded assertion before any write, while presentation, address, stream, and Resource authoring refuse a Nix-owned declaration outright. Publication reads that authority from the incumbent, so creating a declaration and republishing byte-identical bytes stay open — neither replaces bytes another writer owns. A marker-matched edit rewrites nothing but the lifecycle line and is admitted through the same full-catalog gate as a compare-and-swap publication of the same bytes, so it cannot commit a declaration the catalog would reject. | `src/agent_author.rs::marker_matched_lifecycle_authority_is_exact_and_source_preserving`; `src/agent_author.rs::marker_matched_retirement_refuses_a_candidate_admission_would_reject`; `tests/agent_desired_state.rs::cli_managed_by_authority_retires_a_projected_seat_and_refuses_every_inexact_claim`; `tests/agent_publish.rs::publication_honours_the_incumbent_ownership_marker`; `tests/agent_publish.rs::publication_refuses_an_unresolvable_owner_and_repairs_unreadable_bytes` |
4747
| **Proof references resolve** | Every qualified test named in this table exists in its named source file, so stale invariant claims fail the suite instead of silently surviving a refactor. | `tests/invariants.rs::qualified_proof_references_resolve` |

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -751,7 +751,11 @@ verdict. Without the directive, Agent Spec-shaped files inside a fixture remain
751751
evals retain their flat bus and completion semantics.
752752

753753
`st2 agent publish --catalog ROOT (--spec FILE | --bundle DIR) --input-sha256 HEX
754-
(--expect-absent | --expect-sha256 HEX)` is the single-agent declaration writer.
754+
(--expect-absent | --expect-sha256 HEX) [--managed-by MARKER]` is the single-agent declaration
755+
writer. Replacing an incumbent that carries `meta { managed-by "nix" }` requires
756+
`--managed-by nix`, because that publication rewrites bytes the Nix projection owns; an assertion
757+
is admitted only when it names exactly the marker the incumbent carries. Creating a declaration
758+
and republishing byte-identical bytes need no assertion.
755759
`st2 catalog apply --catalog ROOT
756760
(--prepared DIR --input-sha256 INPUT_HEX --expect-sha256 ROOT_HEX [--raw-preimage] | --resume)` is the complete
757761
declaration-plane writer. Each admits the complete prospective catalog under a

docs/vrs/02-agent-spec/spec.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,12 @@ The st2 transaction performs these steps in order:
157157
external lock service, or shared receipt is involved.
158158
3. Re-read the current target under the lock and require the caller's exact
159159
absent-or-SHA-256 precondition. Require the staged digest to equal the
160-
caller's input digest.
160+
caller's input digest. When that incumbent carries an ownership marker and
161+
the candidate would replace its bytes, require the caller to assert exactly
162+
that marker with `--managed-by`: an unasserted replacement refuses for the
163+
Nix marker, and a mismatched, unmarked, unresolvable, or malformed assertion
164+
always refuses — here, before any write. Creation and a byte-identical
165+
republication replace no owned bytes and need no assertion.
161166
4. Overlay the staged candidate on the locked catalog snapshot and run shared
162167
parsing, core admission, and st2 catalog policy over that exact projection.
163168
5. Publish by an atomic, durable file or bundle transition. Preserve exact

docs/vrs/requirements.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,15 @@ accepted.
238238
yet.
239239
Presence, messages, context, and Resource state remain independently writable
240240
and are never serialized behind catalog authoring.
241+
A publication that replaces an incumbent declaration carrying an ownership
242+
marker is admitted only when the caller asserts exactly that marker, which
243+
makes publication no weaker an authority boundary than the lifecycle verb
244+
authoring the same bytes; an unasserted replacement refuses only for the Nix
245+
marker, and a mismatched, unresolvable, or malformed assertion always fails
246+
closed. Creating a declaration and republishing byte-identical bytes replace
247+
no owned bytes and require no assertion; a candidate that merely claims a
248+
marker is not an authority, so the receipt records only a marker the
249+
incumbent confirmed.
241250
A caller binds single-agent publication to the exact no-follow source capture
242251
with an authoritative input digest. A canonical whole-catalog snapshot
243252
externalizes the declaration-root digest while excluding runtime state and

docs/vrs/spec.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ Agent Spec envelope in
340340
`st2 agent digest (--spec FILE | --bundle DIR)` captures a source through
341341
retained no-follow file descriptors and returns its authoritative digest.
342342
`st2 agent publish --catalog ROOT (--spec FILE | --bundle DIR)
343-
--input-sha256 HEX (--expect-absent | --expect-sha256 HEX) --json` binds
343+
--input-sha256 HEX (--expect-absent | --expect-sha256 HEX)
344+
[--managed-by MARKER] --json` binds
344345
publication to that exact capture. It accepts exactly one canonical KDL `agent`
345346
node with an explicit ID plus path-safe host and identity. st2 no longer
346347
exposes an intent compiler: external renderers own the transformation from
@@ -397,6 +398,22 @@ stale declaration writer. Full-catalog admission rejects any
397398
structural validation error before publication. The typed result is
398399
`published` or `unchanged`.
399400

401+
A hash-authorized update also passes the ownership boundary the lifecycle verb
402+
applies, because it rewrites the same declaration wholesale. `--managed-by
403+
MARKER` asserts the ownership marker the caller believes owns the incumbent,
404+
and the replacement is admitted only when the incumbent's own
405+
`meta { managed-by "..." }` names exactly that one marker; a mismatched marker,
406+
an unmarked incumbent, an unresolvable multi-marker incumbent, and an empty or
407+
padded assertion each refuse before any write, under the same refusal codes the
408+
authoring verbs use. Without an assertion, only the Nix marker refuses, which
409+
is what lets each publisher of another marker adopt the assertion on its own
410+
schedule. Create-only publication has no incumbent to protect and a
411+
byte-identical republication authors nothing, so neither requires an assertion;
412+
a candidate that merely claims a marker asserts no authority, and the receipt
413+
reports `managedBy` only for a marker the incumbent confirmed. Incumbent bytes
414+
that are not readable as a declaration carry no ownership claim, so repairing
415+
them stays possible for a writer who could already replace the file directly.
416+
400417
Host-scoped validation rejects a pty task whose session socket path would exceed the
401418
portable `sun_path` bound. `pty` binds `<PTY_ROOT>/<session-id>.sock` and refuses a
402419
bind over the limit, so such a task can never spawn and fails identically on every

src/agent_author.rs

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -623,14 +623,7 @@ pub fn set_desired_state(
623623
validate_desired_state_reason(reason)
624624
.map_err(|error| AuthorError::new("invalid-desired-state", error.to_string()))?;
625625
}
626-
if let Some(marker) = managed_by
627-
&& (marker.is_empty() || marker.trim() != marker)
628-
{
629-
return Err(AuthorError::new(
630-
"invalid-managed-by",
631-
format!("asserted ownership marker {marker:?} is empty or padded"),
632-
));
633-
}
626+
validate_marker_assertion(managed_by)?;
634627
let catalog_lock = CatalogLock::exclusive(catalog_root).map_err(|error| {
635628
AuthorError::new(
636629
"catalog-lock-failed",
@@ -2056,46 +2049,89 @@ fn is_nix_managed(node: &KdlNode) -> bool {
20562049
declared_markers(node).contains(&"nix")
20572050
}
20582051

2059-
/// Decide whether `asserted` authorizes authoring on a possibly generator-owned declaration.
2052+
/// Reject an ownership assertion no declaration could carry, before any lock or read.
2053+
///
2054+
/// A marker is compared byte-exactly against the declaration's own `meta { managed-by "..." }`
2055+
/// value, so an empty or padded assertion can only ever be a caller mistake.
2056+
pub(crate) fn validate_marker_assertion(asserted: Option<&str>) -> Result<(), AuthorError> {
2057+
if let Some(marker) = asserted
2058+
&& (marker.is_empty() || marker.trim() != marker)
2059+
{
2060+
return Err(AuthorError::new(
2061+
"invalid-managed-by",
2062+
format!("asserted ownership marker {marker:?} is empty or padded"),
2063+
));
2064+
}
2065+
Ok(())
2066+
}
2067+
2068+
/// Every ownership marker the declaration source `bytes` carries, in source order.
2069+
///
2070+
/// `agent publish` replaces a whole declaration file rather than one node, so every marker any
2071+
/// `agent` node in the incumbent file declares is at stake in that publication and the union is
2072+
/// what an assertion has to resolve against (#486). Bytes that are not UTF-8 or do not parse as
2073+
/// KDL carry no discoverable claim and read as unmarked: a writer able to leave such bytes in a
2074+
/// declaration leaf already holds direct filesystem write authority over the file, which is
2075+
/// strictly stronger than any st2 write path this marker governs.
2076+
pub(crate) fn declaration_markers(bytes: &[u8]) -> Vec<String> {
2077+
let Ok(text) = std::str::from_utf8(bytes) else {
2078+
return Vec::new();
2079+
};
2080+
let Ok(document) = text.parse::<KdlDocument>() else {
2081+
return Vec::new();
2082+
};
2083+
document
2084+
.nodes()
2085+
.iter()
2086+
.filter(|node| node.name().value() == "agent")
2087+
.flat_map(|node| {
2088+
declared_markers(node)
2089+
.into_iter()
2090+
.map(str::to_owned)
2091+
.collect::<Vec<_>>()
2092+
})
2093+
.collect()
2094+
}
2095+
2096+
/// Decide whether `asserted` authorizes rewriting bytes carrying the `declared` markers.
20602097
///
20612098
/// `meta { managed-by "nix" }` says the Nix projection, not st2, is the writer of these bytes: an
2062-
/// edit made behind it is silently reverted on the next activation, which is why unasserted
2063-
/// authoring refuses (R25, decision 0003). Only that marker refuses; the others are labels on
2064-
/// declarations st2's own verbs are expected to edit.
2099+
/// edit made behind it is silently reverted on the next activation, which is why an unasserted
2100+
/// rewrite refuses (R25, decision 0003). Only that marker refuses; the others are labels on
2101+
/// declarations st2's own verbs and publishers are expected to rewrite.
20652102
///
20662103
/// The generator itself is the one writer that legitimately authors the declaration, and
20672104
/// `--managed-by` is how it says so. An assertion is admitted only when it names exactly the one
20682105
/// marker the declaration carries — a caller wrong about who owns the bytes is wrong about the
20692106
/// edit, so a mismatched marker, an unmarked declaration, and an unresolvable multi-marker
20702107
/// declaration all fail closed. Returns whether an assertion was matched.
2071-
fn authorize_marker(
2072-
target: &KdlNode,
2073-
expected_identity: &str,
2108+
pub(crate) fn authorize_asserted_marker(
2109+
declared: &[&str],
2110+
subject: &str,
20742111
path: &Path,
20752112
asserted: Option<&str>,
20762113
) -> Result<bool, AuthorError> {
2077-
let declared = declared_markers(target);
2078-
match (asserted, declared.as_slice()) {
2114+
match (asserted, declared) {
20792115
(None, _) if !declared.contains(&"nix") => Ok(false),
20802116
(None, _) => Err(AuthorError::new(
20812117
"nix-managed-declaration",
20822118
format!(
2083-
"agent {expected_identity:?} is Nix-owned; edit its Nix source instead of {}, or pass --managed-by \"nix\" if you are that projection",
2119+
"agent {subject:?} is Nix-owned; edit its Nix source instead of {}, or pass --managed-by \"nix\" if you are that projection",
20842120
path.display()
20852121
),
20862122
)),
20872123
(Some(asserted), [marker]) if *marker == asserted => Ok(true),
20882124
(Some(asserted), []) => Err(AuthorError::new(
20892125
"managed-by-unmarked",
20902126
format!(
2091-
"--managed-by {asserted:?} claims agent {expected_identity:?}, whose declaration {} carries no `meta {{ managed-by }}` marker",
2127+
"--managed-by {asserted:?} claims agent {subject:?}, whose declaration {} carries no `meta {{ managed-by }}` marker",
20922128
path.display()
20932129
),
20942130
)),
20952131
(Some(asserted), markers) => Err(AuthorError::new(
20962132
"managed-by-mismatch",
20972133
format!(
2098-
"--managed-by {asserted:?} does not own agent {expected_identity:?}: {} declares owner {}",
2134+
"--managed-by {asserted:?} does not own agent {subject:?}: {} declares owner {}",
20992135
path.display(),
21002136
markers
21012137
.iter()
@@ -2107,6 +2143,16 @@ fn authorize_marker(
21072143
}
21082144
}
21092145

2146+
/// The lifecycle verb's marker authority, read from the exact declaration node it edits.
2147+
fn authorize_marker(
2148+
target: &KdlNode,
2149+
expected_identity: &str,
2150+
path: &Path,
2151+
asserted: Option<&str>,
2152+
) -> Result<bool, AuthorError> {
2153+
authorize_asserted_marker(&declared_markers(target), expected_identity, path, asserted)
2154+
}
2155+
21102156
fn presentation_edit(
21112157
text: &str,
21122158
target: &KdlNode,

0 commit comments

Comments
 (0)