Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions crates/ogar-action-handler/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -83,6 +83,27 @@ pub fn parse_applicabilities(json: &str) -> Result<BTreeMap<String, Vec<KausalSp
Ok(lift_applicabilities(&apps))
}

/// Read a deployed handler's `GET /applicabilities` REST response and lift it
/// **in full** — per handler: the guards PLUS the applicability precedence
/// (arago `Priority`) and the applicability-scoped parameter defaults (arago
/// `Applicability.Parameter{Name,Value}`, e.g. the SSH handler's `SshOpts`).
///
/// The additive sibling of [`parse_applicabilities`] (which stays as the
/// guard-only view the hard gate consumes). A dispatcher concatenates each
/// [`ConcreteApplicability::params`] onto the matched capability's signature so
/// `bind_parameters` fills the scoped defaults exactly the way arago expands
/// them into the capability `Command` template.
///
/// # Errors
/// Returns the `serde_json` error message if the body is not a valid
/// `MapOfApplicabilities`.
pub fn parse_applicabilities_full(
json: &str,
) -> Result<BTreeMap<String, ConcreteApplicability>, 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.
Expand Down Expand Up @@ -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]
Expand Down
186 changes: 186 additions & 0 deletions crates/ogar-from-schema/src/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ pub struct ConcreteCapability {
pub description: Option<String>,
/// The concrete parameter signature — mandatory params first, then optional.
pub params: Vec<ActionParam>,
/// 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<String, String>,
}

/// Lift one deployed capability into its concrete [`ConcreteCapability`] —
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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<ModelFilter>,
/// 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<i64>,
/// 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<ApplicabilityParam>,
}

/// 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
Expand All @@ -196,6 +246,58 @@ pub fn lift_applicabilities(apps: &RegisteredApplicabilities) -> BTreeMap<String
.collect()
}

/// A fully-lifted applicability — the guards PLUS the two config fields the
/// guard-only lift ([`lift_applicability`]) does not carry: the precedence
/// (`Priority`) and the applicability-scoped parameter defaults
/// (`Parameter{Name,Value}`, e.g. the SSH handler's `SshOpts`).
///
/// The `params` are already in [`ActionParam`] form (`mandatory = false`,
/// `default = Some(value)`), so a dispatcher concatenates them onto a
/// capability's own signature and [`crate::action_ws::bind_parameters`] fills
/// them exactly the way arago expands applicability parameters into the
/// capability `Command` template.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ConcreteApplicability {
/// The node-match guard set (same as [`lift_applicability`]).
pub guards: Vec<KausalSpec>,
/// Applicability precedence (arago `Priority`); `None` = unranked.
pub priority: Option<i64>,
/// Applicability-scoped defaults, lifted to bindable [`ActionParam`]s.
pub params: Vec<ActionParam>,
}

/// 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()),
Comment on lines +280 to +283

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent submitAction from overriding applicability injections

When a dispatcher follows the new contract and appends these params to the capability signature, bind_parameters checks request-supplied values before defaults, so representing Applicability.Parameter{Name,Value} as an optional ActionParam lets any submitAction include the same name and replace the configured injection. For the documented SSH SshOpts case, that means a caller can strip or change the operator-provided BatchMode/StrictHostKeyChecking options instead of receiving the fixed applicability value that arago config declares.

Useful? React with 👍 / 👎.

})
.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<String, ConcreteApplicability> {
apps.iter()
.map(|(handler, app)| (handler.clone(), lift_applicability_full(app)))
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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();
Expand All @@ -341,6 +526,7 @@ mod tests {
mode: Some("equals".to_owned()),
value: "ogit/Network/Machine".to_owned(),
}],
..Default::default()
},
);
apps.insert(
Expand Down
38 changes: 38 additions & 0 deletions docs/ARAGO-ACTIONHANDLER-PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>` + `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`.
26 changes: 26 additions & 0 deletions docs/DISCOVERY-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading