From 0e7c22c3647d392a7ccd086283fbc21d7b6d8bea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 07:09:54 +0000 Subject: [PATCH 1/3] =?UTF-8?q?action=20handler:=20reactive=20dispatch=20(?= =?UTF-8?q?B1=20seam)=20+=20native=20executor=20=E2=80=94=20OGAR=20runs=20?= =?UTF-8?q?a=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the arago ActionHandler runtime toward a live drop-in. Two bricks: 1. Reactive dispatch + the B1 executor seam (ogar-from-schema/action_ws.rs): - CapabilityExecutor trait — the seam where a runner plugs in (the only piece that does real I/O); the RBAC/guard gate (commit_via) is owned by the impl downstream, keeping the producer crate lance-graph-free. - handle_submit(msg, def, signature, executor) — the whole handler reaction: validate id -> ack-or-nack -> bind params -> execute -> sendActionResult. Rejects invalid id / unknown capability before ack; reports post-ack bind/exec failure as {"error":...} in the result (documented OGAR convention). - 5 tests with a mock executor (accept+run, unknown-capability nack, invalid-id nack, bind-failure, executor-failure). 2. New crate ogar-action-handler — the runtime (B1 native target): - NativeCommandExecutor runs an ExecuteCommand capability's `command` via a local POSIX shell, returns output/stderr/exitcode as resultParameters (the arago SSH-handler shape, minus SSH). Trust model documented (gate is upstream). - full_dispatch_runs_a_real_command: handle_submit + NativeCommandExecutor run `echo running` end-to-end -> {"output":"running",...}. OGAR runs a command here. - SSH/REST/WinRM targets follow the same trait (rs-graph-llm graph-flow-action for production); this is the reference native impl. 48 tests green across the two crates (43 + 5 + doctest), clippy-clean on new code. Docs: ARAGO-ACTIONHANDLER-PARITY scorecard (dispatch + native exec SHIPPED; remaining B2-transport / B2-lift / non-native executors); D-ACTIONHANDLER-PARITY row. Also scrubbed the internal dev-portal host from the doc (neutral spec refs). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- Cargo.toml | 1 + crates/ogar-action-handler/Cargo.toml | 16 ++ crates/ogar-action-handler/src/lib.rs | 180 ++++++++++++++++++ crates/ogar-from-schema/src/action_ws.rs | 222 +++++++++++++++++++++++ docs/ARAGO-ACTIONHANDLER-PARITY.md | 98 +++++----- docs/DISCOVERY-MAP.md | 2 +- 6 files changed, 473 insertions(+), 46 deletions(-) create mode 100644 crates/ogar-action-handler/Cargo.toml create mode 100644 crates/ogar-action-handler/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 05f332c..8dd7d5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/ogar-from-ruff", "crates/ogar-from-rails", "crates/ogar-from-schema", + "crates/ogar-action-handler", "crates/ogar-class-view", "crates/ogar-render-askama", "crates/ogar-fma-skeleton", diff --git a/crates/ogar-action-handler/Cargo.toml b/crates/ogar-action-handler/Cargo.toml new file mode 100644 index 0000000..da63e0a --- /dev/null +++ b/crates/ogar-action-handler/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ogar-action-handler" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "OGAR-native HIRO ActionHandler runtime — CapabilityExecutor impls (the B1 executor seam) over the action-ws protocol core in ogar-from-schema. The reference NativeCommandExecutor runs a capability's command via a local process; SSH/REST targets follow the same trait. rs-graph-llm's graph-flow-action provides the production executors." + +[features] +default = [] + +[dependencies] +ogar-from-schema = { path = "../ogar-from-schema" } +ogar-vocab = { path = "../ogar-vocab" } diff --git a/crates/ogar-action-handler/src/lib.rs b/crates/ogar-action-handler/src/lib.rs new file mode 100644 index 0000000..e6a8fb8 --- /dev/null +++ b/crates/ogar-action-handler/src/lib.rs @@ -0,0 +1,180 @@ +//! `ogar-action-handler` — the OGAR-native HIRO ActionHandler **runtime** +//! (parity brick **B1**: the executor that actually runs a capability). +//! +//! `ogar-from-schema::action_ws` is the I/O-free protocol core — the typed +//! `action-ws` messages, the `submitAction → ActionInvocation → sendActionResult` +//! binding, and [`handle_submit`] (the reactive dispatch). This crate supplies +//! the **[`CapabilityExecutor`]** the dispatch calls — the only piece that does +//! real I/O — so the producer crate stays pure. +//! +//! [`NativeCommandExecutor`] is the reference target: it runs an +//! `ExecuteCommand` capability's `command` parameter via a local POSIX shell and +//! returns the captured `output` / `stderr` / `exitcode` as `resultParameters` — +//! exactly the shape arago's SSH ActionHandler returns, minus the SSH hop. SSH / +//! REST / WinRM targets follow the same trait; rs-graph-llm's `graph-flow-action` +//! provides the production executors (and runs the RBAC `commit_via` gate before +//! executing). See OGAR `docs/ARAGO-ACTIONHANDLER-PARITY.md`. +//! +//! ``` +//! use ogar_action_handler::NativeCommandExecutor; +//! use ogar_from_schema::action_ws::{handle_submit, Receipt, SubmitAction}; +//! use ogar_from_schema::do_arm::ActionParam; +//! use ogar_vocab::ActionDef; +//! +//! let def = ActionDef::new("ogit-automation/.../ExecuteCommand", "ExecuteCommand", "ogit-automation/mars_machine"); +//! let sig = [ActionParam { name: "command".into(), mandatory: true, default: None }]; +//! let msg = SubmitAction { +//! id: "demo:req-000001".into(), capability: "ExecuteCommand".into(), +//! handler: "h1".into(), scope: None, +//! parameters: vec![("command".into(), "echo hi".into())], +//! timeout_millis: None, +//! }; +//! let reaction = handle_submit(&msg, &def, &sig, &NativeCommandExecutor); +//! assert!(matches!(reaction.receipt, Receipt::Acknowledged(_))); +//! let result = reaction.result.unwrap(); +//! assert!(result.result.contains("\"output\":\"hi\"")); +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use std::process::Command; + +use ogar_from_schema::action_ws::CapabilityExecutor; + +/// 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. +/// +/// Supported capability: `ExecuteCommand` (the arago SSH handler's core verb) — +/// requires a bound `command` parameter; returns `output` (trimmed stdout), +/// `stderr` (trimmed), and `exitcode`. Any other capability is an error (the +/// caller routed the wrong executor). +/// +/// **Trust model:** the executor runs the bound command verbatim — by design +/// (this is what an ActionHandler *is*; arago's SSH handler does the same). The +/// authorization gate (`commit_via`: RBAC ∧ state-guard ∧ MUL) runs +/// **upstream** of the executor, in the production `graph-flow-action` impl; this +/// reference executor assumes its caller already authorized the action. +#[derive(Debug, Clone, Copy, Default)] +pub struct NativeCommandExecutor; + +impl NativeCommandExecutor { + /// The single capability this reference executor implements. + pub const CAPABILITY: &'static str = "ExecuteCommand"; +} + +impl CapabilityExecutor for NativeCommandExecutor { + fn execute( + &self, + capability: &str, + bound: &[(String, String)], + ) -> Result, String> { + if capability != Self::CAPABILITY { + return Err(format!( + "native executor implements only `{}`, not `{capability}`", + Self::CAPABILITY + )); + } + let command = bound + .iter() + .find(|(k, _)| k == "command") + .map(|(_, v)| v.as_str()) + .ok_or_else(|| "missing `command` parameter".to_owned())?; + + let output = Command::new("sh") + .arg("-c") + .arg(command) + .output() + .map_err(|e| format!("failed to spawn `{command}`: {e}"))?; + + let trim = |b: &[u8]| String::from_utf8_lossy(b).trim_end().to_owned(); + Ok(vec![ + ("output".to_owned(), trim(&output.stdout)), + ("stderr".to_owned(), trim(&output.stderr)), + ( + "exitcode".to_owned(), + output.status.code().unwrap_or(-1).to_string(), + ), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execute_command_runs_and_captures_output() { + let bound = vec![("command".to_owned(), "echo hello".to_owned())]; + let result = NativeCommandExecutor + .execute("ExecuteCommand", &bound) + .expect("runs"); + // resultParameters: output / stderr / exitcode. + assert_eq!(result[0], ("output".to_owned(), "hello".to_owned())); + assert_eq!(result[2], ("exitcode".to_owned(), "0".to_owned())); + } + + #[test] + fn nonzero_exit_is_captured_not_errored() { + let bound = vec![("command".to_owned(), "exit 3".to_owned())]; + let result = NativeCommandExecutor + .execute("ExecuteCommand", &bound) + .expect("runs"); + assert_eq!(result[2], ("exitcode".to_owned(), "3".to_owned())); + } + + #[test] + fn missing_command_param_is_an_error() { + let err = NativeCommandExecutor + .execute("ExecuteCommand", &[]) + .unwrap_err(); + assert!(err.contains("command"), "got: {err}"); + } + + #[test] + fn unknown_capability_is_rejected() { + let err = NativeCommandExecutor + .execute("RunScript", &[("command".to_owned(), "x".to_owned())]) + .unwrap_err(); + assert!(err.contains("ExecuteCommand"), "got: {err}"); + } + + /// End-to-end: the dispatch core + this executor run a real command and + /// produce the `sendActionResult` — "OGAR running it here," native target. + #[test] + fn full_dispatch_runs_a_real_command() { + use ogar_from_schema::action_ws::{Receipt, SubmitAction, handle_submit}; + use ogar_from_schema::do_arm::ActionParam; + use ogar_vocab::ActionDef; + + let def = ActionDef::new( + "ogit-automation/action_capability::action_def::ExecuteCommand", + "ExecuteCommand", + "ogit-automation/mars_machine", + ); + let sig = [ActionParam { + name: "command".to_owned(), + mandatory: true, + default: None, + }]; + let msg = SubmitAction { + id: "e2e:req-000001".to_owned(), + capability: "ExecuteCommand".to_owned(), + handler: "h1".to_owned(), + scope: None, + parameters: vec![("command".to_owned(), "echo running".to_owned())], + timeout_millis: None, + }; + + let reaction = handle_submit(&msg, &def, &sig, &NativeCommandExecutor); + assert!(matches!(reaction.receipt, Receipt::Acknowledged(_))); + let result = reaction.result.expect("result"); + assert_eq!(result.id, "e2e:req-000001"); + assert!( + result.result.contains(r#""output":"running""#), + "got: {}", + result.result + ); + } +} diff --git a/crates/ogar-from-schema/src/action_ws.rs b/crates/ogar-from-schema/src/action_ws.rs index 120fbfd..896c10a 100644 --- a/crates/ogar-from-schema/src/action_ws.rs +++ b/crates/ogar-from-schema/src/action_ws.rs @@ -359,6 +359,123 @@ pub fn invocation_to_result( }) } +// ───────────────────────────────────────────────────────────────────── +// The handler reactive core — turn one inbound `submitAction` into the +// ordered outbound messages, with execution behind a trait (the B1 seam). +// Socket-free and pure given the executor; the live transport (B2-transport) +// just ships these messages, and the RBAC/guard gate (`commit_via`, +// lance-graph-contract) wraps the executor downstream. +// ───────────────────────────────────────────────────────────────────── + +/// The executor seam (parity brick **B1**): run a bound capability and return +/// its `resultParameters`. Implemented per `ExecTarget` (SSH / REST / native) by +/// rs-graph-llm's `graph-flow-action`; modelled here as a trait so the dispatch +/// core is testable without real I/O. The impl is also where the +/// RBAC/guard gate (`commit_via`) runs — it owns the `lance-graph` dependency +/// OGAR's producer crate deliberately does not. +pub trait CapabilityExecutor { + /// Execute `capability` with the `bound` parameters. + /// + /// `Ok(result_params)` → the success `resultParameters`; `Err(message)` → a + /// failure the handler reports back in the result. + fn execute( + &self, + capability: &str, + bound: &[(String, String)], + ) -> Result, String>; +} + +/// The immediate receipt response to a `submitAction`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Receipt { + /// Accepted (code 200) — execution follows, then a [`SendActionResult`]. + Acknowledged(Acknowledged), + /// Rejected before execution (invalid id / unknown capability) — no result. + NegativeAcknowledged(NegativeAcknowledged), +} + +/// The ordered outbound reaction to one inbound `submitAction`: the receipt, +/// then (when accepted) the eventual `sendActionResult`. The live transport +/// emits `receipt` first, then `result` when present. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HandlerReaction { + /// The receipt — `Acknowledged` on accept, `NegativeAcknowledged` on reject. + pub receipt: Receipt, + /// The result message — present iff the action was accepted (it ran, or it + /// failed *after* acceptance and reports the failure in the result). + pub result: Option, +} + +/// OGAR convention: a post-acceptance failure (bad params, executor error) is +/// reported in the `sendActionResult` as a one-field `{"error": ""}` +/// object (the action was already acknowledged, so a result is owed). The exact +/// HIRO failure-reporting field is confirmed against a live engine in +/// B2-transport; until then this is the documented OGAR shape. +fn error_result(id: &str, message: &str) -> SendActionResult { + SendActionResult { + id: id.to_owned(), + result: json_object(&[("error".to_owned(), message.to_owned())]), + } +} + +/// Drive one inbound `submitAction` through the handler's reactive flow: +/// validate → ack-or-nack → bind → execute (via `executor`) → result. +/// +/// - invalid `id` / capability ≠ `def.predicate` → reject **before** ack +/// (`NegativeAcknowledged`, no result). +/// - accepted → `Acknowledged`, then bind the inputs against `signature` and run +/// `executor`; the outcome (success `resultParameters`, or an `{"error":…}` +/// on bind/exec failure) rides in the `sendActionResult`. +/// +/// Pure given `executor`; the same flow arago's Python daemon runs, minus the +/// socket (B2-transport) and the real command (the `executor` impl, B1). +#[must_use] +pub fn handle_submit( + msg: &SubmitAction, + def: &ActionDef, + signature: &[ActionParam], + executor: &dyn CapabilityExecutor, +) -> HandlerReaction { + // Reject malformed / mis-routed actions BEFORE acknowledging. + if let Err(e) = validate_id(&msg.id) { + return HandlerReaction { + receipt: Receipt::NegativeAcknowledged(negative_acknowledge( + &msg.id, + 400, + e.to_string(), + )), + result: None, + }; + } + if msg.capability != def.predicate { + return HandlerReaction { + receipt: Receipt::NegativeAcknowledged(negative_acknowledge( + &msg.id, + 400, + format!("unknown capability `{}`", msg.capability), + )), + result: None, + }; + } + + // Accept: acknowledge receipt, then bind + execute. + let ack = acknowledge(msg); + let result = match bind_parameters(&msg.parameters, signature) { + Err(e) => error_result(&msg.id, &e.to_string()), + Ok(bound) => match executor.execute(&msg.capability, &bound) { + Ok(params) => SendActionResult { + id: msg.id.clone(), + result: json_object(¶ms), + }, + Err(e) => error_result(&msg.id, &e), + }, + }; + HandlerReaction { + receipt: Receipt::Acknowledged(ack), + result: Some(result), + } +} + // ───────────────────────────────────────────────────────────── tests ── // // The pure protocol core: the full submitAction → bind → invocation(Pending) @@ -550,4 +667,109 @@ mod tests { assert_eq!(result.id, msg.id); assert_eq!(result.result, r#"{"exitcode":"0"}"#); } + + // ── the handler reactive core (handle_submit + the B1 executor seam) ── + + /// A mock executor: returns a fixed success, or a fixed error. + struct MockExecutor(Result, String>); + impl CapabilityExecutor for MockExecutor { + fn execute( + &self, + _capability: &str, + _bound: &[(String, String)], + ) -> Result, String> { + self.0.clone() + } + } + + /// A spec-valid submit (id ≥ 12 chars) for the dispatch tests. + fn valid_submit() -> SubmitAction { + let mut s = submit(); + s.id = "app1:req-000042".to_owned(); // 15 chars + s + } + + #[test] + fn handle_submit_accepts_runs_and_returns_result() { + let exec = MockExecutor(Ok(vec![("output".to_owned(), "ok".to_owned())])); + let r = handle_submit( + &valid_submit(), + &execute_command_def(), + &execute_command_signature(), + &exec, + ); + match r.receipt { + Receipt::Acknowledged(a) => assert_eq!(a.code, 200), + other => panic!("expected ack, got {other:?}"), + } + let res = r.result.expect("result present"); + assert_eq!(res.id, "app1:req-000042"); + assert_eq!(res.result, r#"{"output":"ok"}"#); + } + + #[test] + fn handle_submit_rejects_unknown_capability_before_ack() { + let exec = MockExecutor(Ok(vec![])); + let mut bad = valid_submit(); + bad.capability = "RunScript".to_owned(); + let r = handle_submit( + &bad, + &execute_command_def(), + &execute_command_signature(), + &exec, + ); + assert!(matches!(r.receipt, Receipt::NegativeAcknowledged(_))); + assert!(r.result.is_none(), "rejected actions carry no result"); + } + + #[test] + fn handle_submit_rejects_invalid_id() { + let exec = MockExecutor(Ok(vec![])); + let short = submit(); // id "app1:req42" is 10 chars (< 12) + let r = handle_submit( + &short, + &execute_command_def(), + &execute_command_signature(), + &exec, + ); + match r.receipt { + Receipt::NegativeAcknowledged(n) => assert_eq!(n.code, 400), + other => panic!("expected nack, got {other:?}"), + } + } + + #[test] + fn handle_submit_reports_bind_failure_after_ack() { + let exec = MockExecutor(Ok(vec![])); + let mut no_command = valid_submit(); + no_command.parameters = vec![("host".to_owned(), "node-9".to_owned())]; // no `command` + let r = handle_submit( + &no_command, + &execute_command_def(), + &execute_command_signature(), + &exec, + ); + // Accepted (acked), but the result carries the bind error. + assert!(matches!(r.receipt, Receipt::Acknowledged(_))); + let res = r.result.expect("result present"); + assert!( + res.result.contains("error"), + "bind failure reported in result: {}", + res.result + ); + } + + #[test] + fn handle_submit_reports_executor_failure_in_result() { + let exec = MockExecutor(Err("ssh: connection refused".to_owned())); + let r = handle_submit( + &valid_submit(), + &execute_command_def(), + &execute_command_signature(), + &exec, + ); + assert!(matches!(r.receipt, Receipt::Acknowledged(_))); + let res = r.result.expect("result present"); + assert_eq!(res.result, r#"{"error":"ssh: connection refused"}"#); + } } diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md index 0e41373..ab86c66 100644 --- a/docs/ARAGO-ACTIONHANDLER-PARITY.md +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -10,8 +10,8 @@ > > **Sources (arago, verbatim):** `github.com/arago/ActionHandlers` (the handler > config format), `arago/python-hiro-stonebranch-actionhandler` (a concrete -> daemon), and the HIRO 7 **Action API** tutorial (the `action-ws` protocol, -> `dev-portal.engine.datagroup.de/7.0/tutorials/tutorial-action-handler-action-api`). +> daemon), and the HIRO 7 **Action API** tutorial + machine-readable specs (the +> `action-ws` protocol, served by the HIRO 7 dev portal). > The OGIT `NTO/Automation` ontology (vendored at `vocab/imports/ogit/NTO/Automation/`) > is the contract's schema. @@ -106,8 +106,8 @@ needs a type OGAR lacks — only the *glue* (§3) is unbuilt. ## 2a. The harvested authoritative contract (`action-ws.yaml` + `action.yaml`) -Harvested from the HIRO 7.0 dev-portal machine-readable specs -(`core.engine.datagroup.de/help/specs/definitions/{action-ws,action}.yaml`). +Harvested from the HIRO 7 Action API machine-readable specs +(`action-ws.yaml` + `action.yaml`, served by the HIRO 7 dev portal). This is the **complete** message set + connection + registration model the OGAR `action_ws` module is built to (corrections folded in — the earlier `result{…}` object framing from the tutorial is superseded by the spec's `result: string`). @@ -178,26 +178,33 @@ is shipped** in `ogar-from-schema::action_ws` (the testable, socket-free binding - `invocation_to_result(committed_inv, result)` — step 6: only a `Committed` invocation (the Rubicon crossing) yields the `sendActionResult`. -The full loop is proven socket-free by `full_action_ws_roundtrip`. The remaining -bricks: - -- **B1 — the executor (`ExecTarget` runner).** Step 5: actually run the - capability's Command/script (SSH, REST, native) and capture stdout/exit → - `resultParameters`. This is `graph-flow-action`'s `ActionHandler` trait in - rs-graph-llm; **no `ExecTarget` impl exists yet** (the audit's red row). It is - the same surface arago's per-handler plugins fill (`StonebranchActionHandler`, - the SSH handler) — in OGAR it's one trait with per-target impls. -- **B2-transport — the live WebSocket loop.** Wrap the shipped `action_ws` - binding in a `tokio-tungstenite` client (connect, decode JSON → `SubmitAction`, - drive the binding, encode `Acknowledged`/`SendActionResult` → JSON, retry-on-no-ack). -- **B2-lift — the instance config lift.** Parse a deployed-handler YAML → the - concrete `ActionDef` + `ActionParam[]` (the param/Command values §1 note ¹), - reusing the `do_arm` contract-assembly that shipped here. - -All three are **glue over existing types** (transport + a command runner + a YAML -parser), not new IR. That is the precise sense in which OGAR is "at parity": the -*contract, the lifecycle, and now the protocol binding* are OGAR-native and -proven; the live daemon is a thin transport that wraps them. +The full loop is proven socket-free by `full_action_ws_roundtrip`. The **reactive +dispatch is now shipped too** — `action_ws::handle_submit(msg, def, signature, +executor)` runs the whole handler reaction (validate → ack-or-nack → bind → +execute → `sendActionResult`) behind the **`CapabilityExecutor`** trait (the B1 +seam), with the RBAC/guard gate (`commit_via`) owned by the executor impl +downstream. The remaining bricks: + +- **B1 — the executor (`CapabilityExecutor` impl).** Step 5: actually run the + capability's Command/script and capture stdout/exit → `resultParameters`. The + **native target is SHIPPED**: `ogar-action-handler::NativeCommandExecutor` runs + `ExecuteCommand` via a local POSIX shell and returns `output`/`stderr`/`exitcode` + — proven end-to-end by `full_dispatch_runs_a_real_command` ("OGAR running it + here," native). SSH / REST / WinRM targets follow the same trait; rs-graph-llm's + `graph-flow-action` provides the production executors (and runs `commit_via`). +- **B2-transport — the live WebSocket loop.** Wrap `handle_submit` in a + `tokio-tungstenite` client (connect with the `token-$TOKEN` subprotocol, JSON- + codec the six `action_ws` message types, drive the dispatch, retry-on-no-ack). + All the message types, connection path, and auth are now pinned (§2a). +- **B2-lift — the instance config lift.** Parse a deployed handler's REST + registration (`GET /capabilities`, `/applicabilities`) → the concrete + `ActionDef` + `ActionParam[]`, reusing `assemble_action_handler`'s shape. + +What remains is **glue over existing types** (a socket loop + a JSON codec + a +registration parser) plus the non-native executor targets — not new IR. The +*contract, the lifecycle, the protocol binding, the reactive dispatch, and a +working native executor* are OGAR-native and proven; the live daemon is a thin +transport over them. --- @@ -209,22 +216,24 @@ proven; the live daemon is a thin transport that wraps them. | **ModelFilter → guard** | ✅ `[G]` | `environmentFilter` → `KausalSpec::StateGuard` (test) | | **Action lifecycle (protocol)** | ✅ `[G]` (type-level) | `action-ws` ⟷ `ActionInvocation` Pending→Committed; `commit_via` is the gate | | **RBAC at execute** | ✅ `[G]` | `commit_via` (verb-gate ∧ guard ∧ MUL) — shipped in `lance-graph-contract` | -| **action-ws protocol core (B2-core)** | ✅ `[G]` SHIPPED | `action_ws`: `SubmitAction`/`Acknowledged`/`SendActionResult` + `submit_to_invocation` / `bind_parameters` / `invocation_to_result` (socket-free, `full_action_ws_roundtrip` proven) | -| **Executor (run the Command)** | ⛔ `[H]` B1 | `ExecTarget` runner — specced, unbuilt; `PROBE-OGAR-ACTIONHANDLER-RUN` | -| **Live WebSocket transport (B2-transport)** | ⛔ `[H]` | wrap `action_ws` in a `tokio-tungstenite` loop + JSON codec | -| **Instance config lift (B2-lift)** | ⛔ `[H]` | deployed-handler-YAML → `ActionDef`/`ActionParam` | - -**Verdict:** OGAR is at **full contract + lifecycle parity** with arago's -ActionHandler, and the **`action-ws` protocol binding is now shipped + tested** -(`action_ws`, socket-free). Every field of the config, ontology, and protocol -has an OGAR type; the execution gate (`commit_via`) and the -`submitAction→invocation→sendActionResult` binding are real. The switch to "OGAR -running it here" reduces to **three glue bricks — B1 (the command executor), -B2-transport (the live WebSocket loop), B2-lift (the handler-YAML lift)** — every -one of them transport/runner/parser glue over existing types, **no missing IR, -no missing protocol mapping**. That is the honest state: parity on *what an -ActionHandler is*, *how an action flows*, and *the protocol that carries it*; a -bounded, well-scoped build for *running one live*. +| **action-ws protocol core (B2-core)** | ✅ `[G]` SHIPPED | `action_ws`: all 6 message types + `submit_to_invocation` / `bind_parameters` / `invocation_to_result` (socket-free, `full_action_ws_roundtrip` proven) | +| **Reactive dispatch + B1 seam** | ✅ `[G]` SHIPPED | `action_ws::handle_submit` + the `CapabilityExecutor` trait (validate→ack→bind→execute→result; tested with a mock) | +| **Executor — native target (B1)** | ✅ `[G]` SHIPPED | `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real; `full_dispatch_runs_a_real_command` | +| **Executor — SSH/REST/WinRM (B1)** | ⛔ `[H]` | further `CapabilityExecutor` impls (rs-graph-llm `graph-flow-action`) | +| **Live WebSocket transport (B2-transport)** | ⛔ `[H]` | wrap `handle_submit` in a `tokio-tungstenite` loop + JSON codec (all shapes pinned, §2a) | +| **Instance config lift (B2-lift)** | ⛔ `[H]` | REST `GET /capabilities`/`/applicabilities` → `ActionDef`/`ActionParam` | + +**Verdict:** OGAR is at **full contract + lifecycle + protocol-binding + +reactive-dispatch parity**, and **a working native executor runs real commands +end-to-end** (`handle_submit` + `NativeCommandExecutor`). Every field of the +config, ontology, and protocol has an OGAR type; the gate (`commit_via`), the +binding, the dispatch, and native execution are real and tested. What's left for +a **live** drop-in replacement of arago's Python daemon: **B2-transport** (the +WebSocket loop — all shapes/auth pinned), **B2-lift** (the REST registration +parse), and the **non-native executor targets** (SSH/REST). Each is +transport/parser/runner glue over existing types — **no missing IR, no missing +protocol mapping**. That is the honest state: OGAR *is* an ActionHandler that +runs commands here; a thin transport away from connecting to a live HIRO engine. --- @@ -251,8 +260,7 @@ is replaceable; the parity claim is certified, not argued. - `rs-graph-llm/graph-flow-action` — the `ActionHandler` executor trait (B1 home). - arago: `github.com/arago/ActionHandlers`, `arago/python-hiro-stonebranch-actionhandler`, HIRO 7 Action API tutorial. -- **HIRO 7.0 dev-portal specs (the authoritative harvest, §2a):** - `core.engine.datagroup.de/help/specs/definitions/action-ws.yaml` (the - WebSocket message contract), `…/action.yaml` (the REST registration API), - `…/auth.yaml` (the token endpoint). Indexed at - `dev-portal.engine.datagroup.de/7.0/api/`. +- **HIRO 7 Action API machine-readable specs (the authoritative harvest, §2a):** + `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/`). diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index e2c7ed2..7ef691b 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -210,7 +210,7 @@ two halves of a cell. ADR‑026 names the cascade that ties them. | D‑ELIXIR | Elixir/HIRO SchemaSource scaffold (`gen_statem`→Rubicon) | G | CODED (scaffold) | `ogar-from-elixir/` | D‑VOCAB | | D‑HIRO‑DO | OGIT Automation → DO arm: `into_action_def` lifts `KnowledgeItem`→`ActionDef` (object_class←`relates`, kausal←`contains Trigger`, body **pointed‑to** not inlined — lossless‑DO §1); schema half of `PROBE‑OGAR‑DO‑ARM‑LIFT` green; lift→`emit_action_def`→SPO triples proven end‑to‑end (`tests/do_arm_emit.rs`, lossless‑DO holds across emit) | G | CODED (schema half) | `ogar-from-schema/src/do_arm.rs` | D‑VOCAB, D‑TTL, D‑ELIXIR, D‑EMIT | | D‑MARS‑CLASSID | MARS/Automation classids MINTED: `ConceptDomain::Automation` (0x0C), 9 concepts (`mars_application/resource/software/machine` 0x0C01‑04, `knowledge_item` 05, `mars_node_template` 06, `action_handler` 07, `action_applicability` 08, `automation_trigger` 09) — one domain spanning MARS structural CMDB + Automation DO‑arm (Auth precedent); resolves MARS‑TRANSCODING §1 deferral; passed 5+3 hardening (theorem/doctrine/integration/runtime savants + drift‑guards). Reserves the speculative rest | G | CODED | `ogar-vocab/src/lib.rs`, `ogar-class-view/src/lib.rs` | D‑VOCAB, D‑HIRO‑DO | -| D‑ACTIONHANDLER‑PARITY | arago HIRO ActionHandler ⟷ OGAR: `assemble_action_handler` walks the OGIT `provides` graph (`ActionHandler→ActionApplicability→ActionCapability`) into `ActionHandlerSpec`/`CapabilitySlot`/`ApplicabilitySlot`/`ActionParam`. Config+ontology+`action-ws` protocol all map to OGAR types: arago `ModelFilter{Var,Mode,Value}`→`StateGuard`; `Capability.Name`→`predicate`; `resultParameters`→output sig; `action-ws` `submitAction→ack→sendActionResult` ⟷ `ActionInvocation` `Pending→Committed` (`commit_via` is the gate). **B2 protocol core SHIPPED + spec-faithful** (`action_ws`: all 6 `action-ws.yaml` message types — submitAction/sendActionResult/acknowledged/negativeAcknowledged/configChanged/error — + `submit_to_invocation`/`bind_parameters`/`invocation_to_result` (result=JSON string ≤1 MiB per spec) + connection consts (`ACTION_WS_PATH`, `auth_subprotocol`, `validate_id`); socket-free, `full_action_ws_roundtrip` proven; harvested from the HIRO 7.0 dev-portal specs §2a). Remaining: B1 executor + B2-transport (live WS) + B2-lift (REST registration→`ActionHandlerSpec`), gated on `PROBE‑OGAR‑ACTIONHANDLER‑RUN` | G (contract+protocol) / H (live runtime) | CODED | `ogar-from-schema/src/{do_arm,action_ws}.rs`, `docs/ARAGO-ACTIONHANDLER-PARITY.md` | D‑HIRO‑DO, D‑MARS‑CLASSID | +| D‑ACTIONHANDLER‑PARITY | arago HIRO ActionHandler ⟷ OGAR: `assemble_action_handler` walks the OGIT `provides` graph (`ActionHandler→ActionApplicability→ActionCapability`) into `ActionHandlerSpec`/`CapabilitySlot`/`ApplicabilitySlot`/`ActionParam`. Config+ontology+`action-ws` protocol all map to OGAR types: arago `ModelFilter{Var,Mode,Value}`→`StateGuard`; `Capability.Name`→`predicate`; `resultParameters`→output sig; `action-ws` `submitAction→ack→sendActionResult` ⟷ `ActionInvocation` `Pending→Committed` (`commit_via` is the gate). **B2 protocol core SHIPPED + spec-faithful** (`action_ws`: all 6 `action-ws.yaml` message types — submitAction/sendActionResult/acknowledged/negativeAcknowledged/configChanged/error — + `submit_to_invocation`/`bind_parameters`/`invocation_to_result` (result=JSON string ≤1 MiB per spec) + connection consts (`ACTION_WS_PATH`, `auth_subprotocol`, `validate_id`); socket-free, `full_action_ws_roundtrip` proven; harvested from the HIRO 7.0 dev-portal specs §2a). **Reactive dispatch + B1 native executor SHIPPED**: `action_ws::handle_submit` (validate→ack/nack→bind→execute→result) over the `CapabilityExecutor` trait (the B1 seam); `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real (`full_dispatch_runs_a_real_command` — OGAR runs a command end-to-end). Remaining for a live drop-in: B2-transport (WS loop), B2-lift (REST registration parse), SSH/REST executor targets; gated on `PROBE‑OGAR‑ACTIONHANDLER‑RUN` | G (contract+protocol+native exec) / H (live socket) | CODED | `ogar-from-schema/src/{do_arm,action_ws}.rs`, `ogar-action-handler/`, `docs/ARAGO-ACTIONHANDLER-PARITY.md` | D‑HIRO‑DO, D‑MARS‑CLASSID | | D‑OSM | `ogar-from-osm-pbf` — Node/Way/Relation; quadkey NiblePath from resolved geometry | H | IDEA | (queued) | D‑VOCAB, `[per rt]` D‑OSM‑3 | | D‑PATTERN | `ogar-pattern` — recognition library + confidence (FMA‑D/FIBO/SKR/PROV‑O) | H | IDEA | (queued) | D‑TTL | | D‑ACTION | `ogar-actionable` — lifecycle → `ActionDef`/`KausalSpec` | H | IDEA | (queued) | D‑PATTERN | From 13b4f1b5653cebaff2b48ea735b95df4ae18c13c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 07:44:04 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20record=20the=20B1-uplink=20?= =?UTF-8?q?=E2=80=94=20OGAR=20executor=20wired=20behind=20the=20hard=20gat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hard gate is now wired to the executor in rs-graph-llm's new `graph-flow-action-ogar` seam crate (`GatedOgarHandler` / `run_gated`): `dispatch_via`'s cold floor (`commit_via`: RBAC ∧ state-guard ∧ MUL) lands before the OGAR `CapabilityExecutor` runs. The structural proof is a negative — `take_result()` is `None` whenever the gate refused, so an unauthorized (`Denied`) or MUL-blocked (`Escalated`) action never reaches the executor. OGAR owns the executor; rs-graph-llm owns the gate; the seam crate joins them without OGAR taking a `lance-graph` dep. - ARAGO-ACTIONHANDLER-PARITY: new B1-uplink §3 bullet + scorecard row (SHIPPED), verdict note (gate wired to executor), cross-ref to the seam crate. - DISCOVERY-MAP: new D-ACTIONHANDLER-UPLINK row (G / CODED), cross-ref D-ACTIONHANDLER-PARITY (append-only; existing row untouched). - EPIPHANIES: E-ACTIONHANDLER-UPLINK (FINDING) — the load-bearing proof is the negative test; the coupling lives in the seam, not in graph-flow-action. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- .claude/board/EPIPHANIES.md | 39 ++++++++++++++++++++++++++++++ docs/ARAGO-ACTIONHANDLER-PARITY.md | 19 ++++++++++++++- docs/DISCOVERY-MAP.md | 1 + 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 5e55a60..b6436d1 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -7,6 +7,45 @@ --- +## 2026-06-24 — E-ACTIONHANDLER-UPLINK — the hard gate is wired to the executor without OGAR ever taking a `lance-graph` dep: OGAR owns the executor, rs-graph-llm owns the gate, one seam crate joins them + +**Status:** FINDING (`[G]`, 3 tests). + +Operator directive: "make the hard actionhandler in OGAR as is but also 'uplink' +into rs-graph-llm so there's a hard gated contract before it lands." The shape +that satisfies it without violating either repo's dependency hygiene: + +- **OGAR owns the executor.** `CapabilityExecutor` (e.g. + `ogar-action-handler::NativeCommandExecutor`) is the only piece that does real + I/O — it runs the capability and returns `resultParameters`. It carries no + authorization logic and no `lance-graph` dep. +- **rs-graph-llm owns the hard gate.** `graph-flow-action::dispatch_via` runs the + cold floor (`commit_via`: def-match → RBAC `ClassRbac` → `StateGuard` → MUL) and + reaches the hot path (`handle`) only on `Committed`. Its dep list is + intentionally contract-only (`I-ACTIONHANDLER-IS-KGV-NOT-CHOKEPOINT`). +- **A third crate is the seam.** New `graph-flow-action-ogar`: `GatedOgarHandler` + wraps a `CapabilityExecutor` as a `graph-flow-action::ActionHandler`, so the + executor runs **only after the contract lands**. `run_gated` drives the whole + thing; `take_result()` is `None` iff the gate refused. + +**The load-bearing proof is a negative.** The test +`unauthorized_action_is_blocked_before_execution` asserts `result.is_none()` — the +OGAR executor *never ran* because the gate said `Denied`. `mul_block_vetoes_before_execution` +proves the same for a MUL `Block` (`Escalated`, `None`). Only +`authorized_action_passes_the_gate_and_runs_the_command` reaches the real +`echo` → `{"output":"gated",…}`. The hard contract demonstrably lands *before* +execution, not alongside it. + +**Why the coupling lives in the seam, not in `graph-flow-action`:** `ogar-from-schema` +carries no `lance-graph` dependency, so the two sides meet only at the seam crate's +API — no second `lance-graph-contract` enters the graph. The seam is the *only* +place the two repos' types touch. (Toolchain: rs-graph-llm pinned to 1.95.0 to +match the AdaWorldAPI stack it consumes via path deps.) This is the B1-uplink row +in the `ARAGO-ACTIONHANDLER-PARITY` scorecard and `D-ACTIONHANDLER-UPLINK` in the +discovery map. + +--- + ## 2026-06-24 — E-ARAGO-ACTIONHANDLER-PARITY — OGAR is at full *contract + lifecycle* parity with arago's HIRO ActionHandler; the live daemon reduces to two glue bricks **Status:** FINDING (contract+lifecycle `[G]`) + CONJECTURE (runtime `[H]`, gated diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md index ab86c66..f053baf 100644 --- a/docs/ARAGO-ACTIONHANDLER-PARITY.md +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -192,6 +192,15 @@ downstream. The remaining bricks: — proven end-to-end by `full_dispatch_runs_a_real_command` ("OGAR running it here," native). SSH / REST / WinRM targets follow the same trait; rs-graph-llm's `graph-flow-action` provides the production executors (and runs `commit_via`). +- **B1-uplink — the hard gate before the executor (SHIPPED).** rs-graph-llm's + `graph-flow-action-ogar` crate is the seam: `GatedOgarHandler` wraps an OGAR + `CapabilityExecutor` as a `graph-flow-action::ActionHandler`, so the executor's + `handle` runs **only after** `dispatch_via`'s cold floor commits + (`commit_via`: def-match → RBAC `ClassRbac` → state-guard → MUL). The structural + proof: `take_result()` is `None` whenever the gate refused — `run_gated` with an + unauthorized actor (`Denied`) or a MUL `Block` (`Escalated`) never reaches the + OGAR executor. Three tests pin it; `NativeCommandExecutor` runs the real command + only on the authorized path. OGAR owns the executor; rs-graph-llm owns the gate. - **B2-transport — the live WebSocket loop.** Wrap `handle_submit` in a `tokio-tungstenite` client (connect with the `token-$TOKEN` subprotocol, JSON- codec the six `action_ws` message types, drive the dispatch, retry-on-no-ack). @@ -219,6 +228,7 @@ transport over them. | **action-ws protocol core (B2-core)** | ✅ `[G]` SHIPPED | `action_ws`: all 6 message types + `submit_to_invocation` / `bind_parameters` / `invocation_to_result` (socket-free, `full_action_ws_roundtrip` proven) | | **Reactive dispatch + B1 seam** | ✅ `[G]` SHIPPED | `action_ws::handle_submit` + the `CapabilityExecutor` trait (validate→ack→bind→execute→result; tested with a mock) | | **Executor — native target (B1)** | ✅ `[G]` SHIPPED | `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real; `full_dispatch_runs_a_real_command` | +| **Hard gate before executor (B1-uplink)** | ✅ `[G]` SHIPPED | rs-graph-llm `graph-flow-action-ogar::GatedOgarHandler` — `commit_via` (RBAC ∧ guard ∧ MUL) lands before `handle`; `take_result()` is `None` iff the gate refused (3 tests) | | **Executor — SSH/REST/WinRM (B1)** | ⛔ `[H]` | further `CapabilityExecutor` impls (rs-graph-llm `graph-flow-action`) | | **Live WebSocket transport (B2-transport)** | ⛔ `[H]` | wrap `handle_submit` in a `tokio-tungstenite` loop + JSON codec (all shapes pinned, §2a) | | **Instance config lift (B2-lift)** | ⛔ `[H]` | REST `GET /capabilities`/`/applicabilities` → `ActionDef`/`ActionParam` | @@ -227,7 +237,11 @@ transport over them. reactive-dispatch parity**, and **a working native executor runs real commands end-to-end** (`handle_submit` + `NativeCommandExecutor`). Every field of the config, ontology, and protocol has an OGAR type; the gate (`commit_via`), the -binding, the dispatch, and native execution are real and tested. What's left for +binding, the dispatch, and native execution are real and tested — and the gate is +now **wired to the executor**: rs-graph-llm's `graph-flow-action-ogar` runs OGAR's +`CapabilityExecutor` only after `commit_via` commits, so an unauthorized or +MUL-blocked action never executes (proven structurally — `take_result()` is +`None`). What's left for a **live** drop-in replacement of arago's Python daemon: **B2-transport** (the WebSocket loop — all shapes/auth pinned), **B2-lift** (the REST registration parse), and the **non-native executor targets** (SSH/REST). Each is @@ -258,6 +272,9 @@ is replaceable; the parity claim is certified, not argued. - `lance-graph-contract::action` — `ActionInvocation` / `commit_via`. - `lance-graph-ogar::OgarActionProvider` — the `classid → ClassActions` DO surface. - `rs-graph-llm/graph-flow-action` — the `ActionHandler` executor trait (B1 home). +- `rs-graph-llm/graph-flow-action-ogar` — the **uplink**: OGAR's + `CapabilityExecutor` behind the hard gate (`GatedOgarHandler` / `run_gated`); + `commit_via` lands before any execution. - arago: `github.com/arago/ActionHandlers`, `arago/python-hiro-stonebranch-actionhandler`, HIRO 7 Action API tutorial. - **HIRO 7 Action API machine-readable specs (the authoritative harvest, §2a):** diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 7ef691b..7ebfb23 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -211,6 +211,7 @@ two halves of a cell. ADR‑026 names the cascade that ties them. | D‑HIRO‑DO | OGIT Automation → DO arm: `into_action_def` lifts `KnowledgeItem`→`ActionDef` (object_class←`relates`, kausal←`contains Trigger`, body **pointed‑to** not inlined — lossless‑DO §1); schema half of `PROBE‑OGAR‑DO‑ARM‑LIFT` green; lift→`emit_action_def`→SPO triples proven end‑to‑end (`tests/do_arm_emit.rs`, lossless‑DO holds across emit) | G | CODED (schema half) | `ogar-from-schema/src/do_arm.rs` | D‑VOCAB, D‑TTL, D‑ELIXIR, D‑EMIT | | D‑MARS‑CLASSID | MARS/Automation classids MINTED: `ConceptDomain::Automation` (0x0C), 9 concepts (`mars_application/resource/software/machine` 0x0C01‑04, `knowledge_item` 05, `mars_node_template` 06, `action_handler` 07, `action_applicability` 08, `automation_trigger` 09) — one domain spanning MARS structural CMDB + Automation DO‑arm (Auth precedent); resolves MARS‑TRANSCODING §1 deferral; passed 5+3 hardening (theorem/doctrine/integration/runtime savants + drift‑guards). Reserves the speculative rest | G | CODED | `ogar-vocab/src/lib.rs`, `ogar-class-view/src/lib.rs` | D‑VOCAB, D‑HIRO‑DO | | D‑ACTIONHANDLER‑PARITY | arago HIRO ActionHandler ⟷ OGAR: `assemble_action_handler` walks the OGIT `provides` graph (`ActionHandler→ActionApplicability→ActionCapability`) into `ActionHandlerSpec`/`CapabilitySlot`/`ApplicabilitySlot`/`ActionParam`. Config+ontology+`action-ws` protocol all map to OGAR types: arago `ModelFilter{Var,Mode,Value}`→`StateGuard`; `Capability.Name`→`predicate`; `resultParameters`→output sig; `action-ws` `submitAction→ack→sendActionResult` ⟷ `ActionInvocation` `Pending→Committed` (`commit_via` is the gate). **B2 protocol core SHIPPED + spec-faithful** (`action_ws`: all 6 `action-ws.yaml` message types — submitAction/sendActionResult/acknowledged/negativeAcknowledged/configChanged/error — + `submit_to_invocation`/`bind_parameters`/`invocation_to_result` (result=JSON string ≤1 MiB per spec) + connection consts (`ACTION_WS_PATH`, `auth_subprotocol`, `validate_id`); socket-free, `full_action_ws_roundtrip` proven; harvested from the HIRO 7.0 dev-portal specs §2a). **Reactive dispatch + B1 native executor SHIPPED**: `action_ws::handle_submit` (validate→ack/nack→bind→execute→result) over the `CapabilityExecutor` trait (the B1 seam); `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real (`full_dispatch_runs_a_real_command` — OGAR runs a command end-to-end). Remaining for a live drop-in: B2-transport (WS loop), B2-lift (REST registration parse), SSH/REST executor targets; gated on `PROBE‑OGAR‑ACTIONHANDLER‑RUN` | G (contract+protocol+native exec) / H (live socket) | CODED | `ogar-from-schema/src/{do_arm,action_ws}.rs`, `ogar-action-handler/`, `docs/ARAGO-ACTIONHANDLER-PARITY.md` | D‑HIRO‑DO, D‑MARS‑CLASSID | +| D‑ACTIONHANDLER‑UPLINK | The hard gate wired to the OGAR executor (cross‑repo seam): rs‑graph‑llm `graph-flow-action-ogar::GatedOgarHandler` wraps an OGAR `CapabilityExecutor` as a `graph-flow-action::ActionHandler`, so `dispatch_via`'s cold floor (`commit_via`: def‑match → RBAC `ClassRbac` → `StateGuard` → MUL) lands **before** the executor's `handle`. Structural proof the contract lands first: `take_result()`/`run_gated` returns `None` whenever the gate refused — unauthorized actor → `Denied` (executor never runs), MUL `Block` → `Escalated` (executor never runs); only the authorized path reaches `NativeCommandExecutor` and runs the real command (3 tests). Dependency hygiene held: `graph-flow-action` stays contract‑only (`I‑ACTIONHANDLER‑IS‑KGV‑NOT‑CHOKEPOINT`); `ogar-from-schema` carries no `lance-graph` dep — the two sides meet only at this crate's API (one `lance-graph-contract`). rs‑graph‑llm pinned to toolchain 1.95.0 to match the AdaWorldAPI stack | G | CODED | `rs-graph-llm/graph-flow-action-ogar/src/lib.rs` | D‑ACTIONHANDLER‑PARITY | | D‑OSM | `ogar-from-osm-pbf` — Node/Way/Relation; quadkey NiblePath from resolved geometry | H | IDEA | (queued) | D‑VOCAB, `[per rt]` D‑OSM‑3 | | D‑PATTERN | `ogar-pattern` — recognition library + confidence (FMA‑D/FIBO/SKR/PROV‑O) | H | IDEA | (queued) | D‑TTL | | D‑ACTION | `ogar-actionable` — lifecycle → `ActionDef`/`KausalSpec` | H | IDEA | (queued) | D‑PATTERN | From a1bc96a8409cfcc37a91515944141892e9421f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 08:24:41 +0000 Subject: [PATCH 3/3] =?UTF-8?q?action=20handler:=20B2-lift=20=E2=80=94=20R?= =?UTF-8?q?EST=20registration=20instance=20lift=20(capabilities)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse a deployed handler's REST registration into the concrete OGAR signatures the *schema* half cannot supply. The OGIT ontology declares only that a capability has mandatoryParameters/optionalParameters slots; the concrete (name, mandatory, default) tuples live in a deployed handler's config. B2-lift reads them from the live REST view, so the two halves compose: schema lift gives the contract shape, instance lift gives the deployed values. Producer stays parser-free (the crate-family split): - ogar-from-schema/src/registration.rs — typed REST DTOs (RegisteredCapability / RegisteredParam / ModelFilter, Deserialize behind the existing `serde` feature) + the pure lift: lift_registration -> ConcreteCapability (concrete ActionParam[]), and model_filter_to_guard (arago ModelFilter{Var,Mode,Value} -> StateGuard, field-for-field). No serde_json, no I/O. 5 tests incl. the lifted signature driving action_ws::bind_parameters. - ogar-action-handler — the runtime owns I/O: parse_capabilities does the serde_json read of a GET /capabilities body. Proven end-to-end by rest_registration_lifts_binds_and_runs (real JSON -> lift -> bind -> the NativeCommandExecutor runs the command). Same producer-defines-types / runtime-does-I/O split the whole crate family keeps: ogar-from-schema gains a REST front-end without gaining a parser dep. Remaining for full B2-lift: the GET /applicabilities MapOfApplicabilities JSON envelope read (the ModelFilter->StateGuard lift is already shipped). Docs: ARAGO-ACTIONHANDLER-PARITY (B2-lift §3 bullet + two scorecard rows + verdict + cross-refs), D-ACTIONHANDLER-B2LIFT discovery row, E-ACTIONHANDLER-B2LIFT epiphany (the parser-free producer / runtime-does-the-read split). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- .claude/board/EPIPHANIES.md | 36 +++ crates/ogar-action-handler/Cargo.toml | 7 +- crates/ogar-action-handler/src/lib.rs | 69 +++++ crates/ogar-from-schema/src/lib.rs | 1 + crates/ogar-from-schema/src/registration.rs | 297 ++++++++++++++++++++ docs/ARAGO-ACTIONHANDLER-PARITY.md | 34 ++- docs/DISCOVERY-MAP.md | 1 + 7 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 crates/ogar-from-schema/src/registration.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index b6436d1..33d2a09 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -7,6 +7,42 @@ --- +## 2026-06-24 — E-ACTIONHANDLER-B2LIFT — the producer stays parser-free even when lifting a JSON REST response: it defines the `Deserialize` DTOs + the pure lift, the runtime does the `from_str` + +**Status:** FINDING (`[G]` for capabilities; `[H]` for the applicabilities envelope). + +B2-lift (the REST registration instance lift) had to read a JSON `GET +/capabilities` body — but `ogar-from-schema` is deliberately parser-free on its +default path (a narrow line-oriented TTL walker; a hand-rolled JSON *encoder* in +`action_ws`, never a decoder). The resolution kept the producer pure by splitting +along the crate family's existing seam: + +- **Producer (`ogar-from-schema::registration`) defines the typed REST DTOs** + (`RegisteredCapability` / `RegisteredParam` / `ModelFilter`, `Deserialize` behind + the already-present `serde` feature) **and the pure lift mapping** + (`lift_registration → ConcreteCapability` with concrete `ActionParam[]`; + `model_filter_to_guard`: arago `ModelFilter{Var,Mode,Value}` → `KausalSpec::StateGuard` + field-for-field). No `serde_json`, no I/O. +- **Runtime (`ogar-action-handler::parse_capabilities`) does the `serde_json::from_str`.** + The runtime crate already owns I/O (it runs commands); reading a REST response is + the same kind of work. `serde_json` lives there, never in the producer. + +This is the same producer-defines-types / runtime-does-I/O split the whole crate +family keeps (schema lift defines `Class`; source-AST producers fill behavior; the +runtime executes). The payoff is concrete: `ogar-from-schema` gains a REST front-end +without gaining a parser dependency. + +**The lift fills a gap the schema cannot reach.** The OGIT ontology declares only +*that* a capability has `mandatoryParameters` / `optionalParameters` slots +(`CapabilitySlot`); the concrete `(name, mandatory, default)` tuples exist only in a +*deployed* handler's config. B2-lift reads them from the live REST view — so the +two halves compose: schema lift gives the contract shape, instance lift gives the +deployed values, and the result drives `bind_parameters` → the executor. Proven by +`rest_registration_lifts_binds_and_runs` (real JSON → lift → bind → run). The B2-lift +rows in the parity scorecard + `D-ACTIONHANDLER-B2LIFT` in the discovery map. + +--- + ## 2026-06-24 — E-ACTIONHANDLER-UPLINK — the hard gate is wired to the executor without OGAR ever taking a `lance-graph` dep: OGAR owns the executor, rs-graph-llm owns the gate, one seam crate joins them **Status:** FINDING (`[G]`, 3 tests). diff --git a/crates/ogar-action-handler/Cargo.toml b/crates/ogar-action-handler/Cargo.toml index da63e0a..7c862a4 100644 --- a/crates/ogar-action-handler/Cargo.toml +++ b/crates/ogar-action-handler/Cargo.toml @@ -12,5 +12,10 @@ description = "OGAR-native HIRO ActionHandler runtime — CapabilityExecutor imp default = [] [dependencies] -ogar-from-schema = { path = "../ogar-from-schema" } +ogar-from-schema = { path = "../ogar-from-schema", features = ["serde"] } ogar-vocab = { path = "../ogar-vocab" } +# The runtime owns I/O: it reads the REST Action API registration response +# (`GET /capabilities`), so it carries the JSON reader. The producer crate +# (`ogar-from-schema`) stays parser-free — it defines the `Deserialize` DTOs + +# the pure lift; this crate does the `from_str`. (B2-lift.) +serde_json = "1.0" diff --git a/crates/ogar-action-handler/src/lib.rs b/crates/ogar-action-handler/src/lib.rs index e6a8fb8..d89869e 100644 --- a/crates/ogar-action-handler/src/lib.rs +++ b/crates/ogar-action-handler/src/lib.rs @@ -41,6 +41,27 @@ use std::process::Command; use ogar_from_schema::action_ws::CapabilityExecutor; +use ogar_from_schema::registration::{ + ConcreteCapability, RegisteredCapabilities, lift_registration, +}; + +/// Read a deployed handler's `GET /capabilities` REST response (a JSON +/// `MapOfCapabilities`) and lift it into the concrete OGAR capability signatures +/// — the **B2-lift** brick's I/O half (`ogar-from-schema::registration` owns the +/// pure lift; this crate owns the JSON read, per the producer/runtime split). +/// +/// Each [`ConcreteCapability`] carries the concrete `ActionParam[]` the schema +/// half cannot supply — feed it to +/// [`ogar_from_schema::action_ws::bind_parameters`] to validate an engine +/// `submitAction`'s `parameters` before executing. +/// +/// # Errors +/// Returns the `serde_json` error message if the body is not a valid +/// `MapOfCapabilities`. +pub fn parse_capabilities(json: &str) -> Result, String> { + let caps: RegisteredCapabilities = serde_json::from_str(json).map_err(|e| e.to_string())?; + Ok(lift_registration(&caps)) +} /// 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 @@ -140,6 +161,54 @@ mod tests { assert!(err.contains("ExecuteCommand"), "got: {err}"); } + /// B2-lift end-to-end: a real `GET /capabilities` REST response (JSON) + /// parses → lifts to a concrete signature → the signature binds engine + /// parameters → the native executor runs the command. The deployed config + /// (not the schema) supplied the concrete `(name, mandatory, default)`. + #[test] + fn rest_registration_lifts_binds_and_runs() { + use ogar_from_schema::action_ws::bind_parameters; + + // A deployed handler's GET /capabilities body (MapOfCapabilities). + let body = r#"{ + "ExecuteCommand": { + "description": "run a command", + "mandatoryParameters": { + "command": { "description": "the command" } + }, + "optionalParameters": { + "shell": { "description": "the shell", "default": "sh" } + } + } + }"#; + + let lifted = parse_capabilities(body).expect("valid MapOfCapabilities"); + assert_eq!(lifted.len(), 1); + let cap = &lifted[0]; + assert_eq!(cap.name, "ExecuteCommand"); + // The concrete signature the schema half could not produce: + assert_eq!(cap.params.len(), 2); + assert!( + cap.params + .iter() + .any(|p| p.name == "command" && p.mandatory) + ); + assert!( + cap.params + .iter() + .any(|p| p.name == "shell" && !p.mandatory && p.default.as_deref() == Some("sh")) + ); + + // The lifted signature drives bind (optional `shell` default filled), + // and the bound command runs on the native executor. + let supplied = vec![("command".to_owned(), "echo lifted".to_owned())]; + let bound = bind_parameters(&supplied, &cap.params).expect("binds"); + let result = NativeCommandExecutor + .execute("ExecuteCommand", &bound) + .expect("runs"); + assert_eq!(result[0], ("output".to_owned(), "lifted".to_owned())); + } + /// 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/lib.rs b/crates/ogar-from-schema/src/lib.rs index f32c18f..51fee59 100644 --- a/crates/ogar-from-schema/src/lib.rs +++ b/crates/ogar-from-schema/src/lib.rs @@ -59,6 +59,7 @@ use ogar_vocab::{Attribute, Class, EnumDecl, EnumSource, Language}; pub mod action_ws; pub mod do_arm; +pub mod registration; pub mod sgo; pub mod ttl; pub mod ttl_emit; diff --git a/crates/ogar-from-schema/src/registration.rs b/crates/ogar-from-schema/src/registration.rs new file mode 100644 index 0000000..de2b6bd --- /dev/null +++ b/crates/ogar-from-schema/src/registration.rs @@ -0,0 +1,297 @@ +//! HIRO **Action API** REST registration lift — parity brick **B2-lift**. +//! +//! Registration is REST, not a WebSocket handshake (`docs/ARAGO-ACTIONHANDLER-PARITY.md` +//! §2a): a deployed handler's capabilities and applicabilities live in the graph +//! and are read via the REST Action API (`/api/action/1.0/`, Bearer token) — +//! `GET /capabilities` → a `MapOfCapabilities` (each +//! `{description, mandatoryParameters, optionalParameters}`), `GET /applicabilities` +//! → a `MapOfApplicabilities` (keyed by handler id, each carrying the node-match +//! `ModelFilter`s). The `action-ws` socket has **no** registration message — a +//! `configChanged` just tells the handler to re-`GET` them. +//! +//! This module is the **instance lift**: it turns that deployed REST view into the +//! concrete OGAR signatures the schema half cannot supply. The OGIT ontology +//! declares only *that* the param slots exist (the `mandatoryParameters` / +//! `optionalParameters` / `resultParameters` attributes — see +//! [`crate::do_arm::CapabilitySlot`]); the concrete `(name, mandatory, default)` +//! tuples come from a *deployed* handler config — exactly the gap +//! [`crate::do_arm::ActionParam`]'s doc-comment names. B2-lift fills it: +//! +//! - `GET /capabilities` → [`ConcreteCapability`] (a [`do_arm::ActionParam`] list +//! per capability — the signature [`crate::action_ws::bind_parameters`] validates +//! the engine's `parameters` against). +//! - `GET /applicabilities` → the node-match guards: arago `ModelFilter{Var,Mode, +//! Value}` lifts field-for-field to [`KausalSpec::StateGuard`] (`Var` → +//! `guard_field`, `Value` → `guard_values`). +//! +//! **No parser here.** Mirroring the crate's parser-free default path, this module +//! defines the typed REST DTOs (`Deserialize` behind the `serde` feature) plus the +//! *pure* lift mapping. The actual JSON read (`serde_json::from_str`) is the +//! runtime crate's job (`ogar-action-handler`), which owns the I/O — the same +//! producer-defines-types / runtime-does-I/O split the rest of the crate keeps. + +use std::collections::BTreeMap; + +#[cfg(feature = "serde")] +use serde::Deserialize; + +use ogar_vocab::KausalSpec; + +use crate::do_arm::ActionParam; + +/// One parameter as the REST Action API reports it: a `description` and an +/// optional `default`. Whether it is *mandatory* is carried by which map it +/// appears in (`mandatoryParameters` vs `optionalParameters`), per the spec — +/// so this DTO does not repeat it. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Deserialize))] +pub struct RegisteredParam { + /// Human-readable description (arago `Parameter.Description`). + #[cfg_attr(feature = "serde", serde(default))] + pub description: Option, + /// Default value when the engine does not supply one (arago `Parameter.Default`). + #[cfg_attr(feature = "serde", serde(default))] + pub default: Option, +} + +/// One capability as `GET /capabilities` reports it. The parameter maps are +/// keyed by parameter name (`MapOfParameters`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct RegisteredCapability { + /// Human-readable description (arago `Capability.Description`). + #[cfg_attr(feature = "serde", serde(default))] + pub description: Option, + /// Required-input signature, keyed by parameter name. + #[cfg_attr(feature = "serde", serde(default))] + pub mandatory_parameters: BTreeMap, + /// Optional-input signature, keyed by parameter name. + #[cfg_attr(feature = "serde", serde(default))] + pub optional_parameters: BTreeMap, +} + +/// The `GET /capabilities` response — a `MapOfCapabilities` keyed by capability +/// name (the arago `Capability.Name`, which matches an `ActionDef::predicate`). +pub type RegisteredCapabilities = BTreeMap; + +/// A single node-match filter as arago reports it: `ModelFilter{Var, Mode, +/// Value}` — "the node attribute `Var` `Mode`-matches `Value`". The documented +/// field-for-field source of a [`KausalSpec::StateGuard`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct ModelFilter { + /// The node attribute being matched (arago `ModelFilter.Var`) → `guard_field`. + pub var: String, + /// The match mode (`equals` / `matches` / …). `StateGuard` is set-membership, + /// so the mode is preserved here for fidelity but does not change the lifted + /// guard's shape (an `equals` over one value). + #[cfg_attr(feature = "serde", serde(default))] + pub mode: Option, + /// The value to match (arago `ModelFilter.Value`) → the single `guard_values`. + pub value: String, +} + +/// A concrete capability signature, lifted from the deployed REST registration: +/// the capability name plus its full [`ActionParam`] list (mandatory params +/// first, then optional — each with its concrete `(name, mandatory, default)`). +/// +/// This is the half the schema lift cannot produce. Feed `params` to +/// [`crate::action_ws::bind_parameters`] to validate an engine `submitAction`'s +/// `parameters` the way arago's Python handler does before executing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConcreteCapability { + /// The capability name (`ActionDef::predicate`). + pub name: String, + /// Human-readable description, if the registration carried one. + pub description: Option, + /// The concrete parameter signature — mandatory params first, then optional. + pub params: Vec, +} + +/// Lift one deployed capability into its concrete [`ConcreteCapability`] — +/// mandatory params (mandatory = `true`) first, then optional (mandatory = +/// `false`), each carrying its `default`. Deterministic order (the source maps +/// are [`BTreeMap`]s, sorted by parameter name). +#[must_use] +pub fn lift_capability(name: &str, cap: &RegisteredCapability) -> ConcreteCapability { + let mandatory = cap.mandatory_parameters.iter().map(|(n, p)| ActionParam { + name: n.clone(), + mandatory: true, + default: p.default.clone(), + }); + let optional = cap.optional_parameters.iter().map(|(n, p)| ActionParam { + name: n.clone(), + mandatory: false, + default: p.default.clone(), + }); + ConcreteCapability { + name: name.to_owned(), + description: cap.description.clone(), + params: mandatory.chain(optional).collect(), + } +} + +/// Lift a whole `GET /capabilities` response into the concrete capability +/// signatures, one [`ConcreteCapability`] per capability (sorted by name). +#[must_use] +pub fn lift_registration(caps: &RegisteredCapabilities) -> Vec { + caps.iter().map(|(n, c)| lift_capability(n, c)).collect() +} + +/// Lift one arago `ModelFilter{Var,Mode,Value}` into its OGAR +/// [`KausalSpec::StateGuard`] — `Var` → `guard_field`, `Value` → the single +/// `guard_values` entry. The documented field-for-field applicability mapping +/// (the same one `do_arm::kausal_from_entity` reaches at the schema level, here +/// carrying the deployed *value*). +#[must_use] +pub fn model_filter_to_guard(filter: &ModelFilter) -> KausalSpec { + KausalSpec::StateGuard { + guard_field: filter.var.clone(), + guard_values: vec![filter.value.clone()], + } +} + +/// Lift a deployed applicability's `ModelFilter`s into the OGAR guard set — one +/// [`KausalSpec::StateGuard`] per filter (an applicability applies where **all** +/// of its filters match; each guard is one such condition). +#[must_use] +pub fn lift_applicability(filters: &[ModelFilter]) -> Vec { + filters.iter().map(model_filter_to_guard).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ssh_execute_command() -> RegisteredCapability { + let mut mandatory = BTreeMap::new(); + mandatory.insert( + "command".to_owned(), + RegisteredParam { + description: Some("the shell command to run".to_owned()), + default: None, + }, + ); + mandatory.insert( + "host".to_owned(), + RegisteredParam { + description: Some("target host".to_owned()), + default: None, + }, + ); + let mut optional = BTreeMap::new(); + optional.insert( + "user".to_owned(), + RegisteredParam { + description: Some("ssh user".to_owned()), + default: Some("root".to_owned()), + }, + ); + RegisteredCapability { + description: Some("run a command over SSH".to_owned()), + mandatory_parameters: mandatory, + optional_parameters: optional, + } + } + + #[test] + fn lifts_concrete_params_mandatory_first_then_optional() { + let cap = lift_capability("ExecuteCommand", &ssh_execute_command()); + assert_eq!(cap.name, "ExecuteCommand"); + assert_eq!(cap.description.as_deref(), Some("run a command over SSH")); + // BTreeMap order: mandatory {command, host} then optional {user}. + assert_eq!( + cap.params, + vec![ + ActionParam { + name: "command".to_owned(), + mandatory: true, + default: None + }, + ActionParam { + name: "host".to_owned(), + mandatory: true, + default: None + }, + ActionParam { + name: "user".to_owned(), + mandatory: false, + default: Some("root".to_owned()) + }, + ] + ); + } + + #[test] + fn lifts_a_full_capabilities_map_sorted_by_name() { + let mut caps: RegisteredCapabilities = BTreeMap::new(); + caps.insert("ExecuteCommand".to_owned(), ssh_execute_command()); + caps.insert("CheckStatus".to_owned(), RegisteredCapability::default()); + let lifted = lift_registration(&caps); + // Sorted by capability name: CheckStatus before ExecuteCommand. + assert_eq!(lifted.len(), 2); + assert_eq!(lifted[0].name, "CheckStatus"); + assert!(lifted[0].params.is_empty()); + assert_eq!(lifted[1].name, "ExecuteCommand"); + assert_eq!(lifted[1].params.len(), 3); + } + + #[test] + fn the_lifted_signature_drives_bind_parameters() { + // The B2-lift signature is exactly what the dispatch's bind step consumes: + // a concrete ActionParam[] feeds action_ws::bind_parameters. + let cap = lift_capability("ExecuteCommand", &ssh_execute_command()); + let supplied = vec![ + ("command".to_owned(), "uptime".to_owned()), + ("host".to_owned(), "node-1".to_owned()), + ]; + let bound = crate::action_ws::bind_parameters(&supplied, &cap.params) + .expect("mandatory present; optional default filled"); + // `user` was omitted → its default "root" is filled in by bind. + assert!(bound.iter().any(|(k, v)| k == "user" && v == "root")); + assert!(bound.iter().any(|(k, v)| k == "command" && v == "uptime")); + } + + #[test] + fn model_filter_lifts_to_state_guard_field_for_field() { + let filter = ModelFilter { + var: "ogit/Network/Machine/class".to_owned(), + mode: Some("equals".to_owned()), + value: "Linux".to_owned(), + }; + assert_eq!( + model_filter_to_guard(&filter), + KausalSpec::StateGuard { + guard_field: "ogit/Network/Machine/class".to_owned(), + guard_values: vec!["Linux".to_owned()], + } + ); + } + + #[test] + fn applicability_lifts_each_filter_to_a_guard() { + let filters = vec![ + ModelFilter { + var: "os".to_owned(), + mode: None, + value: "Linux".to_owned(), + }, + ModelFilter { + var: "env".to_owned(), + mode: Some("equals".to_owned()), + value: "prod".to_owned(), + }, + ]; + let guards = lift_applicability(&filters); + assert_eq!(guards.len(), 2); + assert_eq!( + guards[1], + KausalSpec::StateGuard { + guard_field: "env".to_owned(), + guard_values: vec!["prod".to_owned()], + } + ); + } +} diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md index f053baf..36682ec 100644 --- a/docs/ARAGO-ACTIONHANDLER-PARITY.md +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -205,9 +205,20 @@ downstream. The remaining bricks: `tokio-tungstenite` client (connect with the `token-$TOKEN` subprotocol, JSON- codec the six `action_ws` message types, drive the dispatch, retry-on-no-ack). All the message types, connection path, and auth are now pinned (§2a). -- **B2-lift — the instance config lift.** Parse a deployed handler's REST - registration (`GET /capabilities`, `/applicabilities`) → the concrete - `ActionDef` + `ActionParam[]`, reusing `assemble_action_handler`'s shape. +- **B2-lift — the instance config lift (SHIPPED for capabilities).** Parse a + deployed handler's REST registration → the concrete signatures the *schema* + half cannot supply. `GET /capabilities` is **shipped**: `registration::{RegisteredCapability, + lift_registration}` (the pure lift, in the parser-free producer) + + `ogar-action-handler::parse_capabilities` (the `serde_json` read, in the runtime) + turn a real `MapOfCapabilities` JSON body into `ConcreteCapability` — + `ActionParam[]` with concrete `(name, mandatory, default)` — proven end-to-end by + `rest_registration_lifts_binds_and_runs` (JSON → lift → `bind_parameters` → + `NativeCommandExecutor` runs the command). The applicability side is the + documented field-for-field lift: `ModelFilter{Var,Mode,Value}` → + `KausalSpec::StateGuard` (`model_filter_to_guard` / `lift_applicability`, shipped); + what remains is wiring the `GET /applicabilities` JSON envelope read (the + `MapOfApplicabilities` outer shape) the same way `parse_capabilities` reads + capabilities. What remains is **glue over existing types** (a socket loop + a JSON codec + a registration parser) plus the non-native executor targets — not new IR. The @@ -231,7 +242,8 @@ transport over them. | **Hard gate before executor (B1-uplink)** | ✅ `[G]` SHIPPED | rs-graph-llm `graph-flow-action-ogar::GatedOgarHandler` — `commit_via` (RBAC ∧ guard ∧ MUL) lands before `handle`; `take_result()` is `None` iff the gate refused (3 tests) | | **Executor — SSH/REST/WinRM (B1)** | ⛔ `[H]` | further `CapabilityExecutor` impls (rs-graph-llm `graph-flow-action`) | | **Live WebSocket transport (B2-transport)** | ⛔ `[H]` | wrap `handle_submit` in a `tokio-tungstenite` loop + JSON codec (all shapes pinned, §2a) | -| **Instance config lift (B2-lift)** | ⛔ `[H]` | REST `GET /capabilities`/`/applicabilities` → `ActionDef`/`ActionParam` | +| **Instance config lift — capabilities (B2-lift)** | ✅ `[G]` SHIPPED | `registration::lift_registration` + `ogar-action-handler::parse_capabilities`: real `GET /capabilities` JSON → `ConcreteCapability` (`ActionParam[]`); `rest_registration_lifts_binds_and_runs` (JSON → lift → bind → run) | +| **Instance config lift — applicabilities (B2-lift)** | 🟡 `[G]` lift / `[H]` envelope | `ModelFilter{Var,Mode,Value}` → `StateGuard` shipped (`model_filter_to_guard`/`lift_applicability`); remaining: the `GET /applicabilities` `MapOfApplicabilities` JSON envelope read | **Verdict:** OGAR is at **full contract + lifecycle + protocol-binding + reactive-dispatch parity**, and **a working native executor runs real commands @@ -241,10 +253,13 @@ binding, the dispatch, and native execution are real and tested — and the gate now **wired to the executor**: rs-graph-llm's `graph-flow-action-ogar` runs OGAR's `CapabilityExecutor` only after `commit_via` commits, so an unauthorized or MUL-blocked action never executes (proven structurally — `take_result()` is -`None`). What's left for +`None`). The **capabilities instance lift is shipped too** — a real `GET +/capabilities` JSON body lifts to concrete `ActionParam[]` and runs end-to-end +(`rest_registration_lifts_binds_and_runs`). What's left for a **live** drop-in replacement of arago's Python daemon: **B2-transport** (the -WebSocket loop — all shapes/auth pinned), **B2-lift** (the REST registration -parse), and the **non-native executor targets** (SSH/REST). Each is +WebSocket loop — all shapes/auth pinned), the **B2-lift applicabilities envelope** +(`GET /applicabilities` JSON read; the `ModelFilter→StateGuard` lift is done), and +the **non-native executor targets** (SSH/REST). Each is transport/parser/runner glue over existing types — **no missing IR, no missing protocol mapping**. That is the honest state: OGAR *is* an ActionHandler that runs commands here; a thin transport away from connecting to a live HIRO engine. @@ -267,6 +282,11 @@ is replaceable; the parity claim is certified, not argued. - `crates/ogar-from-schema/src/do_arm.rs` — `assemble_action_handler`, `ActionHandlerSpec` / `CapabilitySlot` / `ApplicabilitySlot` / `ActionParam`. +- `crates/ogar-from-schema/src/registration.rs` — B2-lift: the REST registration + DTOs (`RegisteredCapability` / `ModelFilter`) + the pure lift + (`lift_registration` → `ConcreteCapability`; `model_filter_to_guard`). +- `crates/ogar-action-handler/src/lib.rs` — `parse_capabilities` (the `serde_json` + read of a `GET /capabilities` body; the B2-lift I/O half). - `docs/HIRO-DO-ARM-LIFT.md` — the lossless-DO rule (the body is pointed-to). - `docs/ACTIONHANDLER-TURSTEHER.md` — RBAC-as-`const`, the cold-path gate, Rung. - `lance-graph-contract::action` — `ActionInvocation` / `commit_via`. diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 7ebfb23..719016c 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -212,6 +212,7 @@ two halves of a cell. ADR‑026 names the cascade that ties them. | D‑MARS‑CLASSID | MARS/Automation classids MINTED: `ConceptDomain::Automation` (0x0C), 9 concepts (`mars_application/resource/software/machine` 0x0C01‑04, `knowledge_item` 05, `mars_node_template` 06, `action_handler` 07, `action_applicability` 08, `automation_trigger` 09) — one domain spanning MARS structural CMDB + Automation DO‑arm (Auth precedent); resolves MARS‑TRANSCODING §1 deferral; passed 5+3 hardening (theorem/doctrine/integration/runtime savants + drift‑guards). Reserves the speculative rest | G | CODED | `ogar-vocab/src/lib.rs`, `ogar-class-view/src/lib.rs` | D‑VOCAB, D‑HIRO‑DO | | D‑ACTIONHANDLER‑PARITY | arago HIRO ActionHandler ⟷ OGAR: `assemble_action_handler` walks the OGIT `provides` graph (`ActionHandler→ActionApplicability→ActionCapability`) into `ActionHandlerSpec`/`CapabilitySlot`/`ApplicabilitySlot`/`ActionParam`. Config+ontology+`action-ws` protocol all map to OGAR types: arago `ModelFilter{Var,Mode,Value}`→`StateGuard`; `Capability.Name`→`predicate`; `resultParameters`→output sig; `action-ws` `submitAction→ack→sendActionResult` ⟷ `ActionInvocation` `Pending→Committed` (`commit_via` is the gate). **B2 protocol core SHIPPED + spec-faithful** (`action_ws`: all 6 `action-ws.yaml` message types — submitAction/sendActionResult/acknowledged/negativeAcknowledged/configChanged/error — + `submit_to_invocation`/`bind_parameters`/`invocation_to_result` (result=JSON string ≤1 MiB per spec) + connection consts (`ACTION_WS_PATH`, `auth_subprotocol`, `validate_id`); socket-free, `full_action_ws_roundtrip` proven; harvested from the HIRO 7.0 dev-portal specs §2a). **Reactive dispatch + B1 native executor SHIPPED**: `action_ws::handle_submit` (validate→ack/nack→bind→execute→result) over the `CapabilityExecutor` trait (the B1 seam); `ogar-action-handler::NativeCommandExecutor` runs `ExecuteCommand` for real (`full_dispatch_runs_a_real_command` — OGAR runs a command end-to-end). Remaining for a live drop-in: B2-transport (WS loop), B2-lift (REST registration parse), SSH/REST executor targets; gated on `PROBE‑OGAR‑ACTIONHANDLER‑RUN` | G (contract+protocol+native exec) / H (live socket) | CODED | `ogar-from-schema/src/{do_arm,action_ws}.rs`, `ogar-action-handler/`, `docs/ARAGO-ACTIONHANDLER-PARITY.md` | D‑HIRO‑DO, D‑MARS‑CLASSID | | D‑ACTIONHANDLER‑UPLINK | The hard gate wired to the OGAR executor (cross‑repo seam): rs‑graph‑llm `graph-flow-action-ogar::GatedOgarHandler` wraps an OGAR `CapabilityExecutor` as a `graph-flow-action::ActionHandler`, so `dispatch_via`'s cold floor (`commit_via`: def‑match → RBAC `ClassRbac` → `StateGuard` → MUL) lands **before** the executor's `handle`. Structural proof the contract lands first: `take_result()`/`run_gated` returns `None` whenever the gate refused — unauthorized actor → `Denied` (executor never runs), MUL `Block` → `Escalated` (executor never runs); only the authorized path reaches `NativeCommandExecutor` and runs the real command (3 tests). Dependency hygiene held: `graph-flow-action` stays contract‑only (`I‑ACTIONHANDLER‑IS‑KGV‑NOT‑CHOKEPOINT`); `ogar-from-schema` carries no `lance-graph` dep — the two sides meet only at this crate's API (one `lance-graph-contract`). rs‑graph‑llm pinned to toolchain 1.95.0 to match the AdaWorldAPI stack | G | CODED | `rs-graph-llm/graph-flow-action-ogar/src/lib.rs` | D‑ACTIONHANDLER‑PARITY | +| D‑ACTIONHANDLER‑B2LIFT | REST registration **instance lift** (B2-lift) — turns a deployed handler's `GET /capabilities` JSON (`MapOfCapabilities`) into the concrete signatures the *schema* half can't supply: `registration::{RegisteredCapability,ModelFilter}` (typed DTOs, `Deserialize` behind the `serde` feature) + the pure lift `lift_registration → ConcreteCapability` (concrete `ActionParam[]` with `(name,mandatory,default)`) and `model_filter_to_guard` (arago `ModelFilter{Var,Mode,Value}`→`KausalSpec::StateGuard`, field‑for‑field). Producer stays parser‑free; the runtime `ogar-action-handler::parse_capabilities` does the `serde_json` read (producer‑defines‑types / runtime‑does‑I/O split). Proven end‑to‑end: `rest_registration_lifts_binds_and_runs` (real JSON → lift → `bind_parameters` → `NativeCommandExecutor` runs the command). Remaining: the `GET /applicabilities` `MapOfApplicabilities` envelope read (the `ModelFilter→StateGuard` lift is done) | G (capabilities) / H (applicabilities envelope) | CODED | `ogar-from-schema/src/registration.rs`, `ogar-action-handler/src/lib.rs` | D‑ACTIONHANDLER‑PARITY | | D‑OSM | `ogar-from-osm-pbf` — Node/Way/Relation; quadkey NiblePath from resolved geometry | H | IDEA | (queued) | D‑VOCAB, `[per rt]` D‑OSM‑3 | | D‑PATTERN | `ogar-pattern` — recognition library + confidence (FMA‑D/FIBO/SKR/PROV‑O) | H | IDEA | (queued) | D‑TTL | | D‑ACTION | `ogar-actionable` — lifecycle → `ActionDef`/`KausalSpec` | H | IDEA | (queued) | D‑PATTERN |