From 825bc286727bc27ccaa470700e5406d125c029f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 06:36:29 +0000 Subject: [PATCH] registration: close the three arago config-parity gaps at the instance lift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ground-truth re-check of ARAGO-ACTIONHANDLER-PARITY.md §1 against the actual AdaWorldAPI/ActionHandlers repo (the ssh_based_actions aae.yaml stanza) found two unlisted gaps plus the known footnote-² one. All three close additively at the B2-lift, verbatim-value tested: - Applicability.Parameter{Name,Value} (the SshOpts pattern): new ApplicabilityParam + RegisteredApplicability::parameters, lifted to bindable ActionParam{mandatory:false, default:Some(value)} via lift_applicability_full / lift_applicabilities_full / parse_applicabilities_full — bind_parameters reproduces arago's template injection. - Per-param Description: the DTO carried it, lift_capability dropped it — now ConcreteCapability::param_descriptions (kept OFF ActionParam: binding needs (name,mandatory,default) only; doc metadata rides the lift artifact, not the IR type). - Applicability.Priority: RegisteredApplicability::priority + ConcreteApplicability::priority. Guard-only views (lift_applicabilities / parse_applicabilities) and the schema-level ApplicabilitySlot are untouched — the ontology genuinely does not declare deploy-config fields. Ledger: DISCOVERY-MAP D-ACTIONHANDLER-GROUNDTRUTH-GAPS-CLOSED; parity doc §7 addendum (incl. two fidelity notes: config-YAML nests the filter under Var:, and Mode:string is a match-type discriminator). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01EZ7GvnCGsZTqAZXUxwWfdh --- crates/ogar-action-handler/src/lib.rs | 62 ++++++- crates/ogar-from-schema/src/registration.rs | 186 ++++++++++++++++++++ docs/ARAGO-ACTIONHANDLER-PARITY.md | 38 ++++ docs/DISCOVERY-MAP.md | 26 +++ 4 files changed, 310 insertions(+), 2 deletions(-) diff --git a/crates/ogar-action-handler/src/lib.rs b/crates/ogar-action-handler/src/lib.rs index f4e9abf..7f0e786 100644 --- a/crates/ogar-action-handler/src/lib.rs +++ b/crates/ogar-action-handler/src/lib.rs @@ -44,8 +44,8 @@ use std::collections::BTreeMap; use ogar_from_schema::action_ws::CapabilityExecutor; use ogar_from_schema::registration::{ - ConcreteCapability, RegisteredApplicabilities, RegisteredCapabilities, lift_applicabilities, - lift_registration, + ConcreteApplicability, ConcreteCapability, RegisteredApplicabilities, RegisteredCapabilities, + lift_applicabilities, lift_applicabilities_full, lift_registration, }; use ogar_vocab::KausalSpec; @@ -83,6 +83,27 @@ pub fn parse_applicabilities(json: &str) -> Result Result, String> { + let apps: RegisteredApplicabilities = serde_json::from_str(json).map_err(|e| e.to_string())?; + Ok(lift_applicabilities_full(&apps)) +} + /// Reference executor for the **native** target: runs a capability's command via /// the local POSIX shell (`sh -c`). The concrete B1 impl that makes "OGAR /// running it here" real for local execution. @@ -369,6 +390,43 @@ mod tests { ); } + /// B2-lift applicabilities, FULL view: the canonical `ssh_based_actions` + /// stanza (`AdaWorldAPI/ActionHandlers` `aae.yaml` — Priority 50, + /// `ModelFilter{NodeType,string,Machine}`, scoped `SshOpts`) survives the + /// JSON read: guards + priority + the bindable scoped default all land. + #[test] + fn rest_applicabilities_full_lift_carries_priority_and_scoped_params() { + let body = r#"{ + "ssh-handler": { + "priority": 50, + "modelFilters": [ + { "var": "NodeType", "mode": "string", "value": "Machine" } + ], + "parameters": [ + { "name": "SshOpts", "value": "-o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=quiet" } + ] + } + }"#; + + let lifted = parse_applicabilities_full(body).expect("valid MapOfApplicabilities"); + let app = &lifted["ssh-handler"]; + assert_eq!(app.priority, Some(50)); + assert_eq!( + app.guards, + vec![KausalSpec::StateGuard { + guard_field: "NodeType".to_owned(), + guard_values: vec!["Machine".to_owned()], + }] + ); + assert_eq!(app.params.len(), 1); + assert_eq!(app.params[0].name, "SshOpts"); + assert!(!app.params[0].mandatory); + assert_eq!( + app.params[0].default.as_deref(), + Some("-o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=quiet") + ); + } + /// End-to-end: the dispatch core + this executor run a real command and /// produce the `sendActionResult` — "OGAR running it here," native target. #[test] diff --git a/crates/ogar-from-schema/src/registration.rs b/crates/ogar-from-schema/src/registration.rs index e9518e3..fb021dd 100644 --- a/crates/ogar-from-schema/src/registration.rs +++ b/crates/ogar-from-schema/src/registration.rs @@ -108,6 +108,14 @@ pub struct ConcreteCapability { pub description: Option, /// The concrete parameter signature — mandatory params first, then optional. pub params: Vec, + /// Per-parameter descriptions (arago `Parameter.Description`), keyed by + /// parameter name. Kept OFF [`ActionParam`] deliberately: the binding + /// signature (`bind_parameters`) needs `(name, mandatory, default)` only; + /// descriptions are render/doc metadata, so they ride on the lift artifact + /// instead of widening the IR type (ground-truth gap found 2026-07-08 + /// re-checking `AdaWorldAPI/ActionHandlers` — the DTO carried it, the + /// lift dropped it). + pub param_descriptions: BTreeMap, } /// Lift one deployed capability into its concrete [`ConcreteCapability`] — @@ -126,10 +134,17 @@ pub fn lift_capability(name: &str, cap: &RegisteredCapability) -> ConcreteCapabi mandatory: false, default: p.default.clone(), }); + let param_descriptions = cap + .mandatory_parameters + .iter() + .chain(cap.optional_parameters.iter()) + .filter_map(|(n, p)| p.description.clone().map(|d| (n.clone(), d))) + .collect(); ConcreteCapability { name: name.to_owned(), description: cap.description.clone(), params: mandatory.chain(optional).collect(), + param_descriptions, } } @@ -179,6 +194,41 @@ pub struct RegisteredApplicability { /// The node-match filters (an applicability applies where they all match). #[cfg_attr(feature = "serde", serde(default, alias = "model", alias = "filters"))] pub model_filters: Vec, + /// Applicability precedence (arago `Applicability.Priority` — e.g. `50` in + /// the canonical `ssh_based_actions` stanza). Higher wins when several + /// applicabilities match the same node. `None` when the deployment does not + /// rank. Ground-truth gap closed 2026-07-08 (`AdaWorldAPI/ActionHandlers`); + /// the parity doc had flagged it as the one config field without an OGAR + /// home (§1 footnote ²). + #[cfg_attr(feature = "serde", serde(default, alias = "Priority"))] + pub priority: Option, + /// Applicability-scoped parameter defaults (arago + /// `Applicability.Parameter{Name,Value}`) — values injected into every + /// capability this applicability provides. The canonical example is the + /// SSH handler's `SshOpts` (`-o StrictHostKeyChecking=no -o BatchMode=yes …`), + /// declared once at the applicability and expanded in each capability's + /// `Command` template. Ground-truth gap closed 2026-07-08. + #[cfg_attr( + feature = "serde", + serde(default, alias = "parameter", alias = "Parameter") + )] + pub parameters: Vec, +} + +/// One applicability-scoped parameter default as arago declares it: +/// `Parameter{Name, Value}` on the `Applicability` stanza (NOT the capability +/// signature — capability params carry `Mandatory`/`Default`; applicability +/// params are always concrete `(name, value)` injections). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct ApplicabilityParam { + /// Parameter name (arago `Parameter.Name`). + #[cfg_attr(feature = "serde", serde(alias = "Name"))] + pub name: String, + /// The injected value (arago `Parameter.Value`). + #[cfg_attr(feature = "serde", serde(alias = "Value"))] + pub value: String, } /// The `GET /applicabilities` response — a `MapOfApplicabilities` keyed by @@ -196,6 +246,58 @@ pub fn lift_applicabilities(apps: &RegisteredApplicabilities) -> BTreeMap, + /// Applicability precedence (arago `Priority`); `None` = unranked. + pub priority: Option, + /// Applicability-scoped defaults, lifted to bindable [`ActionParam`]s. + pub params: Vec, +} + +/// Lift one deployed applicability in full — guards + priority + scoped +/// parameter defaults. The additive sibling of [`lift_applicability`] (which +/// stays as the guard-only view the hard gate consumes). +#[must_use] +pub fn lift_applicability_full(app: &RegisteredApplicability) -> ConcreteApplicability { + ConcreteApplicability { + guards: lift_applicability(&app.model_filters), + priority: app.priority, + params: app + .parameters + .iter() + .map(|p| ActionParam { + name: p.name.clone(), + mandatory: false, + default: Some(p.value.clone()), + }) + .collect(), + } +} + +/// Lift a whole `GET /applicabilities` response in full: handler id → +/// [`ConcreteApplicability`]. Deterministic order (the source is a +/// [`BTreeMap`], sorted by handler id). +#[must_use] +pub fn lift_applicabilities_full( + apps: &RegisteredApplicabilities, +) -> BTreeMap { + apps.iter() + .map(|(handler, app)| (handler.clone(), lift_applicability_full(app))) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -330,6 +432,89 @@ mod tests { ); } + /// The canonical `ssh_based_actions` applicability stanza from + /// `AdaWorldAPI/ActionHandlers` (`aae.yaml`, verbatim values): Priority 50, + /// `ModelFilter{NodeType, string, Machine}`, and the applicability-scoped + /// `SshOpts` default. The full lift must carry all three. + #[test] + fn ssh_stanza_lifts_priority_guards_and_scoped_params() { + let app = RegisteredApplicability { + model_filters: vec![ModelFilter { + var: "NodeType".to_owned(), + mode: Some("string".to_owned()), + value: "Machine".to_owned(), + }], + priority: Some(50), + parameters: vec![ApplicabilityParam { + name: "SshOpts".to_owned(), + value: "-o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=quiet".to_owned(), + }], + }; + let full = lift_applicability_full(&app); + assert_eq!(full.priority, Some(50)); + assert_eq!( + full.guards, + vec![KausalSpec::StateGuard { + guard_field: "NodeType".to_owned(), + guard_values: vec!["Machine".to_owned()], + }] + ); + assert_eq!( + full.params, + vec![ActionParam { + name: "SshOpts".to_owned(), + mandatory: false, + default: Some( + "-o StrictHostKeyChecking=no -o BatchMode=yes -o LogLevel=quiet".to_owned() + ), + }] + ); + } + + /// Applicability-scoped params concatenate onto the capability signature and + /// bind exactly like arago expands them into the `Command` template: the + /// engine supplies nothing for `SshOpts`, the applicability default fills it. + #[test] + fn applicability_params_drive_bind_parameters_like_arago_injection() { + let cap = lift_capability("ExecuteCommand", &ssh_execute_command()); + let app = RegisteredApplicability { + parameters: vec![ApplicabilityParam { + name: "SshOpts".to_owned(), + value: "-o BatchMode=yes".to_owned(), + }], + ..Default::default() + }; + let full = lift_applicability_full(&app); + let mut signature = cap.params.clone(); + signature.extend(full.params); + let supplied = vec![ + ("command".to_owned(), "uptime".to_owned()), + ("host".to_owned(), "node-1".to_owned()), + ]; + let bound = crate::action_ws::bind_parameters(&supplied, &signature) + .expect("mandatory present; applicability default filled"); + assert!( + bound + .iter() + .any(|(k, v)| k == "SshOpts" && v == "-o BatchMode=yes") + ); + } + + /// The lift preserves per-parameter descriptions (the DTO carried them; + /// the lift used to drop them — ground-truth gap closed 2026-07-08). + #[test] + fn lift_preserves_per_param_descriptions() { + let cap = lift_capability("ExecuteCommand", &ssh_execute_command()); + assert_eq!( + cap.param_descriptions.get("command").map(String::as_str), + Some("the shell command to run") + ); + assert_eq!( + cap.param_descriptions.get("user").map(String::as_str), + Some("ssh user") + ); + } + #[test] fn applicabilities_map_lifts_per_handler_guard_sets() { let mut apps: RegisteredApplicabilities = BTreeMap::new(); @@ -341,6 +526,7 @@ mod tests { mode: Some("equals".to_owned()), value: "ogit/Network/Machine".to_owned(), }], + ..Default::default() }, ); apps.insert( diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md index d1a8242..1facd57 100644 --- a/docs/ARAGO-ACTIONHANDLER-PARITY.md +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -360,3 +360,41 @@ is replaceable; the parity claim is certified, not argued. `action-ws.yaml` (the WebSocket message contract), `action.yaml` (the REST registration API), `auth.yaml` (the token endpoint) — served by the HIRO 7 dev portal (`/help/specs/`, indexed under `/7.0/api/`). + +--- + +## 7. Addendum (2026-07-08) — ground-truth re-check against `AdaWorldAPI/ActionHandlers` + +A re-check of the §1 config table against the actual vendored handler repo +(`AdaWorldAPI/ActionHandlers`, the arago `ssh_based_actions` `aae.yaml` stanza) +found **two gaps the table did not name**, plus closed the one it did (²): + +| arago config field (ground truth) | prior state | closure | +|---|---|---| +| `Applicability.Parameter{Name,Value}` — applicability-scoped defaults (the SSH handler's `SshOpts`, declared once at the applicability and expanded in every capability `Command`) | **unlisted gap** — `RegisteredApplicability` / `ApplicabilitySlot` had no home for it | `registration::ApplicabilityParam` + `RegisteredApplicability::parameters`; lifts to bindable `ActionParam{mandatory:false, default:Some(value)}` via `lift_applicability_full` so `bind_parameters` fills them exactly like arago's template expansion (test: `applicability_params_drive_bind_parameters_like_arago_injection`) | +| `Capability.Parameter.Description` (per-param) | **unlisted gap** — the REST DTO (`RegisteredParam.description`) carried it; `lift_capability` dropped it | `ConcreteCapability::param_descriptions` (kept OFF `ActionParam` deliberately — binding needs `(name,mandatory,default)` only; descriptions are doc/render metadata, so they ride the lift artifact, not the IR type) | +| `Applicability.Priority` (the known §1 footnote ² gap) | `[H]` — "no OGAR home yet" | `RegisteredApplicability::priority: Option` + `ConcreteApplicability::priority` (the MUL/elevation ranking noted in ² can now consume it) | + +All three land in the **instance lift** (`registration.rs` + the +`ogar-action-handler` I/O half — `parse_applicabilities_full`), NOT in the +OGIT-schema lift: the ontology genuinely does not declare them (they are +deploy-config), so `ApplicabilitySlot` (do_arm) is unchanged. Additive only — +`lift_applicability`/`parse_applicabilities` (the guard-only view the hard gate +consumes) are untouched. + +Two fidelity notes observed in the same re-check, recorded so they don't +re-surface as "gaps": + +- In the **config YAML** the filter nests under `Var:` (`ModelFilter: Var: + {Name, Mode, Value}`); the flat `ModelFilter{Var,Mode,Value}` this doc (and + the REST DTO) uses is the spec/REST projection. Same contract, two framings. +- Ground truth uses `Mode: string` — the mode is a match-*type* discriminator + (`string` vs regex), not only `equals`/`matches` as §the DTO doc-comment + suggests. `ModelFilter.mode` already preserves it verbatim as data, so + nothing is lost; the executor consumes it when match-modes matter. + +Tests grounding this addendum on the verbatim `ssh_based_actions` values +(Priority 50, `NodeType/string/Machine`, the full `SshOpts` string): +`ssh_stanza_lifts_priority_guards_and_scoped_params`, +`lift_preserves_per_param_descriptions`, +`rest_applicabilities_full_lift_carries_priority_and_scoped_params`. diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index afc71e1..ec354bf 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1155,3 +1155,29 @@ isolation. The map's job is to keep them visible. (verified — its module doc lists `ActionDef` / `KausalSpec` under "Not yet supported"), so TTL stays write-only for kausal; no roundtrip case added, per the spec's documented fallback. + +- **D-ACTIONHANDLER-GROUNDTRUTH-GAPS-CLOSED (arago config parity — three + instance-lift fields; 2026-07-08; [G] — shipped + tested against verbatim + `ssh_based_actions` values):** A ground-truth re-check of + `docs/ARAGO-ACTIONHANDLER-PARITY.md` §1 against the actual + `AdaWorldAPI/ActionHandlers` repo (the arago `aae.yaml` SSH stanza) found two + config fields the parity table never listed — **applicability-scoped + parameter defaults** (`Applicability.Parameter{Name,Value}`, the `SshOpts` + pattern) and **per-parameter `Description`** (the REST DTO carried it; the + lift dropped it) — plus the one it did list (`Applicability.Priority`, + footnote ²). All three closed **additively at the instance lift**: + `registration::{ApplicabilityParam, ConcreteApplicability, + lift_applicability_full, lift_applicabilities_full}` + + `RegisteredApplicability::{priority, parameters}` + + `ConcreteCapability::param_descriptions` + + `ogar-action-handler::parse_applicabilities_full`. Scoped params lift as + bindable `ActionParam{mandatory:false, default:Some(value)}` so + `bind_parameters` reproduces arago's template injection. Deliberate + non-moves: `ActionParam` (IR) NOT widened — descriptions are doc/render + metadata and ride the lift artifact (OGAR-AS-IR shape-test discipline); + `ApplicabilitySlot` (schema lift) NOT touched — the OGIT ontology genuinely + does not declare deploy-config fields; the guard-only + `lift_applicabilities`/`parse_applicabilities` view the hard gate consumes is + untouched. Full table + two fidelity notes (config-YAML nests the filter + under `Var:`; `Mode: string` is a match-type discriminator): + `ARAGO-ACTIONHANDLER-PARITY.md` §7 addendum.