diff --git a/crates/openspine-kernel/src/action_catalog.rs b/crates/openspine-kernel/src/action_catalog.rs index 082aeff4..c9a3aa6e 100644 --- a/crates/openspine-kernel/src/action_catalog.rs +++ b/crates/openspine-kernel/src/action_catalog.rs @@ -13,6 +13,8 @@ use openspine_schemas::selection::SelectionTokenType; mod action_catalog_contracts; #[path = "action_catalog_data.rs"] mod action_catalog_data; +#[path = "action_catalog_tool_descriptors.rs"] +mod action_catalog_tool_descriptors; /// The canonical declaration of what a standing rule for an action must bind, /// re-exported so activation-time scope-binding enforcement reads the same /// descriptor table the catalog itself is assembled from. @@ -141,6 +143,7 @@ pub fn canonical_catalog() -> ActionCatalog { SelectionTokenType::email_thread_selection(), )]) .with_egress_declarations(decls) + .with_tool_descriptors(action_catalog_tool_descriptors::tool_descriptors()) .with_delegation_descriptors(action_catalog_data::delegation_descriptors()) .with_implementation_descriptors(action_catalog_data::implementation_descriptors()) .with_effect_paths([ diff --git a/crates/openspine-kernel/src/action_catalog_tests.rs b/crates/openspine-kernel/src/action_catalog_tests.rs index 21166077..f2d52021 100644 --- a/crates/openspine-kernel/src/action_catalog_tests.rs +++ b/crates/openspine-kernel/src/action_catalog_tests.rs @@ -94,6 +94,39 @@ fn handler_registry_requires_explicit_classification() { } } +#[test] +fn every_dispatchable_action_has_a_tool_descriptor() { + // Fail-closed completeness (spec #209 D3): a granted, dispatchable action + // that lacks a tool descriptor is a capability gap a human must + // consciously accept, so it fails here rather than silently omitting. + // Mirrors `handler_registry_requires_explicit_classification`. + let catalog = canonical_catalog(); + let registry = crate::api::handler_registry::ActionHandlerRegistry::default_registrations(); + for action in registry.registered_action_ids() { + let descriptor = catalog + .tool_descriptor_for(&action) + .unwrap_or_else(|| panic!("dispatchable action {action} lacks a tool descriptor")); + // The presentation flag must stay honest against the catalog's own + // selection-token axis: a descriptor may not claim a different + // token requirement than the action actually carries. + assert_eq!( + descriptor.selection_token_required, + catalog.requires_selection_token(&action).is_some(), + "tool descriptor for {action} has a selection_token_required flag \ + that disagrees with the catalog" + ); + } + // Cardinality, not just membership: pins the dispatchable descriptor count + // so a deliberately-removed descriptor (or a stray extra one) fails, not + // just a lookup miss. 15 is the current dispatchable set; changing it is a + // conscious edit here. + assert_eq!( + catalog.tool_descriptor_count(), + 15, + "expected exactly 15 dispatchable tool descriptors" + ); +} + #[test] fn worker_actions_declare_no_egress_and_no_output_channel() { // `worker.commission` / `worker.report_result` / `worker.failed` must diff --git a/crates/openspine-kernel/src/action_catalog_tool_descriptors.rs b/crates/openspine-kernel/src/action_catalog_tool_descriptors.rs new file mode 100644 index 00000000..d4452e84 --- /dev/null +++ b/crates/openspine-kernel/src/action_catalog_tool_descriptors.rs @@ -0,0 +1,371 @@ +//! Curated, kernel-owned tool descriptors for every currently-dispatchable +//! action id (spec #209 IT1). Mirrors the curated-const philosophy of +//! `action_catalog.rs` and the sibling `action_catalog_data.rs`: each entry is +//! reviewed metadata, never derived from a fixture or from connector-supplied +//! data. +//! +//! Scope rule: exactly the ids the kernel actually mediates through +//! `POST /v1/actions` (the `ActionHandlerRegistry::default_registrations()` +//! set). Intentionally-unwired PRD ids (`route.activate`, `workflow.activate`, +//! `capability_pack.change`, `policy.change_proposal`, `connector.enable`) are +//! not dispatchable and carry no descriptor; the completeness gate test is +//! scoped to the dispatchable set. +//! +//! Invariants the gate test pins: +//! - one descriptor per dispatchable id (cardinality == 15); +//! - `selection_token_required` mirrors +//! `ActionCatalog::requires_selection_token(id).is_some()` exactly. +//! +//! `approval_required` is a curated *presentation* flag set by reviewed +//! judgment per action (not derived from any catalog axis); each `true` value +//! carries a one-line rationale. + +use openspine_schemas::action::{ActionId, ToolDescriptor}; +use serde_json::json; + +fn id(s: &str) -> ActionId { + ActionId::new(s) +} + +/// A single-string-field payload schema (the common shape for the reply and +/// bundle actions), with unknown fields rejected to mirror the handlers' +/// `#[serde(deny_unknown_fields)]` payload contracts. +fn single_string_field(field: &str, description: &str) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": [field], + "properties": { field: { "type": "string", "description": description } } + }) +} + +/// One descriptor per currently-dispatchable action id. The set is asserted +/// complete (and its cardinality pinned) against the handler registry by +/// `action_catalog_tests.rs`. +pub(super) fn tool_descriptors() -> Vec<(ActionId, ToolDescriptor)> { + vec![ + ( + id("openspine.status.read"), + ToolDescriptor { + name: "read_status".to_string(), + description: "Read the kernel's current status.".to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": {} + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("telegram.reply:owner_channel"), + ToolDescriptor { + name: "reply_owner_telegram".to_string(), + description: "Send a text reply to the owner on their Telegram channel." + .to_string(), + parameters_schema: single_string_field("text", "The reply text to send."), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("terminal.reply:owner_device"), + ToolDescriptor { + name: "reply_owner_terminal".to_string(), + description: "Send a text reply to the owner on their active terminal device." + .to_string(), + parameters_schema: single_string_field("text", "The reply text to send."), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("email.read_thread:selected_no_attachments"), + ToolDescriptor { + name: "read_selected_email_thread".to_string(), + description: "Read the owner-selected email thread (no attachments), consuming a \ + grant-bound selection token." + .to_string(), + parameters_schema: single_string_field( + "selection_token_id", + "The id of the grant-bound selection token authorizing this read.", + ), + approval_required: false, + // Mirrors the catalog's sole token-requiring dispatchable id. + selection_token_required: true, + }, + ), + ( + id("lyra.ui.preview"), + ToolDescriptor { + name: "preview_owner_ui".to_string(), + description: "Preview a subject/body draft to the owner before any send." + .to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["subject", "body"], + "properties": { + "subject": { "type": "string", "description": "The preview subject." }, + "body": { "type": "string", "description": "The preview body." } + } + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("artifact.propose"), + ToolDescriptor { + name: "propose_artifact".to_string(), + description: "Propose a governed artifact (route/agent/workflow/pack/policy/\ + model_swap/standing_rule/persona) as YAML for owner approval." + .to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["kind", "yaml"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "route", "agent", "workflow", "pack", "policy", + "model_swap", "standing_rule", "persona" + ], + "description": "The proposable artifact kind." + }, + "yaml": { + "type": "string", + "description": "The artifact document as YAML, lifecycle_state: proposed." + } + } + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("artifact.revoke"), + ToolDescriptor { + name: "revoke_standing_rule".to_string(), + description: "Revoke a standing rule by id, narrowing standing authority." + .to_string(), + parameters_schema: single_string_field( + "rule_id", + "The id of the standing rule to revoke.", + ), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("plan.propose"), + ToolDescriptor { + name: "propose_plan".to_string(), + description: "Propose a Plan to the owner for preview and approval.".to_string(), + parameters_schema: json!({ + "type": "object", + "description": "A Plan document (see openspine_schemas::plan::Plan).", + "additionalProperties": true + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("artifact.nominate_upstream"), + ToolDescriptor { + name: "nominate_artifact_upstream".to_string(), + description: "Nominate a compatible, depersonalized learned artifact as an \ + upstream candidate." + .to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["kind", "artifact_id", "version", "depersonalized"], + "properties": { + "kind": { "type": "string", "description": "The proposable artifact kind." }, + "artifact_id": { "type": "string", "description": "The learned artifact id." }, + "version": { + "type": "integer", + "minimum": 0, + "description": "The learned artifact version." + }, + "depersonalized": { + "type": "boolean", + "description": "Must be true; asserts the artifact carries no owner PII." + } + } + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("worker.commission"), + ToolDescriptor { + name: "commission_worker".to_string(), + description: "Commission an attenuated sub-worker with a bounded allowed-action \ + set and expiry." + .to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [ + "agent_id", "allowed_actions", "expires_before", "purpose", + "route_id", "workflow_id", "capability_pack_id", "receipt" + ], + "properties": { + "agent_id": { "type": "string" }, + "allowed_actions": { "type": "array", "items": { "type": "string" } }, + "bound_parameters": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + } + } + }, + "expires_before": { "type": "string", "description": "RFC 3339 timestamp." }, + "purpose": { "type": "string" }, + "route_id": { "type": "string" }, + "workflow_id": { "type": "string" }, + "capability_pack_id": { "type": "string" }, + "counterparty_channel": { "type": ["string", "null"] }, + "counterparty_identifier": { "type": ["string", "null"] }, + "receipt": { + "type": "string", + "description": "Caller-generated idempotency receipt (ULID)." + } + } + }), + // Commissioning mints a fresh, attenuated delegated grant (a new + // actor with authority): reviewed judgment flags it so the owner + // ratifies creating delegated actors. + approval_required: true, + selection_token_required: false, + }, + ), + ( + id("worker.report_result"), + ToolDescriptor { + name: "report_worker_result".to_string(), + description: "Report a commissioned worker's outcome, offered slots, and requests." + .to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "outcome": { + "type": "string", + "enum": ["completed", "failed", "awaiting"] + }, + "offered_slots": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label"], + "properties": { + "id": { "type": "string" }, + "label": { "type": "string" } + } + } + }, + "requests": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "type": "string" }, + "detail_ref": { "type": ["object", "null"] } + } + } + }, + "notes_ref": { "type": ["object", "null"] } + } + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("worker.failed"), + ToolDescriptor { + name: "report_worker_failed".to_string(), + description: "Report that a commissioned worker failed, with a typed reason." + .to_string(), + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "required": ["reason"], + "properties": { + "reason": { + "type": "string", + "enum": ["shell_exited", "crash", "timeout", "lost", "startup_failure"] + }, + "detail_ref": { "type": ["object", "null"] } + } + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("skill.context"), + ToolDescriptor { + name: "read_skill_context".to_string(), + description: "Return installed, approved-shelf skill bodies for the grant's task \ + class as untrusted competence data." + .to_string(), + // Task class is derived from the authenticated grant purpose; + // the caller supplies no parameters. + parameters_schema: json!({ + "type": "object", + "additionalProperties": false, + "properties": {} + }), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("openspine.overlay.export"), + ToolDescriptor { + name: "export_overlay".to_string(), + description: "Stage an export of a named overlay bundle (root owner grant only)." + .to_string(), + parameters_schema: single_string_field( + "bundle_name", + "The overlay bundle name to export.", + ), + approval_required: false, + selection_token_required: false, + }, + ), + ( + id("openspine.overlay.restore"), + ToolDescriptor { + name: "restore_overlay".to_string(), + description: "Restore a named overlay bundle into the live governed set (root \ + owner grant only)." + .to_string(), + parameters_schema: single_string_field( + "bundle_name", + "The overlay bundle name to restore.", + ), + // Restore overwrites the live governed artifact set: a + // privileged mutation the owner should ratify. + approval_required: true, + selection_token_required: false, + }, + ), + ] +} diff --git a/crates/openspine-schemas/src/action.rs b/crates/openspine-schemas/src/action.rs index f0eab256..b451ea32 100644 --- a/crates/openspine-schemas/src/action.rs +++ b/crates/openspine-schemas/src/action.rs @@ -22,6 +22,8 @@ pub use crate::delegation_contract::{ use crate::artifact::ArtifactRef; use crate::egress::EgressClass; use crate::event::TargetRef; +mod catalog_metadata; +pub use catalog_metadata::{ActionEgressDeclaration, ToolDescriptor}; /// Dotted action identifier, exact-match only (D-033) — e.g. `email.send`, /// `telegram.reply:owner_channel`, `email.read_thread:selected_no_attachments`. @@ -87,20 +89,6 @@ pub struct EffectPath { pub classification: EffectPathClass, } -/// The catalog-owned egress metadata for one registered action (blocker 1). -/// -/// Both axes are *declared* per action and owned by the kernel catalog, never -/// derived from optional connector metadata. `None` on an axis means the -/// action carries no requirement on that axis (e.g. a non-egress action has -/// `egress_class: None`; a non-output action has `output_channels: None`). -/// An empty `Some(vec![])` on `output_channels` is a deliberate, -/// fail-closed declaration (the action is classified as delivering to a -/// channel but names none — the gate must deny). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ActionEgressDeclaration { - pub output_channels: Option>, - pub egress_class: Option, -} #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ActionCatalog { ids: HashSet, @@ -129,6 +117,11 @@ pub struct ActionCatalog { /// mandatory output-channel + egress-class declaration. Enforcement reads /// ONLY this map; connector metadata is never consulted. egress_declarations: HashMap, + /// Kernel-owned model-facing tool descriptor per dispatchable action + /// (spec #209, capability-derived tool catalog). Presentation metadata + /// only; never shell-spoofable, never on TaskGrant. The projection (IT2) + /// and wire seam (IT3) consume this axis. + tool_descriptors: HashMap, /// Actions that may carry a standing rule binding NO reviewed scope: the /// rule narrows an approval requirement rather than admitting an effect. /// Fail-closed, so `email.send` can never hold blanket reusable authority. @@ -155,6 +148,7 @@ impl ActionCatalog { counterparty_facing_actions: HashSet::new(), approval_narrowing_actions: HashSet::new(), egress_declarations: HashMap::new(), + tool_descriptors: HashMap::new(), delegation_descriptors: HashMap::new(), implementation_descriptors: HashMap::new(), } diff --git a/crates/openspine-schemas/src/action/catalog_metadata.rs b/crates/openspine-schemas/src/action/catalog_metadata.rs new file mode 100644 index 00000000..4e0054e0 --- /dev/null +++ b/crates/openspine-schemas/src/action/catalog_metadata.rs @@ -0,0 +1,88 @@ +//! Kernel-owned per-action catalog metadata value types, split from +//! `action.rs` to keep it under the 500-line module cap. Both types are +//! *declared* per action and owned by the kernel `ActionCatalog`, never +//! derived from optional connector metadata and never carried on a +//! `TaskGrant`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ActionCatalog, ActionId}; +use crate::egress::EgressClass; + +/// The catalog-owned egress metadata for one registered action (blocker 1). +/// +/// Both axes are *declared* per action and owned by the kernel catalog, never +/// derived from optional connector metadata. `None` on an axis means the +/// action carries no requirement on that axis (e.g. a non-egress action has +/// `egress_class: None`; a non-output action has `output_channels: None`). +/// An empty `Some(vec![])` on `output_channels` is a deliberate, +/// fail-closed declaration (the action is classified as delivering to a +/// channel but names none — the gate must deny). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ActionEgressDeclaration { + pub output_channels: Option>, + pub egress_class: Option, +} + +/// The catalog-owned, model-facing tool descriptor for one dispatchable action +/// (spec #209, the pre-inference "capability-derived tool catalog" lane). This +/// is the presentation surface a worker projects into its model's tool schema; +/// it is kernel-owned catalog metadata, never shell-spoofable and never carried +/// on a `TaskGrant`. The projection function (IT2) and wire seam (IT3) consume +/// this axis; this ticket only defines and populates it. +/// +/// The descriptor carries no `action_id` field — the catalog map key carries +/// the id, exactly as [`ActionEgressDeclaration`] does. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolDescriptor { + /// The LLM-facing tool name presented to the model. + pub name: String, + /// A one-line, model-facing description of what the tool does. + pub description: String, + /// A JSON Schema for the action's invocation payload. Free-form JSON so + /// the crate's established embedded-schema convention (`serde_json::Value`) + /// applies; this is a projection/presentation surface, not a dispatch-time + /// validation layer. + pub parameters_schema: Value, + /// Presentation flag: the tool is proposable but its effect pauses for + /// owner approval via the existing gate/approval flow. + pub approval_required: bool, + /// Presentation flag: invoking the tool requires a valid, grant-bound + /// selection token. Mirrors `ActionCatalog::requires_selection_token`. + pub selection_token_required: bool, +} + +/// `parameters_schema` is a `serde_json::Value`, which is only `PartialEq`; +/// this manual `impl Eq` lets `ToolDescriptor` be a value in `ActionCatalog`'s +/// derived `Eq` without changing any other type. +impl Eq for ToolDescriptor {} + +impl ActionCatalog { + /// Declare the catalog-owned tool descriptors for a set of actions (spec + /// #209). Assigns (overwrites) the map, mirroring `with_egress_declarations` + /// exactly. The kernel populates one descriptor per dispatchable action; a + /// dispatchable action lacking one is caught by a fail-closed completeness + /// test in the gate. + pub fn with_tool_descriptors( + mut self, + descriptors: impl IntoIterator, + ) -> Self { + self.tool_descriptors = descriptors.into_iter().collect(); + self + } + + /// If `id` carries a tool descriptor, returns it; `None` otherwise. + /// Mirrors `egress_decl_for`. + pub fn tool_descriptor_for(&self, id: &ActionId) -> Option<&ToolDescriptor> { + self.tool_descriptors.get(id) + } + + /// How many tool descriptors the catalog holds. Exposed so a completeness + /// test can pin the dispatchable descriptor count by cardinality, not only + /// by membership (mirrors `non_effect_stub_count`). + pub fn tool_descriptor_count(&self) -> usize { + self.tool_descriptors.len() + } +} diff --git a/crates/openspine-schemas/src/action/tests.rs b/crates/openspine-schemas/src/action/tests.rs index 5324454d..98cd3934 100644 --- a/crates/openspine-schemas/src/action/tests.rs +++ b/crates/openspine-schemas/src/action/tests.rs @@ -37,3 +37,39 @@ fn approval_required_never_serializes_as_allow() { assert_eq!(value["outcome"], "approval_required"); assert_ne!(value["outcome"], "allow"); } + +#[test] +fn tool_descriptor_round_trips_through_serde() { + let descriptor = ToolDescriptor { + name: "email.read_thread".to_string(), + description: "Read a selected email thread without attachments".to_string(), + parameters_schema: serde_json::json!({ + "type": "object", + "properties": { + "thread_id": { "type": "string" }, + "selection_token": { "type": "string" } + }, + "required": ["thread_id", "selection_token"] + }), + approval_required: false, + selection_token_required: true, + }; + let value = serde_json::to_value(&descriptor).unwrap(); + assert_eq!(value["selection_token_required"], true); + assert_eq!(value["parameters_schema"]["type"], "object"); + let back: ToolDescriptor = serde_json::from_value(value).unwrap(); + assert_eq!(descriptor, back); +} + +#[test] +fn tool_descriptor_rejects_unknown_fields() { + let value = serde_json::json!({ + "name": "x", + "description": "y", + "parameters_schema": {}, + "approval_required": false, + "selection_token_required": false, + "unexpected": true + }); + assert!(serde_json::from_value::(value).is_err()); +}