Skip to content

Commit 149a73b

Browse files
authored
Merge pull request #123 from AdaWorldAPI/claude/actionhandler-b2lift-applicabilities
action handler: B2-lift applicabilities envelope (GET /applicabilities → StateGuards)
2 parents 048a2ce + 50e7f58 commit 149a73b

4 files changed

Lines changed: 137 additions & 20 deletions

File tree

crates/ogar-action-handler/src/lib.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,14 @@
4040

4141
use std::process::Command;
4242

43+
use std::collections::BTreeMap;
44+
4345
use ogar_from_schema::action_ws::CapabilityExecutor;
4446
use ogar_from_schema::registration::{
45-
ConcreteCapability, RegisteredCapabilities, lift_registration,
47+
ConcreteCapability, RegisteredApplicabilities, RegisteredCapabilities, lift_applicabilities,
48+
lift_registration,
4649
};
50+
use ogar_vocab::KausalSpec;
4751

4852
/// Read a deployed handler's `GET /capabilities` REST response (a JSON
4953
/// `MapOfCapabilities`) and lift it into the concrete OGAR capability signatures
@@ -63,6 +67,22 @@ pub fn parse_capabilities(json: &str) -> Result<Vec<ConcreteCapability>, String>
6367
Ok(lift_registration(&caps))
6468
}
6569

70+
/// Read a deployed handler's `GET /applicabilities` REST response (a JSON
71+
/// `MapOfApplicabilities`) and lift it into the per-handler guard sets — handler
72+
/// id → its [`KausalSpec::StateGuard`] list (the B2-lift applicabilities half).
73+
///
74+
/// Each handler's guards are the OGAR form of arago's node-match `ModelFilter`s:
75+
/// the action applies where they all match. The same `commit_via` state-guard the
76+
/// hard gate evaluates before executing.
77+
///
78+
/// # Errors
79+
/// Returns the `serde_json` error message if the body is not a valid
80+
/// `MapOfApplicabilities`.
81+
pub fn parse_applicabilities(json: &str) -> Result<BTreeMap<String, Vec<KausalSpec>>, String> {
82+
let apps: RegisteredApplicabilities = serde_json::from_str(json).map_err(|e| e.to_string())?;
83+
Ok(lift_applicabilities(&apps))
84+
}
85+
6686
/// Reference executor for the **native** target: runs a capability's command via
6787
/// the local POSIX shell (`sh -c`). The concrete B1 impl that makes "OGAR
6888
/// running it here" real for local execution.
@@ -209,6 +229,34 @@ mod tests {
209229
assert_eq!(result[0], ("output".to_owned(), "lifted".to_owned()));
210230
}
211231

232+
/// B2-lift applicabilities: a real `GET /applicabilities` REST response
233+
/// (JSON `MapOfApplicabilities`) parses → lifts to per-handler guard sets,
234+
/// the `ModelFilter`s becoming `StateGuard`s the hard gate evaluates.
235+
#[test]
236+
fn rest_applicabilities_lift_to_per_handler_guards() {
237+
// `modelFilters` is the primary field name; `model` / `filters` are
238+
// accepted aliases (the inner field name isn't pinned by the spec).
239+
let body = r#"{
240+
"ssh-handler": {
241+
"modelFilters": [
242+
{ "var": "ogit/_type", "mode": "equals", "value": "ogit/Network/Machine" }
243+
]
244+
},
245+
"any-handler": { "model": [] }
246+
}"#;
247+
248+
let lifted = parse_applicabilities(body).expect("valid MapOfApplicabilities");
249+
assert_eq!(lifted.len(), 2);
250+
assert!(lifted["any-handler"].is_empty());
251+
assert_eq!(
252+
lifted["ssh-handler"],
253+
vec![KausalSpec::StateGuard {
254+
guard_field: "ogit/_type".to_owned(),
255+
guard_values: vec!["ogit/Network/Machine".to_owned()],
256+
}]
257+
);
258+
}
259+
212260
/// End-to-end: the dispatch core + this executor run a real command and
213261
/// produce the `sendActionResult` — "OGAR running it here," native target.
214262
#[test]

crates/ogar-from-schema/src/registration.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,41 @@ pub fn lift_applicability(filters: &[ModelFilter]) -> Vec<KausalSpec> {
161161
filters.iter().map(model_filter_to_guard).collect()
162162
}
163163

