diff --git a/apis/README.md b/apis/README.md index 27f1a5cb..fe8ef623 100644 --- a/apis/README.md +++ b/apis/README.md @@ -2,7 +2,7 @@ **Monorepo layout:** in the private `plasm` repo, `apis/` at the repository root is a **symlink** to this directory (`plasm-oss/apis`). Commits to API definitions belong in the **plasm-oss** / plasm-core submodule, not a duplicate `apis/` tree in the monorepo. -This directory holds **split** Plasm CGS trees: each API is a folder with `domain.yaml` + `mappings.yaml` (and a **README** describing scope, auth, and how to run `**plasm-repl`** / `**plasm-cgs`** / `**plasm-mcp`**). Wire types and shared gloss live under top-level **`values:`**; entity **fields** and capability **parameters** use **`value_ref`** into those **semantic slots** (sharing vs splitting keys is an authoring choice—see **[Value domains](../skills/plasm-authoring/reference.md#value-domains-values-and-value_ref)** in the authoring reference). Optional **`views:`** in **`domain.yaml`** models **composed read-only** rows over existing **`query`/`get`** capabilities; matching **`mappings.yaml`** entries use **`transport: view`** (see **[Composed read views](../skills/plasm-authoring/reference.md#composed-read-views)**). Optional **`schema_overlay:`** merges **workspace-specific typed entities or columns** at execute session open for APIs with user-defined schema (Fibery, Notion, Jira, …) — see **[Runtime schema overlay](../skills/plasm-authoring/reference.md#runtime-schema-overlay-schema_overlay)**. `**domain.yaml` validation:** `kind: action` requires non-empty `**provides:`** and/or `**output:`** with `**type: side_effect`** and a non-empty `**description:`** (effectful ops with no entity projection must say what they change). Authoring details: [skills/plasm-authoring/reference.md](../skills/plasm-authoring/reference.md#action-output-provides-vs-outputside_effect). +This directory holds **split** Plasm CGS trees: each API is a folder with `domain.yaml` + `mappings.yaml` (and a **README** describing scope, auth, and how to run `**plasm-repl`** / `**plasm-cgs`** / `**plasm-mcp`**). Wire types and shared gloss live under top-level **`values:`**; entity **fields** and capability **parameters** use **`value_ref`** into those **semantic slots** (sharing vs splitting keys is an authoring choice—see **[Value domains](../skills/plasm-authoring/reference.md#value-domains-values-and-value_ref)** in the authoring reference). Optional **`views:`** in **`domain.yaml`** models **composed read-only** rows over existing **`query`/`get`** capabilities; matching **`mappings.yaml`** entries use **`transport: view`** (see **[Composed read views](../skills/plasm-authoring/reference.md#composed-read-views)**). Optional **`schema_overlay:`** merges **workspace-specific typed entities or columns** at execute session open for APIs with user-defined schema (Fibery, Notion, Jira, …) — see **[Runtime schema overlay](../skills/plasm-authoring/reference.md#runtime-schema-overlay-schema_overlay)**. `**domain.yaml` validation:** `kind: action` is effectful by default and requires non-empty `**provides:`** and/or `**output:`**. A reviewed RPC-shaped read may declare `**effect: read`**; it cannot use `**output.type: side_effect`** or mutation sink parameters. Effectful ops with no entity projection use `**output.type: side_effect`** with a non-empty description of what changes. Authoring details: [skills/plasm-authoring/reference.md](../skills/plasm-authoring/reference.md#action-output-provides-vs-outputside_effect). **Fixtures:** `fixtures/schemas/` holds **test** CGS trees and tiny interchange files (`test_schema.cgs.yaml`, `capability_with_input/`, plus small slices such as **[PokéAPI mini](../fixtures/schemas/pokeapi_mini/)** for Hermit e2e, integration tests, and eval). **Curated** REST (and EVM) product APIs live only under `apis/`. diff --git a/apis/grafana/domain.yaml b/apis/grafana/domain.yaml index 9af3018c..d5a93b8b 100644 --- a/apis/grafana/domain.yaml +++ b/apis/grafana/domain.yaml @@ -1131,6 +1131,7 @@ capabilities: datasource_query_run: kind: action + effect: read entity: Datasource description: Run PromQL, LogQL, or other queries through the unified datasource query path. preflight: diff --git a/crates/plasm-agent-core/src/cli_builder.rs b/crates/plasm-agent-core/src/cli_builder.rs index 006edb38..dcefabe5 100644 --- a/crates/plasm-agent-core/src/cli_builder.rs +++ b/crates/plasm-agent-core/src/cli_builder.rs @@ -867,6 +867,7 @@ mod tests { name: "query_accounts".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Account".into(), identity_key: None, mapping: CapabilityMapping { @@ -923,6 +924,7 @@ mod tests { name: "query_contacts".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Contact".into(), identity_key: None, mapping: CapabilityMapping { @@ -1170,6 +1172,7 @@ mod tests { name: "balance_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Balance".into(), identity_key: None, mapping: CapabilityMapping { @@ -1300,6 +1303,7 @@ mod tests { name: "order_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Order".into(), identity_key: None, mapping: CapabilityMapping { @@ -1323,6 +1327,7 @@ mod tests { name: "pet_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Pet".into(), identity_key: None, mapping: CapabilityMapping { @@ -1369,6 +1374,7 @@ mod tests { name: "order_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Order".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-agent-core/src/dispatch.rs b/crates/plasm-agent-core/src/dispatch.rs index 54b71334..239bd856 100644 --- a/crates/plasm-agent-core/src/dispatch.rs +++ b/crates/plasm-agent-core/src/dispatch.rs @@ -855,6 +855,7 @@ mod tests { name: "balance_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Balance".into(), identity_key: None, mapping: CapabilityMapping { @@ -953,6 +954,7 @@ mod tests { name: "transfer_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Transfer".into(), identity_key: None, mapping: CapabilityMapping { @@ -1081,6 +1083,7 @@ mod tests { name: "issue_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Issue".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-agent-core/src/flow_catalog.rs b/crates/plasm-agent-core/src/flow_catalog.rs index 649ba600..8e49aec2 100644 --- a/crates/plasm-agent-core/src/flow_catalog.rs +++ b/crates/plasm-agent-core/src/flow_catalog.rs @@ -2,7 +2,9 @@ use crate::plan_flow::{QualifiedCapabilityKey, SinkParamRef}; use plasm_core::schema::ViewDefinition; -use plasm_core::{flow_control_param_names, CapabilityKind, CapabilitySchema, DataClassName, CGS}; +use plasm_core::{ + flow_control_param_names, CapabilityKind, CapabilitySchema, DataClassName, SemanticEffect, CGS, +}; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -15,6 +17,7 @@ pub struct CatalogPin<'a> { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct CapabilityWorkflowMeta { pub kind: CapabilityKind, + pub effect: SemanticEffect, pub identity_key: Option>, pub idempotent: bool, } @@ -156,22 +159,13 @@ fn ingest_capability( let key = QualifiedCapabilityKey::from_parts(entry_id, entity_name, cap_name); let idempotent = cap.output_schema.as_ref().is_some_and(|o| o.idempotent); - let is_mutator = matches!( - cap.kind, - CapabilityKind::Create - | CapabilityKind::Update - | CapabilityKind::Delete - | CapabilityKind::Action - ); - let is_read = matches!( - cap.kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ); - if is_mutator || is_read || cap.identity_key.is_some() || idempotent { + let effect = cap.effective_effect(); + if cap.is_remote_mutation() || cap.is_read() || cap.identity_key.is_some() || idempotent { view.capability_workflow.insert( key.clone(), CapabilityWorkflowMeta { kind: cap.kind, + effect, identity_key: cap.identity_key.clone(), idempotent, }, diff --git a/crates/plasm-agent-core/src/invoke_args.rs b/crates/plasm-agent-core/src/invoke_args.rs index d9940d9a..8a672829 100644 --- a/crates/plasm-agent-core/src/invoke_args.rs +++ b/crates/plasm-agent-core/src/invoke_args.rs @@ -84,6 +84,7 @@ mod tests { name: "update_account".into(), description: String::new(), kind: CapabilityKind::Update, + effect: None, domain: "Account".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-agent-core/src/output/mod.rs b/crates/plasm-agent-core/src/output/mod.rs index 603a955a..ec24d7ba 100644 --- a/crates/plasm-agent-core/src/output/mod.rs +++ b/crates/plasm-agent-core/src/output/mod.rs @@ -706,6 +706,7 @@ mod tests { name: "note_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Note".into(), identity_key: None, mapping: CapabilityMapping { @@ -808,6 +809,7 @@ mod tests { name: "spell_get".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Spell".into(), identity_key: None, mapping: CapabilityMapping { @@ -982,6 +984,7 @@ mod tests { name: "file_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "File".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-agent-core/src/plan_dry_display.rs b/crates/plasm-agent-core/src/plan_dry_display.rs index b4d51d1a..0200c603 100644 --- a/crates/plasm-agent-core/src/plan_dry_display.rs +++ b/crates/plasm-agent-core/src/plan_dry_display.rs @@ -131,6 +131,7 @@ pub struct PlanDryStep { pub enum PlanDryOp { Surface { kind: PlanNodeKind, + effect_class: EffectClass, expr: String, }, Project { @@ -308,11 +309,14 @@ pub fn render_plan_dry_compact_text( /// Operator-facing step title for synthetic IR nodes (not tuned `r1`/`c2` labels). pub(crate) fn human_ux_headline_for_op(op: &PlanDryOp) -> String { match op { - PlanDryOp::Surface { kind, .. } => match kind { + PlanDryOp::Surface { + kind, effect_class, .. + } => match kind { PlanNodeKind::Query | PlanNodeKind::Search | PlanNodeKind::Get => "Read list".into(), PlanNodeKind::Create => "Create".into(), PlanNodeKind::Update => "Update".into(), PlanNodeKind::Delete => "Delete".into(), + PlanNodeKind::Action if *effect_class == EffectClass::Read => "Read".into(), PlanNodeKind::Action => "Write".into(), _ => render_kind(*kind).to_string(), }, @@ -352,13 +356,20 @@ pub(crate) fn human_ux_summary_for_op(op: &PlanDryOp) -> String { } PlanDryOp::Filter { .. } => "Filter rows".into(), PlanDryOp::Project { fields } => format!("Fields: {}", fields.join(", ")), - PlanDryOp::Surface { kind, expr } => match kind { + PlanDryOp::Surface { + kind, + effect_class, + expr, + } => match kind { PlanNodeKind::Search => format!("Search · {expr}"), PlanNodeKind::Get => format!("Get · {expr}"), PlanNodeKind::Query => format!("Read · {expr}"), PlanNodeKind::Create => format!("Create · {expr}"), PlanNodeKind::Update => format!("Update · {expr}"), PlanNodeKind::Delete => format!("Delete · {expr}"), + PlanNodeKind::Action if *effect_class == EffectClass::Read => { + format!("Read · {expr}") + } PlanNodeKind::Action => format!("Write · {expr}"), _ => format!("{} · {expr}", render_kind(*kind)), }, @@ -390,7 +401,12 @@ pub(crate) fn human_ux_summary_for_op(op: &PlanDryOp) -> String { pub(crate) fn render_plan_dry_op(op: &PlanDryOp) -> String { match op { - PlanDryOp::Surface { kind, expr } => format!("{} {expr}", render_kind(*kind)), + PlanDryOp::Surface { + kind: PlanNodeKind::Action, + effect_class: EffectClass::Read, + expr, + } => format!("read {expr}"), + PlanDryOp::Surface { kind, expr, .. } => format!("{} {expr}", render_kind(*kind)), PlanDryOp::Project { fields } => format!("project {}", fields.join(", ")), PlanDryOp::Filter { predicates } => format!("filter {}", predicates.join(", ")), PlanDryOp::GroupBy { keys, aggregates } => { @@ -441,6 +457,7 @@ fn compact_op_from_node( match node { ValidatedPlanNode::Surface(s) => PlanDryOp::Surface { kind: s.kind, + effect_class: s.effect_class, expr: surface_compact_expr(s, es), }, ValidatedPlanNode::Data(n) => PlanDryOp::Data { @@ -935,6 +952,18 @@ mod tests { assert_eq!(render_plan_dry_op(&op), "project identifier, title"); } + #[test] + fn read_action_is_presented_as_read_not_write() { + let op = PlanDryOp::Surface { + kind: PlanNodeKind::Action, + effect_class: EffectClass::Read, + expr: "e1.m1()".into(), + }; + assert_eq!(render_plan_dry_op(&op), "read e1.m1()"); + assert_eq!(human_ux_headline_for_op(&op), "Read"); + assert_eq!(human_ux_summary_for_op(&op), "Read · e1.m1()"); + } + #[test] fn plan_expr_wire_surface_is_not_il_summary() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/plasm-agent-core/src/plan_flow.rs b/crates/plasm-agent-core/src/plan_flow.rs index be9a45c4..05713514 100644 --- a/crates/plasm-agent-core/src/plan_flow.rs +++ b/crates/plasm-agent-core/src/plan_flow.rs @@ -312,7 +312,7 @@ impl<'a, P: FlowPolicyEvaluator + ?Sized> FlowPass<'a, P> { let id = node.id().as_str().to_string(); match node { ValidatedPlanNode::Surface(surface) => { - if is_read_kind(surface.kind) { + if surface.effect_class == EffectClass::Read { self.transfer_read_surface(surface); } else if is_remote_mutation(surface.kind, surface.effect_class) { self.transfer_mutation_surface(node, surface); @@ -694,22 +694,16 @@ fn policy_disposition_for_node( } } -fn is_read_kind(kind: PlanNodeKind) -> bool { - matches!( - kind, - PlanNodeKind::Query | PlanNodeKind::Search | PlanNodeKind::Get - ) -} - /// Whether a node may mutate a remote system. /// -/// This remains deliberately structural: callers must refuse automatic execution whenever -/// either the declared node kind or its effect class indicates a remote mutation. +/// Create/update/delete remain mutations regardless of a malformed plan effect. Actions default +/// to mutation and are exempted only by the validated read effect produced from CGS semantics. pub(crate) fn is_remote_mutation(kind: PlanNodeKind, effect_class: EffectClass) -> bool { matches!( kind, - PlanNodeKind::Create | PlanNodeKind::Update | PlanNodeKind::Delete | PlanNodeKind::Action + PlanNodeKind::Create | PlanNodeKind::Update | PlanNodeKind::Delete ) || matches!(effect_class, EffectClass::Write | EffectClass::SideEffect) + || (kind == PlanNodeKind::Action && effect_class != EffectClass::Read) } /// Whether a validated plan contains any node that may mutate a remote system. diff --git a/crates/plasm-agent-core/src/plan_flow_existence.rs b/crates/plasm-agent-core/src/plan_flow_existence.rs index 78fae824..b9d2b5e2 100644 --- a/crates/plasm-agent-core/src/plan_flow_existence.rs +++ b/crates/plasm-agent-core/src/plan_flow_existence.rs @@ -9,7 +9,7 @@ use crate::plasm_plan::{ }; use plasm_core::schema::{ViewDefinition, ViewNodeSpec}; use plasm_core::{ - schema::CapabilityKind, CompOp, EntityKey, Expr, Predicate, TypedComparisonValue, Value, + CompOp, EntityKey, Expr, Predicate, SemanticEffect, TypedComparisonValue, Value, ViewNodeCondition, ViewNodeWhen, }; use std::collections::{BTreeMap, BTreeSet}; @@ -91,11 +91,14 @@ pub fn check_view_existence_flow( } continue; }; - if is_read_kind(meta.kind) { + if meta.effect == SemanticEffect::Read { prior_read_nodes.insert(node.id.clone()); continue; } - if !is_mutator_kind(meta.kind) { + if !matches!( + meta.effect, + SemanticEffect::Write | SemanticEffect::SideEffect + ) { continue; } if meta.idempotent || view_node_guarded_by_when(node, &prior_read_nodes) { @@ -134,7 +137,7 @@ fn is_read_capability_name( .capability_workflow_meta(&QualifiedCapabilityKey::from_parts( entry_id, entity, capability, )) - .is_some_and(|m| is_read_kind(m.kind)) + .is_some_and(|m| m.effect == SemanticEffect::Read) } fn guarded_ok() -> ExistenceCheckOutcome { @@ -403,23 +406,6 @@ fn identity_binding_from_value(v: &serde_json::Value) -> Option None } -fn is_read_kind(kind: CapabilityKind) -> bool { - matches!( - kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) -} - -fn is_mutator_kind(kind: CapabilityKind) -> bool { - matches!( - kind, - CapabilityKind::Create - | CapabilityKind::Update - | CapabilityKind::Delete - | CapabilityKind::Action - ) -} - pub(crate) fn apply_unguarded_mutation_review( disposition: &mut NodeDisposition, violations: &mut Vec, diff --git a/crates/plasm-agent-core/src/plan_flow_view_expand.rs b/crates/plasm-agent-core/src/plan_flow_view_expand.rs index 0a79b153..b553bb1b 100644 --- a/crates/plasm-agent-core/src/plan_flow_view_expand.rs +++ b/crates/plasm-agent-core/src/plan_flow_view_expand.rs @@ -9,7 +9,8 @@ use crate::plan_flow_existence::{apply_unguarded_mutation_review, check_view_exi use crate::plan_flow_ports::FlowPolicyEvaluator; use crate::plan_flow_sanitizer::apply_label_clearance; use crate::plasm_plan::PlanResultUse; -use plasm_core::schema::{CapabilityKind, ViewDefinition}; +use plasm_core::schema::ViewDefinition; +use plasm_core::SemanticEffect; use std::collections::{BTreeMap, BTreeSet}; pub(crate) struct ViewExpandOutcome { @@ -67,11 +68,14 @@ pub(crate) fn expand_view_inner_mutations( let Some(meta) = catalog.capability_workflow_meta(&inner_key) else { continue; }; - if is_read_kind(meta.kind) { + if meta.effect == SemanticEffect::Read { prior_reads.insert(node.id.clone()); continue; } - if !is_mutator_kind(meta.kind) { + if !matches!( + meta.effect, + SemanticEffect::Write | SemanticEffect::SideEffect + ) { continue; } @@ -135,20 +139,3 @@ pub(crate) fn expand_view_inner_mutations( out } - -fn is_read_kind(kind: CapabilityKind) -> bool { - matches!( - kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) -} - -fn is_mutator_kind(kind: CapabilityKind) -> bool { - matches!( - kind, - CapabilityKind::Create - | CapabilityKind::Update - | CapabilityKind::Delete - | CapabilityKind::Action - ) -} diff --git a/crates/plasm-agent-core/src/plan_ux_reflection.rs b/crates/plasm-agent-core/src/plan_ux_reflection.rs index eb8584b6..83969299 100644 --- a/crates/plasm-agent-core/src/plan_ux_reflection.rs +++ b/crates/plasm-agent-core/src/plan_ux_reflection.rs @@ -363,11 +363,15 @@ fn widget_for_node( crate::plan_dry_display::PlanDryOp::ForEach { .. } => PlanUxWidgetKind::ForEach, crate::plan_dry_display::PlanDryOp::Derive { .. } => PlanUxWidgetKind::Derive, crate::plan_dry_display::PlanDryOp::Data { .. } => PlanUxWidgetKind::Data, - crate::plan_dry_display::PlanDryOp::Surface { kind, .. } => match kind { + crate::plan_dry_display::PlanDryOp::Surface { + kind, effect_class, .. + } => match kind { PlanNodeKind::Query | PlanNodeKind::Get | PlanNodeKind::Search => { PlanUxWidgetKind::ReadSurface } - PlanNodeKind::Action => PlanUxWidgetKind::ActionSurface, + PlanNodeKind::Action if *effect_class != EffectClass::Read => { + PlanUxWidgetKind::ActionSurface + } _ => PlanUxWidgetKind::ReadSurface, }, _ => PlanUxWidgetKind::Compute, @@ -375,7 +379,9 @@ fn widget_for_node( } match node { ValidatedPlanNode::Surface(s) => match s.kind { - PlanNodeKind::Action => PlanUxWidgetKind::ActionSurface, + PlanNodeKind::Action if s.effect_class != EffectClass::Read => { + PlanUxWidgetKind::ActionSurface + } _ => PlanUxWidgetKind::ReadSurface, }, ValidatedPlanNode::RelationTraversal(_) => PlanUxWidgetKind::RelationHop, @@ -501,12 +507,27 @@ mod tests { fn plan_ux_step_operation_is_human_not_debug() { let op = crate::plan_dry_display::PlanDryOp::Surface { kind: PlanNodeKind::Query, + effect_class: EffectClass::Read, expr: "e1.identifier".into(), }; let rendered = crate::plan_dry_display::human_ux_summary_for_op(&op); assert!(rendered.contains("Read")); assert!(!rendered.contains("PlanDryOp")); assert!(!rendered.contains("Surface")); + let read_action = crate::plan_dry_display::PlanDryOp::Surface { + kind: PlanNodeKind::Action, + effect_class: EffectClass::Read, + expr: "e1.m1()".into(), + }; + assert_eq!( + crate::plan_dry_display::human_ux_headline_for_op(&read_action), + "Read" + ); + assert_eq!( + crate::plan_dry_display::human_ux_summary_for_op(&read_action), + "Read · e1.m1()" + ); + let filter = crate::plan_dry_display::PlanDryOp::Filter { predicates: vec!["cost<100".into()], }; diff --git a/crates/plasm-agent-core/src/plasm_dag/plan_serialize/surface_infer.rs b/crates/plasm-agent-core/src/plasm_dag/plan_serialize/surface_infer.rs index b8f02a4f..49df83cb 100644 --- a/crates/plasm-agent-core/src/plasm_dag/plan_serialize/surface_infer.rs +++ b/crates/plasm-agent-core/src/plasm_dag/plan_serialize/surface_infer.rs @@ -21,7 +21,7 @@ pub(in crate::plasm_dag) fn infer_surface_contract( ); } - let (mut kind, entity, effect, shape) = infer_surface_contract_from_expr(expr)?; + let (mut kind, entity, mut effect, mut shape) = infer_surface_contract_from_expr(expr)?; let qe = if matches!(shape, crate::plasm_plan::ResultShape::Page) { if let Some(qe) = expr.qualified_entity_key() { QualifiedEntityKey::from(qe) @@ -80,10 +80,64 @@ pub(in crate::plasm_dag) fn infer_surface_contract( } } } + } else if let Expr::Invoke(invoke) = expr { + let resolving_cgs = cgs_for_qualified_entity(session, &qe).ok_or_else(|| { + format!( + "catalog `{}` is not loaded for entity `{}`", + qe.entry_id, qe.entity + ) + })?; + let cap = resolving_cgs + .get_capability(invoke.capability.as_str()) + .ok_or_else(|| format!("unknown action capability `{}`", invoke.capability))?; + (effect, shape) = invoke_effect_and_shape(cap)?; } Ok((kind, qe, effect, shape)) } +fn invoke_effect_and_shape( + cap: &CapabilitySchema, +) -> Result<(EffectClass, crate::plasm_plan::ResultShape), String> { + if cap.effect == Some(plasm_core::CapabilityEffect::Read) + && cap.output_schema.as_ref().is_some_and(|output| { + matches!( + &output.output_type, + plasm_core::OutputType::SideEffect { .. } + ) + }) + { + return Err(format!( + "capability `{}` cannot combine a read effect with side-effect output", + cap.name + )); + } + let effect = EffectClass::from(cap.effective_effect()); + if effect != EffectClass::Read { + return Ok((effect, crate::plasm_plan::ResultShape::SideEffectAck)); + } + + let shape = match cap.output_schema.as_ref().map(|output| &output.output_type) { + Some(plasm_core::OutputType::Entity { .. }) + | Some(plasm_core::OutputType::Custom { .. }) + | Some(plasm_core::OutputType::Status { .. }) => crate::plasm_plan::ResultShape::Single, + Some(plasm_core::OutputType::Collection { .. }) => crate::plasm_plan::ResultShape::List, + Some(plasm_core::OutputType::SideEffect { .. }) => { + return Err(format!( + "capability `{}` cannot combine a read effect with side-effect output", + cap.name + )); + } + None if !cap.provides.is_empty() => crate::plasm_plan::ResultShape::Single, + None => { + return Err(format!( + "action capability `{}` has no modeled response", + cap.name + )); + } + }; + Ok((effect, shape)) +} + pub(in crate::plasm_dag) fn infer_surface_contract_from_expr( expr: &Expr, ) -> Result< @@ -226,6 +280,99 @@ pub(in crate::plasm_dag) fn single_unknown_schema(entity: &str) -> SyntheticResu } } +#[cfg(test)] +mod tests { + use super::*; + use plasm_core::{CapabilityEffect, OutputSchema, OutputType, SemanticEffect}; + + fn read_action(output_type: Option, provides: bool) -> CapabilitySchema { + let mut cap = CapabilitySchema::minimal_test(); + cap.effect = Some(CapabilityEffect::Read); + cap.provides = provides.then(|| vec!["id".into()]).unwrap_or_default(); + cap.output_schema = output_type.map(|output_type| OutputSchema { + output_type, + decoder: serde_json::json!({}), + idempotent: false, + reconcile: None, + }); + cap + } + + #[test] + fn read_action_effect_and_output_matrix_lower_without_ack_shape() { + let single_outputs = [ + OutputType::Entity { + entity_type: "TestEntity".into(), + }, + OutputType::Custom { + schema: serde_json::json!({"type": "array"}), + }, + OutputType::Status { + success_indicators: vec!["ok".into()], + }, + ]; + for output in single_outputs { + assert_eq!( + invoke_effect_and_shape(&read_action(Some(output), false)).expect("lower"), + (EffectClass::Read, crate::plasm_plan::ResultShape::Single) + ); + } + assert_eq!( + invoke_effect_and_shape(&read_action( + Some(OutputType::Collection { + entity_type: "TestEntity".into(), + max_count: None, + }), + false, + )) + .expect("lower collection"), + (EffectClass::Read, crate::plasm_plan::ResultShape::List) + ); + assert_eq!( + invoke_effect_and_shape(&read_action(None, true)).expect("lower provides-only"), + (EffectClass::Read, crate::plasm_plan::ResultShape::Single) + ); + assert!(invoke_effect_and_shape(&read_action(None, false)).is_err()); + assert!(invoke_effect_and_shape(&read_action( + Some(OutputType::SideEffect { + description: "changes state".into(), + }), + false, + )) + .is_err()); + + let default_action = CapabilitySchema::minimal_test(); + assert_eq!( + invoke_effect_and_shape(&default_action).expect("default action"), + ( + EffectClass::SideEffect, + crate::plasm_plan::ResultShape::SideEffectAck + ) + ); + + for (semantic, expected, expected_core) in [ + ( + SemanticEffect::Read, + EffectClass::Read, + plasm_core::EffectClass::Read, + ), + ( + SemanticEffect::Write, + EffectClass::Write, + plasm_core::EffectClass::Write, + ), + ( + SemanticEffect::SideEffect, + EffectClass::SideEffect, + plasm_core::EffectClass::SideEffect, + ), + ] { + assert_eq!(EffectClass::from(semantic), expected); + assert_eq!(plasm_core::EffectClass::from(semantic), expected_core); + } + } +} + pub(in crate::plasm_dag) fn looks_like_plasm_effect_template(rhs: &str) -> bool { // Distinguish for-each side effects from `source => { … }` derive. `.m#` (teaching-table methods) and // all readable verbs must register here—`.label(`, `.update(`, etc.—not just `.m`. diff --git a/crates/plasm-agent-core/src/plasm_plan.rs b/crates/plasm-agent-core/src/plasm_plan.rs index f3948ab7..b1868256 100644 --- a/crates/plasm-agent-core/src/plasm_plan.rs +++ b/crates/plasm-agent-core/src/plasm_plan.rs @@ -100,6 +100,16 @@ pub enum EffectClass { ArtifactRead, } +impl From for EffectClass { + fn from(effect: plasm_core::SemanticEffect) -> Self { + match effect { + plasm_core::SemanticEffect::Read => Self::Read, + plasm_core::SemanticEffect::Write => Self::Write, + plasm_core::SemanticEffect::SideEffect => Self::SideEffect, + } + } +} + /// Expected host result shape for dry-run / planning. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1095,12 +1105,9 @@ pub fn validate_plan_artifact(plan: &Plan) -> Result { .filter(|n| { matches!( n.kind, - PlanNodeKind::Create - | PlanNodeKind::Update - | PlanNodeKind::Delete - | PlanNodeKind::Action - | PlanNodeKind::ForEach + PlanNodeKind::Create | PlanNodeKind::Update | PlanNodeKind::Delete ) || matches!(n.effect_class, EffectClass::Write | EffectClass::SideEffect) + || (n.kind == PlanNodeKind::Action && n.effect_class != EffectClass::Read) }) .map(|n| PlanNodeId::new(n.id.clone())) .collect::, _>>()?; diff --git a/crates/plasm-agent-core/src/plasm_plan_run/dry.rs b/crates/plasm-agent-core/src/plasm_plan_run/dry.rs index c2005706..f0a30b8b 100644 --- a/crates/plasm-agent-core/src/plasm_plan_run/dry.rs +++ b/crates/plasm-agent-core/src/plasm_plan_run/dry.rs @@ -578,10 +578,7 @@ pub(crate) fn attach_flow_approval_gates( } pub(crate) fn remote_mutation_effect(kind: PlanNodeKind, effect_class: EffectClass) -> bool { - matches!( - kind, - PlanNodeKind::Create | PlanNodeKind::Update | PlanNodeKind::Delete | PlanNodeKind::Action - ) || matches!(effect_class, EffectClass::Write | EffectClass::SideEffect) + crate::plan_flow::is_remote_mutation(kind, effect_class) } /// Remote mutation inside a `for_each` body (fan-out / multi-write risk). Read-only bodies excluded. diff --git a/crates/plasm-agent-core/src/query_args.rs b/crates/plasm-agent-core/src/query_args.rs index 118332f6..7c156c98 100644 --- a/crates/plasm-agent-core/src/query_args.rs +++ b/crates/plasm-agent-core/src/query_args.rs @@ -169,6 +169,7 @@ mod tests { name: "thing_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Thing".into(), identity_key: None, mapping: CapabilityMapping { @@ -198,6 +199,7 @@ mod tests { name: "index_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Thing".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-agent-core/src/tool_model.rs b/crates/plasm-agent-core/src/tool_model.rs index fa545fb3..f4980d05 100644 --- a/crates/plasm-agent-core/src/tool_model.rs +++ b/crates/plasm-agent-core/src/tool_model.rs @@ -297,6 +297,13 @@ fn explorer_return_inferred_when_no_output_schema( let domain = cap.domain.to_string(); let navigable = cgs.entities.contains_key(domain.as_str()); match cap.kind { + CapabilityKind::Action if cap.is_read() && !cap.provides.is_empty() => ExplorerReturn { + kind: "entity".into(), + label: domain.clone(), + description: String::new(), + entity: Some(domain), + entity_navigable: navigable, + }, CapabilityKind::Get => ExplorerReturn { kind: "entity".into(), label: domain.clone(), @@ -1496,6 +1503,8 @@ fn project_entity( CapabilityKind::Update | CapabilityKind::Action => { let k = if cap.kind == CapabilityKind::Update { "update" + } else if cap.is_read() { + "read_action" } else { "action" }; diff --git a/crates/plasm-core/src/catalog_search_index.rs b/crates/plasm-core/src/catalog_search_index.rs index 1dfff506..e8bf62fe 100644 --- a/crates/plasm-core/src/catalog_search_index.rs +++ b/crates/plasm-core/src/catalog_search_index.rs @@ -331,6 +331,7 @@ mod tests { name: CapabilityName::from("create_pull_request"), description: "Open a pull request.".into(), kind: CapabilityKind::Create, + effect: None, domain: EntityName::from("PullRequest"), mapping: CapabilityMapping { template: CapabilityTemplateJson(serde_json::json!({ "method": "POST" })), diff --git a/crates/plasm-core/src/cross_entity.rs b/crates/plasm-core/src/cross_entity.rs index 725c0955..7fee99fd 100644 --- a/crates/plasm-core/src/cross_entity.rs +++ b/crates/plasm-core/src/cross_entity.rs @@ -325,6 +325,7 @@ mod tests { name: "pet_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Pet".into(), identity_key: None, mapping: CapabilityMapping { @@ -358,6 +359,7 @@ mod tests { name: "order_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Order".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/discovery/exposure_surface.rs b/crates/plasm-core/src/discovery/exposure_surface.rs index 4cb158d9..94d681f1 100644 --- a/crates/plasm-core/src/discovery/exposure_surface.rs +++ b/crates/plasm-core/src/discovery/exposure_surface.rs @@ -166,15 +166,14 @@ pub fn derive_intent_exposure_surface_batch( ) { true } else { - match cap.kind { - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get => { - score > 0 - } - _ => seeded_mutating_capability_admitted( + if cap.is_read() { + score > 0 + } else { + seeded_mutating_capability_admitted( score, ranked_capability_names, cap.name.as_str(), - ), + ) } }; if !include { @@ -198,10 +197,7 @@ pub fn derive_intent_exposure_surface_batch( } } - if matches!( - cap.kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) { + if cap.is_read() { for fk in fields_for_admitted_read_cap(cgs, cap, ename) { surface.slots.insert(ExposureSlotKey::EntityField { entity: ekey.clone(), @@ -254,13 +250,7 @@ pub fn derive_intent_exposure_surface_batch( let Some(cap) = cgs.capabilities.get(cap_name) else { continue; }; - if !matches!( - cap.kind, - CapabilityKind::Create - | CapabilityKind::Update - | CapabilityKind::Delete - | CapabilityKind::Action - ) { + if !cap.is_remote_mutation() { continue; } let score = bm25.capability_score(cid.as_str(), cap.name.as_str(), intent); @@ -299,13 +289,11 @@ pub fn derive_intent_exposure_surface_batch( let Some(cap) = cgs.capabilities.get(cap_name) else { continue; }; - let is_read = matches!( - cap.kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) || target_ent - .primary_read - .as_deref() - .is_some_and(|pr| pr == cap.name.as_str()); + let is_read = cap.is_read() + || target_ent + .primary_read + .as_deref() + .is_some_and(|pr| pr == cap.name.as_str()); if !is_read { continue; } @@ -315,10 +303,7 @@ pub fn derive_intent_exposure_surface_batch( capability: cap.name.clone(), }; surface.capabilities.insert(ckey); - if matches!( - cap.kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) { + if cap.is_read() { for fk in fields_for_admitted_read_cap(cgs, cap, target) { surface.slots.insert(ExposureSlotKey::EntityField { entity: tkey.clone(), @@ -370,13 +355,7 @@ pub fn relation_target_deferred_mutator_wires( let Some(cap) = cgs.capabilities.get(cap_name) else { continue; }; - if !matches!( - cap.kind, - CapabilityKind::Create - | CapabilityKind::Update - | CapabilityKind::Delete - | CapabilityKind::Action - ) { + if !cap.is_remote_mutation() { continue; } let score = bm25.capability_score(cid.as_str(), cap.name.as_str(), intent); diff --git a/crates/plasm-core/src/discovery/exposure_surface_tests.rs b/crates/plasm-core/src/discovery/exposure_surface_tests.rs index 9b1ac14a..2d01edc0 100644 --- a/crates/plasm-core/src/discovery/exposure_surface_tests.rs +++ b/crates/plasm-core/src/discovery/exposure_surface_tests.rs @@ -71,6 +71,37 @@ fn mutating_capability_admitted_requires_nonzero_score() { assert!(mutating_capability_admitted(1, None, "langitem_create")); } +#[test] +fn intent_only_read_surface_excludes_unrequested_mutations() { + let dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/schemas/plasm_language_matrix"); + let cgs = load_schema_dir(&dir).expect("plasm_language_matrix"); + let endpoints = relation_keys("matrix", &["LangItem"]); + let delta = derive_intent_exposure_surface_batch( + &cgs, + "matrix", + "browse language item inventory metadata", + &endpoints, + &["LangItem".to_string()], + None, + ExposureSurfaceOptions { + mutator_admit: MutatorAdmit::IntentOnly, + }, + ); + assert!(delta + .required + .capabilities + .iter() + .filter_map(|key| cgs.get_capability(key.capability.as_str())) + .any(crate::CapabilitySchema::is_read)); + assert!(!delta + .required + .capabilities + .iter() + .filter_map(|key| cgs.get_capability(key.capability.as_str())) + .any(crate::CapabilitySchema::is_remote_mutation)); +} + #[test] fn intent_surface_ranked_admits_seeded_mutator_at_zero_score() { let dir = diff --git a/crates/plasm-core/src/discovery/mod.rs b/crates/plasm-core/src/discovery/mod.rs index 074cfabf..8b4fde56 100644 --- a/crates/plasm-core/src/discovery/mod.rs +++ b/crates/plasm-core/src/discovery/mod.rs @@ -1443,6 +1443,7 @@ mod tests { name: CapabilityName::from("thread_list"), description: "List mailbox threads.".into(), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from("Thread"), mapping: CapabilityMapping { template: CapabilityTemplateJson(serde_json::json!({ "method": "GET" })), diff --git a/crates/plasm-core/src/discovery/mutator_admit.rs b/crates/plasm-core/src/discovery/mutator_admit.rs index bf133845..5c224012 100644 --- a/crates/plasm-core/src/discovery/mutator_admit.rs +++ b/crates/plasm-core/src/discovery/mutator_admit.rs @@ -3,7 +3,7 @@ //! Seeded mutators under [`MutatorAdmit::IntentOnly`]: BM25 score > 0 **or** ranked wire boost. //! Non-seeded relation-target mutators: score required; ranked is a whitelist when non-empty. -use crate::schema::{CapabilityKind, CapabilitySchema, EntityDef}; +use crate::schema::{CapabilitySchema, EntityDef}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -100,10 +100,7 @@ pub(crate) fn seeded_entity_cap_always_includes( if cap.domain.as_str() != entity_name || !seeded_entities.contains(entity_name) { return false; } - if matches!( - cap.kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) { + if cap.is_read() { return true; } if ent @@ -113,12 +110,5 @@ pub(crate) fn seeded_entity_cap_always_includes( { return true; } - matches!(mutator_admit, MutatorAdmit::AlwaysOnSeeds) - && matches!( - cap.kind, - CapabilityKind::Create - | CapabilityKind::Update - | CapabilityKind::Delete - | CapabilityKind::Action - ) + matches!(mutator_admit, MutatorAdmit::AlwaysOnSeeds) && cap.is_remote_mutation() } diff --git a/crates/plasm-core/src/discovery_auto_seed/helpers.rs b/crates/plasm-core/src/discovery_auto_seed/helpers.rs index 23c50948..28936b3a 100644 --- a/crates/plasm-core/src/discovery_auto_seed/helpers.rs +++ b/crates/plasm-core/src/discovery_auto_seed/helpers.rs @@ -44,7 +44,7 @@ pub(crate) fn capability_kind_label( }; cgs.capabilities .get(capability_name) - .map(|c| format!("{:?}", c.kind)) + .map(|capability| format!("{:?}", capability.kind)) .unwrap_or_default() } @@ -65,6 +65,11 @@ pub(crate) fn push_capability_evidence( capability_id: cap_id, capability_name: cand.capability_name.clone(), kind: capability_kind_label(catalogs, &cand.entry_id, &cand.capability_name), + effect: catalogs + .get(&cand.entry_id) + .and_then(|cgs| cgs.capabilities.get(cand.capability_name.as_str())) + .map(|capability| capability.effective_effect()) + .unwrap_or(crate::SemanticEffect::SideEffect), description: cand.capability_description.clone(), reason_codes: cand.reason_codes.clone(), lexical_score: cand.score, diff --git a/crates/plasm-core/src/discovery_auto_seed/inject.rs b/crates/plasm-core/src/discovery_auto_seed/inject.rs index a0b67791..3db3ee7c 100644 --- a/crates/plasm-core/src/discovery_auto_seed/inject.rs +++ b/crates/plasm-core/src/discovery_auto_seed/inject.rs @@ -82,8 +82,16 @@ pub(crate) fn read_capabilities_for_entity( let mut out = Vec::new(); let mut seen = HashSet::new(); - for kind in [CapabilityKind::Query, CapabilityKind::Get] { + for kind in [ + CapabilityKind::Query, + CapabilityKind::Get, + CapabilityKind::Search, + CapabilityKind::Action, + ] { for cap in cgs.find_capabilities(entity, kind) { + if !cap.is_read() { + continue; + } if push_capability_evidence(&mut out, &mut seen, entry_id, entity, cap, max) { return out; } @@ -228,6 +236,7 @@ pub(crate) fn push_capability_evidence( capability_id: capability_id(entry_id, entity, cap.name.as_str()), capability_name: cap.name.to_string(), kind: format!("{:?}", cap.kind), + effect: cap.effective_effect(), description: cap.description.clone(), reason_codes: Vec::new(), lexical_score: 1, @@ -255,6 +264,9 @@ fn select_intent_relevant_mutation_caps( CapabilityKind::Update, ] { for cap in cgs.find_capabilities(entity, kind) { + if !cap.is_remote_mutation() { + continue; + } let mut score = 0i32; if let Some(caps) = index.mutation_caps.get(entity) { if let Some(meta) = caps.iter().find(|meta| meta.name == cap.name.as_str()) { @@ -342,6 +354,9 @@ pub(crate) fn mutation_capabilities_for_entity_with_intent( CapabilityKind::Update, ] { for cap in cgs.find_capabilities(entity, kind) { + if !cap.is_remote_mutation() { + continue; + } if push_capability_evidence(&mut out, &mut seen, entry_id, entity, cap, max) { return out; } @@ -597,12 +612,15 @@ fn primary_read_entity_for_mirror(cgs: &CGS, entry_id: &str) -> Option { let mut best: Option<(usize, String)> = None; for entity_name in cgs.entities.keys() { let entity = entity_name.as_str(); - let has_read = cgs - .find_capabilities(entity, CapabilityKind::Query) - .into_iter() - .chain(cgs.find_capabilities(entity, CapabilityKind::Get)) - .next() - .is_some(); + let has_read = [ + CapabilityKind::Query, + CapabilityKind::Get, + CapabilityKind::Search, + CapabilityKind::Action, + ] + .into_iter() + .flat_map(|kind| cgs.find_capabilities(entity, kind)) + .any(|cap| cap.is_read()); if !has_read { continue; } @@ -660,22 +678,7 @@ fn inject_mirror_catalog_targets( if bundles.contains_key(&key) { continue; } - let mut capabilities = Vec::new(); - for cap in cgs - .find_capabilities(entity.as_str(), crate::schema::CapabilityKind::Query) - .into_iter() - .chain(cgs.find_capabilities(entity.as_str(), crate::schema::CapabilityKind::Get)) - .take(2) - { - capabilities.push(EntityCapabilityEvidence { - capability_id: capability_id(&catalog, entity.as_str(), cap.name.as_str()), - capability_name: cap.name.to_string(), - kind: format!("{:?}", cap.kind), - description: cap.description.clone(), - reason_codes: Vec::new(), - lexical_score: 1, - }); - } + let capabilities = read_capabilities_for_entity(cgs.as_ref(), &catalog, entity.as_str(), 2); bundles.insert( key.clone(), EntityCandidateBundle { diff --git a/crates/plasm-core/src/discovery_auto_seed/tests.rs b/crates/plasm-core/src/discovery_auto_seed/tests.rs index 9d78bca3..fc034db7 100644 --- a/crates/plasm-core/src/discovery_auto_seed/tests.rs +++ b/crates/plasm-core/src/discovery_auto_seed/tests.rs @@ -156,6 +156,7 @@ fn merge_required_adds_injected_issue_from_pool() { capability_id: "jira:Issue:issue_transition".into(), capability_name: "issue_transition".into(), kind: "Action".into(), + effect: crate::SemanticEffect::SideEffect, description: String::new(), reason_codes: vec![], lexical_score: 1, diff --git a/crates/plasm-core/src/discovery_auto_seed/types.rs b/crates/plasm-core/src/discovery_auto_seed/types.rs index bca2a814..2d09297a 100644 --- a/crates/plasm-core/src/discovery_auto_seed/types.rs +++ b/crates/plasm-core/src/discovery_auto_seed/types.rs @@ -15,6 +15,8 @@ pub struct EntityCapabilityEvidence { pub capability_id: String, pub capability_name: String, pub kind: String, + #[serde(default = "default_evidence_effect")] + pub effect: crate::SemanticEffect, pub description: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reason_codes: Vec, @@ -23,6 +25,31 @@ pub struct EntityCapabilityEvidence { use crate::discovery_candidate_graph::TypedCandidateGraph; +fn default_evidence_effect() -> crate::SemanticEffect { + crate::SemanticEffect::SideEffect +} + +impl EntityCapabilityEvidence { + pub fn is_read(&self) -> bool { + self.effect == crate::SemanticEffect::Read + } + + pub fn is_remote_mutation(&self) -> bool { + matches!( + self.effect, + crate::SemanticEffect::Write | crate::SemanticEffect::SideEffect + ) + } + + pub(crate) fn witness_kind(&self) -> &str { + if self.kind == "Action" && self.is_read() { + "ReadAction" + } else { + self.kind.as_str() + } + } +} + /// One entity-level candidate for seed-set selection. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct EntityCandidateBundle { diff --git a/crates/plasm-core/src/discovery_coverage/enumerate.rs b/crates/plasm-core/src/discovery_coverage/enumerate.rs index 6a32cfae..458417e0 100644 --- a/crates/plasm-core/src/discovery_coverage/enumerate.rs +++ b/crates/plasm-core/src/discovery_coverage/enumerate.rs @@ -163,6 +163,7 @@ fn capability_evidence( capability_id: format!("{entry_id}:{entity}:{}", cap.name), capability_name: cap.name.to_string(), kind: format!("{:?}", cap.kind), + effect: cap.effective_effect(), description: cap.description.clone(), reason_codes: Vec::new(), lexical_score, @@ -550,19 +551,22 @@ fn bundle_has_read_kind(bundle: &EntityCandidateBundle) -> bool { bundle .capabilities .iter() - .any(|cap| matches!(cap.kind.as_str(), "Query" | "Search" | "Get")) + .any(EntityCapabilityEvidence::is_read) } fn bundle_has_kind(bundle: &EntityCandidateBundle, kind: CapabilityKind) -> bool { let label = format!("{kind:?}"); - bundle.capabilities.iter().any(|cap| cap.kind == label) + bundle + .capabilities + .iter() + .any(|cap| cap.kind == label && cap.is_remote_mutation()) } fn bundle_has_mutation_kind(bundle: &EntityCandidateBundle) -> bool { bundle .capabilities .iter() - .any(|cap| matches!(cap.kind.as_str(), "Create" | "Update" | "Delete" | "Action")) + .any(EntityCapabilityEvidence::is_remote_mutation) } fn entity_matches_hint(entity: &str, hint: &str) -> bool { diff --git a/crates/plasm-core/src/discovery_coverage/tests.rs b/crates/plasm-core/src/discovery_coverage/tests.rs index 0770217b..ffeac97d 100644 --- a/crates/plasm-core/src/discovery_coverage/tests.rs +++ b/crates/plasm-core/src/discovery_coverage/tests.rs @@ -37,8 +37,28 @@ fn load_api_pair(a: &str, b: &str) -> Option<(IndexMap CatalogSeedIndex { CapabilityKind::Delete, ] { for cap in cgs.find_capabilities(entity_name.as_str(), kind) { + if !cap.is_remote_mutation() { + continue; + } caps.push(CatalogCapabilityMeta { name: cap.name.to_string(), kind: cap.kind, diff --git a/crates/plasm-core/src/discovery_seed_select/ambiguity.rs b/crates/plasm-core/src/discovery_seed_select/ambiguity.rs index b895a802..79a2d9df 100644 --- a/crates/plasm-core/src/discovery_seed_select/ambiguity.rs +++ b/crates/plasm-core/src/discovery_seed_select/ambiguity.rs @@ -79,7 +79,7 @@ pub fn deterministic_provider_ambiguity( requirements: Vec::new(), selected_ids: Vec::new(), supporting_capability_ids: Vec::new(), - teaching_satellites: vec![], + teaching_satellites: vec![], alternative_sets: alternatives, uncovered_requirements: Vec::new(), reasoning: @@ -131,6 +131,7 @@ mod tests { capability_id: format!("{catalog}:{entity}:query"), capability_name: "query".into(), kind: "Query".into(), + effect: crate::SemanticEffect::Read, description: String::new(), reason_codes: Vec::new(), lexical_score: score, diff --git a/crates/plasm-core/src/discovery_seed_select/rewriter.rs b/crates/plasm-core/src/discovery_seed_select/rewriter.rs index dccf3bcc..97295d75 100644 --- a/crates/plasm-core/src/discovery_seed_select/rewriter.rs +++ b/crates/plasm-core/src/discovery_seed_select/rewriter.rs @@ -173,12 +173,10 @@ fn is_relation_leaf(entry_id: &str, entity: &str, bundles: &[EntityCandidateBund } fn is_mutation_bundle(bundle: &EntityCandidateBundle) -> bool { - bundle.capabilities.iter().any(|cap| { - matches!( - cap.kind.as_str(), - "Create" | "Action" | "Update" | "Delete" | "Transition" - ) - }) + bundle + .capabilities + .iter() + .any(|capability| capability.is_remote_mutation()) } fn is_localized_mutation_anchor( @@ -449,6 +447,11 @@ mod tests { capability_id: format!("{eid}:{ent}:cap"), capability_name: "cap".into(), kind: kind.into(), + effect: match kind { + "Query" | "Search" | "Get" | "ReadAction" => crate::SemanticEffect::Read, + "Action" => crate::SemanticEffect::SideEffect, + _ => crate::SemanticEffect::Write, + }, description: String::new(), reason_codes: vec![], lexical_score: 1, diff --git a/crates/plasm-core/src/discovery_seed_select/tests.rs b/crates/plasm-core/src/discovery_seed_select/tests.rs index ea39b166..9d1e1b21 100644 --- a/crates/plasm-core/src/discovery_seed_select/tests.rs +++ b/crates/plasm-core/src/discovery_seed_select/tests.rs @@ -26,6 +26,11 @@ fn bundle( capability_id: format!("{eid}:{ent}:{cap}"), capability_name: cap.into(), kind: kind.into(), + effect: match kind { + "Query" | "Search" | "Get" | "ReadAction" => crate::SemanticEffect::Read, + "Action" => crate::SemanticEffect::SideEffect, + _ => crate::SemanticEffect::Write, + }, description: String::new(), reason_codes: vec![], lexical_score: 1, diff --git a/crates/plasm-core/src/discovery_seed_symbol_map.rs b/crates/plasm-core/src/discovery_seed_symbol_map.rs index 82e80242..82760658 100644 --- a/crates/plasm-core/src/discovery_seed_symbol_map.rs +++ b/crates/plasm-core/src/discovery_seed_symbol_map.rs @@ -137,7 +137,7 @@ fn capability_kind_summary(bundle: &EntityCandidateBundle) -> String { let mut kinds: Vec = bundle .capabilities .iter() - .map(|capability| capability.kind.clone()) + .map(|capability| capability.witness_kind().to_string()) .collect(); kinds.sort_unstable(); kinds.dedup(); @@ -164,6 +164,7 @@ mod tests { capability_id: format!("{catalog}:{entity}:query"), capability_name: "query".into(), kind: "Query".into(), + effect: crate::SemanticEffect::Read, description: String::new(), reason_codes: vec![], lexical_score: 1, diff --git a/crates/plasm-core/src/discovery_seed_witness/corpus.rs b/crates/plasm-core/src/discovery_seed_witness/corpus.rs index b39655e8..65a72a5c 100644 --- a/crates/plasm-core/src/discovery_seed_witness/corpus.rs +++ b/crates/plasm-core/src/discovery_seed_witness/corpus.rs @@ -184,7 +184,7 @@ pub fn build_witness_corpus( for cap in &bundle.capabilities { let summary = format!( "{} {}.{} [{}] {}", - cap.kind, + cap.witness_kind(), bundle.entry_id, bundle.entity, cap.capability_name, @@ -197,7 +197,7 @@ pub fn build_witness_corpus( entity: bundle.entity.clone(), capability_id: cap.capability_id.clone(), capability_name: cap.capability_name.clone(), - kind: cap.kind.clone(), + kind: cap.witness_kind().to_string(), description: cap.description.clone(), }, owner_candidate_id: bundle.candidate_id.clone(), diff --git a/crates/plasm-core/src/discovery_seed_witness/kind.rs b/crates/plasm-core/src/discovery_seed_witness/kind.rs index dfb1c288..e932a3cc 100644 --- a/crates/plasm-core/src/discovery_seed_witness/kind.rs +++ b/crates/plasm-core/src/discovery_seed_witness/kind.rs @@ -16,6 +16,11 @@ pub enum CapBucket { impl CapBucket { /// Parse witness / evidence kind strings (`Query`, `query`, …) via [`CapabilityKind`]. pub fn parse(kind: &str) -> Self { + if kind.trim().eq_ignore_ascii_case("read_action") + || kind.trim().eq_ignore_ascii_case("readaction") + { + return Self::Read; + } match parse_capability_kind(kind) { Some(CapabilityKind::Query) | Some(CapabilityKind::Search) @@ -59,6 +64,11 @@ impl CapBucket { /// Lexical bias when picking a parent read Direct among Query/Search/Get. pub fn read_rank(kind: &str) -> u32 { + if kind.trim().eq_ignore_ascii_case("read_action") + || kind.trim().eq_ignore_ascii_case("readaction") + { + return 10; + } match parse_capability_kind(kind) { Some(CapabilityKind::Query) => 30, Some(CapabilityKind::Search) => 20, diff --git a/crates/plasm-core/src/discovery_seed_witness/plans.rs b/crates/plasm-core/src/discovery_seed_witness/plans.rs index 06961d63..f97a0721 100644 --- a/crates/plasm-core/src/discovery_seed_witness/plans.rs +++ b/crates/plasm-core/src/discovery_seed_witness/plans.rs @@ -340,7 +340,7 @@ fn materialize_plan( let kinds: Vec<&str> = bundle .capabilities .iter() - .map(|c| c.kind.as_str()) + .map(|capability| capability.witness_kind()) .collect(); parts.push(format!( "{}.{} ops=[{}]", diff --git a/crates/plasm-core/src/discovery_seed_witness/satellites.rs b/crates/plasm-core/src/discovery_seed_witness/satellites.rs index c4c6147f..75d31bfb 100644 --- a/crates/plasm-core/src/discovery_seed_witness/satellites.rs +++ b/crates/plasm-core/src/discovery_seed_witness/satellites.rs @@ -551,6 +551,11 @@ mod tests { capability_id: id.into(), capability_name: name.into(), kind: kind.into(), + effect: match kind { + "Query" | "Search" | "Get" | "ReadAction" => crate::SemanticEffect::Read, + "Action" => crate::SemanticEffect::SideEffect, + _ => crate::SemanticEffect::Write, + }, description: name.into(), reason_codes: vec![], lexical_score: score, diff --git a/crates/plasm-core/src/discovery_seed_witness/tests.rs b/crates/plasm-core/src/discovery_seed_witness/tests.rs index 827dce1a..bb2dd32d 100644 --- a/crates/plasm-core/src/discovery_seed_witness/tests.rs +++ b/crates/plasm-core/src/discovery_seed_witness/tests.rs @@ -8,6 +8,11 @@ fn cap(id: &str, name: &str, kind: &str, score: u32) -> EntityCapabilityEvidence capability_id: id.into(), capability_name: name.into(), kind: kind.into(), + effect: match kind { + "Query" | "Search" | "Get" | "ReadAction" => crate::SemanticEffect::Read, + "Action" => crate::SemanticEffect::SideEffect, + _ => crate::SemanticEffect::Write, + }, description: format!("{kind} {name}"), reason_codes: vec![], lexical_score: score, @@ -491,6 +496,7 @@ fn corpus_stamps_attach_on_label_and_prune_drops_label_read() { name: CapabilityName::from(name), description: format!("Query {domain}"), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from(domain), mapping: CapabilityMapping { template: CapabilityTemplateJson(serde_json::json!({ "method": "GET" })), @@ -676,6 +682,7 @@ fn corpus_stamps_own_pair_on_both_ends_of_own_edge() { name: CapabilityName::from(name), description: format!("Query {domain}"), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from(domain), mapping: CapabilityMapping { template: CapabilityTemplateJson(serde_json::json!({ "method": "GET" })), diff --git a/crates/plasm-core/src/error.rs b/crates/plasm-core/src/error.rs index df5b90ba..22c719d8 100644 --- a/crates/plasm-core/src/error.rs +++ b/crates/plasm-core/src/error.rs @@ -257,10 +257,25 @@ pub enum SchemaError { MultiSelectParamMissingAllowedValues { capability: String, param: String }, #[error( - "Capability '{capability}' (entity '{entity}') is `kind: action` but has no modeled response: add non-empty `provides:` and/or `output:` with `type: side_effect` and a non-empty `description:` of what the operation changes, or model read-only HTTP as `get` + an entity" + "Capability '{capability}' (entity '{entity}') is `kind: action` but has no modeled response: add non-empty `provides:` and/or `output:`; mutations without an entity response use `type: side_effect` plus a non-empty change description, while a trusted RPC read uses `effect: read` plus a modeled response" )] ActionUntypedResponse { capability: String, entity: String }, + #[error( + "Capability '{capability}': `effect: read` is valid only on `kind: action` (found `kind: {kind}`)" + )] + ReadEffectRequiresAction { capability: String, kind: String }, + + #[error( + "Capability '{capability}': `effect: read` cannot be combined with `output.type: side_effect`" + )] + ReadEffectWithSideEffectOutput { capability: String }, + + #[error( + "Capability '{capability}': `effect: read` cannot declare mutation sink parameter '{param}' (`sink_class` is mutation-only)" + )] + ReadEffectWithSinkParam { capability: String, param: String }, + #[error( "Capability '{capability}': `output.type: side_effect` requires non-empty `description:` (state what changes in the domain)" )] diff --git a/crates/plasm-core/src/expr_parser/chained_groups_tests.rs b/crates/plasm-core/src/expr_parser/chained_groups_tests.rs index a18de077..ca2c86bd 100644 --- a/crates/plasm-core/src/expr_parser/chained_groups_tests.rs +++ b/crates/plasm-core/src/expr_parser/chained_groups_tests.rs @@ -45,6 +45,7 @@ fn ticket_query_fixture_cgs() -> CGS { name: "ticket_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Ticket".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/expr_parser/mod.rs b/crates/plasm-core/src/expr_parser/mod.rs index a87a1fff..4ffa8c9d 100644 --- a/crates/plasm-core/src/expr_parser/mod.rs +++ b/crates/plasm-core/src/expr_parser/mod.rs @@ -4140,6 +4140,7 @@ mod tests { name: "document_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Document".into(), identity_key: None, mapping: CapabilityMapping { @@ -4167,6 +4168,7 @@ mod tests { name: "document_suggest".into(), description: String::new(), kind: CapabilityKind::Action, + effect: None, domain: "Document".into(), identity_key: None, mapping: CapabilityMapping { @@ -5090,6 +5092,7 @@ mod tests { name: "widget_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Widget".into(), identity_key: None, mapping: CapabilityMapping { @@ -5198,6 +5201,7 @@ mod tests { name: "book_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Book".into(), identity_key: None, mapping: CapabilityMapping { @@ -5220,6 +5224,7 @@ mod tests { name: "library_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Library".into(), identity_key: None, mapping: CapabilityMapping { @@ -5411,6 +5416,7 @@ mod tests { name: "ticket_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Ticket".into(), identity_key: None, mapping: CapabilityMapping { @@ -5548,6 +5554,7 @@ mod tests { name: "library_get_nested_fixture".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Library".into(), identity_key: None, mapping: CapabilityMapping { @@ -5645,6 +5652,7 @@ mod tests { name: "parent_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Parent".into(), identity_key: None, mapping: CapabilityMapping { @@ -5670,6 +5678,7 @@ mod tests { name: "child_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Child".into(), identity_key: None, mapping: CapabilityMapping { @@ -6129,6 +6138,7 @@ mod tests { name: "pet_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Pet".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/lib.rs b/crates/plasm-core/src/lib.rs index 66f71217..188ef807 100644 --- a/crates/plasm-core/src/lib.rs +++ b/crates/plasm-core/src/lib.rs @@ -315,19 +315,20 @@ pub use schema::{ capability_method_label_kebab, capability_template_all_var_names, flow_control_param_names, is_flow_control_param_name, template_domain_exemplar_requires_entity_anchor, template_invoke_requires_explicit_anchor_id, AgentPresentation, ArrayItemsSchema, - AttachmentMediaKind, AuthScheme, CapabilityKind, CapabilityManifest, CapabilityMapping, - CapabilitySchema, CapabilityTemplateJson, Cardinality, CgsCapabilityIndex, CrossFieldRule, - CrossFieldRuleType, DataClassDimension, DataClassName, DataClassSchema, DataClassSeverity, - DiscoveryCapabilityHints, DiscoveryEntityHints, DiscoveryRelationHints, DiscoverySeedClass, - DiscoverySeedNav, EmbedOnMissPolicy, EntityDef, FieldDeriveRule, FieldSchema, FieldValueKind, - IdFormat, InputFieldSchema, InputFieldWire, InputSchema, InputType, InputValidation, - InputVariantSchema, JsonPathSegment, NamedValueSchema, OauthDefaultScopeSet, OauthExtension, - OauthRequirements, OauthScopeEntry, OutputSchema, OutputType, ParameterRole, - RelationMaterialization, RelationSchema, RelationScopedFallback, ResourceSchema, - ScopeAggregateKeyPolicy, ScopeRequirement, SinkClassName, StringSemantics, ValidationOp, - ValidationPredicate, ValueDomainKey, ValueDomainSlot, ViewDefinition, ViewNodeSpec, - ViewOutputBinding, ViewParamBinding, ViewRelationBinding, ViewRelationOutputSpec, - ViewScopeInject, ViewScopeParam, WireVariantDiscriminator, CGS, DEFAULT_HTTP_BACKEND, + AttachmentMediaKind, AuthScheme, CapabilityEffect, CapabilityKind, CapabilityManifest, + CapabilityMapping, CapabilitySchema, CapabilityTemplateJson, Cardinality, CgsCapabilityIndex, + CrossFieldRule, CrossFieldRuleType, DataClassDimension, DataClassName, DataClassSchema, + DataClassSeverity, DiscoveryCapabilityHints, DiscoveryEntityHints, DiscoveryRelationHints, + DiscoverySeedClass, DiscoverySeedNav, EmbedOnMissPolicy, EntityDef, FieldDeriveRule, + FieldSchema, FieldValueKind, IdFormat, InputFieldSchema, InputFieldWire, InputSchema, + InputType, InputValidation, InputVariantSchema, JsonPathSegment, NamedValueSchema, + OauthDefaultScopeSet, OauthExtension, OauthRequirements, OauthScopeEntry, OutputSchema, + OutputType, ParameterRole, RelationMaterialization, RelationSchema, RelationScopedFallback, + ResourceSchema, ScopeAggregateKeyPolicy, ScopeRequirement, SemanticEffect, SinkClassName, + StringSemantics, ValidationOp, ValidationPredicate, ValueDomainKey, ValueDomainSlot, + ViewDefinition, ViewNodeSpec, ViewOutputBinding, ViewParamBinding, ViewRelationBinding, + ViewRelationOutputSpec, ViewScopeInject, ViewScopeParam, WireVariantDiscriminator, CGS, + DEFAULT_HTTP_BACKEND, }; pub use schema_overlay::{ build_decode_scope_key, build_schema_overlay, overlay_bind_cache_suffix, overlay_collect_rows, diff --git a/crates/plasm-core/src/loader.rs b/crates/plasm-core/src/loader.rs index 3becb939..e6118bfe 100644 --- a/crates/plasm-core/src/loader.rs +++ b/crates/plasm-core/src/loader.rs @@ -280,6 +280,9 @@ pub struct DomainCapability { #[serde(default)] pub description: String, pub kind: String, + /// Trusted read-only attestation; currently valid only for `kind: action`. + #[serde(default)] + pub effect: Option, pub entity: String, /// Policy for compound `entity_ref` scope parameters after runtime splat (`retain` default). #[serde(default)] @@ -919,6 +922,7 @@ fn assemble_cgs_core( name: CapabilityName::from(cap_name.clone()), description: cap.description.clone(), kind, + effect: cap.effect, domain: EntityName::from(cap.entity.clone()), mapping: CapabilityMapping { template: CapabilityTemplateJson(template), @@ -1818,6 +1822,114 @@ capabilities: ); } + #[test] + fn validates_read_effect_trust_boundary() { + fn load_with_capability(capability: &str) -> Result { + let dir = tempfile::tempdir().expect("temp catalog"); + let domain = format!( + r#"http_backend: http://localhost:1080 +workflow_identity: true +data_classes: + external_publish: + description: Outbound mutation payload. + severity: sensitive +values: + nv_id: + type: string + string_semantics: short + nv_payload: + type: string + string_semantics: json_text +entities: + E: + id_field: id + fields: + id: + value_ref: nv_id + required: true +capabilities: +{capability} +"# + ); + std::fs::write(dir.path().join("domain.yaml"), domain).expect("write domain"); + std::fs::write( + dir.path().join("mappings.yaml"), + r#"read_op: + method: POST + path: + - type: literal + value: e + - type: var + name: id + - type: literal + value: read +"#, + ) + .expect("write mappings"); + load_schema_dir(dir.path()) + } + + let valid = r#" read_op: + kind: action + effect: read + entity: E + provides: [id]"#; + let mut cgs = load_with_capability(valid).expect("read action must load"); + assert!(cgs.get_capability("read_op").expect("capability").is_read()); + + // Projection hydration must never turn a default action's modeled response into an + // unreviewed transport call. Only the trusted read-action is indexed as a provider. + let mut default_action = cgs.get_capability("read_op").expect("capability").clone(); + default_action.name = crate::CapabilityName::from("write_op"); + default_action.effect = None; + cgs.capabilities + .insert(default_action.name.clone(), default_action); + let providers = cgs.field_providers("E"); + assert_eq!(providers.get("id"), Some(&vec!["read_op".to_string()])); + + let wrong_kind = valid.replace("kind: action", "kind: create"); + let err = load_with_capability(&wrong_kind).expect_err("read create must fail"); + assert!( + err.contains("effect: read") && err.contains("kind: action"), + "{err}" + ); + + let contradictory = r#" read_op: + kind: action + effect: read + entity: E + output: + type: side_effect + description: changes state"#; + let err = load_with_capability(contradictory).expect_err("read side effect must fail"); + assert!( + err.contains("effect: read") && err.contains("output.type: side_effect"), + "{err}" + ); + + let sink_read = r#" read_op: + kind: action + effect: read + entity: E + parameters: + - name: payload + value_ref: nv_payload + sink_class: external_publish + provides: [id]"#; + let err = load_with_capability(sink_read).expect_err("read sink must fail"); + assert!( + err.contains("effect: read") && err.contains("sink_class"), + "{err}" + ); + + let missing_response = r#" read_op: + kind: action + effect: read + entity: E"#; + let err = load_with_capability(missing_response).expect_err("response model required"); + assert!(err.contains("no modeled response"), "{err}"); + } + #[test] fn rejects_side_effect_with_empty_description() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/plasm-core/src/plasm_monad/step.rs b/crates/plasm-core/src/plasm_monad/step.rs index ce4f7632..cfd8cc3f 100644 --- a/crates/plasm-core/src/plasm_monad/step.rs +++ b/crates/plasm-core/src/plasm_monad/step.rs @@ -10,6 +10,16 @@ pub enum EffectClass { ArtifactRead, } +impl From for EffectClass { + fn from(effect: crate::SemanticEffect) -> Self { + match effect { + crate::SemanticEffect::Read => Self::Read, + crate::SemanticEffect::Write => Self::Write, + crate::SemanticEffect::SideEffect => Self::SideEffect, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ResultShape { diff --git a/crates/plasm-core/src/prompt_render/capability_delta.rs b/crates/plasm-core/src/prompt_render/capability_delta.rs index 3688dbb8..92ea84dc 100644 --- a/crates/plasm-core/src/prompt_render/capability_delta.rs +++ b/crates/plasm-core/src/prompt_render/capability_delta.rs @@ -10,7 +10,7 @@ use crate::symbol_tuning::{ registry_backed_compact_wire_label, CapabilityParamSurfaceFilter, ExposureCapabilityKey, ExposureEntityKey, SymbolMap, TeachingExposureSession, }; -use crate::{CapabilityKind, CGS}; +use crate::CGS; use super::{ parse_trailing_projection_bracket, render_prompt_tsv_from_bundle, @@ -390,10 +390,7 @@ pub(crate) fn render_mutator_recap_lines_for_caps( let Some(cap) = cgs.capabilities.get(cap_key.capability.as_str()) else { continue; }; - if matches!( - cap.kind, - CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get - ) { + if cap.is_read() { continue; } let entity = cap_key.domain.as_str(); diff --git a/crates/plasm-core/src/prompt_render/input_legend.rs b/crates/plasm-core/src/prompt_render/input_legend.rs index 8d3b95d2..6732c723 100644 --- a/crates/plasm-core/src/prompt_render/input_legend.rs +++ b/crates/plasm-core/src/prompt_render/input_legend.rs @@ -30,12 +30,22 @@ impl ReturnArrow { /// Classify the return shape from the domain-line kind and result gloss. /// - /// Writes (`Method`) are terminal regardless of gloss (`e#` provides slice or `()`). Query / - /// search are lists. Everything else falls back to gloss shape (`[…]` list vs single). + /// Method rows default to terminal writes. [`Self::classify_with_effect`] exempts trusted + /// read-actions and derives their chainable shape from the result gloss. Query/search are lists. pub fn classify(kind: crate::prompt_render::DomainLineKind, gloss: &str) -> Self { + Self::classify_with_effect(kind, gloss, None) + } + + /// Classify a row with the capability's derived effect when the row is method-shaped. + /// Read-actions remain invoke methods for dispatch but have chainable read result glyphs. + pub fn classify_with_effect( + kind: crate::prompt_render::DomainLineKind, + gloss: &str, + effect: Option, + ) -> Self { use crate::prompt_render::DomainLineKind as K; match kind { - K::Method => ReturnArrow::Terminal, + K::Method if effect != Some(crate::SemanticEffect::Read) => ReturnArrow::Terminal, K::Query | K::Search => ReturnArrow::List, _ if gloss.trim_start().starts_with('[') => ReturnArrow::List, _ => ReturnArrow::Single, diff --git a/crates/plasm-core/src/prompt_render/mcp_prompt_fragments.rs b/crates/plasm-core/src/prompt_render/mcp_prompt_fragments.rs index 5ca3539d..e375f317 100644 --- a/crates/plasm-core/src/prompt_render/mcp_prompt_fragments.rs +++ b/crates/plasm-core/src/prompt_render/mcp_prompt_fragments.rs @@ -92,7 +92,6 @@ fn levenshtein(a: &str, b: &str) -> usize { /// Mutator wire names on seeded entities (full catalog, not only teaching surface). fn mutator_wires_on_seeded_entities(exp: &TeachingExposureSession) -> Vec { - use crate::schema::CapabilityKind; let mut wires = Vec::new(); for (entity, entry_id) in exp.entities.iter().zip(exp.entity_catalog_entry_ids.iter()) { let Some(cgs) = exp.catalog_cgs_for_entry(entry_id.as_str()) else { @@ -102,13 +101,7 @@ fn mutator_wires_on_seeded_entities(exp: &TeachingExposureSession) -> Vec CGS { name: name.into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: domain.into(), identity_key: None, mapping: CapabilityMapping { @@ -2740,6 +2741,7 @@ fn p_slot_redefinition_fixture_cgs(id_desc_a: &str, id_desc_b: &str) -> CGS { name: cap_name.into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: name.into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/result_gloss.rs b/crates/plasm-core/src/result_gloss.rs index 2a947956..4501daa5 100644 --- a/crates/plasm-core/src/result_gloss.rs +++ b/crates/plasm-core/src/result_gloss.rs @@ -1,7 +1,7 @@ //! CGS-derived **evaluates-to** hints for teaching table `;;` comments (`=> [e#]` / `e#` / `=> ()`), not mixed into the expression. //! Relation navigation lines use the same shape: `expr ;; => e#` or `=> [e#]` (see [`result_gloss_for_relation_nav`]). -use crate::schema::{CapabilityKind, CapabilitySchema, CGS}; +use crate::schema::{CapabilityKind, CapabilitySchema, OutputType, CGS}; /// Canonical or symbolic entity name for gloss text (string is the serialization boundary). pub fn entity_sym_for_gloss(map: Option<&crate::symbol_tuning::SymbolMap>, entity: &str) -> String { @@ -26,6 +26,18 @@ pub fn result_gloss_for_capability( } } + if cap.is_read() { + if matches!( + cap.output_schema.as_ref().map(|output| &output.output_type), + Some(OutputType::Collection { .. }) + ) { + return Some(collection_gloss(cap.domain.as_str(), map)); + } + if cap.kind == CapabilityKind::Action { + return Some(single_gloss(cap.domain.as_str(), map)); + } + } + match cap.kind { CapabilityKind::Query | CapabilityKind::Search => { Some(collection_gloss(cap.domain.as_str(), map)) diff --git a/crates/plasm-core/src/schema.rs b/crates/plasm-core/src/schema.rs index 21d33027..aea6aef8 100644 --- a/crates/plasm-core/src/schema.rs +++ b/crates/plasm-core/src/schema.rs @@ -813,6 +813,9 @@ pub struct CapabilitySchema { #[serde(default)] pub description: String, pub kind: CapabilityKind, + /// Trusted catalog attestation that an RPC-shaped action is read-only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effect: Option, pub domain: EntityName, // Entity this capability operates on pub mapping: CapabilityMapping, /// Input schema for invoke capabilities (optional for query/get) @@ -896,6 +899,25 @@ impl std::fmt::Display for CapabilityKind { } } +/// Catalog-authorable effect attestation. +/// +/// Only read-only actions may declare this today. Mutating actions retain their safe default +/// without an authorable `write` spelling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityEffect { + Read, +} + +/// Derived semantic effect used by core classifiers and converted exhaustively by plan layers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SemanticEffect { + Read, + Write, + SideEffect, +} + /// Semantic role of a capability parameter. /// /// All roles produce the same HTTP transport (a query param or path segment), @@ -3021,9 +3043,8 @@ impl CGS { if !self.workflow_identity { return Ok(()); } - use CapabilityKind::{Action, Create, Delete, Update}; for (cap_name, cap) in &self.capabilities { - let is_mutator = matches!(cap.kind, Create | Update | Delete | Action); + let is_mutator = cap.is_remote_mutation(); if !is_mutator { continue; } @@ -3399,6 +3420,33 @@ impl CGS { } } + // Validate effect attestations before any classifier consumes them. `effect: read` is a + // deliberately narrow trust boundary: only actions may carry it, and a side-effect output + // would directly contradict it. + for (cap_name, cap) in &self.capabilities { + if cap.effect == Some(CapabilityEffect::Read) { + if cap.kind != CapabilityKind::Action { + return Err(SchemaError::ReadEffectRequiresAction { + capability: cap_name.to_string(), + kind: cap.kind.to_string(), + }); + } + if cap.output_schema.as_ref().is_some_and(|output| { + matches!(&output.output_type, OutputType::SideEffect { .. }) + }) { + return Err(SchemaError::ReadEffectWithSideEffectOutput { + capability: cap_name.to_string(), + }); + } + if let Some(param) = self.capability_sink_params(cap).first() { + return Err(SchemaError::ReadEffectWithSinkParam { + capability: cap_name.to_string(), + param: param.name.clone(), + }); + } + } + } + for (cap_name, cap) in &self.capabilities { if cap.kind != CapabilityKind::Action { continue; @@ -5370,24 +5418,25 @@ impl CGS { /// Used by the runtime's auto-resolution path: when a projection requests a field that /// is absent from the cache, the engine looks up which capability to invoke. /// - /// Result: `field_name → Vec` in priority order: - /// `Get` first (most specific), then `Action`, then `Query`/`Search` (least specific). + /// Result: `field_name → Vec` in priority order. Automatic hydration is a + /// read path: default actions and all write capabilities are excluded even when they declare + /// `provides`. pub fn field_providers(&self, entity: &str) -> IndexMap> { let mut index: IndexMap> = IndexMap::new(); - // Priority ordering: Get > Action > Query/Search (so the most specific provider - // is tried first when multiple capabilities cover the same field). + // Keep Gets first, then trusted read-actions, then list reads. let priority_order = [ CapabilityKind::Get, CapabilityKind::Action, - CapabilityKind::Create, - CapabilityKind::Update, CapabilityKind::Query, CapabilityKind::Search, ]; for kind in priority_order { for cap in self.find_capabilities(entity, kind) { + if !cap.is_read() { + continue; + } let provided = self.effective_provides(cap); if provided.is_empty() { continue; @@ -5403,6 +5452,44 @@ impl CGS { } impl CapabilitySchema { + /// Derive the capability's semantic effect using the catalog precedence rules. + /// + /// Loader validation rejects incoherent declarations before this is consumed. Keeping this + /// method total also makes hand-built test schemas fail closed: only an action carrying the + /// trusted `read` attestation is treated as a read action. + pub fn effective_effect(&self) -> SemanticEffect { + if self + .output_schema + .as_ref() + .is_some_and(|output| matches!(&output.output_type, OutputType::SideEffect { .. })) + { + return SemanticEffect::SideEffect; + } + match (self.kind, self.effect) { + (CapabilityKind::Action, Some(CapabilityEffect::Read)) => SemanticEffect::Read, + (CapabilityKind::Query | CapabilityKind::Search | CapabilityKind::Get, _) => { + SemanticEffect::Read + } + (CapabilityKind::Create | CapabilityKind::Update | CapabilityKind::Delete, _) => { + SemanticEffect::Write + } + (CapabilityKind::Action, _) => SemanticEffect::SideEffect, + } + } + + #[inline] + pub fn is_read(&self) -> bool { + self.effective_effect() == SemanticEffect::Read + } + + #[inline] + pub fn is_remote_mutation(&self) -> bool { + matches!( + self.effective_effect(), + SemanticEffect::Write | SemanticEffect::SideEffect + ) + } + /// Whether this capability is a deterministic transform (default true when unset). pub fn is_deterministic(&self) -> bool { self.deterministic.unwrap_or(true) @@ -5458,6 +5545,7 @@ impl CapabilitySchema { name: CapabilityName::from("test_cap"), description: String::new(), kind: CapabilityKind::Action, + effect: None, domain: EntityName::from("TestEntity"), mapping: CapabilityMapping { template: CapabilityTemplateJson(serde_json::json!({ "method": "POST" })), @@ -5539,6 +5627,72 @@ pub mod registry_test_util { } } +#[cfg(test)] +mod capability_effect_tests { + use super::*; + + fn capability(kind: CapabilityKind) -> CapabilitySchema { + CapabilitySchema { + kind, + ..CapabilitySchema::minimal_test() + } + } + + #[test] + fn capability_effect_precedence_and_predicates_fail_closed() { + for kind in [ + CapabilityKind::Query, + CapabilityKind::Search, + CapabilityKind::Get, + ] { + let cap = capability(kind); + assert_eq!(cap.effective_effect(), SemanticEffect::Read); + assert!(cap.is_read()); + assert!(!cap.is_remote_mutation()); + } + for kind in [ + CapabilityKind::Create, + CapabilityKind::Update, + CapabilityKind::Delete, + ] { + let cap = capability(kind); + assert_eq!(cap.effective_effect(), SemanticEffect::Write); + assert!(!cap.is_read()); + assert!(cap.is_remote_mutation()); + } + + let default_action = capability(CapabilityKind::Action); + assert_eq!( + default_action.effective_effect(), + SemanticEffect::SideEffect, + "an unattested action must retain the safe historical default" + ); + + let mut read_action = capability(CapabilityKind::Action); + read_action.effect = Some(CapabilityEffect::Read); + read_action.provides = vec!["id".into()]; + assert_eq!(read_action.effective_effect(), SemanticEffect::Read); + assert!(read_action.is_read()); + assert!(!read_action.is_remote_mutation()); + let yaml = serde_yaml::to_string(&read_action).expect("serialize read action"); + assert!(yaml.contains("effect: read"), "{yaml}"); + + read_action.output_schema = Some(OutputSchema { + output_type: OutputType::SideEffect { + description: "changes state".into(), + }, + decoder: serde_json::json!({}), + idempotent: false, + reconcile: None, + }); + assert_eq!( + read_action.effective_effect(), + SemanticEffect::SideEffect, + "side-effect output takes precedence even for a hand-built invalid schema" + ); + } +} + #[cfg(test)] mod capability_index_tests { use super::*; diff --git a/crates/plasm-core/src/scope_entity_ref_infer.rs b/crates/plasm-core/src/scope_entity_ref_infer.rs index cf959a80..f0d036f7 100644 --- a/crates/plasm-core/src/scope_entity_ref_infer.rs +++ b/crates/plasm-core/src/scope_entity_ref_infer.rs @@ -304,6 +304,7 @@ mod tests { name: CapabilityName::from("repo_branch_create"), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from("Repository"), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/scope_entity_ref_splat.rs b/crates/plasm-core/src/scope_entity_ref_splat.rs index 17211d9b..04c7f25d 100644 --- a/crates/plasm-core/src/scope_entity_ref_splat.rs +++ b/crates/plasm-core/src/scope_entity_ref_splat.rs @@ -238,6 +238,7 @@ mod tests { name: CapabilityName::from("repo_forks_query"), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from("Repository"), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/symbol_tuning/mod.rs b/crates/plasm-core/src/symbol_tuning/mod.rs index ebd36bf6..19cf40a0 100644 --- a/crates/plasm-core/src/symbol_tuning/mod.rs +++ b/crates/plasm-core/src/symbol_tuning/mod.rs @@ -4639,6 +4639,7 @@ mod tests { name: "widget_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Widget".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-core/src/symbol_tuning/session_bindings.rs b/crates/plasm-core/src/symbol_tuning/session_bindings.rs index 67998444..7edba1bd 100644 --- a/crates/plasm-core/src/symbol_tuning/session_bindings.rs +++ b/crates/plasm-core/src/symbol_tuning/session_bindings.rs @@ -2,7 +2,7 @@ //! (`expose_entities`, method waves, [`assign_new_slot_symbols`]) — not recomputed at snapshot time. use crate::identity::{CapabilityName, EntityName, RegistryEntryId, RelationName}; -use crate::CapabilityKind; +use crate::{CapabilityKind, SemanticEffect}; use super::keys::{OpaqueESym, OpaqueMSym, OpaqueRSym}; use super::{IdentMetadata, TeachingExposureSession}; @@ -31,6 +31,13 @@ pub struct MethodBinding { pub domain: EntityName, pub capability: CapabilityName, pub kind: CapabilityKind, + /// Derived semantic effect; method/RPC shape does not imply mutation. + #[serde(default = "default_method_effect")] + pub effect: SemanticEffect, +} + +fn default_method_effect() -> SemanticEffect { + SemanticEffect::SideEffect } impl MethodBinding { @@ -99,12 +106,14 @@ impl TeachingExposureSession { domain: EntityName, capability: CapabilityName, kind: CapabilityKind, + effect: SemanticEffect, ) { let binding = MethodBinding { entry_id: entry_id.clone(), domain: domain.clone(), capability: capability.clone(), kind, + effect, }; self.tables.sym_to_method.insert(sym, binding); } diff --git a/crates/plasm-core/src/symbol_tuning/symbol_allocate.rs b/crates/plasm-core/src/symbol_tuning/symbol_allocate.rs index 99edca23..5f41375f 100644 --- a/crates/plasm-core/src/symbol_tuning/symbol_allocate.rs +++ b/crates/plasm-core/src/symbol_tuning/symbol_allocate.rs @@ -77,18 +77,23 @@ impl TeachingExposureSession { ), }; self.tables.method_segment_to_sym.insert(segment, sym); - let kind = self + let capability = self .catalog_cgs .get(key.entry_id.as_str()) - .and_then(|cgs| cgs.capabilities.get(key.capability.as_str())) + .and_then(|cgs| cgs.capabilities.get(key.capability.as_str())); + let kind = capability .map(|cap| cap.kind) .unwrap_or(CapabilityKind::Action); + let effect = capability + .map(|cap| cap.effective_effect()) + .unwrap_or(crate::SemanticEffect::SideEffect); self.record_method_binding( sym, key.entry_id.clone(), key.domain.clone(), key.capability.clone(), kind, + effect, ); } diff --git a/crates/plasm-core/src/type_checker.rs b/crates/plasm-core/src/type_checker.rs index d98b95bc..0dfd2d0c 100644 --- a/crates/plasm-core/src/type_checker.rs +++ b/crates/plasm-core/src/type_checker.rs @@ -1114,6 +1114,7 @@ mod tests { name: "pet_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Pet".into(), identity_key: None, mapping: CapabilityMapping { @@ -1139,6 +1140,7 @@ mod tests { name: "order_get".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Order".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-discovery/src/engine.rs b/crates/plasm-discovery/src/engine.rs index 432799ae..36c19985 100644 --- a/crates/plasm-discovery/src/engine.rs +++ b/crates/plasm-discovery/src/engine.rs @@ -750,6 +750,7 @@ mod relation_intent_rank_tests { name: CapabilityName::from("query_parent"), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from("Parent"), identity_key: None, mapping: tmpl.clone(), @@ -768,6 +769,7 @@ mod relation_intent_rank_tests { name: CapabilityName::from("query_child"), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: EntityName::from("Child"), identity_key: None, mapping: tmpl, diff --git a/crates/plasm-runtime/src/execution/mod.rs b/crates/plasm-runtime/src/execution/mod.rs index e1b6525d..fd113102 100644 --- a/crates/plasm-runtime/src/execution/mod.rs +++ b/crates/plasm-runtime/src/execution/mod.rs @@ -3281,6 +3281,7 @@ mod tests { name: "query_accounts".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "Account".into(), identity_key: None, mapping: CapabilityMapping { @@ -3314,6 +3315,7 @@ mod tests { name: "get_account".into(), description: String::new(), kind: CapabilityKind::Get, + effect: None, domain: "Account".into(), identity_key: None, mapping: CapabilityMapping { @@ -3401,6 +3403,7 @@ mod tests { name: "managed_resource_query".into(), description: String::new(), kind: CapabilityKind::Query, + effect: None, domain: "ManagedResource".into(), identity_key: None, mapping: CapabilityMapping { diff --git a/crates/plasm-runtime/src/execution/projection.rs b/crates/plasm-runtime/src/execution/projection.rs index 1ac3e4d0..70b161c1 100644 --- a/crates/plasm-runtime/src/execution/projection.rs +++ b/crates/plasm-runtime/src/execution/projection.rs @@ -92,6 +92,16 @@ impl ExecutionEngine { let Some(cap) = cgs.get_capability(&cap_name) else { continue; }; + // Defense in depth: projection hydration is outside the agent plan gate and + // therefore may invoke only catalog-classified reads. + if !cap.is_read() { + tracing::error!( + target: "plasm_runtime::projection", + capability = cap_name.as_str(), + "refusing non-read projection provider" + ); + continue; + } // Deduplicate IDs let mut unique_ids = ids; diff --git a/crates/plasm-runtime/src/workflow_reconcile.rs b/crates/plasm-runtime/src/workflow_reconcile.rs index 86513c0a..6f77a436 100644 --- a/crates/plasm-runtime/src/workflow_reconcile.rs +++ b/crates/plasm-runtime/src/workflow_reconcile.rs @@ -357,6 +357,7 @@ mod tests { name: CapabilityName::from("workitem_create_idempotent"), description: String::new(), kind: CapabilityKind::Action, + effect: None, domain: EntityName::from("WorkItem"), identity_key: Some(vec!["title".into()]), mapping: CapabilityMapping { diff --git a/doc-site/docs/authoring/index.md b/doc-site/docs/authoring/index.md index da4efc67..8c59e208 100644 --- a/doc-site/docs/authoring/index.md +++ b/doc-site/docs/authoring/index.md @@ -245,7 +245,7 @@ When the API has **workspace-defined columns** on generic rows (Fibery databases Authoring details, spec table, checklists, and reference catalogs: [reference.md — Runtime schema overlay](reference.md#runtime-schema-overlay-schema_overlay). Runtime behavior: monorepo [docs/schema-overlay.md](../reference/schema-overlay.md). -**`kind: action` output:** Every action must declare either non-empty **`provides:`** or **`output:`** with **`type: side_effect`** and a non-empty `description:` that states **what** the operation changes. There is no `output.type: none`. See [reference.md — Action output](reference.md#action-output-provides-vs-outputside_effect). +**`kind: action` effect and output:** Actions are treated as remote side effects unless a reviewed catalog explicitly declares **`effect: read`**. Use that declaration only for RPC-shaped reads; it is an author attestation, not an inference from HTTP method or idempotence. Read-actions cannot use `output.type: side_effect` or mutation sink parameters. Every action must still declare either non-empty **`provides:`** or **`output:`**. There is no `output.type: none`. See [reference.md — Action output](reference.md#action-output-provides-vs-outputside_effect). ### Information-flow annotations (Guardians / plan flow typing) diff --git a/doc-site/docs/authoring/reference.md b/doc-site/docs/authoring/reference.md index f5a8e6e8..f0e9949a 100644 --- a/doc-site/docs/authoring/reference.md +++ b/doc-site/docs/authoring/reference.md @@ -666,10 +666,13 @@ Ordered steps on **`create`**, **`update`**, **`action`**, and **`delete`** capa ### Action output: `provides:` vs `output.side_effect` -`kind: action` must declare **how the response is modeled**: +`kind: action` is RPC-shaped and is a remote side effect by default. A reviewed catalog may declare `effect: read` when the operation is semantically read-only despite using action/invoke grammar. This declaration is authoritative author attestation: do not infer it from POST, idempotence, response shape, or operation naming. `effect: read` is allowed only on actions and is rejected with `output.type: side_effect` or mutation sink parameters. -1. **Entity projection** — non-empty `provides:` lists which entity fields the HTTP response populates. -2. **No projection** — the call is effectful (something changes) but the response is empty, opaque, or not mapped onto entity fields. Declare `output` with `type: side_effect` and a non-empty `description:` string that states what changes in the domain (not generic "updates resource", not HTTP status or path trivia). +Every action must declare **how the response is modeled**: + +1. **Entity projection** — non-empty `provides:` lists which entity fields the HTTP response populates. This is also valid for a read-action and yields a single structured result. +2. **Typed output** — `output.type: entity | collection | status | custom` models a read-action response explicitly. +3. **No projection** — an effectful call whose response is empty, opaque, or not mapped onto entity fields uses `output.type: side_effect` with a non-empty `description:` stating what changes in the domain. There is **no** `output.type: none` in the schema: it invited silent, incomplete modeling. diff --git a/doc-site/docs/reference/apis-readme.md b/doc-site/docs/reference/apis-readme.md index 03b83b72..eea1664f 100644 --- a/doc-site/docs/reference/apis-readme.md +++ b/doc-site/docs/reference/apis-readme.md @@ -2,7 +2,7 @@ **Monorepo layout:** in the private `plasm` repo, `apis/` at the repository root is a **symlink** to this directory (`plasm-oss/apis`). Commits to API definitions belong in the **plasm-oss** / plasm-core submodule, not a duplicate `apis/` tree in the monorepo. -This directory holds **split** Plasm CGS trees: each API is a folder with `domain.yaml` + `mappings.yaml` (and a **README** describing scope, auth, and how to run `**plasm-repl`** / `**plasm-cgs`** / `**plasm-mcp`**). Wire types and shared gloss live under top-level **`values:`**; entity **fields** and capability **parameters** use **`value_ref`** into those **semantic slots** (sharing vs splitting keys is an authoring choice—see **[Value domains](../authoring/reference.md#value-domains-values-and-value_ref)** in the authoring reference). Optional **`views:`** in **`domain.yaml`** models **composed read-only** rows over existing **`query`/`get`** capabilities; matching **`mappings.yaml`** entries use **`transport: view`** (see **[Composed read views](../authoring/reference.md#composed-read-views)**). Optional **`schema_overlay:`** merges **workspace-specific typed entities or columns** at execute session open for APIs with user-defined schema (Fibery, Notion, Jira, …) — see **[Runtime schema overlay](../authoring/reference.md#runtime-schema-overlay-schema_overlay)**. `**domain.yaml` validation:** `kind: action` requires non-empty `**provides:`** and/or `**output:`** with `**type: side_effect`** and a non-empty `**description:`** (effectful ops with no entity projection must say what they change). Authoring details: [skills/plasm-authoring/reference.md](../authoring/reference.md#action-output-provides-vs-outputside_effect). +This directory holds **split** Plasm CGS trees: each API is a folder with `domain.yaml` + `mappings.yaml` (and a **README** describing scope, auth, and how to run `**plasm-repl`** / `**plasm-cgs`** / `**plasm-mcp`**). Wire types and shared gloss live under top-level **`values:`**; entity **fields** and capability **parameters** use **`value_ref`** into those **semantic slots** (sharing vs splitting keys is an authoring choice—see **[Value domains](../authoring/reference.md#value-domains-values-and-value_ref)** in the authoring reference). Optional **`views:`** in **`domain.yaml`** models **composed read-only** rows over existing **`query`/`get`** capabilities; matching **`mappings.yaml`** entries use **`transport: view`** (see **[Composed read views](../authoring/reference.md#composed-read-views)**). Optional **`schema_overlay:`** merges **workspace-specific typed entities or columns** at execute session open for APIs with user-defined schema (Fibery, Notion, Jira, …) — see **[Runtime schema overlay](../authoring/reference.md#runtime-schema-overlay-schema_overlay)**. `**domain.yaml` validation:** `kind: action` is effectful by default and requires non-empty `**provides:`** and/or `**output:`**. A reviewed RPC-shaped read may declare `**effect: read`**; it cannot use `**output.type: side_effect`** or mutation sink parameters. Effectful ops with no entity projection use `**output.type: side_effect`** with a non-empty description of what changes. Authoring details: [skills/plasm-authoring/reference.md](../authoring/reference.md#action-output-provides-vs-outputside_effect). **Fixtures:** `fixtures/schemas/` holds **test** CGS trees and tiny interchange files (`test_schema.cgs.yaml`, `capability_with_input/`, plus small slices such as **[PokéAPI mini](https://github.com/PlasmTools/plasm-core/tree/main/fixtures/schemas/pokeapi_mini/)** for Hermit e2e, integration tests, and eval). **Curated** REST (and EVM) product APIs live only under `apis/`. diff --git a/doc-site/docs/reference/incremental-teaching-prompts.md b/doc-site/docs/reference/incremental-teaching-prompts.md index fc36a750..c1cb0a8e 100644 --- a/doc-site/docs/reference/incremental-teaching-prompts.md +++ b/doc-site/docs/reference/incremental-teaching-prompts.md @@ -84,7 +84,7 @@ Session identity (`prompt_hash`, `session` id) stays stable across waves; the ha MCP initialize teaches the plan/run split alongside entity/query grammar: author programs only for `plasm`, then pass the returned **`run_ref`** to `plasm_run`. Tool-model `execute` notes mirror the same MCP await-by-default discipline ([tool-model-http.md](tool-model-http.md)). -**Intent-scoped exposure** (when `plasm_context` sets `context_intent`): capabilities on **non-seeded** entities still require lexicon overlap with `intent`. Each **seeded** `{ api, entity }` always teaches that entity’s **query / search / get** surface (and `primary_read` when declared). Production waves use [`MutatorAdmit::IntentOnly`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-core/src/discovery.rs): seeded **create / update / delete / action** require intent lexicon overlap (or appear in `ranked_capabilities` when the ranked gate is enabled). [`MutatorAdmit::AlwaysOnSeeds`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-core/src/discovery.rs) is test/benchmark overshow only. HTTP `CreateExecuteSessionBody.mutator_admit` defaults to **IntentOnly**; live open/federate/expand and exposure replay always pass **IntentOnly**. Updates / deletes / actions on non-seeded entities remain intent-filtered. See [`derive_intent_exposure_surface_batch`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-core/src/discovery.rs). +**Intent-scoped exposure** (when `plasm_context` sets `context_intent`): capabilities on **non-seeded** entities still require lexicon overlap with `intent`. Each **seeded** `{ api, entity }` always teaches that entity’s effective read surface—**query / search / get** plus actions explicitly attested with **`effect: read`**—and `primary_read` when declared. Production waves use [`MutatorAdmit::IntentOnly`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-core/src/discovery.rs): seeded effective mutations require intent lexicon overlap (or appear in `ranked_capabilities` when the ranked gate is enabled). [`MutatorAdmit::AlwaysOnSeeds`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-core/src/discovery.rs) is test/benchmark overshow only. HTTP `CreateExecuteSessionBody.mutator_admit` defaults to **IntentOnly**; live open/federate/expand and exposure replay always pass **IntentOnly**. Effective mutations on non-seeded entities remain intent-filtered. See [`derive_intent_exposure_surface_batch`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-core/src/discovery.rs). Cardinality: **many** logical sessions per MCP **transport** (`MCP-Session-Id`); **one** active Plasm execute binding per **logical session** (see [`mcp_server.rs`](https://github.com/PlasmTools/plasm-core/blob/main/crates/plasm-agent-core/src/mcp_server.rs) module docs). diff --git a/skills/plasm-authoring/SKILL.md b/skills/plasm-authoring/SKILL.md index c2c6c263..2d08ea8d 100644 --- a/skills/plasm-authoring/SKILL.md +++ b/skills/plasm-authoring/SKILL.md @@ -245,7 +245,7 @@ When the API has **workspace-defined columns** on generic rows (Fibery databases Authoring details, spec table, checklists, and reference catalogs: [reference.md — Runtime schema overlay](reference.md#runtime-schema-overlay-schema_overlay). Runtime behavior: monorepo [docs/schema-overlay.md](../../../docs/schema-overlay.md). -**`kind: action` output:** Every action must declare either non-empty **`provides:`** or **`output:`** with **`type: side_effect`** and a non-empty `description:` that states **what** the operation changes. There is no `output.type: none`. See [reference.md — Action output](reference.md#action-output-provides-vs-outputside_effect). +**`kind: action` effect and output:** Actions are treated as remote side effects unless a reviewed catalog explicitly declares **`effect: read`**. Use that declaration only for RPC-shaped reads; it is an author attestation, not an inference from HTTP method or idempotence. Read-actions cannot use `output.type: side_effect` or mutation sink parameters. Every action must still declare either non-empty **`provides:`** or **`output:`**. There is no `output.type: none`. See [reference.md — Action output](reference.md#action-output-provides-vs-outputside_effect). ### Information-flow annotations (Guardians / plan flow typing) diff --git a/skills/plasm-authoring/reference.md b/skills/plasm-authoring/reference.md index 9813a412..a86d3047 100644 --- a/skills/plasm-authoring/reference.md +++ b/skills/plasm-authoring/reference.md @@ -666,10 +666,13 @@ Ordered steps on **`create`**, **`update`**, **`action`**, and **`delete`** capa ### Action output: `provides:` vs `output.side_effect` -`kind: action` must declare **how the response is modeled**: +`kind: action` is RPC-shaped and is a remote side effect by default. A reviewed catalog may declare `effect: read` when the operation is semantically read-only despite using action/invoke grammar. This declaration is authoritative author attestation: do not infer it from POST, idempotence, response shape, or operation naming. `effect: read` is allowed only on actions and is rejected with `output.type: side_effect` or mutation sink parameters. -1. **Entity projection** — non-empty `provides:` lists which entity fields the HTTP response populates. -2. **No projection** — the call is effectful (something changes) but the response is empty, opaque, or not mapped onto entity fields. Declare `output` with `type: side_effect` and a non-empty `description:` string that states what changes in the domain (not generic "updates resource", not HTTP status or path trivia). +Every action must declare **how the response is modeled**: + +1. **Entity projection** — non-empty `provides:` lists which entity fields the HTTP response populates. This is also valid for a read-action and yields a single structured result. +2. **Typed output** — `output.type: entity | collection | status | custom` models a read-action response explicitly. +3. **No projection** — an effectful call whose response is empty, opaque, or not mapped onto entity fields uses `output.type: side_effect` with a non-empty `description:` stating what changes in the domain. There is **no** `output.type: none` in the schema: it invited silent, incomplete modeling.