From b5e2ac0f6d4b0a998b85a21bf0df6c84333037e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 06:23:27 +0000 Subject: [PATCH 1/3] =?UTF-8?q?do=5Farm:=20arago=20ActionHandler=20contrac?= =?UTF-8?q?t=20parity=20=E2=80=94=20assemble=20+=20action-ws=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings OGAR to full CONTRACT + LIFECYCLE parity with arago's HIRO ActionHandler (the operator's "switch from Python to OGAR running it here" goal), grounded in the real arago sources (github.com/arago/ActionHandlers config format, python-hiro-stonebranch-actionhandler daemon, HIRO 7 Action API action-ws protocol) + the vendored OGIT NTO/Automation ontology. do_arm additions (string-keyed; no new codebook mint needed): - assemble_action_handler(entities) walks the OGIT `provides` graph (ActionHandler -> ActionApplicability -> ActionCapability) into ActionHandlerSpec / ApplicabilitySlot / CapabilitySlot. - ActionParam { name, mandatory, default } — the arago Parameter I/O tuple. - Parity points proven by tests: arago ModelFilter{Var,Mode,Value} -> KausalSpec::StateGuard (environmentFilter); Capability declares the mandatory/optional/result param slots (resultParameters = the output sig). docs/ARAGO-ACTIONHANDLER-PARITY.md — the scorecard: - config+ontology contract: full parity [G] (the assembly). - action-ws protocol <-> ActionInvocation Rubicon lifecycle: submitAction -> ack -> execute -> sendActionResult -> ack maps onto Pending -> (commit_via: RBAC ^ guard ^ MUL) -> Committed -> Lance-append, field-for-field. - the switch path: an OGAR action-ws adapter replaces the Python daemon; the only unbuilt pieces are B1 (the ExecTarget executor) and B2 (the action-ws adapter + deployed-YAML instance lift) — glue over existing types, not new IR. - PROBE-OGAR-ACTIONHANDLER-RUN certifies B1/B2 (replay a real arago submitAction corpus; assert sendActionResult matches bit-for-bit). 27 do_arm tests green (4 new), clippy-clean. Ledger D-ACTIONHANDLER-PARITY; EPIPHANIES E-ARAGO-ACTIONHANDLER-PARITY. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- .claude/board/EPIPHANIES.md | 50 +++++ crates/ogar-from-schema/src/do_arm.rs | 215 +++++++++++++++++++ crates/ogar-from-schema/tests/do_arm_emit.rs | 15 +- docs/ARAGO-ACTIONHANDLER-PARITY.md | 187 ++++++++++++++++ docs/DISCOVERY-MAP.md | 1 + 5 files changed, 461 insertions(+), 7 deletions(-) create mode 100644 docs/ARAGO-ACTIONHANDLER-PARITY.md diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 6f16912..5e55a60 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -7,6 +7,56 @@ --- +## 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 +on `PROBE-OGAR-ACTIONHANDLER-RUN`). + +Operator goal: parity with arago's HIRO ActionHandler such that one "could +basically switch from [arago's] Python to OGAR running it here." Researched the +real arago sources (`github.com/arago/ActionHandlers` config format, +`arago/python-hiro-stonebranch-actionhandler` daemon, HIRO 7 Action API +`action-ws` protocol) and scored OGAR against all three layers. + +**The three parity findings:** + +1. **Config + ontology = one contract, and OGAR lifts it.** arago's handler YAML + (`Capability{Name,Description,Command,Parameter[]}` + `Applicability{ModelFilter,…}`) + and the OGIT `NTO/Automation` ontology (`ActionHandler→provides→ActionApplicability + →provides→ActionCapability`) are two encodings of one shape. + `do_arm::assemble_action_handler` walks the vendored `provides` graph into + `ActionHandlerSpec`/`CapabilitySlot`/`ApplicabilitySlot`/`ActionParam` — proven + by `assembles_the_full_action_handler_contract`. + +2. **`ModelFilter` IS `StateGuard`.** arago's node-match `ModelFilter{Var,Mode,Value}` + maps field-for-field to OGAR `KausalSpec::StateGuard{guard_field,guard_values}` + (carried by the `environmentFilter` attribute). The applicability guard was + already an OGAR type. + +3. **The `action-ws` lifecycle IS the `ActionInvocation` Rubicon.** `submitAction → + handler acknowledged → execute → sendActionResult → server acknowledged` maps + onto `Pending → (commit_via: RBAC ∧ guard ∧ MUL) → Committed → Lance-append`. + `submitAction.timeout`→`state_timeout_millis`; `submitAction.id`→`idempotency_key`; + `sendActionResult.result`→the `resultParameters` output; the server ack→the + `CommitHook` Lance commit ("state history IS the version log"). Nothing in the + protocol needs a type OGAR lacks. + +**The honest verdict:** OGAR is at parity on *what an ActionHandler is* (contract) +and *how an action flows* (lifecycle) — every config/ontology/protocol field has +an OGAR type, and the execution gate (`commit_via`) is shipped. The +switch to "OGAR running it here" reduces to **two glue bricks over existing +types**: **B1** the `ExecTarget` executor (run the Command → result; +`graph-flow-action`'s trait, still no impl) and **B2** the action-ws adapter + +deployed-handler-YAML→`ActionDef`/`ActionParam` instance lift. Both are glue, not +new IR. Certified by `PROBE-OGAR-ACTIONHANDLER-RUN` (replay a real arago +`submitAction` corpus; assert `sendActionResult` matches bit-for-bit). + +`action_capability` / `intent` / `automation_issue` stay RESERVED in the codebook +— the assembly is string-keyed; they mint when B1 resolves them by classid. +Full treatment: `docs/ARAGO-ACTIONHANDLER-PARITY.md`; ledger D-ACTIONHANDLER-PARITY. + +--- + ## 2026-06-24 — E-MARS-AUTOMATION-MINT — the MARS/Automation classids are minted: `ConceptDomain::Automation` (0x0C), the deferred 5+3 codebook pass **Status:** FINDING (grounded `[G]` — shipped + drift-guard-green). diff --git a/crates/ogar-from-schema/src/do_arm.rs b/crates/ogar-from-schema/src/do_arm.rs index ac9f09d..740934d 100644 --- a/crates/ogar-from-schema/src/do_arm.rs +++ b/crates/ogar-from-schema/src/do_arm.rs @@ -257,6 +257,158 @@ pub fn lift_action_defs(entities: &[EntityDecl]) -> Vec { entities.iter().filter_map(into_action_def).collect() } +// ───────────────────────────────────────────────────────────────────── +// The arago ActionHandler contract — parity with arago's HIRO ActionHandler +// (`github.com/arago/ActionHandlers`, `arago/python-hiro-*-actionhandler`). +// +// An arago ActionHandler is a daemon that registers a **Configuration** + one +// or more **Capabilities** + **Applicabilities** with the HIRO engine, receives +// actions over the action-ws API, executes them, and returns a result. The OGIT +// `NTO/Automation` ontology IS that contract's schema: +// +// ActionHandler ──provides──► ActionApplicability ──provides──► ActionCapability +// (the daemon) (node-match guard) (named op + I/O) +// +// arago's deployed-handler config (the SSH/Stonebranch YAML) maps field-for-field: +// Capability { Name, Description, Command/Interpreter, Parameter[]{Name,Mandatory,Default} } +// Applicability{ Priority, ModelFilter{Var,Mode,Value}, Capability, Parameter[] } +// +// The parity points (see `docs/ARAGO-ACTIONHANDLER-PARITY.md`): +// Applicability.ModelFilter{Var,Mode,Value} → KausalSpec::StateGuard{field,[value]} +// Capability.Name → ActionDef.predicate +// Capability.Parameter[] / resultParameters → the typed I/O signature (below) +// Capability.Command/Interpreter → ActionDef.body_source (pointed-to, lossless-DO) +// ───────────────────────────────────────────────────────────────────── + +/// One typed parameter of a capability/applicability — an element of the +/// action's I/O signature. Mirrors arago's deployed-handler `Parameter +/// { Name, Mandatory, Default }` (the SSH / Stonebranch handler config shape). +/// +/// At the *schema* level the OGIT ontology declares only that the param *slots* +/// exist (the `mandatoryParameters` / `optionalParameters` / `resultParameters` +/// attributes); the concrete `(name, mandatory, default)` tuples come from a +/// *deployed* handler config (the instance lift — see the parity doc). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionParam { + /// Parameter name (arago `Parameter.Name`). + pub name: String, + /// Whether the parameter is required (arago `Parameter.Mandatory`). + pub mandatory: bool, + /// Default value when not supplied (arago `Parameter.Default`). + pub default: Option, +} + +/// An **ActionCapability** — a named operation plus its typed I/O signature. +/// Mirrors arago's `Capability { Name, Description, Command/Interpreter, +/// Parameter[], resultParameters }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilitySlot { + /// Canonical identity (`ogit-automation/action_capability`). + pub identity: String, + /// Declares a `mandatoryParameters` slot (the required-input signature). + pub declares_mandatory_params: bool, + /// Declares an `optionalParameters` slot (the optional-input signature). + pub declares_optional_params: bool, + /// Declares a `resultParameters` slot — **the output signature**. This is + /// the half that makes an action *runnable*: a caller knows what the + /// capability returns (arago `resultParameters` → the action result). + pub declares_result_params: bool, +} + +/// An **ActionApplicability** — the node-match guard selecting *when/where* a +/// handler applies. arago's `ModelFilter { Var, Mode, Value }` is OGAR's +/// [`KausalSpec::StateGuard`] (`Var` → `guard_field`, `Value` → `guard_values`); +/// the schema-level `environmentFilter` attribute is its carrier. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApplicabilitySlot { + /// Canonical identity (`ogit-automation/action_applicability`). + pub identity: String, + /// The node-match guard (the `environmentFilter` → `StateGuard`). + pub kausal: Option, + /// The capabilities this applicability `provides`. + pub capabilities: Vec, +} + +/// The full **ActionHandler** contract — arago's ActionHandler assembled from +/// the OGIT `provides` graph (`ActionHandler → ActionApplicability → +/// ActionCapability`). The OGAR-native parity surface for an arago HIRO +/// ActionHandler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionHandlerSpec { + /// Canonical identity (`ogit-automation/action_handler`). + pub identity: String, + /// The applicabilities this handler `provides` (each gating its capabilities). + pub applicabilities: Vec, +} + +/// Find an entity by its local name (`ActionApplicability`) in a set. +fn find_by_local<'a>(entities: &'a [EntityDecl], local_name: &str) -> Option<&'a EntityDecl> { + entities.iter().find(|e| e.name == local_name) +} + +/// Does the entity declare the given parameter slot (in either its mandatory or +/// optional attribute list)? — e.g. `"resultParameters"`. +fn declares_param_slot(entity: &EntityDecl, slot: &str) -> bool { + entity + .mandatory_attributes + .iter() + .chain(entity.optional_attributes.iter()) + .any(|curie| local(curie) == slot) +} + +/// The local names an entity `provides` (the `ogit:provides` allowed-edges). +fn provides_targets(entity: &EntityDecl) -> impl Iterator { + entity + .allowed + .iter() + .filter(|(verb, _)| local(verb) == "provides") + .map(|(_, target)| local(target)) +} + +/// Lift an `ActionCapability` entity into a [`CapabilitySlot`]. +fn capability_slot(entity: &EntityDecl) -> CapabilitySlot { + CapabilitySlot { + identity: canonical_object_class(&entity.curie), + declares_mandatory_params: declares_param_slot(entity, "mandatoryParameters"), + declares_optional_params: declares_param_slot(entity, "optionalParameters"), + declares_result_params: declares_param_slot(entity, "resultParameters"), + } +} + +/// Lift an `ActionApplicability` entity into an [`ApplicabilitySlot`], resolving +/// the capabilities it `provides` against `entities`. +fn applicability_slot(entity: &EntityDecl, entities: &[EntityDecl]) -> ApplicabilitySlot { + let capabilities = provides_targets(entity) + .filter_map(|target| find_by_local(entities, target)) + .map(capability_slot) + .collect(); + ApplicabilitySlot { + identity: canonical_object_class(&entity.curie), + kausal: kausal_from_entity(entity), + capabilities, + } +} + +/// Assemble the full arago ActionHandler contract from a set of OGIT Automation +/// entities — walk `ActionHandler → provides → ActionApplicability → provides → +/// ActionCapability`. Returns `None` if the set carries no `ActionHandler`. +/// +/// This is the OGAR-native, ontology-grounded parity surface for an arago HIRO +/// ActionHandler: the SAME `(handler, applicabilities[guard], capabilities[I/O])` +/// shape arago registers with the HIRO engine, expressed in OGAR types. +#[must_use] +pub fn assemble_action_handler(entities: &[EntityDecl]) -> Option { + let handler = find_by_local(entities, "ActionHandler")?; + let applicabilities = provides_targets(handler) + .filter_map(|target| find_by_local(entities, target)) + .map(|app| applicability_slot(app, entities)) + .collect(); + Some(ActionHandlerSpec { + identity: canonical_object_class(&handler.curie), + applicabilities, + }) +} + // ───────────────────────────────────────────────────────────── tests ── // // The schema-level half of PROBE-OGAR-DO-ARM-LIFT: prove the §4 field mapping @@ -422,4 +574,67 @@ mod tests { ); assert_eq!(defs[0].predicate, "execute"); } + + // ── arago ActionHandler contract parity (the `provides` graph) ── + + const ACTION_CAPABILITY_TTL: &str = + include_str!("../../../vocab/imports/ogit/NTO/Automation/entities/ActionCapability.ttl"); + + /// The full arago ActionHandler contract assembles from the vendored OGIT + /// `provides` graph: ActionHandler → ActionApplicability → ActionCapability. + #[test] + fn assembles_the_full_action_handler_contract() { + let entities = [ + entity(ACTION_HANDLER_TTL), + entity(ACTION_APPLICABILITY_TTL), + entity(ACTION_CAPABILITY_TTL), + ]; + let spec = assemble_action_handler(&entities).expect("ActionHandler present"); + + assert_eq!(spec.identity, "ogit-automation/action_handler"); + // ActionHandler provides one ActionApplicability. + assert_eq!(spec.applicabilities.len(), 1); + let app = &spec.applicabilities[0]; + assert_eq!(app.identity, "ogit-automation/action_applicability"); + // arago ModelFilter{Var,Mode,Value} ⟷ OGAR StateGuard (environmentFilter). + assert_eq!( + app.kausal, + Some(KausalSpec::StateGuard { + guard_field: "environment_filter".to_owned(), + guard_values: Vec::new(), + }) + ); + // The applicability provides one ActionCapability with its I/O signature. + assert_eq!(app.capabilities.len(), 1); + let cap = &app.capabilities[0]; + assert_eq!(cap.identity, "ogit-automation/action_capability"); + assert!(cap.declares_mandatory_params, "capability declares inputs"); + assert!(cap.declares_optional_params); + // The OUTPUT signature — what makes the action runnable. + assert!( + cap.declares_result_params, + "capability must declare resultParameters (the output signature)" + ); + } + + /// No ActionHandler in the set ⇒ no contract to assemble. + #[test] + fn assemble_returns_none_without_a_handler() { + let entities = [entity(KNOWLEDGE_ITEM_TTL), entity(TRIGGER_TTL)]; + assert!(assemble_action_handler(&entities).is_none()); + } + + /// An `ActionParam` carries the arago `Parameter{Name,Mandatory,Default}` + /// I/O-signature tuple (the instance-level shape the executor binds). + #[test] + fn action_param_models_the_arago_parameter_tuple() { + let p = ActionParam { + name: "command".to_owned(), + mandatory: true, + default: None, + }; + assert_eq!(p.name, "command"); + assert!(p.mandatory); + assert!(p.default.is_none()); + } } diff --git a/crates/ogar-from-schema/tests/do_arm_emit.rs b/crates/ogar-from-schema/tests/do_arm_emit.rs index 39f9921..ac4937c 100644 --- a/crates/ogar-from-schema/tests/do_arm_emit.rs +++ b/crates/ogar-from-schema/tests/do_arm_emit.rs @@ -15,9 +15,9 @@ //! level). This is the lift→triples seam the RBAC / query layers consume. use ogar_emitter::TripleEmitter; +use ogar_from_schema::TtlDeclaration; use ogar_from_schema::do_arm::into_action_def; use ogar_from_schema::ttl::parse_file; -use ogar_from_schema::TtlDeclaration; const KNOWLEDGE_ITEM_TTL: &str = include_str!("../../../vocab/imports/ogit/NTO/Automation/entities/KnowledgeItem.ttl"); @@ -43,7 +43,10 @@ fn knowledge_item_lifts_and_emits_as_action_def_triples() { let triples = TripleEmitter::emit_action_def(&def); // Type triple — the def IS an ogar:ActionDef on the SPO surface. - assert_eq!(obj(&triples, &id, "rdf:type").as_deref(), Some("ogar:ActionDef")); + assert_eq!( + obj(&triples, &id, "rdf:type").as_deref(), + Some("ogar:ActionDef") + ); // The §4 field mapping survives the lift→emit path verbatim. assert_eq!( @@ -66,11 +69,9 @@ fn knowledge_item_lifts_and_emits_as_action_def_triples() { // The §4 actuator verbs ride as ogar:actionDecorator provenance. for verb in ["relates", "contains", "uses"] { assert!( - triples - .iter() - .any(|t| t.subject == id - && t.predicate == "ogar:actionDecorator" - && t.object == verb), + triples.iter().any(|t| t.subject == id + && t.predicate == "ogar:actionDecorator" + && t.object == verb), "decorator `{verb}` missing from emitted triples" ); } diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md new file mode 100644 index 0000000..d1fc0ee --- /dev/null +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -0,0 +1,187 @@ +# Arago ActionHandler ⟷ OGAR — parity scorecard + the Python→OGAR switch path + +> **Status:** FINDING (contract parity `[G]`, grounded in shipped code + the +> vendored OGIT ontology + arago's published sources) + the runtime executor as +> a specced, NOT-yet-built brick (`[H]`, gated on `PROBE-OGAR-ACTIONHANDLER-RUN`). +> +> **Goal (operator):** reach parity with arago's HIRO ActionHandler such that one +> "could basically switch from [arago's] Python to OGAR running it here." This +> doc scores exactly how far that is true today and names the remaining runtime. +> +> **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`). +> The OGIT `NTO/Automation` ontology (vendored at `vocab/imports/ogit/NTO/Automation/`) +> is the contract's schema. + +--- + +## 0. What an arago ActionHandler *is* (three layers) + +``` + ┌─────────────────── the daemon ───────────────────┐ + HIRO engine ◄── action-ws (WebSocket) ──► ActionHandler process + (issues need │ registers: Configuration + a Capability │ + Capabilities + on a Node) │ + Applicabilities + ▼ + submitAction ──► [match Capability + Applicability] ──► execute Command ──► sendActionResult +``` + +1. **Config layer** (declarative — `arago/ActionHandlers` YAML): a handler + registers **Capabilities** (named operations + typed params + a Command/script) + and **Applicabilities** (a node-match `ModelFilter` gating which capability + applies where). +2. **Ontology layer** (OGIT `NTO/Automation`): the same shape as RDF entities — + `ActionHandler --provides--> ActionApplicability --provides--> ActionCapability`. +3. **Runtime layer** (`action-ws`): the engine `submitAction`s; the handler + `acknowledged`s, executes, `sendActionResult`s; the engine `acknowledged`s. + +OGAR must reach parity on all three for the switch to be real. The scorecards +below take them in turn. + +--- + +## 1. Config + ontology parity — `[G]` SHIPPED + +The arago handler config (the SSH/Stonebranch YAML) and the OGIT ontology are +two encodings of one contract. OGAR lifts that contract via +`ogar-from-schema::do_arm` (`assemble_action_handler`), grounded in the vendored +OGIT `provides` graph — **proven by `assembles_the_full_action_handler_contract`**. + +| arago config field | OGIT ontology | OGAR type (`do_arm`) | status | +|---|---|---|---| +| `Capability.Name` | `ActionCapability` | `ActionDef.predicate` | `[G]` | +| `Capability.Description` | `ogit:description` | (class description) | `[G]` | +| `Capability.Parameter[]{Name,Mandatory,Default}` | `mandatoryParameters` / `optionalParameters` | `CapabilitySlot.declares_{mandatory,optional}_params` + `ActionParam{name,mandatory,default}` | `[G]` shape / `[H]` values¹ | +| `Capability.resultParameters` (**the output**) | `resultParameters` | `CapabilitySlot.declares_result_params` | `[G]` shape / `[H]` values¹ | +| `Capability.Command` / `Interpreter` | `knowledgeItemFormalRepresentation` (the body) | `ActionDef.body_source` — **pointed-to, never inlined** (lossless-DO §1) | `[G]` | +| `Applicability.ModelFilter{Var,Mode,Value}` | `environmentFilter` | **`KausalSpec::StateGuard{guard_field,guard_values}`** | `[G]` | +| `Applicability.Priority` | — | (gap — no priority field) | `[H]`² | +| `ActionHandler` (the registration) | `ActionHandler` `--provides-->` | `ActionHandlerSpec{identity, applicabilities[]}` | `[G]` | + +¹ **shape vs values:** the OGIT ontology (and OGAR's lift of it) declares the +param *slots*. The concrete `(name, mandatory, default)` tuples live in a +*deployed* handler's config YAML — an **instance lift** (§4, the one new producer +needed). `ActionParam` is the OGAR type that carries them. + +² `Priority` (applicability precedence) has no OGAR home yet — a one-field add +when the executor needs it (the MUL/elevation layer already ranks; priority maps +there). + +**Net: the contract SHAPE is at full parity.** The OGAR `ActionHandlerSpec` +carries the same `(handler → applicabilities[guard] → capabilities[I/O signature])` +arago registers with the engine. + +--- + +## 2. Runtime parity — the `action-ws` protocol ⟷ OGAR `ActionInvocation` + +The HIRO Action API lifecycle maps **field-for-field** onto OGAR's +`ActionInvocation` Rubicon lifecycle (`lance-graph-contract::action`). This is +the load-bearing claim for "run it here": the wire protocol is unchanged; only +the daemon's *insides* become OGAR. + +| `action-ws` message / field | direction | OGAR mapping | status | +|---|---|---|---| +| `submitAction.capability` | engine → handler | resolve the `ActionDef` whose `predicate` == capability | `[G]` | +| `submitAction.parameters{…}` | engine → handler | bind to the capability's `ActionParam[]` → `ActionInvocation` inputs | `[H]` (needs the param-bind step) | +| `submitAction.handler` / `scope` | engine → handler | `ActionInvocation.lokal{actor,tenant}` | `[G]` type / `[H]` wiring | +| `submitAction.timeout` (ms) | engine → handler | `ActionDef.state_timeout_millis` (`ogar:StateTimeout`) | `[G]` | +| `submitAction.id` (`$appId:$requestId`) | engine → handler | `ActionInvocation.idempotency_key` (OLD↔NEW correlation) | `[G]` | +| **handler** `acknowledged{id,code:200}` | handler → engine | `ActionInvocation.state = Pending` (received, not yet committed) | `[G]` type / `[H]` emit | +| *execute* | (internal) | `commit_via`: RBAC verb-gate ∧ `StateGuard` (the ModelFilter) ∧ MUL impact | `[G]` gate / `[H]` body-exec | +| `sendActionResult{id, result{…}}` | handler → engine | `state = Committed`; `result` = the `resultParameters` output payload | `[G]` lifecycle / `[H]` result-build | +| **engine** `acknowledged` (result received) | engine → handler | the **Lance commit** (`CommitHook::on_commit`) — "state history IS the version log" | `[G]` | +| retry-on-no-ack | engine | at-least-once → `idempotency_key` dedup (`ModalSpec::Idempotent`) | `[G]` | + +**The lifecycle is already OGAR's lifecycle.** `submitAction → acknowledged → +execute → sendActionResult → acknowledged` IS `Pending → (RBAC∧guard∧MUL) → +Committed → Lance-append`. The Rubicon crossing (`Pending → Committed`, +`is_commit`) is exactly the `sendActionResult` moment. Nothing in the protocol +needs a type OGAR lacks — only the *glue* (§3) is unbuilt. + +--- + +## 3. The switch path — replacing the Python daemon with OGAR + +To "switch from Python to OGAR running it here," one OGAR-side component +replaces the arago Python `ActionHandlerDaemon`: an **action-ws adapter** that +speaks the same WebSocket protocol but routes through OGAR types. + +``` + HIRO engine ──submitAction──► [OGAR action-ws adapter] + │ 1. resolve ActionDef by `capability` (canonical_concept_id / OgarActionProvider) + │ 2. build ActionInvocation, bind `parameters` (ActionParam[]) + │ 3. acknowledged{200} (emit) + │ 4. commit_via(rbac, actor, guard…) (RBAC ∧ ModelFilter-StateGuard ∧ MUL) + │ 5. execute the capability body (Command) ◄── the ExecTarget executor + │ 6. sendActionResult{ result: resultParameters } (emit) + ▼ + Lance commit (CommitHook) ── server acknowledged +``` + +Steps 1, 3, 4, 6 are OGAR types that **exist today**. The two unbuilt 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 — the action-ws adapter + instance config lift.** The WebSocket glue + (steps 1–3, 6) and the deployed-handler-YAML → `ActionDef` + `ActionParam[]` + lift (the concrete param/Command values §1 note ¹). Reuses the `do_arm` + contract-assembly that shipped here. + +Both are **glue over existing types**, not new IR. That is the precise sense in +which OGAR is "at parity": the *contract and lifecycle* are OGAR-native and +proven; the daemon is a thin adapter that two well-scoped bricks complete. + +--- + +## 4. Scorecard — are we at parity? + +| Layer | Parity | Evidence / remaining | +|---|---|---| +| **Config + ontology contract** | ✅ `[G]` | `assemble_action_handler` over the vendored OGIT graph; `ActionHandlerSpec` / `CapabilitySlot` / `ApplicabilitySlot` / `ActionParam` | +| **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` | +| **Executor (run the Command)** | ⛔ `[H]` B1 | `ExecTarget` runner — specced, unbuilt; `PROBE-OGAR-ACTIONHANDLER-RUN` | +| **action-ws adapter + config lift** | ⛔ `[H]` B2 | WebSocket glue + deployed-YAML → `ActionDef`/`ActionParam` lift | + +**Verdict:** OGAR is at **full contract + lifecycle parity** with arago's +ActionHandler — every field of the config, ontology, and `action-ws` protocol +has an OGAR type, and the execution gate (`commit_via`) is shipped. The switch +to "OGAR running it here" reduces to **two glue bricks (B1 executor, B2 +adapter)** over those types — no missing IR, no missing protocol mapping. That +is the honest state: parity on *what an ActionHandler is*; a bounded build for +*running one live*. + +--- + +## 5. The probe that promotes B1/B2 from `[H]` to `[G]` + +**`PROBE-OGAR-ACTIONHANDLER-RUN`** (the falsifier, mirroring +`PROBE-OGAR-DO-ARM-LIFT` / `PROBE-OGAR-RBAC-AUTHORIZE`): stand up the OGAR +action-ws adapter against a recorded `submitAction` corpus from a real arago +handler (e.g. the SSH `ExecuteCommand`), run it through `commit_via` + the +`ExecTarget` executor, and assert the emitted `sendActionResult.result` matches +the arago handler's recorded result **bit-for-bit**. Green ⇒ the Python daemon +is replaceable; the parity claim is certified, not argued. + +--- + +## 6. Cross-references + +- `crates/ogar-from-schema/src/do_arm.rs` — `assemble_action_handler`, + `ActionHandlerSpec` / `CapabilitySlot` / `ApplicabilitySlot` / `ActionParam`. +- `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`. +- `lance-graph-ogar::OgarActionProvider` — the `classid → ClassActions` DO surface. +- `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. diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index e994061..5332649 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -210,6 +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). Contract+lifecycle parity G; executor (B1) + action‑ws adapter (B2) specced, gated on `PROBE‑OGAR‑ACTIONHANDLER‑RUN` | G (contract) / H (runtime) | CODED (contract) | `ogar-from-schema/src/do_arm.rs`, `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 29f28f47bfff6efe7f9be9262a2bf669e9a4fc6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 06:33:40 +0000 Subject: [PATCH 2/3] =?UTF-8?q?action=5Fws:=20HIRO=20action-ws=20protocol?= =?UTF-8?q?=20core=20=E2=80=94=20the=20testable=20B2=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The socket-free heart of the action-ws adapter (parity brick B2-core): the typed protocol messages + the submitAction -> ActionInvocation -> sendActionResult binding, no live WebSocket, no command execution. Builds on the contract parity (#121); together they make the Python->OGAR switch a transport/runner/parser job over existing types, not new IR. crates/ogar-from-schema/src/action_ws.rs: - SubmitAction / Acknowledged / SendActionResult — the typed action-ws messages (serde-gated for the wire). - acknowledge(submit) — the 200 receipt. - bind_parameters(supplied, ActionParam[]) — validates inputs against the capability signature (mandatory present, defaults filled) — the same check arago's Python handler runs before executing. - submit_to_invocation(submit, def) — builds the Pending ActionInvocation (capability->predicate match, id->idempotency_key, host->object_instance, handler/scope->lokal); rejects capability mismatch. - invocation_to_result(committed_inv, result) — only a Committed invocation (the Rubicon crossing) yields the sendActionResult. 7 new tests incl. full_action_ws_roundtrip (submit -> ack -> bind -> invoke -> commit -> result, id-correlated). 34 do_arm/action_ws tests green under default AND serde features; action_ws clippy-clean. Docs: ARAGO-ACTIONHANDLER-PARITY scorecard updated (B2-core shipped; remaining B1 executor + B2-transport live-WS + B2-lift YAML); D-ACTIONHANDLER-PARITY row. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-from-schema/src/action_ws.rs | 379 +++++++++++++++++++++++ crates/ogar-from-schema/src/lib.rs | 1 + docs/ARAGO-ACTIONHANDLER-PARITY.md | 57 +++- docs/DISCOVERY-MAP.md | 2 +- 4 files changed, 422 insertions(+), 17 deletions(-) create mode 100644 crates/ogar-from-schema/src/action_ws.rs diff --git a/crates/ogar-from-schema/src/action_ws.rs b/crates/ogar-from-schema/src/action_ws.rs new file mode 100644 index 0000000..85cca94 --- /dev/null +++ b/crates/ogar-from-schema/src/action_ws.rs @@ -0,0 +1,379 @@ +//! HIRO **Action API** (`action-ws`) protocol core — the testable runtime +//! binding for an OGAR-native ActionHandler (parity brick **B2**). +//! +//! This is the *protocol core* of the action-ws adapter — the typed messages +//! plus the binding `submitAction → ActionInvocation → sendActionResult` — with +//! **no live WebSocket and no command execution**. It is the deterministic, +//! unit-tested heart that an outer transport (the live `tokio-tungstenite` loop) +//! and the executor (parity brick **B1**, the `ExecTarget` runner) wrap. +//! +//! Source: the HIRO 7 Action API tutorial (`tutorial-action-handler-action-api`), +//! transcribed verbatim in `docs/ARAGO-ACTIONHANDLER-PARITY.md` §2. The lifecycle +//! +//! ```text +//! engine ──submitAction──► handler ──acknowledged{200}──► engine +//! handler (execute) +//! handler ──sendActionResult──► engine ──acknowledged──► +//! ``` +//! +//! maps field-for-field onto OGAR's [`ActionInvocation`] Rubicon lifecycle +//! (`Pending → Committed`): `submitAction` builds a `Pending` invocation +//! ([`submit_to_invocation`]); the engine's final ack is the Lance commit; a +//! `Committed` invocation yields the `sendActionResult` ([`invocation_to_result`]). +//! Parameter binding ([`bind_parameters`]) validates the engine's `parameters` +//! against the capability's [`ActionParam`] signature — the same check arago's +//! Python handler performs before executing. + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use ogar_vocab::{ + ActionDef, ActionInvocation, ActionState, ActionSubject, LokalSpec, ModalSpec, TemporalSpec, +}; + +use crate::do_arm::ActionParam; + +/// A `submitAction` message (engine → handler). The engine asks the handler to +/// run `capability` on a target with the supplied `parameters`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct SubmitAction { + /// Correlation id — `"$appId:$requestId"`. Carried through to the result. + pub id: String, + /// The capability requested (e.g. `"ExecuteCommand"`) — must match an + /// [`ActionDef::predicate`]. + pub capability: String, + /// The handler id this action is routed to. + pub handler: String, + /// The instance scope (tenant). + pub scope: Option, + /// The action inputs (`{host, command, user, …}`), as `(key, value)` pairs. + pub parameters: Vec<(String, String)>, + /// Per-action SLA in milliseconds. + pub timeout_millis: Option, +} + +/// An `acknowledged` message (either direction): receipt confirmation. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct Acknowledged { + /// The id of the message being acknowledged. + pub id: String, + /// Status code (`200` on success). + pub code: u16, + /// Human-readable note. + pub message: String, +} + +/// A `sendActionResult` message (handler → engine): the outcome payload — the +/// capability's `resultParameters` as `(key, value)` pairs. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct SendActionResult { + /// The same correlation id as the originating [`SubmitAction`]. + pub id: String, + /// The result fields (the `resultParameters` output signature, bound). + pub result: Vec<(String, String)>, +} + +/// Errors in the protocol binding (the pure core — no I/O errors here). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum ActionWsError { + /// `submitAction.capability` does not match the [`ActionDef`] it was routed to. + CapabilityMismatch { + /// The def's predicate. + expected: String, + /// The submitAction's capability. + got: String, + }, + /// A mandatory parameter of the capability signature was not supplied and + /// has no default. + MissingMandatoryParam(String), + /// A result was requested from an invocation that has not reached + /// [`ActionState::Committed`] (the Rubicon crossing). + NotCommitted(ActionState), +} + +impl core::fmt::Display for ActionWsError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::CapabilityMismatch { expected, got } => { + write!( + f, + "capability mismatch: def expects `{expected}`, got `{got}`" + ) + } + Self::MissingMandatoryParam(p) => write!(f, "missing mandatory parameter `{p}`"), + Self::NotCommitted(s) => write!(f, "invocation not committed (state = {s:?})"), + } + } +} + +impl std::error::Error for ActionWsError {} + +/// The handler's immediate receipt acknowledgement (code 200), echoing the +/// action's `id`. Sent before execution; the engine re-sends `submitAction` +/// until this arrives (at-least-once → idempotency). +#[must_use] +pub fn acknowledge(msg: &SubmitAction) -> Acknowledged { + Acknowledged { + id: msg.id.clone(), + code: 200, + message: "Received the action".to_owned(), + } +} + +/// Bind the engine-supplied `parameters` to the capability's [`ActionParam`] +/// signature: every mandatory param must be supplied (or have a default); +/// optional params fall back to their default when present, and are dropped +/// when absent. Returns the bound `(name, value)` set in signature order — the +/// same validation arago's handler runs before executing the `Command`. +/// +/// # Errors +/// +/// [`ActionWsError::MissingMandatoryParam`] if a mandatory param is neither +/// supplied nor defaulted. +pub fn bind_parameters( + supplied: &[(String, String)], + signature: &[ActionParam], +) -> Result, ActionWsError> { + let mut bound = Vec::with_capacity(signature.len()); + for p in signature { + if let Some((_, v)) = supplied.iter().find(|(k, _)| k == &p.name) { + bound.push((p.name.clone(), v.clone())); + } else if let Some(default) = &p.default { + bound.push((p.name.clone(), default.clone())); + } else if p.mandatory { + return Err(ActionWsError::MissingMandatoryParam(p.name.clone())); + } + // optional + absent + no default → omitted + } + Ok(bound) +} + +/// The target node an action acts on — arago routes by the `host` parameter +/// (the MARS node); fall back to the handler id when absent. +fn target_node(msg: &SubmitAction) -> String { + msg.parameters + .iter() + .find(|(k, _)| k == "host" || k == "node") + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| msg.handler.clone()) +} + +/// Build a **`Pending`** [`ActionInvocation`] from a `submitAction`, realizing +/// `def` (whose [`predicate`](ActionDef::predicate) must equal the action's +/// `capability`). This is the `submitAction → ActionInvocation` half of the +/// lifecycle; the invocation then passes through the RBAC/guard/MUL gate +/// (`commit_via` in `lance-graph-contract`) before reaching `Committed`. +/// +/// Field mapping (`docs/ARAGO-ACTIONHANDLER-PARITY.md` §2): +/// `capability`→`def.predicate`, `id`→`idempotency_key`, `handler`→`lokal.actor`, +/// `scope`→`lokal.tenant`, target node→`object_instance`. Automation defaults: +/// `subject = System`, `temporal = Deferred`, `modal = Idempotent` (at-least-once). +/// +/// # Errors +/// +/// [`ActionWsError::CapabilityMismatch`] if `msg.capability != def.predicate`. +pub fn submit_to_invocation( + msg: &SubmitAction, + def: &ActionDef, +) -> Result { + if msg.capability != def.predicate { + return Err(ActionWsError::CapabilityMismatch { + expected: def.predicate.clone(), + got: msg.capability.clone(), + }); + } + let object_instance = target_node(msg); + let identity = format!("{}::invocation::{}", def.object_class, msg.id); + let mut inv = ActionInvocation::new(identity, def.identity.clone(), object_instance); + inv.subject = ActionSubject::System; + inv.temporal = TemporalSpec::Deferred; + inv.modal = ModalSpec::Idempotent; + inv.state = ActionState::Pending; + inv.idempotency_key = Some(msg.id.clone()); + // LokalSpec is #[non_exhaustive] — build via Default + field set, not a literal. + let mut lokal = LokalSpec::default(); + lokal.actor = Some(msg.handler.clone()); + lokal.tenant = msg.scope.clone(); + inv.lokal = lokal; + Ok(inv) +} + +/// Build the `sendActionResult` from a **`Committed`** invocation plus the +/// executor's result payload (the bound `resultParameters`). Only a committed +/// invocation (the Rubicon crossing) yields a result — a `Pending` / `Failed` / +/// `Cancelled` invocation has nothing to report on the success path. +/// +/// # Errors +/// +/// [`ActionWsError::NotCommitted`] if the invocation has not reached +/// [`ActionState::Committed`]. +pub fn invocation_to_result( + inv: &ActionInvocation, + result: Vec<(String, String)>, +) -> Result { + if inv.state != ActionState::Committed { + return Err(ActionWsError::NotCommitted(inv.state)); + } + Ok(SendActionResult { + id: inv.idempotency_key.clone().unwrap_or_default(), + result, + }) +} + +// ───────────────────────────────────────────────────────────── tests ── +// +// The pure protocol core: the full submitAction → bind → invocation(Pending) +// → (Committed) → sendActionResult flow, deterministic and socket-free. + +#[cfg(test)] +mod tests { + use super::*; + + /// An ExecuteCommand-shaped capability signature (the arago SSH handler): + /// mandatory `command`, optional `timeout` defaulting to `60000`. + fn execute_command_signature() -> Vec { + vec![ + ActionParam { + name: "command".to_owned(), + mandatory: true, + default: None, + }, + ActionParam { + name: "timeout".to_owned(), + mandatory: false, + default: Some("60000".to_owned()), + }, + ] + } + + fn execute_command_def() -> ActionDef { + ActionDef::new( + "ogit-automation/action_capability::action_def::ExecuteCommand", + "ExecuteCommand", + "ogit-automation/mars_machine", + ) + } + + fn submit() -> SubmitAction { + SubmitAction { + id: "app1:req42".to_owned(), + capability: "ExecuteCommand".to_owned(), + handler: "handler-7".to_owned(), + scope: Some("tenant-A".to_owned()), + parameters: vec![ + ("host".to_owned(), "node-9".to_owned()), + ("command".to_owned(), "uptime".to_owned()), + ], + timeout_millis: Some(60_000), + } + } + + #[test] + fn acknowledge_echoes_id_with_200() { + let ack = acknowledge(&submit()); + assert_eq!(ack.id, "app1:req42"); + assert_eq!(ack.code, 200); + } + + #[test] + fn bind_parameters_fills_default_and_keeps_supplied() { + let bound = + bind_parameters(&submit().parameters, &execute_command_signature()).expect("binds"); + // `command` supplied, `timeout` defaulted; signature order preserved. + assert_eq!( + bound, + vec![ + ("command".to_owned(), "uptime".to_owned()), + ("timeout".to_owned(), "60000".to_owned()), + ] + ); + } + + #[test] + fn bind_parameters_rejects_missing_mandatory() { + let supplied = vec![("timeout".to_owned(), "5".to_owned())]; + let err = bind_parameters(&supplied, &execute_command_signature()).unwrap_err(); + assert_eq!( + err, + ActionWsError::MissingMandatoryParam("command".to_owned()) + ); + } + + #[test] + fn submit_builds_pending_invocation_with_provenance() { + let inv = submit_to_invocation(&submit(), &execute_command_def()).expect("builds"); + assert_eq!(inv.state, ActionState::Pending); + assert_eq!(inv.object_instance, "node-9"); // routed by the `host` param + assert_eq!(inv.idempotency_key.as_deref(), Some("app1:req42")); + assert_eq!(inv.action_def, execute_command_def().identity); + assert_eq!(inv.lokal.actor.as_deref(), Some("handler-7")); + assert_eq!(inv.lokal.tenant.as_deref(), Some("tenant-A")); + assert!(matches!(inv.modal, ModalSpec::Idempotent)); + } + + #[test] + fn submit_rejects_capability_mismatch() { + let mut bad = submit(); + bad.capability = "RunScript".to_owned(); + let err = submit_to_invocation(&bad, &execute_command_def()).unwrap_err(); + assert_eq!( + err, + ActionWsError::CapabilityMismatch { + expected: "ExecuteCommand".to_owned(), + got: "RunScript".to_owned(), + } + ); + } + + #[test] + fn committed_invocation_yields_result_pending_does_not() { + let mut inv = submit_to_invocation(&submit(), &execute_command_def()).expect("builds"); + + // Pending → no result on the success path. + let pending = invocation_to_result(&inv, vec![]); + assert_eq!( + pending.unwrap_err(), + ActionWsError::NotCommitted(ActionState::Pending) + ); + + // The Rubicon crossing (the gate would set this) → result emitted. + inv.state = ActionState::Committed; + let result = invocation_to_result( + &inv, + vec![("output".to_owned(), "12:00 up 3 days".to_owned())], + ) + .expect("committed → result"); + assert_eq!(result.id, "app1:req42"); // correlation id round-trips + assert_eq!( + result.result, + vec![("output".to_owned(), "12:00 up 3 days".to_owned())] + ); + } + + /// The whole loop, end-to-end (socket-free): submit → ack → bind → invoke + /// → commit → result, with the `id` correlating throughout. + #[test] + fn full_action_ws_roundtrip() { + let msg = submit(); + let def = execute_command_def(); + + let ack = acknowledge(&msg); + assert_eq!(ack.code, 200); + + let _bound = bind_parameters(&msg.parameters, &execute_command_signature()).expect("bind"); + + let mut inv = submit_to_invocation(&msg, &def).expect("invoke"); + assert_eq!(inv.state, ActionState::Pending); + + // (the executor + commit_via gate run here; we simulate the crossing) + inv.state = ActionState::Committed; + + let result = invocation_to_result(&inv, vec![("exitcode".to_owned(), "0".to_owned())]) + .expect("result"); + assert_eq!(result.id, msg.id); + } +} diff --git a/crates/ogar-from-schema/src/lib.rs b/crates/ogar-from-schema/src/lib.rs index 192c1db..f32c18f 100644 --- a/crates/ogar-from-schema/src/lib.rs +++ b/crates/ogar-from-schema/src/lib.rs @@ -57,6 +57,7 @@ use ogar_vocab::{Attribute, Class, EnumDecl, EnumSource, Language}; +pub mod action_ws; pub mod do_arm; pub mod sgo; pub mod ttl; diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md index d1fc0ee..0c3edae 100644 --- a/docs/ARAGO-ACTIONHANDLER-PARITY.md +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -122,7 +122,23 @@ speaks the same WebSocket protocol but routes through OGAR types. Lance commit (CommitHook) ── server acknowledged ``` -Steps 1, 3, 4, 6 are OGAR types that **exist today**. The two unbuilt bricks: +Steps 1–4 and 6 are OGAR types/logic that **exist today** — **the protocol core +is shipped** in `ogar-from-schema::action_ws` (the testable, socket-free binding): + +- `SubmitAction` / `Acknowledged` / `SendActionResult` — the typed `action-ws` + messages (serde-gated for the wire). +- `acknowledge(submit)` — step 3 (the 200 receipt). +- `bind_parameters(supplied, signature)` — validates the engine's `parameters` + against the capability's `ActionParam[]` (mandatory present, defaults filled) — + the same check arago's Python handler runs before executing. +- `submit_to_invocation(submit, def)` — step 1+2: builds the `Pending` + `ActionInvocation` (capability→predicate match, `id`→`idempotency_key`, + `host`→`object_instance`, `handler`/`scope`→`lokal`). +- `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 → @@ -130,14 +146,17 @@ Steps 1, 3, 4, 6 are OGAR types that **exist today**. The two unbuilt bricks: 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 — the action-ws adapter + instance config lift.** The WebSocket glue - (steps 1–3, 6) and the deployed-handler-YAML → `ActionDef` + `ActionParam[]` - lift (the concrete param/Command values §1 note ¹). Reuses the `do_arm` - contract-assembly that shipped here. - -Both are **glue over existing types**, not new IR. That is the precise sense in -which OGAR is "at parity": the *contract and lifecycle* are OGAR-native and -proven; the daemon is a thin adapter that two well-scoped bricks complete. +- **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. --- @@ -149,16 +168,22 @@ proven; the daemon is a thin adapter that two well-scoped bricks complete. | **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` | -| **action-ws adapter + config lift** | ⛔ `[H]` B2 | WebSocket glue + deployed-YAML → `ActionDef`/`ActionParam` lift | +| **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 — every field of the config, ontology, and `action-ws` protocol -has an OGAR type, and the execution gate (`commit_via`) is shipped. The switch -to "OGAR running it here" reduces to **two glue bricks (B1 executor, B2 -adapter)** over those types — no missing IR, no missing protocol mapping. That -is the honest state: parity on *what an ActionHandler is*; a bounded build for -*running one live*. +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*. --- diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 5332649..d04bb30 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). Contract+lifecycle parity G; executor (B1) + action‑ws adapter (B2) specced, gated on `PROBE‑OGAR‑ACTIONHANDLER‑RUN` | G (contract) / H (runtime) | CODED (contract) | `ogar-from-schema/src/do_arm.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** (`action_ws`: typed messages + `submit_to_invocation`/`bind_parameters`/`invocation_to_result`, socket-free, `full_action_ws_roundtrip` proven). Remaining: B1 executor + B2-transport (live WS) + B2-lift (YAML), 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‑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 2f91935d4484a3aa7f8d04bd791e12cd4534ab60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 06:40:51 +0000 Subject: [PATCH 3/3] =?UTF-8?q?action=5Fws:=20harvest=20the=20HIRO=207.0?= =?UTF-8?q?=20dev-portal=20specs=20=E2=80=94=20spec-faithful=20protocol=20?= =?UTF-8?q?core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harvested the authoritative machine-readable contract from the HIRO 7.0 dev portal (core.engine.datagroup.de/help/specs/definitions/{action-ws,action}.yaml) and folded the corrections into the action-ws core, so it now matches the spec rather than the tutorial-only first pass. Corrections the spec forced: - result is a SINGLE STRING (<= 1048576 chars), not an object: SendActionResult.result is now String; invocation_to_result JSON-encodes the bound resultParameters via a new minimal escaping json_object(); MAX_RESULT_LEN + ResultTooLarge guard. - id is 12-256 chars: validate_id() + InvalidId. - three missing message types added: NegativeAcknowledged {id,code,message}, ConfigChanged (re-fetch capabilities), InboundError {code,message}. New connection facts captured (for B2-transport): - ACTION_WS_PATH = "/api/action-ws/1.0/connect" - auth_subprotocol(token) = "token-$TOKEN" (the sec-websocket-protocol header) - registration is REST (GET /capabilities, /applicabilities), NOT a WS handshake; configChanged just signals a re-fetch. The REST view lifts straight into do_arm::assemble_action_handler's ActionHandlerSpec (the B2-lift brick). 38 do_arm/action_ws tests green (4 new: nack, validate_id, json_object escaping, auth_subprotocol) under default AND serde features; action_ws clippy-clean. Docs: ARAGO-ACTIONHANDLER-PARITY §2a (the full harvested contract + the corrections) + §6 spec URLs; D-ACTIONHANDLER-PARITY row updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-from-schema/src/action_ws.rs | 206 +++++++++++++++++++++-- docs/ARAGO-ACTIONHANDLER-PARITY.md | 46 +++++ docs/DISCOVERY-MAP.md | 2 +- 3 files changed, 237 insertions(+), 17 deletions(-) diff --git a/crates/ogar-from-schema/src/action_ws.rs b/crates/ogar-from-schema/src/action_ws.rs index 85cca94..120fbfd 100644 --- a/crates/ogar-from-schema/src/action_ws.rs +++ b/crates/ogar-from-schema/src/action_ws.rs @@ -33,6 +33,23 @@ use ogar_vocab::{ use crate::do_arm::ActionParam; +/// The `action-ws` WebSocket connect path (HIRO Action API 1.0). The full URL is +/// `wss:///api/action-ws/1.0/connect`. +pub const ACTION_WS_PATH: &str = "/api/action-ws/1.0/connect"; + +/// The WebSocket subprotocol header value carrying the auth token — HIRO passes +/// the token as `sec-websocket-protocol: token-$TOKEN`. +#[must_use] +pub fn auth_subprotocol(token: &str) -> String { + format!("token-{token}") +} + +/// Spec bounds on a `submitAction` / `sendActionResult` correlation `id` +/// (12–256 chars). [`validate_id`] enforces it. +pub const ID_MIN_LEN: usize = 12; +/// Upper bound on the correlation `id` length (spec). +pub const ID_MAX_LEN: usize = 256; + /// A `submitAction` message (engine → handler). The engine asks the handler to /// run `capability` on a target with the supplied `parameters`. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -65,15 +82,55 @@ pub struct Acknowledged { pub message: String, } -/// A `sendActionResult` message (handler → engine): the outcome payload — the -/// capability's `resultParameters` as `(key, value)` pairs. +/// A `sendActionResult` message (handler → engine): the outcome payload. +/// +/// Per the `action-ws` spec the `result` is a **single string** (max +/// `1048576` chars) — the capability's `resultParameters` JSON-encoded into one +/// field (build it with [`json_object`]). The engine replies `acknowledged` / +/// `negativeAcknowledged`. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct SendActionResult { /// The same correlation id as the originating [`SubmitAction`]. pub id: String, - /// The result fields (the `resultParameters` output signature, bound). - pub result: Vec<(String, String)>, + /// The result value — a JSON object string of the bound `resultParameters` + /// (spec: `string`, max 1 MiB). + pub result: String, +} + +/// Max length of the [`SendActionResult::result`] string (spec: `1048576`). +pub const MAX_RESULT_LEN: usize = 1_048_576; + +/// A `negativeAcknowledged` message (engine ↔ handler): receipt *rejection* +/// (e.g. `code = 400`). The negative twin of [`Acknowledged`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct NegativeAcknowledged { + /// The id of the message being rejected. + pub id: String, + /// Error code (e.g. `400`). + pub code: u16, + /// Error description. + pub message: String, +} + +/// A `configChanged` notification (engine → handler): the handler's +/// capabilities / applicabilities changed; the handler must re-fetch them from +/// the REST Action API (`GET /capabilities`, `GET /applicabilities`). Carries +/// no payload beyond `type`; the handler replies `acknowledged`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ConfigChanged; + +/// An asynchronous `error` message (engine → handler) — not tied to a specific +/// request id. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct InboundError { + /// Error code. + pub code: u16, + /// Error details. + pub message: String, } /// Errors in the protocol binding (the pure core — no I/O errors here). @@ -93,6 +150,12 @@ pub enum ActionWsError { /// A result was requested from an invocation that has not reached /// [`ActionState::Committed`] (the Rubicon crossing). NotCommitted(ActionState), + /// A correlation `id` outside the spec bounds (12–256 chars); carries the + /// offending length. + InvalidId(usize), + /// The encoded `result` exceeds [`MAX_RESULT_LEN`] (spec: 1 MiB); carries the + /// offending length. + ResultTooLarge(usize), } impl core::fmt::Display for ActionWsError { @@ -106,6 +169,8 @@ impl core::fmt::Display for ActionWsError { } Self::MissingMandatoryParam(p) => write!(f, "missing mandatory parameter `{p}`"), Self::NotCommitted(s) => write!(f, "invocation not committed (state = {s:?})"), + Self::InvalidId(n) => write!(f, "correlation id length {n} out of bounds (12..=256)"), + Self::ResultTooLarge(n) => write!(f, "result length {n} exceeds 1 MiB"), } } } @@ -124,6 +189,70 @@ pub fn acknowledge(msg: &SubmitAction) -> Acknowledged { } } +/// Reject a message by id (the `negativeAcknowledged` twin of [`acknowledge`]). +#[must_use] +pub fn negative_acknowledge( + id: &str, + code: u16, + message: impl Into, +) -> NegativeAcknowledged { + NegativeAcknowledged { + id: id.to_owned(), + code, + message: message.into(), + } +} + +/// Validate a correlation `id` against the spec bounds (12–256 chars). +/// +/// # Errors +/// +/// [`ActionWsError::InvalidId`] when the length is out of range. +pub fn validate_id(id: &str) -> Result<(), ActionWsError> { + if (ID_MIN_LEN..=ID_MAX_LEN).contains(&id.len()) { + Ok(()) + } else { + Err(ActionWsError::InvalidId(id.len())) + } +} + +/// Encode `(key, value)` pairs as a JSON object string — the wire form of the +/// [`SendActionResult::result`] field (the bound `resultParameters`). A minimal, +/// correctly-escaping encoder; the live transport may use `serde_json` instead. +#[must_use] +pub fn json_object(pairs: &[(String, String)]) -> String { + let mut s = String::from("{"); + for (i, (k, v)) in pairs.iter().enumerate() { + if i > 0 { + s.push(','); + } + json_string(k, &mut s); + s.push(':'); + json_string(v, &mut s); + } + s.push('}'); + s +} + +/// Append `raw` as a JSON string literal (RFC 8259 escaping) to `out`. +fn json_string(raw: &str, out: &mut String) { + out.push('"'); + for c in raw.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); +} + /// Bind the engine-supplied `parameters` to the capability's [`ActionParam`] /// signature: every mandatory param must be supplied (or have a default); /// optional params fall back to their default when present, and are dropped @@ -213,11 +342,17 @@ pub fn submit_to_invocation( /// [`ActionState::Committed`]. pub fn invocation_to_result( inv: &ActionInvocation, - result: Vec<(String, String)>, + result_params: &[(String, String)], ) -> Result { if inv.state != ActionState::Committed { return Err(ActionWsError::NotCommitted(inv.state)); } + // The spec's `result` is a single string (max 1 MiB) — JSON-encode the bound + // resultParameters into it. + let result = json_object(result_params); + if result.len() > MAX_RESULT_LEN { + return Err(ActionWsError::ResultTooLarge(result.len())); + } Ok(SendActionResult { id: inv.idempotency_key.clone().unwrap_or_default(), result, @@ -334,26 +469,64 @@ mod tests { let mut inv = submit_to_invocation(&submit(), &execute_command_def()).expect("builds"); // Pending → no result on the success path. - let pending = invocation_to_result(&inv, vec![]); + let pending = invocation_to_result(&inv, &[]); assert_eq!( pending.unwrap_err(), ActionWsError::NotCommitted(ActionState::Pending) ); - // The Rubicon crossing (the gate would set this) → result emitted. + // The Rubicon crossing (the gate would set this) → result emitted as a + // JSON object string (the spec's single `result` field). inv.state = ActionState::Committed; - let result = invocation_to_result( - &inv, - vec![("output".to_owned(), "12:00 up 3 days".to_owned())], - ) - .expect("committed → result"); + let result = + invocation_to_result(&inv, &[("output".to_owned(), "12:00 up 3 days".to_owned())]) + .expect("committed → result"); assert_eq!(result.id, "app1:req42"); // correlation id round-trips + assert_eq!(result.result, r#"{"output":"12:00 up 3 days"}"#); + } + + #[test] + fn negative_acknowledge_carries_code_and_message() { + let nack = negative_acknowledge("app1:req42", 400, "bad capability"); + assert_eq!(nack.id, "app1:req42"); + assert_eq!(nack.code, 400); + assert_eq!(nack.message, "bad capability"); + } + + #[test] + fn validate_id_enforces_spec_bounds() { + assert!(validate_id("123456789012").is_ok()); // 12 chars (min) + assert_eq!( + validate_id("short").unwrap_err(), + ActionWsError::InvalidId(5) + ); + let too_long = "x".repeat(257); assert_eq!( - result.result, - vec![("output".to_owned(), "12:00 up 3 days".to_owned())] + validate_id(&too_long).unwrap_err(), + ActionWsError::InvalidId(257) ); } + #[test] + fn json_object_escapes_correctly() { + // Empty, simple, and escape-needing values. + assert_eq!(json_object(&[]), "{}"); + assert_eq!( + json_object(&[("k".to_owned(), "v".to_owned())]), + r#"{"k":"v"}"# + ); + assert_eq!( + json_object(&[("out".to_owned(), "a\"b\\c\nd".to_owned())]), + r#"{"out":"a\"b\\c\nd"}"# + ); + } + + #[test] + fn auth_subprotocol_prefixes_the_token() { + assert_eq!(auth_subprotocol("abc123"), "token-abc123"); + assert_eq!(ACTION_WS_PATH, "/api/action-ws/1.0/connect"); + } + /// The whole loop, end-to-end (socket-free): submit → ack → bind → invoke /// → commit → result, with the `id` correlating throughout. #[test] @@ -372,8 +545,9 @@ mod tests { // (the executor + commit_via gate run here; we simulate the crossing) inv.state = ActionState::Committed; - let result = invocation_to_result(&inv, vec![("exitcode".to_owned(), "0".to_owned())]) - .expect("result"); + let result = + invocation_to_result(&inv, &[("exitcode".to_owned(), "0".to_owned())]).expect("result"); assert_eq!(result.id, msg.id); + assert_eq!(result.result, r#"{"exitcode":"0"}"#); } } diff --git a/docs/ARAGO-ACTIONHANDLER-PARITY.md b/docs/ARAGO-ACTIONHANDLER-PARITY.md index 0c3edae..0e41373 100644 --- a/docs/ARAGO-ACTIONHANDLER-PARITY.md +++ b/docs/ARAGO-ACTIONHANDLER-PARITY.md @@ -104,6 +104,47 @@ 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`). +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`). + +**Connection.** `wss:///api/action-ws/1.0/connect` — token passed as the +WebSocket subprotocol `sec-websocket-protocol: token-$TOKEN`. (`action_ws::{ACTION_WS_PATH, +auth_subprotocol}`.) + +**The six message types** (✓ = modelled in `action_ws`): + +| type | dir | fields | OGAR type | +|---|---|---|---| +| `submitAction` | engine → handler | `id` (12–256), `handler`, `capability`, `parameters` (obj), `timeout` (ms) | `SubmitAction` ✓ | +| `sendActionResult` | handler → engine | `id`, `result` (**string**, ≤ `1048576`) | `SendActionResult` ✓ (`result` = `json_object(resultParams)`) | +| `acknowledged` | both | `id`, `code` (200), `message` | `Acknowledged` ✓ (`acknowledge`) | +| `negativeAcknowledged` | both | `id`, `code` (e.g. 400), `message` | `NegativeAcknowledged` ✓ (`negative_acknowledge`) | +| `configChanged` | engine → handler | `type` only → re-fetch capabilities | `ConfigChanged` ✓ | +| `error` | engine → handler | `code`, `message` (no `id`) | `InboundError` ✓ | + +**Corrections the spec forced** (vs the tutorial-only first pass): `result` is a +**single string** (≤ 1 MiB), not an object — `invocation_to_result` JSON-encodes +the bound `resultParameters` into it (`MAX_RESULT_LEN`, `ResultTooLarge`); `id` +is **12–256 chars** (`validate_id`, `InvalidId`); the nack / configChanged / +error message types now exist. + +**Registration is REST, not a WS handshake.** A 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` → +`MapOfCapabilities` (each `{description, mandatoryParameters, optionalParameters}`), +`GET /applicabilities` → `MapOfApplicabilities` (keyed by handler id). The +`action-ws` socket has **no registration message** — `configChanged` just tells +the handler to re-`GET` them. This is exactly the `do_arm::assemble_action_handler` +shape (handler → applicabilities → capabilities), so the REST registration view +lifts straight into `ActionHandlerSpec` (the B2-lift brick). + +--- + ## 3. The switch path — replacing the Python daemon with OGAR To "switch from Python to OGAR running it here," one OGAR-side component @@ -210,3 +251,8 @@ 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/`. diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index d04bb30..e2c7ed2 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** (`action_ws`: typed messages + `submit_to_invocation`/`bind_parameters`/`invocation_to_result`, socket-free, `full_action_ws_roundtrip` proven). Remaining: B1 executor + B2-transport (live WS) + B2-lift (YAML), 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). 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‑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 |