From a1931fb7fe6a59ddb086e2adb5a30835c6a97bdf Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Fri, 21 Aug 2026 15:46:07 +0200 Subject: [PATCH 1/3] feat(agent-spec): simplify resource references --- crates/agent-spec/src/kdl_format.rs | 25 ++--- crates/agent-spec/src/spec.rs | 112 +++++++++-------------- crates/agent-spec/tests/discovery.rs | 131 ++++++++++++--------------- src/agents.rs | 3 +- tests/reconcile.rs | 10 +- 5 files changed, 125 insertions(+), 156 deletions(-) diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 0e54a3de..bd458d62 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -371,8 +371,8 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou let mut name = None; let mut uri = None; - let mut relation = None; let mut reason = None; + let mut inactive_reason = None; for entry in &node.entries { let Some(property) = entry.name.as_deref() else { if name.is_some() { @@ -396,15 +396,6 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou anyhow::bail!("resource binding needs string `uri`"); } } - "relation" => { - if relation.is_some() { - anyhow::bail!("resource binding has duplicate `relation`"); - } - relation = value; - if relation.is_none() { - anyhow::bail!("resource binding needs string `relation`"); - } - } "reason" => { if reason.is_some() { anyhow::bail!("resource binding has duplicate `reason`"); @@ -414,6 +405,15 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou anyhow::bail!("resource binding needs string `reason`"); } } + "inactive-reason" => { + if inactive_reason.is_some() { + anyhow::bail!("resource binding has duplicate `inactive-reason`"); + } + inactive_reason = value; + if inactive_reason.is_none() { + anyhow::bail!("resource binding needs string `inactive-reason`"); + } + } other => anyhow::bail!("resource binding has unsupported property `{other}`"), } } @@ -422,8 +422,9 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou name.ok_or_else(|| anyhow::anyhow!("resource binding needs a string name"))?, RawResource { uri: uri.ok_or_else(|| anyhow::anyhow!("resource binding needs string `uri`"))?, - relation, - reason, + reason: reason + .ok_or_else(|| anyhow::anyhow!("resource binding needs string `reason`"))?, + inactive_reason, }, )) } diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index 2d666fcd..d911852f 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -201,17 +201,16 @@ pub struct AgentSpec { /// One agent-local semantic binding to an externally identified resource. /// -/// `name` is the role the resource plays for this agent and `uri` is the exact absolute identity. -/// The URI scheme selects the downstream resource profile. The envelope deliberately carries no -/// policy. +/// `name` is an agent-local label and `uri` is the exact absolute identity. `reason` explains why +/// the reference belongs in this Agent Spec. `inactive_reason` preserves a reference that is no +/// longer active for this agent without asserting anything about the resource itself. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Resource { name: String, uri: String, + reason: String, #[serde(skip_serializing_if = "Option::is_none")] - relation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option, + inactive_reason: Option, } #[derive(Deserialize)] @@ -219,38 +218,38 @@ pub struct Resource { struct ResourceDescriptor { name: String, uri: String, - relation: Option, - reason: Option, + reason: String, + inactive_reason: Option, } impl Resource { /// Construct a descriptor after enforcing the same invariants as catalog parsing. - pub fn new(name: String, uri: String) -> Result { + pub fn new(name: String, uri: String, reason: String) -> Result { if name.is_empty() { return Err("resource binding name cannot be empty".into()); } validate_absolute_uri(&uri).map_err(|reason| { format!("resource binding '{name}' `uri` must be an exact absolute URI: {reason}") })?; + validate_resource_explanation(&name, "reason", &reason)?; Ok(Self { name, uri, - relation: None, - reason: None, + reason, + inactive_reason: None, }) } - /// Construct a descriptor with an explicit semantic relation and human-facing rationale. - pub fn new_with_relation_reason( + /// Construct a preserved reference that is inactive for this agent. + pub fn new_inactive( name: String, uri: String, - relation: String, reason: String, + inactive_reason: String, ) -> Result { - let mut resource = Self::new(name, uri)?; - validate_relation_reason(&resource.name, &relation, &reason)?; - resource.relation = Some(relation); - resource.reason = Some(reason); + let mut resource = Self::new(name, uri, reason)?; + validate_resource_explanation(&resource.name, "inactive-reason", &inactive_reason)?; + resource.inactive_reason = Some(inactive_reason); Ok(resource) } @@ -262,12 +261,12 @@ impl Resource { &self.uri } - pub fn relation(&self) -> Option<&str> { - self.relation.as_deref() + pub fn reason(&self) -> &str { + &self.reason } - pub fn reason(&self) -> Option<&str> { - self.reason.as_deref() + pub fn inactive_reason(&self) -> Option<&str> { + self.inactive_reason.as_deref() } } @@ -277,19 +276,14 @@ impl<'de> Deserialize<'de> for Resource { D: serde::Deserializer<'de>, { let descriptor = ResourceDescriptor::deserialize(deserializer)?; - let resource = match (descriptor.relation, descriptor.reason) { - (None, None) => Self::new(descriptor.name, descriptor.uri), - (Some(relation), Some(reason)) => { - Self::new_with_relation_reason(descriptor.name, descriptor.uri, relation, reason) - } - (Some(_), None) => Err(format!( - "resource binding '{}' with `relation` must also declare string `reason`", - descriptor.name - )), - (None, Some(_)) => Err(format!( - "resource binding '{}' with `reason` must also declare string `relation`", - descriptor.name - )), + let resource = match descriptor.inactive_reason { + None => Self::new(descriptor.name, descriptor.uri, descriptor.reason), + Some(inactive_reason) => Self::new_inactive( + descriptor.name, + descriptor.uri, + descriptor.reason, + inactive_reason, + ), }; resource.map_err(de::Error::custom) } @@ -606,8 +600,8 @@ pub(crate) struct RawResources(BTreeMap); #[serde(deny_unknown_fields)] pub(crate) struct RawResource { pub(crate) uri: String, - pub(crate) relation: Option, - pub(crate) reason: Option, + pub(crate) reason: String, + pub(crate) inactive_reason: Option, } #[derive(Debug, Default, Deserialize)] @@ -667,17 +661,11 @@ impl RawResources { self.0 .into_iter() .map(|(name, resource)| { - match (resource.relation, resource.reason) { - (None, None) => Resource::new(name, resource.uri), - (Some(relation), Some(reason)) => Resource::new_with_relation_reason( - name, resource.uri, relation, reason, - ), - (Some(_), None) => Err(format!( - "resource binding '{name}' with `relation` must also declare string `reason`" - )), - (None, Some(_)) => Err(format!( - "resource binding '{name}' with `reason` must also declare string `relation`" - )), + match resource.inactive_reason { + None => Resource::new(name, resource.uri, resource.reason), + Some(inactive_reason) => { + Resource::new_inactive(name, resource.uri, resource.reason, inactive_reason) + } } .map_err(anyhow::Error::msg) }) @@ -719,35 +707,19 @@ impl<'de> Deserialize<'de> for RawResources { } } -fn validate_relation_reason(name: &str, relation: &str, reason: &str) -> Result<(), String> { - let relation_bytes = relation.as_bytes(); - let valid_relation = (1..=64).contains(&relation_bytes.len()) - && relation_bytes - .iter() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') - && relation_bytes - .first() - .is_some_and(u8::is_ascii_alphanumeric) - && relation_bytes.last().is_some_and(u8::is_ascii_alphanumeric) - && !relation_bytes.windows(2).any(|pair| pair == b"--"); - if !valid_relation { - return Err(format!( - "resource binding '{name}' `relation` must be ASCII kebab-case of 1..64 bytes" - )); - } - - if reason.is_empty() || reason.len() > 160 { +fn validate_resource_explanation(name: &str, field: &str, value: &str) -> Result<(), String> { + if value.is_empty() || value.len() > 160 { return Err(format!( - "resource binding '{name}' `reason` must be 1..160 UTF-8 bytes" + "resource binding '{name}' `{field}` must be 1..160 UTF-8 bytes" )); } - if reason.trim() != reason - || reason + if value.trim() != value + || value .chars() .any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')) { return Err(format!( - "resource binding '{name}' `reason` must have no surrounding Unicode whitespace, controls, or line separators" + "resource binding '{name}' `{field}` must have no surrounding Unicode whitespace, controls, or line separators" )); } Ok(()) diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index 860682ba..29ea1071 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -897,8 +897,8 @@ fn named_resource_bindings_are_uri_identities_and_order_independent() { "agents/h/kdl/agent.kdl", r#"agent "kdl" { host "h" - resource "source" uri="worktree://github.com/example/project/main" relation="uses" reason="Primary checkout." - resource "work" uri="github-issue://example/project/41" relation="current-work" reason="Current implementation task." + resource "source" uri="worktree://github.com/example/project/main" reason="Primary checkout." + resource "work" uri="github-issue://example/project/41" reason="Current implementation task." inactive-reason="Merged and retained for traceability." command "true" }"#, ); @@ -909,8 +909,8 @@ fn named_resource_bindings_are_uri_identities_and_order_independent() { "identity": "json", "host": "h", "resource": { - "work": {"uri": "github-issue://example/project/41", "relation": "current-work", "reason": "Current implementation task."}, - "source": {"uri": "worktree://github.com/example/project/main", "relation": "uses", "reason": "Primary checkout."} + "work": {"uri": "github-issue://example/project/41", "reason": "Current implementation task.", "inactive_reason": "Merged and retained for traceability."}, + "source": {"uri": "worktree://github.com/example/project/main", "reason": "Primary checkout."} }, "command": "true" }"#, @@ -924,12 +924,11 @@ command = "true" [resource.work] uri = "github-issue://example/project/41" -relation = "current-work" reason = "Current implementation task." +inactive_reason = "Merged and retained for traceability." [resource.source] uri = "worktree://github.com/example/project/main" -relation = "uses" reason = "Primary checkout." "#, ); @@ -937,18 +936,17 @@ reason = "Primary checkout." let found = discover(tmp.path()); assert!(found.errors.is_empty(), "{:?}", found.errors); let expected = vec![ - Resource::new_with_relation_reason( + Resource::new( "source".into(), "worktree://github.com/example/project/main".into(), - "uses".into(), "Primary checkout.".into(), ) .unwrap(), - Resource::new_with_relation_reason( + Resource::new_inactive( "work".into(), "github-issue://example/project/41".into(), - "current-work".into(), "Current implementation task.".into(), + "Merged and retained for traceability.".into(), ) .unwrap(), ]; @@ -959,7 +957,7 @@ reason = "Primary checkout." let json = serde_json::to_string(&expected).unwrap(); assert_eq!( json, - r#"[{"name":"source","uri":"worktree://github.com/example/project/main","relation":"uses","reason":"Primary checkout."},{"name":"work","uri":"github-issue://example/project/41","relation":"current-work","reason":"Current implementation task."}]"# + r#"[{"name":"source","uri":"worktree://github.com/example/project/main","reason":"Primary checkout."},{"name":"work","uri":"github-issue://example/project/41","reason":"Current implementation task.","inactive_reason":"Merged and retained for traceability."}]"# ); assert_eq!( serde_json::from_str::>(&json).unwrap(), @@ -968,18 +966,23 @@ reason = "Primary checkout." } #[test] -fn resources_without_context_remain_valid_and_relation_reason_are_an_optional_pair() { - let resource = Resource::new("source".into(), "worktree://example/project".into()).unwrap(); - assert_eq!(resource.relation(), None); - assert_eq!(resource.reason(), None); +fn resources_require_a_reason_and_may_carry_an_inactive_reason() { + let resource = Resource::new( + "source".into(), + "worktree://example/project".into(), + "Primary checkout.".into(), + ) + .unwrap(); + assert_eq!(resource.reason(), "Primary checkout."); + assert_eq!(resource.inactive_reason(), None); assert_eq!( serde_json::to_string(&resource).unwrap(), - r#"{"name":"source","uri":"worktree://example/project"}"# + r#"{"name":"source","uri":"worktree://example/project","reason":"Primary checkout."}"# ); for descriptor in [ - r#"{"name":"work","uri":"issue://one","relation":"uses"}"#, - r#"{"name":"work","uri":"issue://one","reason":"Needed here."}"#, + r#"{"name":"work","uri":"issue://one"}"#, + r#"{"name":"work","uri":"issue://one","relation":"uses","reason":"Needed here."}"#, ] { assert!( serde_json::from_str::(descriptor).is_err(), @@ -989,37 +992,30 @@ fn resources_without_context_remain_valid_and_relation_reason_are_an_optional_pa } #[test] -fn malformed_relation_and_reason_values_are_rejected_causally() { +fn malformed_resource_explanations_are_rejected_causally() { let tmp = tempfile::tempdir().unwrap(); - for (identity, relation, reason) in [ - ("missing-reason", " relation=\"uses\"", ""), - ("missing-relation", "", " reason=\"Needed here.\""), + for (identity, properties) in [ + ("missing-reason", ""), ( - "relation-uppercase", - " relation=\"Uses\"", - " reason=\"Needed here.\"", + "unsupported-relation", + " relation=\"uses\" reason=\"Needed here.\"", ), + ("reason-leading-space", " reason=\" Needed here.\""), + ("reason-line-separator", " reason=\"Needed\u{2028}here.\""), ( - "relation-double-hyphen", - " relation=\"current--work\"", - " reason=\"Needed here.\"", + "inactive-empty", + " reason=\"Needed here.\" inactive-reason=\"\"", ), ( - "reason-leading-space", - " relation=\"uses\"", - " reason=\" Needed here.\"", - ), - ( - "reason-line-separator", - " relation=\"uses\"", - " reason=\"Needed\u{2028}here.\"", + "inactive-line-separator", + " reason=\"Needed here.\" inactive-reason=\"No\u{2028}longer.\"", ), ] { write( tmp.path(), &format!("agents/h/{identity}/agent.kdl"), &format!( - "agent \"{identity}\" {{\n host \"h\"\n resource \"work\" uri=\"issue://one\"{relation}{reason}\n command \"true\"\n}}" + "agent \"{identity}\" {{\n host \"h\"\n resource \"work\" uri=\"issue://one\"{properties}\n command \"true\"\n}}" ), ); } @@ -1035,17 +1031,17 @@ fn malformed_relation_and_reason_values_are_rejected_causally() { assert!( messages .iter() - .any(|error| error.contains("must also declare string `reason`")) + .any(|error| error.contains("needs string `reason`")) ); assert!( messages .iter() - .any(|error| error.contains("must also declare string `relation`")) + .any(|error| error.contains("unsupported property `relation`")) ); assert!( messages .iter() - .any(|error| error.contains("ASCII kebab-case")) + .any(|error| error.contains("`inactive-reason` must be 1..160")) ); assert!( messages @@ -1055,35 +1051,24 @@ fn malformed_relation_and_reason_values_are_rejected_causally() { } #[test] -fn relation_and_reason_byte_bounds_are_enforced() { - let valid_relation = "a".repeat(64); - let too_long_relation = "a".repeat(65); +fn resource_explanation_byte_bounds_are_enforced() { let valid_reason = "x".repeat(160); let multibyte_too_long_reason = "é".repeat(81); + assert!(Resource::new("work".into(), "issue://one".into(), valid_reason,).is_ok()); assert!( - Resource::new_with_relation_reason( - "work".into(), - "issue://one".into(), - valid_relation, - valid_reason, - ) - .is_ok() - ); - assert!( - Resource::new_with_relation_reason( + Resource::new_inactive( "work".into(), "issue://one".into(), - too_long_relation, "Needed here.".into(), + "x".repeat(161), ) .is_err() ); assert!( - Resource::new_with_relation_reason( + Resource::new( "work".into(), "issue://one".into(), - "uses".into(), multibyte_too_long_reason, ) .is_err() @@ -1096,22 +1081,25 @@ fn malformed_resource_envelopes_are_rejected_without_defining_downstream_types() for (identity, resource) in [ ( "duplicate", - r#"resource "work" uri="issue://one" - resource "work" uri="pull-request://two""#, + r#"resource "work" uri="issue://one" reason="First." + resource "work" uri="pull-request://two" reason="Second.""#, ), ( "unexpected-tag", - r#"resource "work" _tag="issue" uri="issue://example/1""#, + r#"resource "work" _tag="issue" uri="issue://example/1" reason="Task.""#, + ), + ("missing-uri", r#"resource "work" reason="Task.""#), + ( + "relative-uri", + r#"resource "work" uri="./issue/1" reason="Task.""#, ), - ("missing-uri", r#"resource "work""#), - ("relative-uri", r#"resource "work" uri="./issue/1""#), ( "policy", - r#"resource "work" uri="issue://example/1" required=#true"#, + r#"resource "work" uri="issue://example/1" reason="Task." required=#true"#, ), ( "payload", - r#"resource "work" uri="issue://example/1" { token "secret" }"#, + r#"resource "work" uri="issue://example/1" reason="Task." { token "secret" }"#, ), ] { write( @@ -1167,8 +1155,8 @@ fn duplicate_json_resource_names_are_rejected_instead_of_last_write_winning() { "identity": "dup", "command": "true", "resource": { - "work": {"uri": "issue://one"}, - "work": {"uri": "issue://two"} + "work": {"uri": "issue://one", "reason": "First."}, + "work": {"uri": "issue://two", "reason": "Second."} } }"#, ); @@ -1185,10 +1173,10 @@ fn duplicate_json_resource_names_are_rejected_instead_of_last_write_winning() { #[test] fn public_resource_json_deserialization_enforces_the_catalog_invariants() { for descriptor in [ - r#"{"name":"","uri":"issue://one"}"#, - r#"{"name":"work","uri":"./relative"}"#, - r#"{"name":"work","uri":"issue://one","_tag":"issue"}"#, - r#"{"name":"work","uri":"issue://one","required":true}"#, + r#"{"name":"","uri":"issue://one","reason":"Task."}"#, + r#"{"name":"work","uri":"./relative","reason":"Task."}"#, + r#"{"name":"work","uri":"issue://one","reason":"Task.","_tag":"issue"}"#, + r#"{"name":"work","uri":"issue://one","reason":"Task.","required":true}"#, ] { assert!( serde_json::from_str::(descriptor).is_err(), @@ -1205,7 +1193,7 @@ fn forbidden_raw_uri_characters_are_rejected_across_declaration_formats() { "agents/h/kdl/agent.kdl", r##"agent "kdl" { command "true" - resource "work" uri=#"thing://bad\slash"# + resource "work" uri=#"thing://bad\slash"# reason="Task." }"##, ); write( @@ -1214,7 +1202,7 @@ fn forbidden_raw_uri_characters_are_rejected_across_declaration_formats() { r#"{ "identity": "json", "command": "true", - "resource": {"work": {"uri": "thing://bad Date: Sun, 23 Aug 2026 10:19:03 +0200 Subject: [PATCH 2/3] fix(agent-spec): carry required resource reason through bundled declarations and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review (P1): requiring reason invalidated the repository's own examples — validate.rs/status_agents.rs fixtures parse-errored into empty rosters, and README/vrs spec still documented a name+uri-only envelope. Add reason to every bundled declaration and update the contract prose to name+uri+reason with optional inactive-reason. --- README.md | 11 ++++++----- docs/vrs/spec.md | 5 +++-- tests/status_agents.rs | 5 +++-- tests/validate.rs | 10 +++++----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b198a621..fb46a5b2 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ The compact declaration shape is: agent "" { host "" workspace "" - resource "work" uri="github-issue://example/project/123" + resource "work" uri="github-issue://example/project/123" reason="release work item" // Optional metadata: // role "worker" // supervisor "" @@ -250,12 +250,13 @@ It neither registers schemes, owns profile schemas, nor resolves targets. Binding order is irrelevant and names must be unique within the agent: ```kdl -resource "work" uri="github-issue://example/project/123" -resource "source" uri="worktree://github.com/example/project/change" -resource "delivery" uri="ding://host/agent" +resource "work" uri="github-issue://example/project/123" reason="release work item" +resource "source" uri="worktree://github.com/example/project/change" reason="primary checkout" +resource "delivery" uri="ding://host/agent" reason="notification channel for this agent" ``` -The envelope is intentionally only `name` + `uri`. It carries no required/optional, +The envelope is `name` + `uri` + a required human-facing `reason`, plus an optional +`inactive-reason` that preserves a retired binding without deleting it. It carries no access, readiness, or lifecycle policy, and URI possession conveys no authority. A Resource URI may be referenced by any number of agent declarations. Resource-only declaration edits do not stop, replace, or relaunch a live task. Resource profiles and resolvers remain opaque to st2; catalog diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index 26cd8b0a..5d8edd34 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -191,10 +191,11 @@ the drift is fenced by An agent may directly declare zero or more generic Resource bindings: ```kdl -resource "work" uri="github-issue://example/project/123" +resource "work" uri="github-issue://example/project/123" reason="release work item" ``` -The positional name is an agent-local semantic role. `uri` is the exact RFC 3986 absolute resource +The positional name is an agent-local semantic role. `reason` explains why the reference belongs +to this agent (required; optional `inactive-reason` retains inactive bindings). `uri` is the exact RFC 3986 absolute resource identity, preserved byte-for-byte without normalization, and its scheme selects the open, downstream-owned Resource profile. Declaration order has no meaning and binding names are unique within one diff --git a/tests/status_agents.rs b/tests/status_agents.rs index 69a2d32e..44cb3220 100644 --- a/tests/status_agents.rs +++ b/tests/status_agents.rs @@ -23,7 +23,7 @@ fn write(root: &Path, rel: &str, contents: &str) { fn agent_kdl(identity: &str, host: &str) -> String { format!( "agent \"{identity}\" {{\n identity \"{identity}\"\n host \"{host}\"\n \ - type \"service\"\n resource \"work\" uri=\"issue://example/{identity}\"\n \ + type \"service\"\n resource \"work\" uri=\"issue://example/{identity}\" reason=\"example work item\"\n \ pty \"agent\" {{ command \"exec claude boot\" }}\n}}\n" ) } @@ -218,7 +218,8 @@ fn roster_json_and_human_output_distinguish_retirement_from_presence() { rows[0]["resources"], serde_json::json!([{ "name": "work", - "uri": "issue://example/live" + "uri": "issue://example/live", + "reason": "example work item" }]) ); assert_eq!(rows[1]["identity"], "h.retired"); diff --git a/tests/validate.rs b/tests/validate.rs index 1b1586e7..c1d5f831 100644 --- a/tests/validate.rs +++ b/tests/validate.rs @@ -72,7 +72,7 @@ fn opaque_resource_bindings_are_structurally_valid() { "Silber/cos/agent.kdl", r#"agent "cos" { host "Silber" - resource "work" uri="vendor+thing://authority/exact%20identity" + resource "work" uri="vendor+thing://authority/exact%20identity" reason="example vendor work item" command "codex" }"#, )]); @@ -88,7 +88,7 @@ fn active_agents_may_share_an_opaque_resource_uri() { "h/reviewer/agent.kdl", r#"agent "reviewer" { host "h" - resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" + resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit" command "true" }"#, ), @@ -96,7 +96,7 @@ fn active_agents_may_share_an_opaque_resource_uri() { "h/integrator/agent.kdl", r#"agent "integrator" { host "h" - resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" + resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit" command "true" }"#, ), @@ -114,7 +114,7 @@ fn duplicate_bus_ids_remain_an_error_when_resources_are_shared() { "h/one/agent.kdl", r#"agent "worker" { host "h" - resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" + resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit" command "true" }"#, ), @@ -122,7 +122,7 @@ fn duplicate_bus_ids_remain_an_error_when_resources_are_shared() { "h/two/agent.kdl", r#"agent "worker" { host "h" - resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" + resource "subject" uri="git-commit://github.com/example/project/0123456789abcdef" reason="reviewed example commit" command "true" }"#, ), From 4922457bfa626d1b42bd56e57be2bd15c55ac1bf Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Sun, 23 Aug 2026 10:19:03 +0200 Subject: [PATCH 3/3] fix(catalog): project resource reason fields into semantic diffs Codex review (P2): normalize_agent projected only /resources//uri, so edits touching just reason or inactive-reason produced a modified-file marker with no agent semantic field. Emit /reason and optional /inactive-reason alongside /uri so diff consumers can observe explanation and active-state changes. --- src/catalog_transaction.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index 75d69df8..243bde09 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -767,6 +767,18 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result