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
75 changes: 75 additions & 0 deletions .claude/board/EPIPHANIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,81 @@

---

## 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).

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
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions crates/ogar-action-handler/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[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", 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"
249 changes: 249 additions & 0 deletions crates/ogar-action-handler/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//! `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;
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<Vec<ConcreteCapability>, 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
/// 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<ClassRbac>`: 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<Vec<(String, String)>, 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}");
}

/// 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]
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
);
}
}
Loading
Loading