Skip to content

Commit 2f91935

Browse files
committed
action_ws: harvest the HIRO 7.0 dev-portal specs — spec-faithful protocol core
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP
1 parent 29f28f4 commit 2f91935

3 files changed

Lines changed: 237 additions & 17 deletions

File tree

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

Lines changed: 190 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ use ogar_vocab::{
3333

3434
use crate::do_arm::ActionParam;
3535

36+
/// The `action-ws` WebSocket connect path (HIRO Action API 1.0). The full URL is
37+
/// `wss://<host>/api/action-ws/1.0/connect`.
38+
pub const ACTION_WS_PATH: &str = "/api/action-ws/1.0/connect";
39+
40+
/// The WebSocket subprotocol header value carrying the auth token — HIRO passes
41+
/// the token as `sec-websocket-protocol: token-$TOKEN`.
42+
#[must_use]
43+
pub fn auth_subprotocol(token: &str) -> String {
44+
format!("token-{token}")
45+
}
46+
47+
/// Spec bounds on a `submitAction` / `sendActionResult` correlation `id`
48+
/// (12–256 chars). [`validate_id`] enforces it.
49+
pub const ID_MIN_LEN: usize = 12;
50+
/// Upper bound on the correlation `id` length (spec).
51+
pub const ID_MAX_LEN: usize = 256;
52+
3653
/// A `submitAction` message (engine → handler). The engine asks the handler to
3754
/// run `capability` on a target with the supplied `parameters`.
3855
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -65,15 +82,55 @@ pub struct Acknowledged {
6582
pub message: String,
6683
}
6784

68-
/// A `sendActionResult` message (handler → engine): the outcome payload — the
69-
/// capability's `resultParameters` as `(key, value)` pairs.
85+
/// A `sendActionResult` message (handler → engine): the outcome payload.
86+
///
87+
/// Per the `action-ws` spec the `result` is a **single string** (max
88+
/// `1048576` chars) — the capability's `resultParameters` JSON-encoded into one
89+
/// field (build it with [`json_object`]). The engine replies `acknowledged` /
90+
/// `negativeAcknowledged`.
7091
#[derive(Debug, Clone, PartialEq, Eq, Default)]
7192
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
7293
pub struct SendActionResult {
7394
/// The same correlation id as the originating [`SubmitAction`].
7495
pub id: String,
75-
/// The result fields (the `resultParameters` output signature, bound).
76-
pub result: Vec<(String, String)>,
96+
/// The result value — a JSON object string of the bound `resultParameters`
97+
/// (spec: `string`, max 1 MiB).
98+
pub result: String,
99+
}
100+
101+
/// Max length of the [`SendActionResult::result`] string (spec: `1048576`).
102+
pub const MAX_RESULT_LEN: usize = 1_048_576;
103+
104+
/// A `negativeAcknowledged` message (engine ↔ handler): receipt *rejection*
105+
/// (e.g. `code = 400`). The negative twin of [`Acknowledged`].
106+
#[derive(Debug, Clone, PartialEq, Eq)]
107+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
108+
pub struct NegativeAcknowledged {
109+
/// The id of the message being rejected.
110+
pub id: String,
111+
/// Error code (e.g. `400`).
112+
pub code: u16,
113+
/// Error description.
114+
pub message: String,
115+
}
116+
117+
/// A `configChanged` notification (engine → handler): the handler's
118+
/// capabilities / applicabilities changed; the handler must re-fetch them from
119+
/// the REST Action API (`GET /capabilities`, `GET /applicabilities`). Carries
120+
/// no payload beyond `type`; the handler replies `acknowledged`.
121+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
123+
pub struct ConfigChanged;
124+
125+
/// An asynchronous `error` message (engine → handler) — not tied to a specific
126+
/// request id.
127+
#[derive(Debug, Clone, PartialEq, Eq)]
128+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
129+
pub struct InboundError {
130+
/// Error code.
131+
pub code: u16,
132+
/// Error details.
133+
pub message: String,
77134
}
78135

79136
/// Errors in the protocol binding (the pure core — no I/O errors here).
@@ -93,6 +150,12 @@ pub enum ActionWsError {
93150
/// A result was requested from an invocation that has not reached
94151
/// [`ActionState::Committed`] (the Rubicon crossing).
95152
NotCommitted(ActionState),
153+
/// A correlation `id` outside the spec bounds (12–256 chars); carries the
154+
/// offending length.
155+
InvalidId(usize),
156+
/// The encoded `result` exceeds [`MAX_RESULT_LEN`] (spec: 1 MiB); carries the
157+
/// offending length.
158+
ResultTooLarge(usize),
96159
}
97160

98161
impl core::fmt::Display for ActionWsError {
@@ -106,6 +169,8 @@ impl core::fmt::Display for ActionWsError {
106169
}
107170
Self::MissingMandatoryParam(p) => write!(f, "missing mandatory parameter `{p}`"),
108171
Self::NotCommitted(s) => write!(f, "invocation not committed (state = {s:?})"),
172+
Self::InvalidId(n) => write!(f, "correlation id length {n} out of bounds (12..=256)"),
173+
Self::ResultTooLarge(n) => write!(f, "result length {n} exceeds 1 MiB"),
109174
}
110175
}
111176
}
@@ -124,6 +189,70 @@ pub fn acknowledge(msg: &SubmitAction) -> Acknowledged {
124189
}
125190
}
126191

