pointlock_store/error.rs
1//! Error types of the store: fold-level (pure, structural), store-level
2//! (SQLite / IO / serialization plus the fold errors they wrap), and the
3//! typed rejections of the human-response arbitration (06 §4.3).
4
5use std::fmt;
6
7/// A structural violation detected while folding a RunLog into a
8/// [`pointlock_ir::CheckpointView`].
9///
10/// The fold is deliberately *not* lenient about impossible sequences
11/// (M0 iron rule: events the fold does not understand or cannot anchor are
12/// surfaced, never silently ignored) — a fold error inside
13/// [`crate::Store::append_event`] rolls the whole append back, so a log that
14/// cannot fold is never persisted.
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
16pub enum FoldError {
17 /// An event in the fold input belongs to a different run.
18 #[error("event at seq {seq} belongs to run {actual}, fold input is for run {expected}")]
19 RunIdMismatch {
20 /// Sequence number of the offending event.
21 seq: u64,
22 /// The run the fold input is for.
23 expected: String,
24 /// The run the event claims.
25 actual: String,
26 },
27 /// Event sequence numbers must be strictly increasing.
28 #[error("event seq {seq} does not increase monotonically (previous {prev})")]
29 NonMonotonicSeq {
30 /// The previous sequence number.
31 prev: u64,
32 /// The offending sequence number.
33 seq: u64,
34 },
35 /// Any event other than `runStarted` arrived before `runStarted`.
36 #[error("event {event_type} at seq {seq} precedes runStarted")]
37 EventBeforeRunStarted {
38 /// Sequence number of the offending event.
39 seq: u64,
40 /// Wire discriminant of the offending event.
41 event_type: &'static str,
42 },
43 /// A second `runStarted` was appended (later segments start with
44 /// `runResumed`).
45 #[error("duplicate runStarted at seq {seq} (later segments start with runResumed)")]
46 DuplicateRunStarted {
47 /// Sequence number of the offending event.
48 seq: u64,
49 },
50 /// A step-scoped event arrived with no in-flight step to anchor to.
51 #[error("{event_type} at seq {seq} has no in-flight step (missing stepEntered)")]
52 EventOutsideStep {
53 /// Sequence number of the offending event.
54 seq: u64,
55 /// Wire discriminant of the offending event.
56 event_type: &'static str,
57 },
58 /// `stepExited` arrived without a matching `stepEntered`.
59 #[error("stepExited at seq {seq} without a matching stepEntered")]
60 StepExitedWithoutEntry {
61 /// Sequence number of the offending event.
62 seq: u64,
63 },
64 /// `verdictRecorded` targets neither the in-flight step nor any
65 /// completed record at its run path.
66 #[error("verdictRecorded at seq {seq} targets neither the in-flight step nor a completed step")]
67 VerdictWithoutTarget {
68 /// Sequence number of the offending event.
69 seq: u64,
70 },
71 /// `callFramePopped` would pop the root flow frame (or an empty stack).
72 #[error("callFramePopped at seq {seq} would pop the root frame")]
73 PoppedRootFrame {
74 /// Sequence number of the offending event.
75 seq: u64,
76 },
77 /// `stepExited` with no active call frame to advance (structurally
78 /// impossible after a well-formed `runStarted`).
79 #[error("stepExited at seq {seq} with no active call frame")]
80 NoActiveFrame {
81 /// Sequence number of the offending event.
82 seq: u64,
83 },
84 /// A `callFramePushed` marked `rebase` names a stack level that is not
85 /// open (07 §5.2: a rebase re-enters an already-open frame; it never
86 /// creates one).
87 #[error(
88 "callFramePushed(rebase) at seq {seq} targets frame level {level}, stack depth is {depth}"
89 )]
90 RebaseWithoutFrame {
91 /// Sequence number of the offending event.
92 seq: u64,
93 /// The stack level the event's run path addresses.
94 level: usize,
95 /// The number of frames currently open.
96 depth: usize,
97 },
98 /// `humanResponded` pairs no pending request.
99 #[error("humanResponded at seq {seq} pairs no pending request (requestId {request_id})")]
100 UnpairedHumanResponse {
101 /// Sequence number of the offending event.
102 seq: u64,
103 /// The unpaired request id.
104 request_id: String,
105 },
106}
107
108/// Store-level error: SQLite / filesystem / serialization failures, fold
109/// errors surfaced through the write path, and the self-check verdicts of
110/// [`crate::Store::verify_checkpoint`].
111#[derive(Debug, thiserror::Error)]
112pub enum StoreError {
113 /// SQLite error.
114 #[error("sqlite error: {0}")]
115 Sqlite(#[from] rusqlite::Error),
116 /// Filesystem error (evidence area, store directory).
117 #[error("i/o error: {0}")]
118 Io(#[from] std::io::Error),
119 /// JSON (de)serialization error.
120 #[error("serialization error: {0}")]
121 Serde(#[from] serde_json::Error),
122 /// The referenced run does not exist.
123 #[error("unknown run {0}")]
124 UnknownRun(String),
125 /// `begin_run` was called with a run id that already exists.
126 #[error("run {0} already exists")]
127 DuplicateRun(String),
128 /// The run has no materialized checkpoint row yet (no events appended).
129 #[error("run {0} has no materialized checkpoint")]
130 NoCheckpoint(String),
131 /// A fold error (see [`FoldError`] for the rollback semantics on the
132 /// write path).
133 #[error(transparent)]
134 Fold(#[from] FoldError),
135 /// The checkpoint row lags the log head — rule 2 of 07 §3.3 (same-
136 /// transaction materialization) was violated; a store-layer bug.
137 #[error(
138 "checkpoint for run {run_id} is stale: materialized at seq \
139 {materialized_seq}, log head is {log_seq}"
140 )]
141 StaleCheckpoint {
142 /// The run whose checkpoint is stale.
143 run_id: String,
144 /// `checkpoint.log_seq` as stored.
145 materialized_seq: u64,
146 /// The actual `MAX(seq)` of the run's log.
147 log_seq: u64,
148 },
149 /// The materialized view differs from the full-log refold — I1's
150 /// runtime self-check tripped; a store-layer bug (07 §3.3).
151 #[error(
152 "materialized checkpoint for run {run_id} (log_seq {log_seq}) differs from the rebuilt fold"
153 )]
154 CheckpointMismatch {
155 /// The run whose checkpoint mismatches.
156 run_id: String,
157 /// The `log_seq` the stored view claims.
158 log_seq: u64,
159 /// Canonical JSON of the stored view.
160 materialized: String,
161 /// Canonical JSON of the rebuilt view.
162 rebuilt: String,
163 },
164 /// The `run.status` column differs from the folded status.
165 #[error("run {run_id} status '{stored}' differs from folded status '{folded}'")]
166 StatusMismatch {
167 /// The run whose status mismatches.
168 run_id: String,
169 /// `run.status` as stored.
170 stored: String,
171 /// Status produced by the fold.
172 folded: String,
173 },
174 /// A stored row failed to parse back into its typed shape.
175 #[error("corrupt stored data for run {run_id}: {reason}")]
176 Corrupt {
177 /// The run whose stored data is corrupt.
178 run_id: String,
179 /// What failed to parse.
180 reason: String,
181 },
182 /// [`crate::Store::submit_human_response`] refused the response.
183 /// Typed and side-effect free: a rejected response never becomes a
184 /// `humanResponded` event (06 §4.3 — bad data does not enter the
185 /// ledger).
186 #[error("human response for request {request_id} of run {run_id} rejected: {reason}")]
187 HumanResponseRejected {
188 /// The run the response targeted.
189 run_id: String,
190 /// The request the response tried to pair with.
191 request_id: String,
192 /// Why the arbitration refused it.
193 reason: HumanResponseRejection,
194 },
195 /// A locate/dossier query referenced a step instance the ledger never
196 /// entered (spine §9: locate resolves recorded instances only).
197 #[error("run {run_id} has no step instance at '{path}'")]
198 UnknownStepInstance {
199 /// The queried run.
200 run_id: String,
201 /// The canonical path (or bare step id) that failed to resolve.
202 path: String,
203 },
204 /// A bare step id matched several instances (iterations/hook entries);
205 /// the caller must pick one canonical path.
206 #[error("step '{step}' of run {run_id} is ambiguous; candidates: {}", candidates.join(", "))]
207 AmbiguousStep {
208 /// The queried run.
209 run_id: String,
210 /// The bare step id.
211 step: String,
212 /// Canonical strings of every matching instance.
213 candidates: Vec<String>,
214 },
215 /// `PRAGMA journal_mode = WAL` did not take at open (e.g. a network or
216 /// read-only filesystem): the durability contract (actionIntent fsync
217 /// under `synchronous=FULL` in WAL) cannot be honoured, so the store
218 /// refuses to open rather than run silently in another journal mode.
219 #[error(
220 "store at {root} could not enable WAL journaling; sqlite answered journal_mode '{mode}'"
221 )]
222 JournalModeNotWal {
223 /// The store root that failed to open.
224 root: String,
225 /// The journal mode sqlite actually reported.
226 mode: String,
227 },
228 /// A canonical run-path string failed to parse (spine §9 grammar).
229 #[error("run path '{input}' does not parse: {message}")]
230 BadRunPath {
231 /// The offending input.
232 input: String,
233 /// Parser message with offset context.
234 message: String,
235 },
236}
237
238/// The closed rejection vocabulary of the human-response arbitration
239/// (06 §4.3: `unknownRequest | alreadyResponded | deadlineExceeded |
240/// schemaViolation`, plus the lazily-settled leftover).
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum HumanResponseRejection {
243 /// No `humanRequested` event carries this request id.
244 UnknownRequest,
245 /// A final response is already paired (first response wins; a
246 /// supervision `suspend` answer is non-final and does not pair).
247 AlreadyResponded,
248 /// The response arrived after the request's absolute deadline, judged
249 /// by the store-receipt clock — the only timeout judge (06 §4.3 rule
250 /// 2). Lazy settlement of the expired request stays the runner's job;
251 /// the arbitration only refuses the late response.
252 DeadlineExpired {
253 /// The request's absolute deadline (ms since epoch).
254 deadline_at_ms: u64,
255 /// When the store received the response (ms since epoch).
256 received_at_ms: u64,
257 },
258 /// The request is no longer pending (its step was already settled).
259 Settled,
260 /// The response payload does not match the shape the request's
261 /// purpose/mode demands (includes `outputSchema` violations of
262 /// `provideInput` and out-of-vocabulary decisions).
263 InvalidShape {
264 /// What exactly is wrong, human-readable.
265 reason: String,
266 },
267}
268
269impl fmt::Display for HumanResponseRejection {
270 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271 match self {
272 HumanResponseRejection::UnknownRequest => write!(f, "unknown request"),
273 HumanResponseRejection::AlreadyResponded => {
274 write!(f, "already responded (first response wins)")
275 }
276 HumanResponseRejection::DeadlineExpired {
277 deadline_at_ms,
278 received_at_ms,
279 } => write!(
280 f,
281 "deadline expired (deadlineAtMs {deadline_at_ms}, received at {received_at_ms})"
282 ),
283 HumanResponseRejection::Settled => {
284 write!(f, "the request is no longer pending (already settled)")
285 }
286 HumanResponseRejection::InvalidShape { reason } => {
287 write!(f, "invalid response shape: {reason}")
288 }
289 }
290 }
291}