Skip to main content

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}