pointlock_ir/step.rs
1//! Steps: the closed 7-kind vocabulary (02 §4) plus the act-chain binding
2//! types (02 §5).
3//!
4//! ## Wire shape vs. Rust shape
5//!
6//! On the wire, `StepIR` is a `kind`-discriminated union whose variants
7//! compose `StepBase` (baseline schema: `allOf` + `unevaluatedProperties:
8//! false`). In Rust, each variant struct carries an explicit const `kind`
9//! marker field and `#[serde(flatten)]`s [`StepBase`]; [`StepIR`] itself is
10//! `#[serde(untagged)]`. This produces the exact same wire bytes as serde's
11//! internal tagging *and* lets a variant struct (e.g. [`HumanStepIR`] inside
12//! `HandlerAction::escalate`) serialize standalone with its `kind` field, as
13//! the baseline requires.
14//!
15//! ## Closedness caveat (documented divergence)
16//!
17//! serde's `deny_unknown_fields` cannot be combined with `flatten`, so the
18//! step variant structs are *serde-lenient* about unknown fields at runtime.
19//! The schema stays authoritative: `#[schemars(deny_unknown_fields)]`
20//! (schema-only, no serde effect) closes each variant in the generated
21//! schema, mirroring the baseline's `unevaluatedProperties: false`. Golden
22//! fixture behavioral equivalence is judged schema-vs-schema (02 §1.1), so
23//! runtime leniency of the DTO loader is an implementation note, not a
24//! contract change.
25
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use crate::assertion::AssertionIR;
30use crate::expr::{Expr, ExprMap};
31use crate::flow::FlowRef;
32use crate::handler::{HandlerBinding, RetryPolicy};
33use crate::primitives::{
34 ActionName, FeatureId, Hash, JsonSchemaDocument, OnTimeout, Protection, StepId, literal_marker,
35};
36use crate::vocab::{
37 ActChannel, CanonicalVerb, EffectClassAction, ExecutionMode, HumanMode, ObservationWhich,
38};
39
40/// Common step envelope (02 §3).
41///
42/// Deliberately NOT closed (baseline exemption class 3): each `StepIR`
43/// variant composes it and closes itself. `stepId` is identity, the two
44/// hashes are content — the pivot of the resume-alignment mechanism;
45/// `stepId` deliberately participates in neither hash.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "camelCase")]
48pub struct StepBase {
49 /// Author-provided, flow-unique, stable step identity.
50 pub step_id: StepId,
51 /// Canonical hash of "what this step does to the world" (02 §12.3).
52 pub effect_hash: Hash,
53 /// Canonical hash of "how this step is judged" (02 §12.3).
54 pub judge_hash: Hash,
55 /// Pre-entry world probes; double as resume drift detection
56 /// (spine §6.7-C). Distinct from post-hoc `assertions` (`expect`).
57 #[serde(skip_serializing_if = "Option::is_none")]
58 #[schemars(length(min = 1))]
59 pub preflight: Option<Vec<AssertionIR>>,
60 /// Retry policy — applies to the act phase only (spine §6.5 mount 1).
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub retry: Option<RetryPolicy>,
63 /// Step budget in milliseconds.
64 #[serde(skip_serializing_if = "Option::is_none")]
65 #[schemars(range(min = 1))]
66 pub timeout_ms: Option<u64>,
67 /// Step-level handler hooks; override flow-level ones.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 #[schemars(length(min = 1))]
70 pub handlers: Option<Vec<HandlerBinding>>,
71 /// Whether to materialize a checkpoint at this step boundary. Required
72 /// because sealed IR materializes all defaulted fields
73 /// (single-representation rule; default true, false inside macro
74 /// expansions).
75 pub checkpoint: bool,
76}
77
78// ─── kind markers (const tags) ──────────────────────────────────────────────
79
80literal_marker! {
81 /// `kind: "action"`.
82 ActionKind => "action"
83}
84literal_marker! {
85 /// `kind: "assert"`.
86 AssertKind => "assert"
87}
88literal_marker! {
89 /// `kind: "call"`.
90 CallKind => "call"
91}
92literal_marker! {
93 /// `kind: "human"`.
94 HumanKind => "human"
95}
96literal_marker! {
97 /// `kind: "if"`.
98 IfKind => "if"
99}
100literal_marker! {
101 /// `kind: "foreach"`.
102 ForeachKind => "foreach"
103}
104literal_marker! {
105 /// `kind: "let"`.
106 LetKind => "let"
107}
108
109/// The closed step union (7 kinds, spine A.4), discriminated by `kind` on
110/// the wire. See the module docs for why this is `untagged` in serde while
111/// remaining a `kind`-tagged union on the wire.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
113#[serde(untagged)]
114pub enum StepIR {
115 /// `kind: "action"` — fixed pipeline `preflight? → act → observe → assert`.
116 Action(ActionStepIR),
117 /// `kind: "assert"` — side-effect-free observation and judgment.
118 Assert(AssertStepIR),
119 /// `kind: "call"` — subflow invocation, pinned by content hash.
120 Call(CallStepIR),
121 /// `kind: "human"` — human collaboration node (principle 8).
122 Human(HumanStepIR),
123 /// `kind: "if"` — conditional container.
124 If(IfStepIR),
125 /// `kind: "foreach"` — iteration container.
126 Foreach(ForeachStepIR),
127 /// `kind: "let"` — pure bindings into `vars.*` (SSA).
128 Let(LetStepIR),
129}
130
131impl StepIR {
132 /// The step's identity, independent of kind.
133 pub fn step_id(&self) -> &StepId {
134 &self.base().step_id
135 }
136
137 /// The shared step envelope, independent of kind.
138 pub fn base(&self) -> &StepBase {
139 match self {
140 StepIR::Action(s) => &s.base,
141 StepIR::Assert(s) => &s.base,
142 StepIR::Call(s) => &s.base,
143 StepIR::Human(s) => &s.base,
144 StepIR::If(s) => &s.base,
145 StepIR::Foreach(s) => &s.base,
146 StepIR::Let(s) => &s.base,
147 }
148 }
149
150 /// The wire value of the `kind` discriminator.
151 pub fn kind(&self) -> &'static str {
152 match self {
153 StepIR::Action(_) => "action",
154 StepIR::Assert(_) => "assert",
155 StepIR::Call(_) => "call",
156 StepIR::Human(_) => "human",
157 StepIR::If(_) => "if",
158 StepIR::Foreach(_) => "foreach",
159 StepIR::Let(_) => "let",
160 }
161 }
162}
163
164/// Action step (02 §4.1): fixed pipeline `preflight? → act → observe → assert`.
165///
166/// `assertions` MAY be empty: a mutating action step without assertions
167/// yields no verdict (report annotation `unverified`, spine R4).
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
169#[serde(rename_all = "camelCase")]
170#[schemars(deny_unknown_fields)]
171pub struct ActionStepIR {
172 /// Const `"action"`.
173 pub kind: ActionKind,
174 /// Common step envelope (flattened on the wire).
175 #[serde(flatten)]
176 pub base: StepBase,
177 /// Canonical verb — pure metadata for reports; the runner has no verb
178 /// switch (spine R7).
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub verb: Option<CanonicalVerb>,
181 /// `mutating | readonly` (`pure` is excluded — it belongs to `let`).
182 pub effect: EffectClassAction,
183 /// Author-declared idempotence (materialized default: false). Governs
184 /// timed-out auto-retry and reconcile-uncertain replay permission.
185 pub idempotent: bool,
186 /// The compile-time fully bound act-chain.
187 pub binding: ActionBinding,
188 /// Post-hoc assertions. Empty array ⇒ this step yields no verdict.
189 pub assertions: Vec<AssertionIR>,
190 /// Output projection: `Record<name, Expr>` over `ActionResult.output` /
191 /// observation metadata. Self-refs inside refer to the *raw* output
192 /// (02 §4.1.1). Absent ⇒ identity projection.
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub outputs: Option<ExprMap>,
195 /// Data contract of the projected output, for downstream static checks.
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub output_schema: Option<JsonSchemaDocument>,
198}
199
200/// Ordered, closed act-chain. No declared fallback ⇒ exactly one attempt
201/// (principle 6: the runner never improvises a downgrade).
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
203#[serde(deny_unknown_fields)]
204pub struct ActionBinding {
205 /// The attempts, in declared order. A subsequent attempt is tried only
206 /// after `action_failed_final` of the previous one (spine §6.2).
207 #[schemars(length(min = 1))]
208 pub attempts: Vec<BoundAttempt>,
209}
210
211/// One fully bound attempt of the act-chain (02 §5.1).
212///
213/// `protection` is const `"standard"` in v0.1: bind rejects protected
214/// actions (spine R6). `coordinate` attempts must carry literal static
215/// coordinates in `args` (bind-phase check).
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
217#[serde(rename_all = "camelCase", deny_unknown_fields)]
218pub struct BoundAttempt {
219 /// Locating channel — [`ActChannel`], so `vision` is structurally
220 /// impossible here (principle 7).
221 pub channel: ActChannel,
222 /// Provider-native action name (per lockfile.device.actions).
223 pub action_name: ActionName,
224 /// Arguments as expressions; shape-checked against the action's
225 /// `inputSchema` at bind time and re-checked after evaluation at runtime.
226 pub args: ExprMap,
227 /// Feature this attempt depends on (e.g. `device.semanticActions.v1`).
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub requires_feature: Option<FeatureId>,
230 /// Whitelist of daemon-internal execution modes (spine §6.4 R-degrade).
231 /// Derived per attempt from its own `channel` only; semantic attempts
232 /// never include `coordinateFallback`. Set semantics — serialized in
233 /// declaration order of [`ExecutionMode`], deduplicated on load.
234 #[schemars(length(min = 1))]
235 pub accept_execution_modes: std::collections::BTreeSet<ExecutionMode>,
236 /// Const `"standard"` in v0.1 (spine R6).
237 pub protection: Protection,
238}
239
240/// Assert step (02 §4.2): side-effect-free observation and judgment.
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
242#[serde(rename_all = "camelCase")]
243#[schemars(deny_unknown_fields)]
244pub struct AssertStepIR {
245 /// Const `"assert"`.
246 pub kind: AssertKind,
247 /// Common step envelope (flattened on the wire).
248 #[serde(flatten)]
249 pub base: StepBase,
250 /// Observation source: fresh capture or reuse of an action step's
251 /// before/after observation (offline re-judgeable).
252 pub observe: ObservationSource,
253 /// At least one assertion (an assertion-free assert step is meaningless).
254 #[schemars(length(min = 1))]
255 pub assertions: Vec<AssertionIR>,
256}
257
258literal_marker! {
259 /// The `"fresh"` literal of [`ObservationSource`].
260 FreshMarker => "fresh"
261}
262
263/// Where an assert step's observation comes from.
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
265#[serde(untagged)]
266pub enum ObservationSource {
267 /// `"fresh"` — trigger one `ProviderSession.observe` (readonly,
268 /// replay-safe).
269 Fresh(FreshMarker),
270 /// Reuse the referenced action step's archived observation — no device
271 /// contact, purely offline re-judgeable.
272 FromStep(ObservationFromStep),
273}
274
275impl ObservationSource {
276 /// The `"fresh"` source.
277 pub fn fresh() -> Self {
278 ObservationSource::Fresh(FreshMarker::Value)
279 }
280
281 /// A `fromStep` source.
282 pub fn from_step(from_step: StepId, which: ObservationWhich) -> Self {
283 ObservationSource::FromStep(ObservationFromStep { from_step, which })
284 }
285}
286
287/// The object branch of [`ObservationSource`] (inline in the baseline).
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
289#[serde(rename_all = "camelCase", deny_unknown_fields)]
290#[schemars(inline)]
291pub struct ObservationFromStep {
292 /// The action step whose observation is reused.
293 pub from_step: StepId,
294 /// Which observation (`after` | `before`).
295 pub which: ObservationWhich,
296}
297
298/// Call step (02 §6): subflow invocation. Callee pinned by content hash
299/// (`flowRef.irHash` must appear in `FlowIR.subflows`). Call-by-value:
300/// `inputs` are evaluated in the caller scope and snapshotted.
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
302#[serde(rename_all = "camelCase")]
303#[schemars(deny_unknown_fields)]
304pub struct CallStepIR {
305 /// Const `"call"`.
306 pub kind: CallKind,
307 /// Common step envelope (flattened on the wire).
308 #[serde(flatten)]
309 pub base: StepBase,
310 /// The callee, pinned by `flowId` + `irHash`.
311 pub flow_ref: FlowRef,
312 /// Caller-scope input expressions (call-by-value snapshot).
313 pub inputs: ExprMap,
314}
315
316/// Human step (02 §4.4): human collaboration is a formal node (principle 8).
317///
318/// `onTimeout` is const `"unknown"`: a human step that times out never
319/// defaults to pass or fail (principles 4/8). `timeoutMs` is required
320/// (unbounded waits are `runSuspended`, not silent hangs). The
321/// `provideInput` ⇒ `outputSchema` requirement is a schema conditional.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
323#[serde(rename_all = "camelCase")]
324#[schemars(
325 deny_unknown_fields,
326 extend(
327 "if" = { "properties": { "mode": { "const": "provideInput" } }, "required": ["mode"] },
328 "then" = { "required": ["outputSchema"] }
329 )
330)]
331pub struct HumanStepIR {
332 /// Const `"human"`.
333 pub kind: HumanKind,
334 /// Common step envelope (flattened on the wire).
335 #[serde(flatten)]
336 pub base: StepBase,
337 /// Interaction mode.
338 pub mode: HumanMode,
339 /// The question posed to the human.
340 #[schemars(length(min = 1, max = 16384))]
341 pub prompt: String,
342 /// Evidence/values presented to the human (expressions).
343 pub presents: Vec<Expr>,
344 /// Enumerated options for judge/confirm modes.
345 #[serde(skip_serializing_if = "Option::is_none")]
346 #[schemars(length(min = 1), inner(length(min = 1, max = 256)))]
347 pub decisions: Option<Vec<String>>,
348 /// Input contract for `provideInput` mode (required there, schema-enforced).
349 #[serde(skip_serializing_if = "Option::is_none")]
350 pub output_schema: Option<JsonSchemaDocument>,
351 /// Required budget; expiry yields verdict `unknown`.
352 #[schemars(range(min = 1))]
353 pub timeout_ms: u64,
354 /// Const `"unknown"` (principle 4).
355 pub on_timeout: OnTimeout,
356}
357
358/// If step (02 §4.5): conditional container. Container hashes exclude the
359/// subtree — child steps carry their own identity and hashes (02 §12.3).
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
361#[serde(rename_all = "camelCase")]
362#[schemars(deny_unknown_fields)]
363pub struct IfStepIR {
364 /// Const `"if"`.
365 pub kind: IfKind,
366 /// Common step envelope (flattened on the wire).
367 #[serde(flatten)]
368 pub base: StepBase,
369 /// The branch condition.
370 pub cond: Expr,
371 /// Steps executed when the condition holds (≥ 1).
372 #[schemars(length(min = 1))]
373 pub then: Vec<StepIR>,
374 /// Steps executed otherwise (≥ 1 when present). Unselected branch steps
375 /// are `skipped`.
376 #[serde(skip_serializing_if = "Option::is_none")]
377 #[schemars(length(min = 1))]
378 pub r#else: Option<Vec<StepIR>>,
379}
380
381/// Foreach step (02 §4.6): iteration container. The iteration variable is
382/// referenced via `iter.<as>`; `RunPath` disambiguates rounds with
383/// `{ kind: "iteration", index }` frames, so body stepIds do not (and must
384/// not) vary per round.
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
386#[serde(rename_all = "camelCase")]
387#[schemars(deny_unknown_fields)]
388pub struct ForeachStepIR {
389 /// Const `"foreach"`.
390 pub kind: ForeachKind,
391 /// Common step envelope (flattened on the wire).
392 #[serde(flatten)]
393 pub base: StepBase,
394 /// The collection expression.
395 pub items: Expr,
396 /// Iteration variable name (scoped as `iter.<as>`).
397 pub r#as: crate::primitives::Identifier,
398 /// Loop body (≥ 1 step).
399 #[schemars(length(min = 1))]
400 pub body: Vec<StepIR>,
401}
402
403/// Let step (02 §4.7): pure bindings into the `vars.*` scope, SSA single
404/// assignment (rebinding an existing var name is a check-phase error).
405#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
406#[serde(rename_all = "camelCase")]
407#[schemars(deny_unknown_fields)]
408pub struct LetStepIR {
409 /// Const `"let"`.
410 pub kind: LetKind,
411 /// Common step envelope (flattened on the wire).
412 #[serde(flatten)]
413 pub base: StepBase,
414 /// The bindings (≥ 1 entry).
415 #[schemars(schema_with = "let_bindings_schema")]
416 pub bindings: ExprMap,
417}
418
419fn let_bindings_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
420 schemars::json_schema!({
421 "allOf": [generator.subschema_for::<ExprMap>()],
422 "type": "object",
423 "minProperties": 1
424 })
425}