192+
/// Reject a message by id (the `negativeAcknowledged` twin of [`acknowledge`]).
193+
#[must_use]
194+
pub fn negative_acknowledge(
195+
id: &str,
196+
code: u16,
197+
message: impl Into<String>,
198+
) -> NegativeAcknowledged {
199+
NegativeAcknowledged {
200+
id: id.to_owned(),
201+
code,
202+
message: message.into(),
203+
}
204+
}
205+
206+
/// Validate a correlation `id` against the spec bounds (12–256 chars).
207+
///
208+
/// # Errors
209+
///
210+
/// [`ActionWsError::InvalidId`] when the length is out of range.
211+
pub fn validate_id(id: &str) -> Result<(), ActionWsError> {
212+
if (ID_MIN_LEN..=ID_MAX_LEN).contains(&id.len()) {
213+
Ok(())
214+
} else {
215+
Err(ActionWsError::InvalidId(id.len()))
216+
}
217+
}
218+
219+
/// Encode `(key, value)` pairs as a JSON object string — the wire form of the
220+
/// [`SendActionResult::result`] field (the bound `resultParameters`). A minimal,
221+
/// correctly-escaping encoder; the live transport may use `serde_json` instead.
222+
#[must_use]
223+
pub fn json_object(pairs: &[(String, String)]) -> String {
224+
let mut s = String::from("{");
225+
for (i, (k, v)) in pairs.iter().enumerate() {
226+
if i > 0 {
227+
s.push(',');
228+
}
229+
json_string(k, &mut s);
230+
s.push(':');
231+
json_string(v, &mut s);
232+
}
233+
s.push('}');
234+
s
235+
}
236+
237+
/// Append `raw` as a JSON string literal (RFC 8259 escaping) to `out`.
238+
fn json_string(raw: &str, out: &mut String) {
239+
out.push('"');
240+
for c in raw.chars() {
241+
match c {
242+
'"' => out.push_str("\\\""),
243+
'\\' => out.push_str("\\\\"),
244+
'\n' => out.push_str("\\n"),
245+
'\r' => out.push_str("\\r"),
246+
'\t' => out.push_str("\\t"),
247+
c if (c as u32) < 0x20 => {
248+
out.push_str(&format!("\\u{:04x}", c as u32));
249+
}
250+
c => out.push(c),
251+
}
252+
}
253+
out.push('"');
254+
}
255+
127256
/// Bind the engine-supplied `parameters` to the capability's [`ActionParam`]
128257
/// signature: every mandatory param must be supplied (or have a default);
129258
/// optional params fall back to their default when present, and are dropped
@@ -213,11 +342,17 @@ pub fn submit_to_invocation(
213342
/// [`ActionState::Committed`].
214343
pub fn invocation_to_result(
215344
inv: &ActionInvocation,
216-
result: Vec<(String, String)>,
345+
result_params: &[(String, String)],
217346
) -> Result<SendActionResult, ActionWsError> {
218347
if inv.state != ActionState::Committed {
219348
return Err(ActionWsError::NotCommitted(inv.state));
220349
}
350+
// The spec's `result` is a single string (max 1 MiB) — JSON-encode the bound
351+
// resultParameters into it.
352+
let result = json_object(result_params);
353+
if result.len() > MAX_RESULT_LEN {
354+
return Err(ActionWsError::ResultTooLarge(result.len()));
355+
}
221356
Ok(SendActionResult {
222357
id: inv.idempotency_key.clone().unwrap_or_default(),
223358
result,
@@ -334,26 +469,64 @@ mod tests {
334469
let mut inv = submit_to_invocation(&submit(), &execute_command_def()).expect("builds");
335470

336471
// Pending → no result on the success path.
337-
let pending = invocation_to_result(&inv, vec![]);
472+
let pending = invocation_to_result(&inv, &[]);
338473
assert_eq!(
339474
pending.unwrap_err(),
340475
ActionWsError::NotCommitted(ActionState::Pending)
341476
);
342477

343-
// The Rubicon crossing (the gate would set this) → result emitted.
478+
// The Rubicon crossing (the gate would set this) → result emitted as a
479+
// JSON object string (the spec's single `result` field).
344480
inv.state = ActionState::Committed;
345-
let result = invocation_to_result(
346-
&inv,
347-
vec![("output".to_owned(), "12:00 up 3 days".to_owned())],
348-
)
349-
.expect("committed → result");
481+
let result =
482+
invocation_to_result(&inv, &[("output".to_owned(), "12:00 up 3 days".to_owned())])
483+
.expect("committed → result");
350484
assert_eq!(result.id, "app1:req42"); // correlation id round-trips
485+
assert_eq!(result.result, r#"{"output":"12:00 up 3 days"}"#);
486+
}
487+
488+
#[test]
489+
fn negative_acknowledge_carries_code_and_message() {
490+
let nack = negative_acknowledge("app1:req42", 400, "bad capability");
491+
assert_eq!(nack.id, "app1:req42");
492+
assert_eq!(nack.code, 400);
493+
assert_eq!(nack.message, "bad capability");
494+
}
495+
496+
#[test]
497+
fn validate_id_enforces_spec_bounds() {
498+
assert!(validate_id("123456789012").is_ok()); // 12 chars (min)
499+
assert_eq!(
500+
validate_id("short").unwrap_err(),
501+
ActionWsError::InvalidId(5)
502+
);
503+
let too_long = "x".repeat(257);
351504
assert_eq!(
352-
result.result,
353-
vec![("output".to_owned(), "12:00 up 3 days".to_owned())]
505+
validate_id(&too_long).unwrap_err(),
506+
ActionWsError::InvalidId(257)
354507
);
355508
}
356509

510+
#[test]
511+
fn json_object_escapes_correctly() {
512+
// Empty, simple, and escape-needing values.
513+
assert_eq!(json_object(&[]), "{}");
514+
assert_eq!(
515+
json_object(&[("k".to_owned(), "v".to_owned())]),
516+
r#"{"k":"v"}"#
517+
);
518+
assert_eq!(
519+
json_object(&[("out".to_owned(), "a\"b\\c\nd".to_owned())]),
520+
r#"{"out":"a\"b\\c\nd"}"#
521+
);
522+
}
523+
524+
#[test]
525+
fn auth_subprotocol_prefixes_the_token() {
526+
assert_eq!(auth_subprotocol("abc123"), "token-abc123");
527+
assert_eq!(ACTION_WS_PATH, "/api/action-ws/1.0/connect");
528+
}
529+
357530
/// The whole loop, end-to-end (socket-free): submit → ack → bind → invoke
358531
/// → commit → result, with the `id` correlating throughout.
359532
#[test]
@@ -372,8 +545,9 @@ mod tests {
372545
// (the executor + commit_via gate run here; we simulate the crossing)
373546
inv.state = ActionState::Committed;
374547

375-
let result = invocation_to_result(&inv, vec![("exitcode".to_owned(), "0".to_owned())])
376-
.expect("result");
548+
let result =
549+
invocation_to_result(&inv, &[("exitcode".to_owned(), "0".to_owned())]).expect("result");
377550
assert_eq!(result.id, msg.id);
551+
assert_eq!(result.result, r#"{"exitcode":"0"}"#);
378552
}
379553
}

docs/ARAGO-ACTIONHANDLER-PARITY.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,47 @@ needs a type OGAR lacks — only the *glue* (§3) is unbuilt.
104104

105105
---
106106

107+
## 2a. The harvested authoritative contract (`action-ws.yaml` + `action.yaml`)
108+
109+
Harvested from the HIRO 7.0 dev-portal machine-readable specs
110+
(`core.engine.datagroup.de/help/specs/definitions/{action-ws,action}.yaml`).
111+
This is the **complete** message set + connection + registration model the OGAR
112+
`action_ws` module is built to (corrections folded in — the earlier `result{…}`
113+
object framing from the tutorial is superseded by the spec's `result: string`).
114+
115+
**Connection.** `wss://<host>/api/action-ws/1.0/connect` — token passed as the
116+
WebSocket subprotocol `sec-websocket-protocol: token-$TOKEN`. (`action_ws::{ACTION_WS_PATH,
117+
auth_subprotocol}`.)
118+
119+
**The six message types** (✓ = modelled in `action_ws`):
120+
121+
| type | dir | fields | OGAR type |
122+
|---|---|---|---|
123+
| `submitAction` | engine → handler | `id` (12–256), `handler`, `capability`, `parameters` (obj), `timeout` (ms) | `SubmitAction`|
124+
| `sendActionResult` | handler → engine | `id`, `result` (**string**, ≤ `1048576`) | `SendActionResult` ✓ (`result` = `json_object(resultParams)`) |
125+
| `acknowledged` | both | `id`, `code` (200), `message` | `Acknowledged` ✓ (`acknowledge`) |
126+
| `negativeAcknowledged` | both | `id`, `code` (e.g. 400), `message` | `NegativeAcknowledged` ✓ (`negative_acknowledge`) |
127+
| `configChanged` | engine → handler | `type` only → re-fetch capabilities | `ConfigChanged`|
128+
| `error` | engine → handler | `code`, `message` (no `id`) | `InboundError`|
129+
130+
**Corrections the spec forced** (vs the tutorial-only first pass): `result` is a
131+
**single string** (≤ 1 MiB), not an object — `invocation_to_result` JSON-encodes
132+
the bound `resultParameters` into it (`MAX_RESULT_LEN`, `ResultTooLarge`); `id`
133+
is **12–256 chars** (`validate_id`, `InvalidId`); the nack / configChanged /
134+
error message types now exist.
135+
136+
**Registration is REST, not a WS handshake.** A handler's capabilities and
137+
applicabilities live in the graph and are read via the REST **Action API**
138+
(`/api/action/1.0/`, Bearer token): `GET /capabilities`
139+
`MapOfCapabilities` (each `{description, mandatoryParameters, optionalParameters}`),
140+
`GET /applicabilities``MapOfApplicabilities` (keyed by handler id). The
141+
`action-ws` socket has **no registration message**`configChanged` just tells
142+
the handler to re-`GET` them. This is exactly the `do_arm::assemble_action_handler`
143+
shape (handler → applicabilities → capabilities), so the REST registration view
144+
lifts straight into `ActionHandlerSpec` (the B2-lift brick).
145+
146+
---
147+
107148
## 3. The switch path — replacing the Python daemon with OGAR
108149

109150
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.
210251
- `rs-graph-llm/graph-flow-action` — the `ActionHandler` executor trait (B1 home).
211252
- arago: `github.com/arago/ActionHandlers`,
212253
`arago/python-hiro-stonebranch-actionhandler`, HIRO 7 Action API tutorial.
254+
- **HIRO 7.0 dev-portal specs (the authoritative harvest, §2a):**
255+
`core.engine.datagroup.de/help/specs/definitions/action-ws.yaml` (the
256+
WebSocket message contract), `…/action.yaml` (the REST registration API),
257+
`…/auth.yaml` (the token endpoint). Indexed at
258+
`dev-portal.engine.datagroup.de/7.0/api/`.

docs/DISCOVERY-MAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ two halves of a cell. ADR‑026 names the cascade that ties them.
210210
| D‑ELIXIR | Elixir/HIRO SchemaSource scaffold (`gen_statem`→Rubicon) | G | CODED (scaffold) | `ogar-from-elixir/` | D‑VOCAB |
211211
| 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 |
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 |
213-
| 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 |
213+
| 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 |
214214
| D‑OSM | `ogar-from-osm-pbf` — Node/Way/Relation; quadkey NiblePath from resolved geometry | H | IDEA | (queued) | D‑VOCAB, `[per rt]` D‑OSM‑3 |
215215
| D‑PATTERN | `ogar-pattern` — recognition library + confidence (FMA‑D/FIBO/SKR/PROV‑O) | H | IDEA | (queued) | D‑TTL |
216216
| D‑ACTION | `ogar-actionable` — lifecycle → `ActionDef`/`KausalSpec` | H | IDEA | (queued) | D‑PATTERN |

0 commit comments

Comments
 (0)