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 /// Another writer holds the run's advisory writer lease
126 /// ([`crate::WriterLease`], 07 §3.3 rule 5): a live segment is driving
127 /// the run and this process must not append a second walk.
128 #[error("run {run_id} has a live writer (its writer lease is held by another process)")]
129 WriterBusy {
130 /// The run whose lease is held.
131 run_id: String,
132 },
133 /// `begin_run` was called with a run id that already exists.
134 #[error("run {0} already exists")]
135 DuplicateRun(String),
136 /// The run has no materialized checkpoint row yet (no events appended).
137 #[error("run {0} has no materialized checkpoint")]
138 NoCheckpoint(String),
139 /// A fold error (see [`FoldError`] for the rollback semantics on the
140 /// write path).
141 #[error(transparent)]
142 Fold(#[from] FoldError),
143 /// The checkpoint row lags the log head — rule 2 of 07 §3.3 (same-
144 /// transaction materialization) was violated; a store-layer bug.
145 #[error(
146 "checkpoint for run {run_id} is stale: materialized at seq \
147 {materialized_seq}, log head is {log_seq}"
148 )]
149 StaleCheckpoint {
150 /// The run whose checkpoint is stale.
151 run_id: String,
152 /// `checkpoint.log_seq` as stored.
153 materialized_seq: u64,
154 /// The actual `MAX(seq)` of the run's log.
155 log_seq: u64,
156 },
157 /// The materialized view differs from the full-log refold — I1's
158 /// runtime self-check tripped; a store-layer bug (07 §3.3).
159 #[error(
160 "materialized checkpoint for run {run_id} (log_seq {log_seq}) differs from the rebuilt fold"
161 )]
162 CheckpointMismatch {
163 /// The run whose checkpoint mismatches.
164 run_id: String,
165 /// The `log_seq` the stored view claims.
166 log_seq: u64,
167 /// Canonical JSON of the stored view.
168 materialized: String,
169 /// Canonical JSON of the rebuilt view.
170 rebuilt: String,
171 },
172 /// The `run.status` column differs from the folded status.
173 #[error("run {run_id} status '{stored}' differs from folded status '{folded}'")]
174 StatusMismatch {
175 /// The run whose status mismatches.
176 run_id: String,
177 /// `run.status` as stored.
178 stored: String,
179 /// Status produced by the fold.
180 folded: String,
181 },
182 /// A stored row failed to parse back into its typed shape.
183 #[error("corrupt stored data for run {run_id}: {reason}")]
184 Corrupt {
185 /// The run whose stored data is corrupt.
186 run_id: String,
187 /// What failed to parse.
188 reason: String,
189 },
190 /// [`crate::Store::submit_human_response`] refused the response.
191 /// Typed and side-effect free: a rejected response never becomes a
192 /// `humanResponded` event (06 §4.3 — bad data does not enter the
193 /// ledger).
194 #[error("human response for request {request_id} of run {run_id} rejected: {reason}")]
195 HumanResponseRejected {
196 /// The run the response targeted.
197 run_id: String,
198 /// The request the response tried to pair with.
199 request_id: String,
200 /// Why the arbitration refused it.
201 reason: HumanResponseRejection,
202 },
203 /// A locate/dossier query referenced a step instance the ledger never
204 /// entered (spine §9: locate resolves recorded instances only).
205 #[error("run {run_id} has no step instance at '{path}'")]
206 UnknownStepInstance {
207 /// The queried run.
208 run_id: String,
209 /// The canonical path (or bare step id) that failed to resolve.
210 path: String,
211 },
212 /// A bare step id matched several instances (iterations/hook entries);
213 /// the caller must pick one canonical path.
214 #[error("step '{step}' of run {run_id} is ambiguous; candidates: {}", candidates.join(", "))]
215 AmbiguousStep {
216 /// The queried run.
217 run_id: String,
218 /// The bare step id.
219 step: String,
220 /// Canonical strings of every matching instance.
221 candidates: Vec<String>,
222 },
223 /// `PRAGMA journal_mode = WAL` did not take at open (e.g. a network or
224 /// read-only filesystem): the durability contract (actionIntent fsync
225 /// under `synchronous=FULL` in WAL) cannot be honoured, so the store
226 /// refuses to open rather than run silently in another journal mode.
227 #[error(
228 "store at {root} could not enable WAL journaling; sqlite answered journal_mode '{mode}'"
229 )]
230 JournalModeNotWal {
231 /// The store root that failed to open.
232 root: String,
233 /// The journal mode sqlite actually reported.
234 mode: String,
235 },
236 /// A canonical run-path string failed to parse (spine §9 grammar).
237 #[error("run path '{input}' does not parse: {message}")]
238 BadRunPath {
239 /// The offending input.
240 input: String,
241 /// Parser message with offset context.
242 message: String,
243 },
244}
245
246/// The closed rejection vocabulary of the human-response arbitration
247/// (06 §4.3: `unknownRequest | alreadyResponded | deadlineExceeded |
248/// schemaViolation`, plus the lazily-settled leftover).
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum HumanResponseRejection {
251 /// No `humanRequested` event carries this request id.
252 UnknownRequest,
253 /// A final response is already paired (first response wins; a
254 /// supervision `suspend` answer is non-final and does not pair).
255 AlreadyResponded,
256 /// The response arrived after the request's absolute deadline, judged
257 /// by the store-receipt clock — the only timeout judge (06 §4.3 rule
258 /// 2). Lazy settlement of the expired request stays the runner's job;
259 /// the arbitration only refuses the late response.
260 DeadlineExpired {
261 /// The request's absolute deadline (ms since epoch).
262 deadline_at_ms: u64,
263 /// When the store received the response (ms since epoch).
264 received_at_ms: u64,
265 },
266 /// The request is no longer pending (its step was already settled).
267 Settled,
268 /// The response payload does not match the shape the request's
269 /// purpose/mode demands (includes `outputSchema` violations of
270 /// `provideInput` and out-of-vocabulary decisions).
271 InvalidShape {
272 /// What exactly is wrong, human-readable.
273 reason: String,
274 },
275}
276
277impl fmt::Display for HumanResponseRejection {
278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279 match self {
280 HumanResponseRejection::UnknownRequest => write!(f, "unknown request"),
281 HumanResponseRejection::AlreadyResponded => {
282 write!(f, "already responded (first response wins)")
283 }
284 HumanResponseRejection::DeadlineExpired {
285 deadline_at_ms,
286 received_at_ms,
287 } => write!(
288 f,
289 "deadline expired (deadlineAtMs {deadline_at_ms}, received at {received_at_ms})"
290 ),
291 HumanResponseRejection::Settled => {
292 write!(f, "the request is no longer pending (already settled)")
293 }
294 HumanResponseRejection::InvalidShape { reason } => {
295 write!(f, "invalid response shape: {reason}")
296 }
297 }
298 }
299}