164+
/// One applicability as `GET /applicabilities` reports it — its node-match
165+
/// `ModelFilter`s.
166+
///
167+
/// The harvested spec (`docs/ARAGO-ACTIONHANDLER-PARITY.md` §2a) pins the
168+
/// *outer* shape (a `MapOfApplicabilities` keyed by handler id) and the
169+
/// `ModelFilter{Var,Mode,Value}` element, but not the exact name of the field
170+
/// that carries the filter list. This DTO accepts the plausible names
171+
/// (`modelFilters`, `model`, `filters`) via serde aliases and defaults to an
172+
/// empty list, so a real response binds without a code change once the field
173+
/// name is confirmed. The element mapping itself (`ModelFilter → StateGuard`) is
174+
/// the documented, `[G]` part.
175+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
176+
#[cfg_attr(feature = "serde", derive(Deserialize))]
177+
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
178+
pub struct RegisteredApplicability {
179+
/// The node-match filters (an applicability applies where they all match).
180+
#[cfg_attr(feature = "serde", serde(default, alias = "model", alias = "filters"))]
181+
pub model_filters: Vec<ModelFilter>,
182+
}
183+
184+
/// The `GET /applicabilities` response — a `MapOfApplicabilities` keyed by
185+
/// handler id.
186+
pub type RegisteredApplicabilities = BTreeMap<String, RegisteredApplicability>;
187+
188+
/// Lift a whole `GET /applicabilities` response into the per-handler guard sets:
189+
/// handler id → its [`KausalSpec::StateGuard`] list (each handler applies where
190+
/// **all** of its guards match). Deterministic order (the source is a
191+
/// [`BTreeMap`], sorted by handler id).
192+
#[must_use]
193+
pub fn lift_applicabilities(apps: &RegisteredApplicabilities) -> BTreeMap<String, Vec<KausalSpec>> {
194+
apps.iter()
195+
.map(|(handler, app)| (handler.clone(), lift_applicability(&app.model_filters)))
196+
.collect()
197+
}
198+
164199
#[cfg(test)]
165200
mod tests {
166201
use super::*;
@@ -294,4 +329,34 @@ mod tests {
294329
}
295330
);
296331
}
332+
333+
#[test]
334+
fn applicabilities_map_lifts_per_handler_guard_sets() {
335+
let mut apps: RegisteredApplicabilities = BTreeMap::new();
336+
apps.insert(
337+
"ssh-handler".to_owned(),
338+
RegisteredApplicability {
339+
model_filters: vec![ModelFilter {
340+
var: "ogit/_type".to_owned(),
341+
mode: Some("equals".to_owned()),
342+
value: "ogit/Network/Machine".to_owned(),
343+
}],
344+
},
345+
);
346+
apps.insert(
347+
"noop-handler".to_owned(),
348+
RegisteredApplicability::default(),
349+
);
350+
let lifted = lift_applicabilities(&apps);
351+
// Keyed by handler id (BTreeMap → sorted): noop-handler, ssh-handler.
352+
assert_eq!(lifted.len(), 2);
353+
assert!(lifted["noop-handler"].is_empty());
354+
assert_eq!(
355+
lifted["ssh-handler"],
356+
vec![KausalSpec::StateGuard {
357+
guard_field: "ogit/_type".to_owned(),
358+
guard_values: vec!["ogit/Network/Machine".to_owned()],
359+
}]
360+
);
361+
}
297362
}

docs/ARAGO-ACTIONHANDLER-PARITY.md

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,15 @@ downstream. The remaining bricks:
213213
turn a real `MapOfCapabilities` JSON body into `ConcreteCapability`
214214
`ActionParam[]` with concrete `(name, mandatory, default)` — proven end-to-end by
215215
`rest_registration_lifts_binds_and_runs` (JSON → lift → `bind_parameters`
216-
`NativeCommandExecutor` runs the command). The applicability side is the
217-
documented field-for-field lift: `ModelFilter{Var,Mode,Value}`
218-
`KausalSpec::StateGuard` (`model_filter_to_guard` / `lift_applicability`, shipped);
219-
what remains is wiring the `GET /applicabilities` JSON envelope read (the
220-
`MapOfApplicabilities` outer shape) the same way `parse_capabilities` reads
221-
capabilities.
216+
`NativeCommandExecutor` runs the command). The applicability side is **shipped
217+
too**: `GET /applicabilities` → `registration::{RegisteredApplicability,
218+
lift_applicabilities}` + `ogar-action-handler::parse_applicabilities` turn a real
219+
`MapOfApplicabilities` JSON body into per-handler `StateGuard` sets (handler id →
220+
`Vec<KausalSpec>`) — the documented field-for-field `ModelFilter{Var,Mode,Value}`
221+
`KausalSpec::StateGuard` lift, proven by `rest_applicabilities_lift_to_per_handler_guards`.
222+
The only residual is cosmetic: the inner filter-list field name is alias-flexible
223+
(`modelFilters` / `model` / `filters`) pending confirmation against a live
224+
response — the lift itself is exact.
222225

223226
What remains is **glue over existing types** (a socket loop + a JSON codec + a
224227
registration parser) plus the non-native executor targets — not new IR. The
@@ -243,7 +246,7 @@ transport over them.
243246
| **Executor — SSH/REST/WinRM (B1)** |`[H]` | further `CapabilityExecutor` impls (rs-graph-llm `graph-flow-action`) |
244247
| **Live WebSocket transport (B2-transport)** |`[H]` | wrap `handle_submit` in a `tokio-tungstenite` loop + JSON codec (all shapes pinned, §2a) |
245248
| **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) |
246-
| **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 |
249+
| **Instance config lift — applicabilities (B2-lift)** | `[G]` SHIPPED | `registration::lift_applicabilities` + `ogar-action-handler::parse_applicabilities`: real `GET /applicabilities` JSON → per-handler `StateGuard` sets; `rest_applicabilities_lift_to_per_handler_guards`. Residual: inner filter-list field name is alias-flexible pending a live response |
247250

