pointlock_ir/handler.rs
1//! Handlers and retry policies (02 §10).
2//!
3//! Handlers are explicit strategies mounted on state-machine hooks; they
4//! yield dispositions, never data-flow outputs (spine R10). Retry mounts at
5//! exactly two places: `StepBase.retry` (act phase, same attempt, new
6//! callId) and `HandlerAction::Retry` (whole-step re-entry from `acting`,
7//! independent budget) — there is structurally no third place.
8
9use std::collections::BTreeSet;
10
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use crate::flow::FlowRef;
15use crate::step::HumanStepIR;
16use crate::vocab::{ErrorClass, HandlerHook};
17
18/// A handler mounted on a hook.
19///
20/// The `errorClasses` filter is legal only on `onError` — the baseline
21/// `if`/`else` conditional is reproduced via `#[schemars(extend)]`.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24#[schemars(extend(
25 "if" = { "properties": { "hook": { "const": "onError" } }, "required": ["hook"] },
26 "else" = { "not": { "required": ["errorClasses"] } }
27))]
28pub struct HandlerBinding {
29 /// The hook this handler fires on.
30 pub hook: HandlerHook,
31 /// Error-class filter — only meaningful (and only legal) on `onError`.
32 #[serde(skip_serializing_if = "Option::is_none")]
33 #[schemars(length(min = 1))]
34 pub error_classes: Option<BTreeSet<ErrorClass>>,
35 /// What to do when the hook fires.
36 pub action: HandlerAction,
37 /// Trigger budget (loop guard).
38 #[schemars(range(min = 1))]
39 pub max_triggers: u32,
40}
41
42/// The disposition produced by a handler; `kind` values are the closed
43/// `Disposition` enum: `retry | continue | escalate | abort | repair`
44/// (spine A.4). No variant carries outputs — error paths cannot enter the
45/// data flow (spine R10).
46/// Note on closedness: `#[schemars(deny_unknown_fields)]` closes each
47/// variant object in the generated schema (baseline parity); serde's
48/// internally-tagged deserialization is lenient about unknown fields at
49/// runtime — the schema stays authoritative.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51#[serde(tag = "kind", rename_all = "camelCase")]
52#[schemars(deny_unknown_fields)]
53pub enum HandlerAction {
54 /// Re-enter the step from `acting` with an independent retry budget.
55 Retry {
56 /// The retry policy for the re-entry.
57 policy: RetryPolicy,
58 },
59 /// Record and move on (verdict unchanged).
60 Continue,
61 /// Escalate to a human node (compiler-synthesized stepId, 02 §3).
62 Escalate {
63 /// The embedded human step (boxed: much larger than sibling variants).
64 human: Box<HumanStepIR>,
65 },
66 /// Abort the run.
67 Abort,
68 /// Run a repair subflow — no data outputs; afterwards re-probe
69 /// (`onResumeDrift`) or re-enter (`onFail`).
70 #[serde(rename_all = "camelCase")]
71 Repair {
72 /// The repair subflow, pinned like a `call`.
73 flow_ref: FlowRef,
74 },
75}
76
77/// Retry policy. Applies to the act phase only (spine §6.5 mount point 1);
78/// each retry mints a new `callId` and a new `actionIntent` WAL record.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
80#[serde(rename_all = "camelCase", deny_unknown_fields)]
81pub struct RetryPolicy {
82 /// Attempt budget (≥ 1).
83 #[schemars(range(min = 1))]
84 pub max_attempts: u32,
85 /// Backoff: fixed milliseconds or an exponential schedule.
86 pub backoff_ms: BackoffMs,
87 /// Which error classes are retryable here. Semantically meaningful:
88 /// `action_failed_retryable`, `target_stale` (forces re-observe), and —
89 /// for idempotent steps — `action_timed_out`; `check` warns on the rest.
90 /// Set semantics — serialized in [`ErrorClass`] declaration order.
91 #[schemars(length(min = 1))]
92 pub retry_on: BTreeSet<ErrorClass>,
93}
94
95/// Backoff declaration: a plain number of milliseconds, or an exponential
96/// schedule. Numbers use `serde_json::Number` so integers round-trip
97/// canonically (02 §12.1 rule 4).
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
99#[serde(untagged)]
100pub enum BackoffMs {
101 /// Fixed backoff in milliseconds (≥ 0).
102 Fixed(#[schemars(range(min = 0))] serde_json::Number),
103 /// Exponential schedule.
104 Schedule(BackoffSchedule),
105}
106
107/// Exponential backoff schedule (inline object in the baseline).
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
109#[serde(deny_unknown_fields)]
110#[schemars(inline)]
111pub struct BackoffSchedule {
112 /// Initial delay in milliseconds (≥ 0).
113 #[schemars(range(min = 0))]
114 pub initial: serde_json::Number,
115 /// Multiplication factor (≥ 1).
116 #[schemars(range(min = 1))]
117 pub factor: serde_json::Number,
118 /// Delay ceiling in milliseconds (≥ 0).
119 #[schemars(range(min = 0))]
120 pub max: serde_json::Number,
121}