diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 910e228c7..c36f9284a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -187,12 +187,40 @@ fn reconcile_inbound_persona_event_blocking( } KIND_MANAGED_AGENT => { let mut agents = load_managed_agents(&app)?; - apply_inbound_managed_agent( + // §2.8 canonical linkage is resolved against the raw keyless + // definition store (`library_ref` is not wire-carried). + let definitions = load_agent_definitions(&app)?; + let linkage = apply_inbound_managed_agent( &mut agents, + &definitions, &d_tag, managed_agent_content_from_event(&event)?, ); save_managed_agents(&app, &agents)?; + + // §2.8 convergence: a frozen linkage means the retained head (the + // inbound event, retained above) authored a library-owned or + // inadmissible linkage change. Re-retain the LOCAL record's + // projection at a monotonically newer `created_at` so the relay head + // converges back to the library-authoritative linkage — the same + // mechanism §2.7's 30175 rule uses. The frozen record still exists + // (only its linkage was left intact), so it always has a projection + // to reassert. + if let InboundAgentLinkage::Frozen(reason) = linkage { + if let Some(local) = agents.iter().find(|record| record.pubkey == d_tag) { + if let Err(e) = crate::managed_agents::reconcile::retain_agent_record( + &conn, + &scope.owner_keys, + local, + ) { + eprintln!( + "buzz-desktop: inbound 30177 convergence re-retain failed for \ + {d_tag}: {e}" + ); + } + } + eprintln!("buzz-desktop: {}", reason.note(&d_tag)); + } } _ => unreachable!("kind gated above"), } @@ -410,7 +438,60 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi } } -/// Merge an inbound kind:30177 managed-agent projection into the local set. +/// The outcome of applying an inbound kind:30177 event under the §2.8 +/// canonical-linkage rule. +#[derive(Debug, Clone, PartialEq, Eq)] +enum InboundAgentLinkage { + /// No local match, or any linkage change the event carried was admissible: + /// the event applied exactly as at head. + Applied, + /// The event tried to author or clear a library-owned linkage, or to admit + /// a projected link that only the Phase-4b coordinator may admit. The + /// linkage change (and the definition quad it derives) was IGNORED and only + /// safe per-instance fields were applied. The caller must re-retain the + /// local record at a newer `created_at` so the relay head converges back + /// (§2.8, mirroring §2.7's 30175 rule) and surface the typed reason. + Frozen(LinkageFreezeReason), +} + +/// Why an inbound kind:30177 event's linkage authorship was rejected (§2.8). +/// Typed so the convergence + degradation surfacing at the call site is not a +/// bare string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinkageFreezeReason { + /// The matched instance is currently linked to a library-projected + /// definition and the inbound event would clear or re-point its + /// `persona_id`. Linkage is owned by the library machinery — the same + /// OUTPUT-ONLY discipline §2.7 applies to projected definition metadata. + OwnedByLibrary, + /// The inbound event would newly link a plain instance to a projected + /// definition. Admitting a projected link requires the coordinator's + /// `admit_instance_link()` step (Phase 4b); until it lands the interim + /// posture fails the new link closed. + InadmissibleNewLink, +} + +impl LinkageFreezeReason { + /// A human-readable degradation note for logging (§2.8 surfacing). The + /// coordinator (Phase 4b) will replace this interim posture with an + /// admission decision. + fn note(self, agent_pubkey: &str) -> String { + match self { + Self::OwnedByLibrary => format!( + "inbound kind:30177 for {agent_pubkey}: refused to re-point/clear a \ + library-owned linkage; safe fields applied, re-retaining local head (§2.8)" + ), + Self::InadmissibleNewLink => format!( + "inbound kind:30177 for {agent_pubkey}: refused to newly link a plain \ + instance to a library-projected definition (needs the Phase-4b \ + coordinator); safe fields applied, re-retaining local head (§2.8)" + ), + } + } +} + +/// Merge an inbound kind:30177 managed-agent projection into the local set +/// under the §2.8 canonical-linkage rule. /// /// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag /// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the @@ -420,35 +501,86 @@ fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefi /// untouched. The projection type carries none of them, so they cannot be /// reached here even if a foreign event tried to inject them. /// +/// # §2.8 canonical linkage +/// +/// A library-linked instance's `persona_id` is owned by the library machinery, +/// not the relay. `definitions` is the raw keyless definition store, resolved +/// through the [`MutationRoute::for_linked_definition`] read-side resolver so +/// this arm and every other library mechanism discover the instance↔definition +/// relationship one way. Two linkage changes fail closed (returning +/// [`InboundAgentLinkage::Frozen`], applying only safe per-instance fields): +/// - the matched instance is currently linked to a projected definition and the +/// event would clear or re-point `persona_id` ([`OwnedByLibrary`]); +/// - the event would newly link a currently-plain instance to a projected +/// definition ([`InadmissibleNewLink`]) — a Phase-4b coordinator admission. +/// +/// Every other case (no local match, no linkage change, or a plain↔plain +/// relink) applies exactly as at head. +/// /// No match is a no-op: managed agents carry device-local secrets and are never /// minted from a relay event — an agent that does not already exist locally has /// no secret key to run with, so inserting a secretless shell would be useless /// and misleading. This diverges from the persona path, which DOES insert on no /// match (personas are secretless definitions). Flagged in the reconcile docs. +/// +/// [`OwnedByLibrary`]: LinkageFreezeReason::OwnedByLibrary +/// [`InadmissibleNewLink`]: LinkageFreezeReason::InadmissibleNewLink fn apply_inbound_managed_agent( agents: &mut [ManagedAgentRecord], + definitions: &[ManagedAgentRecord], d_tag: &str, inbound: ManagedAgentEventContent, -) { - if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { - local.name = inbound.name; - // Mirror of the slimmed writer (agent_event_content): a - // definition-linked event omits the definition quad because those - // fields resolve through the kind:30175 definition — absent means - // "not carried", never "clear". Definition-less events still carry - // the quad and apply it unconditionally (including clears). - let definition_linked = inbound.persona_id.is_some(); - local.persona_id = inbound.persona_id; - if !definition_linked { - local.system_prompt = inbound.system_prompt; - local.model = inbound.model; - local.provider = inbound.provider; - local.persona_source_version = inbound.persona_source_version; - } - local.parallelism = inbound.parallelism; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; +) -> InboundAgentLinkage { + let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) else { + return InboundAgentLinkage::Applied; + }; + + // Safe per-instance fields apply as at head regardless of the linkage + // decision — they are genuinely instance-local and never library-authored. + local.name = inbound.name; + local.parallelism = inbound.parallelism; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + + // §2.8 canonical-linkage classification, resolved through the read-side + // resolver against the raw keyless definition store. `library_ref` is not + // wire-carried; the linkage's library status can only be read from the + // definition the instance's `persona_id` resolves to. + let linkage_changes = local.persona_id.as_deref() != inbound.persona_id.as_deref(); + let freeze = if !linkage_changes { + None + } else if MutationRoute::for_linked_definition(definitions, local.persona_id.as_deref()) + == MutationRoute::LibraryProjected + { + Some(LinkageFreezeReason::OwnedByLibrary) + } else if MutationRoute::for_linked_definition(definitions, inbound.persona_id.as_deref()) + == MutationRoute::LibraryProjected + { + Some(LinkageFreezeReason::InadmissibleNewLink) + } else { + None + }; + + if let Some(reason) = freeze { + // Linkage authorship rejected: leave `persona_id` and the + // definition-resolved quad exactly as the local record holds them. The + // caller re-retains this record so the relay head converges back. + return InboundAgentLinkage::Frozen(reason); } + + // Admissible: head behavior. A definition-linked event omits the definition + // quad because those fields resolve through the kind:30175 definition — + // absent means "not carried", never "clear". Definition-less events still + // carry the quad and apply it unconditionally (including clears). + let definition_linked = inbound.persona_id.is_some(); + local.persona_id = inbound.persona_id; + if !definition_linked { + local.system_prompt = inbound.system_prompt; + local.model = inbound.model; + local.provider = inbound.provider; + local.persona_source_version = inbound.persona_source_version; + } + InboundAgentLinkage::Applied } /// Merge an inbound kind:30176 team projection into the local set. diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 087d8188f..ef7da6205 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -265,7 +265,7 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() { let content = crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap(); let mut agents = vec![local_agent()]; - apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + apply_inbound_managed_agent(&mut agents, &[], AGENT_PUBKEY, content); let a = &agents[0]; // Secrets / harness / runtime — every one preserved from the local record. @@ -356,7 +356,7 @@ fn inbound_definition_less_agent_applies_quad() { let content = crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap(); let mut agents = vec![local_agent()]; - apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + apply_inbound_managed_agent(&mut agents, &[], AGENT_PUBKEY, content); let a = &agents[0]; assert_eq!(a.persona_id, None); @@ -376,7 +376,7 @@ fn inbound_managed_agent_no_match_is_noop() { let content = crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap(); let mut agents = vec![local_agent()]; - apply_inbound_managed_agent(&mut agents, "someotheragentpubkey", content); + apply_inbound_managed_agent(&mut agents, &[], "someotheragentpubkey", content); // No agent minted from a relay event — it would have no secret key. assert_eq!(agents.len(), 1); @@ -386,6 +386,177 @@ fn inbound_managed_agent_no_match_is_noop() { ); } +// ── §2.8 canonical-linkage rule (kind:30177) ───────────────────────────── + +/// A keyless definition (former persona) for the linkage resolver: `into_ +/// agent_record` sets `slug = id`, and a projected one carries `library_ref`. +fn definition(slug: &str, projected: bool) -> ManagedAgentRecord { + let mut record = inbound_for(slug, "Definition").into_agent_record(); + if projected { + record.library_ref = Some(format!("lib-{slug}")); + record.library_applied_revision = Some(1); + } + record +} + +/// Inbound kind:30177 content carrying an explicit `persona_id` (or `None`). +/// Mirrors the wire shape `managed_agent_content_from_event` produces. +fn agent_content(name: &str, persona_id: Option<&str>) -> ManagedAgentEventContent { + ManagedAgentEventContent { + name: name.to_string(), + persona_id: persona_id.map(str::to_string), + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + parallelism: 4, + respond_to: crate::managed_agents::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + } +} + +/// A local instance linked to `persona_id`, keyed by `AGENT_PUBKEY`. +fn linked_agent(persona_id: &str) -> ManagedAgentRecord { + let mut agent = local_agent(); + agent.persona_id = Some(persona_id.to_string()); + agent +} + +/// An inbound event that would re-point a library-owned linkage is frozen: +/// `persona_id` stays on the local library-projected definition, safe fields +/// still apply, and the caller is told to converge the relay head back (§2.8). +#[test] +fn inbound_30177_freezes_repoint_of_library_owned_linkage() { + let definitions = vec![definition("shared-def", true)]; + let mut agents = vec![linked_agent("shared-def")]; + + // Inbound tries to re-point the linkage to a different definition. + let outcome = apply_inbound_managed_agent( + &mut agents, + &definitions, + AGENT_PUBKEY, + agent_content("Renamed", Some("other-def")), + ); + + assert_eq!( + outcome, + InboundAgentLinkage::Frozen(LinkageFreezeReason::OwnedByLibrary), + "re-pointing a library-owned linkage must freeze", + ); + let a = &agents[0]; + assert_eq!( + a.persona_id, + Some("shared-def".to_string()), + "linkage must stay on the local library-owned definition", + ); + assert_eq!(a.name, "Renamed", "safe per-instance fields still apply"); + assert_eq!(a.parallelism, 4, "safe per-instance fields still apply"); +} + +/// An inbound event that would CLEAR a library-owned linkage +/// (`persona_id: None`) is frozen the same way — clearing is authorship too. +#[test] +fn inbound_30177_freezes_clear_of_library_owned_linkage() { + let definitions = vec![definition("shared-def", true)]; + let mut agents = vec![linked_agent("shared-def")]; + + let outcome = apply_inbound_managed_agent( + &mut agents, + &definitions, + AGENT_PUBKEY, + agent_content("Renamed", None), + ); + + assert_eq!( + outcome, + InboundAgentLinkage::Frozen(LinkageFreezeReason::OwnedByLibrary), + ); + assert_eq!( + agents[0].persona_id, + Some("shared-def".to_string()), + "a definition-less inbound must not clear a library-owned linkage", + ); +} + +/// An inbound event that would newly link a currently-plain instance to a +/// library-projected definition is frozen as an inadmissible new link — only +/// the Phase-4b coordinator may admit a projected link (§2.8, P6-C1 interim). +#[test] +fn inbound_30177_freezes_inadmissible_new_link_to_projected_definition() { + let definitions = vec![definition("shared-def", true)]; + // Local instance is definition-less (plain). + let mut agents = vec![linked_agent("")]; + agents[0].persona_id = None; + + let outcome = apply_inbound_managed_agent( + &mut agents, + &definitions, + AGENT_PUBKEY, + agent_content("Renamed", Some("shared-def")), + ); + + assert_eq!( + outcome, + InboundAgentLinkage::Frozen(LinkageFreezeReason::InadmissibleNewLink), + "a new link to a projected definition must fail closed", + ); + assert_eq!( + agents[0].persona_id, None, + "the inadmissible new link must not be authored", + ); +} + +/// A linkage change that touches only plain definitions applies as at head — +/// the §2.8 rule freezes ONLY library-owned or projected-target changes, never +/// an ordinary plain relink. +#[test] +fn inbound_30177_applies_plain_relink_unchanged() { + let definitions = vec![definition("plain-a", false), definition("plain-b", false)]; + let mut agents = vec![linked_agent("plain-a")]; + + let outcome = apply_inbound_managed_agent( + &mut agents, + &definitions, + AGENT_PUBKEY, + agent_content("Renamed", Some("plain-b")), + ); + + assert_eq!(outcome, InboundAgentLinkage::Applied); + assert_eq!( + agents[0].persona_id, + Some("plain-b".to_string()), + "a plain→plain relink applies exactly as at head", + ); +} + +/// An inbound event that leaves the linkage unchanged is never frozen even when +/// the linked definition is library-projected — the definition quad is still +/// correctly omitted (linked), and safe fields apply. Freezing keys on a +/// linkage CHANGE, not on the linked definition's library status alone. +#[test] +fn inbound_30177_no_linkage_change_applies_even_when_projected() { + let definitions = vec![definition("shared-def", true)]; + let mut agents = vec![linked_agent("shared-def")]; + agents[0].system_prompt = Some("local prompt".to_string()); + + let outcome = apply_inbound_managed_agent( + &mut agents, + &definitions, + AGENT_PUBKEY, + agent_content("Renamed", Some("shared-def")), + ); + + assert_eq!(outcome, InboundAgentLinkage::Applied); + let a = &agents[0]; + assert_eq!(a.persona_id, Some("shared-def".to_string())); + assert_eq!(a.name, "Renamed"); + assert_eq!( + a.system_prompt, + Some("local prompt".to_string()), + "a linked inbound omits the definition quad — the local snapshot survives", + ); +} + // ── Team (30176) inbound ───────────────────────────────────────────────── const TEAM_ID: &str = "team-local-id"; diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 3e9c76d0a..c976eb95a 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -561,6 +561,31 @@ impl MutationRoute { })) } + /// The route for the DEFINITION a keyed instance links to — the read side + /// of the §2.8 definition–instance relation resolver. An instance's + /// `persona_id` IS the linked definition's slug (they are assigned in + /// lockstep: `into_agent_record` sets `slug = id`, and every linked + /// instance carries `persona_id == definition.slug`). This is the ONE + /// canonical join every library mechanism uses to discover an + /// instance↔definition relationship — the inbound kind:30177 canonical- + /// linkage rule consults it to decide whether an instance's linkage is + /// library-owned; nothing re-derives the `persona_id` join ad hoc. + /// + /// A definition-less instance (`persona_id == None`) links to no definition + /// and is always [`Plain`](Self::Plain), as is a `persona_id` that resolves + /// to a plain definition or to no definition at all (a create, or a stale + /// link). Only a `persona_id` resolving to a `library_ref`-carrying + /// definition is [`LibraryProjected`](Self::LibraryProjected). + pub(crate) fn for_linked_definition( + definitions: &[ManagedAgentRecord], + persona_id: Option<&str>, + ) -> Self { + match persona_id { + Some(slug) => Self::for_slug(definitions, slug), + None => Self::Plain, + } + } + /// Refuse a plain-path mutation whose target `slug` resolves to a /// library-projected record (§2.7). The command boundaries that key by slug /// — `delete_persona` and snapshot/team import — call this on the RAW diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index cc3bce412..82f4a63dc 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -649,6 +649,67 @@ fn mutation_route_for_persona_d_tag_catches_projected_team_slug_that_for_slug_mi ); } +/// §2.8 relation-resolver read side: `for_linked_definition` classifies an +/// instance's linkage by resolving its `persona_id` against the raw keyless +/// definition store. A `persona_id` pointing at a projected definition is +/// `LibraryProjected`; one pointing at a plain definition, at no definition (a +/// stale link), or `None` (a definition-less instance) is `Plain`. This is the +/// canonical join the inbound kind:30177 rule consults — the same `persona_id → +/// slug` derivation every library mechanism uses, resolved one way. +#[test] +fn for_linked_definition_classifies_projected_plain_absent_and_none() { + let definitions = vec![ + projected_record("shared", 3), + custom_persona("custom:plain", "Plain").into_agent_record(), + ]; + + // Instance linked to a projected definition → LibraryProjected. + assert_eq!( + super::MutationRoute::for_linked_definition(&definitions, Some("shared")), + super::MutationRoute::LibraryProjected, + ); + // Instance linked to a plain definition → Plain. + assert_eq!( + super::MutationRoute::for_linked_definition(&definitions, Some("custom:plain")), + super::MutationRoute::Plain, + ); + // Instance whose persona_id resolves to no definition (a stale link) → Plain. + assert_eq!( + super::MutationRoute::for_linked_definition(&definitions, Some("does-not-exist")), + super::MutationRoute::Plain, + ); + // Definition-less instance (`persona_id == None`) → Plain. + assert_eq!( + super::MutationRoute::for_linked_definition(&definitions, None), + super::MutationRoute::Plain, + ); +} + +/// §2.8 resolver vs. `persona_d_tag`: the instance→definition join keys on the +/// definition's UUID `slug` (== the instance `persona_id`), NOT the team- +/// derived d-tag. A team-sourced projected definition whose `persona_id`/`slug` +/// is a UUID but whose d-tag is the pack slug must still resolve as projected +/// through `for_linked_definition` — the resolver and the inbound d-tag +/// preflight key on different fields for different callers, and this pins that +/// the linkage join uses the slug. +#[test] +fn for_linked_definition_keys_on_slug_not_persona_d_tag() { + let mut definition = projected_record("uuid-shared", 5); + definition.source_team_persona_slug = Some("codereviewer".to_string()); + let definitions = vec![definition]; + + // The instance's `persona_id` is the definition's UUID slug — resolves. + assert_eq!( + super::MutationRoute::for_linked_definition(&definitions, Some("uuid-shared")), + super::MutationRoute::LibraryProjected, + ); + // The team d-tag is NOT the slug — it does not resolve the linkage join. + assert_eq!( + super::MutationRoute::for_linked_definition(&definitions, Some("codereviewer")), + super::MutationRoute::Plain, + ); +} + /// The fingerprint narrowing (F3): a plain persona save may freely change a /// projected record's SCOPE-LOCAL fields (`is_active`, `env_vars`) — these are /// not library-authoritative, so the shared fingerprint is unchanged and the