248251
**Verdict:** OGAR is at **full contract + lifecycle + protocol-binding +
249252
reactive-dispatch parity**, and **a working native executor runs real commands
@@ -253,16 +256,16 @@ binding, the dispatch, and native execution are real and tested — and the gate
253256
now **wired to the executor**: rs-graph-llm's `graph-flow-action-ogar` runs OGAR's
254257
`CapabilityExecutor` only after `commit_via` commits, so an unauthorized or
255258
MUL-blocked action never executes (proven structurally — `take_result()` is
256-
`None`). The **capabilities instance lift is shipped too** — a real `GET
257-
/capabilities` JSON body lifts to concrete `ActionParam[]` and runs end-to-end
258-
(`rest_registration_lifts_binds_and_runs`). What's left for
259+
`None`). The **whole instance lift is shipped too** — real `GET /capabilities`
260+
and `GET /applicabilities` JSON bodies lift to concrete `ActionParam[]` (runs
261+
end-to-end, `rest_registration_lifts_binds_and_runs`) and per-handler `StateGuard`
262+
sets (`rest_applicabilities_lift_to_per_handler_guards`). What's left for
259263
a **live** drop-in replacement of arago's Python daemon: **B2-transport** (the
260-
WebSocket loop — all shapes/auth pinned), the **B2-lift applicabilities envelope**
261-
(`GET /applicabilities` JSON read; the `ModelFilter→StateGuard` lift is done), and
262-
the **non-native executor targets** (SSH/REST). Each is
263-
transport/parser/runner glue over existing types — **no missing IR, no missing
264-
protocol mapping**. That is the honest state: OGAR *is* an ActionHandler that
265-
runs commands here; a thin transport away from connecting to a live HIRO engine.
264+
WebSocket loop — all shapes/auth pinned) and the **non-native executor targets**
265+
(SSH/REST). Each is transport/runner glue over existing types — **no missing IR,
266+
no missing protocol mapping**. That is the honest state: OGAR *is* an ActionHandler
267+
that runs commands here, reads its own registration, and gates every action; a thin
268+
WebSocket transport away from connecting to a live HIRO engine.
266269

267270
---
268271

@@ -285,8 +288,9 @@ is replaceable; the parity claim is certified, not argued.
285288
- `crates/ogar-from-schema/src/registration.rs` — B2-lift: the REST registration
286289
DTOs (`RegisteredCapability` / `ModelFilter`) + the pure lift
287290
(`lift_registration``ConcreteCapability`; `model_filter_to_guard`).
288-
- `crates/ogar-action-handler/src/lib.rs``parse_capabilities` (the `serde_json`
289-
read of a `GET /capabilities` body; the B2-lift I/O half).
291+
- `crates/ogar-action-handler/src/lib.rs``parse_capabilities` /
292+
`parse_applicabilities` (the `serde_json` read of the `GET /capabilities` and
293+
`GET /applicabilities` bodies; the B2-lift I/O half).
290294
- `docs/HIRO-DO-ARM-LIFT.md` — the lossless-DO rule (the body is pointed-to).
291295
- `docs/ACTIONHANDLER-TURSTEHER.md` — RBAC-as-`const`, the cold-path gate, Rung.
292296
- `lance-graph-contract::action``ActionInvocation` / `commit_via<ClassRbac>`.

docs/DISCOVERY-MAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ two halves of a cell. ADR‑026 names the cascade that ties them.
212212
| 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 |
213213
| 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 |
214214
| 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 |
215-
| 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 |
215+
| 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). **CORRECTION (canon‑pass 2026‑06‑24): applicabilities envelope now SHIPPED** — `registration::{RegisteredApplicability,lift_applicabilities}` + `ogar-action-handler::parse_applicabilities` lift a real `GET /applicabilities` JSON body into per‑handler `StateGuard` sets (`rest_applicabilities_lift_to_per_handler_guards`); inner filter‑list field name alias‑flexible (`modelFilters`/`model`/`filters`) pending a live response. Supersedes the "Remaining" note | G | CODED | `ogar-from-schema/src/registration.rs`, `ogar-action-handler/src/lib.rs` | D‑ACTIONHANDLER‑PARITY |
216216
| D‑OSM | `ogar-from-osm-pbf` — Node/Way/Relation; quadkey NiblePath from resolved geometry | H | IDEA | (queued) | D‑VOCAB, `[per rt]` D‑OSM‑3 |
217217
| D‑PATTERN | `ogar-pattern` — recognition library + confidence (FMA‑D/FIBO/SKR/PROV‑O) | H | IDEA | (queued) | D‑TTL |
218218
| D‑ACTION | `ogar-actionable` — lifecycle → `ActionDef`/`KausalSpec` | H | IDEA | (queued) | D‑PATTERN |

0 commit comments

Comments
 (0)