Skip to main content

pointlock_store/
store.rs

1//! The single-writer SQLite store: RunLog append path, same-transaction
2//! checkpoint materialization, and the content-addressed evidence area.
3//!
4//! ## Durability & consistency rules (07 §3.3, verbatim discipline)
5//!
6//! 1. **actionIntent WAL**: [`Store::write_action_intent`] appends in its
7//!    own transaction; under `journal_mode=WAL` + `synchronous=FULL` a
8//!    returned commit *is* the fsync — callers MUST call it (and see it
9//!    return) before `provider.execute` dispatch.
10//! 2. **Same-transaction materialization**: [`Store::append_event`] updates
11//!    the `checkpoint` row in the same transaction as the `run_log` insert;
12//!    a read checkpoint is always consistent with an exact `log_seq`.
13//! 3. **file-before-row-before-log**: [`Store::put_evidence`] writes and
14//!    fsyncs the evidence bytes before inserting the `evidence` row; the
15//!    RunLog event referencing the evidence is appended by the caller
16//!    afterwards. A crash leaves at most an orphan file (GC-able), never a
17//!    log that references missing evidence.
18//! 4. **Append-only**: there is no API that UPDATEs or DELETEs `run_log`
19//!    rows — the guarantee is structural, not procedural.
20//!
21//! ## Single writer
22//!
23//! `Store` owns its [`rusqlite::Connection`] (which is `!Sync`); exactly one
24//! `Store` instance must perform writes for a given store directory. WAL
25//! mode lets concurrently opened read-only inspectors (e.g.
26//! `pointlock inspect`) read without blocking the writer.
27
28use std::fmt::Write as _;
29use std::fs;
30use std::io::Write as _;
31use std::path::{Path, PathBuf};
32
33use pointlock_ir::{
34    BindingState, CheckpointView, FlowId, Hash, HumanMode, HumanPurpose, JsonSchemaDocument,
35    RunLogEvent, RunLogPayload, RunPath, to_canonical_json,
36};
37use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
38use serde_json::Value;
39
40use crate::error::{HumanResponseRejection, StoreError};
41use crate::fold::{FoldState, FoldedRun, RunMeta, RunStatus, fold_checkpoint, fold_state};
42
43/// Schema DDL. Follows 07 §3.3 verbatim, with one documented divergence:
44/// the `run` table carries a `binding` column (canonical `BindingState`
45/// JSON) — spine §6.1 defines `Run = (irHash, paramsSnapshot, bindingSpec,
46/// runId)` and the 17-event union carries no binding, so the fold needs it
47/// as input alongside the log.
48const DDL: &str = "
49CREATE TABLE IF NOT EXISTS run (
50  run_id           TEXT PRIMARY KEY,
51  flow_id          TEXT NOT NULL,
52  ir_hash          TEXT NOT NULL,
53  lockfile_digest  TEXT NOT NULL,
54  params_snapshot  TEXT NOT NULL,
55  binding          TEXT NOT NULL,
56  status           TEXT NOT NULL CHECK (status IN
57                     ('running','suspended','awaitingHuman','finished')),
58  created_at_ms    INTEGER NOT NULL
59);
60
61CREATE TABLE IF NOT EXISTS run_log (
62  run_id   TEXT    NOT NULL REFERENCES run(run_id),
63  seq      INTEGER NOT NULL,
64  type     TEXT    NOT NULL,
65  at_ms    INTEGER NOT NULL,
66  run_path TEXT    NOT NULL,
67  payload  TEXT    NOT NULL,
68  PRIMARY KEY (run_id, seq)
69) WITHOUT ROWID;
70
71CREATE TABLE IF NOT EXISTS checkpoint (
72  run_id  TEXT PRIMARY KEY REFERENCES run(run_id),
73  log_seq INTEGER NOT NULL,
74  view    TEXT NOT NULL
75);
76
77CREATE TABLE IF NOT EXISTS evidence (
78  sha256     TEXT PRIMARY KEY,
79  media_type TEXT NOT NULL,
80  byte_size  INTEGER NOT NULL,
81  local_path TEXT NOT NULL
82);
83
84CREATE TABLE IF NOT EXISTS evidence_ref (
85  run_id   TEXT NOT NULL,
86  seq      INTEGER NOT NULL,
87  asset_id TEXT NOT NULL,
88  sha256   TEXT NOT NULL REFERENCES evidence(sha256),
89  PRIMARY KEY (run_id, seq, asset_id)
90);
91";
92
93/// The dispatch identity of an `actionIntent` (2026-07-18 incorporation,
94/// item ②): 1-based chain position plus the bound attempt's channel and
95/// native action name, verbatim.
96#[derive(Debug, Clone)]
97pub struct IntentDispatch {
98    /// 1-based position in `binding.attempts`.
99    pub chain_index: u32,
100    /// The attempt's locating channel.
101    pub channel: pointlock_ir::ActChannel,
102    /// The attempt's provider-native action name.
103    pub action_name: pointlock_ir::ActionName,
104}
105
106/// Parameters of [`Store::begin_run`]. The `runStarted` event is *not*
107/// written here — the caller appends it (07 §3.1: the log is the truth;
108/// `begin_run` only creates the run row the fold takes as [`RunMeta`]).
109#[derive(Debug, Clone)]
110pub struct NewRun {
111    /// Explicit run id; a UUIDv4 is generated when absent.
112    pub run_id: Option<String>,
113    /// The root flow's id.
114    pub flow_id: FlowId,
115    /// Content hash of the executing IR.
116    pub ir_hash: Hash,
117    /// Digest of the bound capability lockfile.
118    pub lockfile_digest: Hash,
119    /// The run's input parameters.
120    pub params_snapshot: Value,
121    /// Provider binding seed (device, initial session lineage, cursor).
122    pub binding: BindingState,
123    /// Run creation timestamp (ms since epoch).
124    pub created_at_ms: u64,
125}
126
127/// One row of [`Store::list_runs`] — the run-index projection source.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct RunListEntry {
130    /// The run id.
131    pub run_id: String,
132    /// The run's flow.
133    pub flow_id: String,
134    /// The executed IR hash (canonical `sha256:` form).
135    pub ir_hash: String,
136    /// Current lifecycle status (folded column).
137    pub status: RunStatus,
138    /// Run creation wall clock.
139    pub created_at_ms: u64,
140}
141
142/// One resolved evidence entry ([`Store::evidence_meta`]).
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct EvidenceMeta {
145    /// Media type of the stored bytes.
146    pub media_type: String,
147    /// Store-relative path (`evidence/sha256/<2>/<2>/<digest>`).
148    pub local_path: String,
149    /// Absolute path under the store root.
150    pub abs_path: PathBuf,
151}
152
153/// Result of [`Store::put_evidence`].
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct EvidencePut {
156    /// Bare lowercase hex sha256 digest of the bytes.
157    pub sha256: String,
158    /// Path relative to the store root
159    /// (`evidence/sha256/<2>/<2>/<digest>`) — the form stored in
160    /// `evidence.local_path` and suitable for `EvidenceRef.localPath`.
161    pub local_path: String,
162    /// Absolute filesystem path of the localized bytes.
163    pub abs_path: PathBuf,
164    /// Byte size of the content.
165    pub byte_size: u64,
166    /// `true` when the content was already present (idempotent dedup).
167    pub deduplicated: bool,
168}
169
170/// The single-writer store handle (see the module docs for the durability
171/// rules and the single-writer discipline).
172pub struct Store {
173    conn: Connection,
174    root: PathBuf,
175    /// Per-run terminal fold state of the last append (single-writer
176    /// append cache): the next `append_event` folds exactly one event
177    /// instead of the whole ledger. Any seq discontinuity (fresh handle,
178    /// failed commit) falls back to the full refold; the persisted view
179    /// is identical either way, and `verify_checkpoint` remains the
180    /// from-scratch cross-check (I1).
181    fold_cache: std::collections::HashMap<String, (u64, FoldState)>,
182}
183
184impl Store {
185    /// Opens (creating if needed) the store rooted at `root`: the SQLite
186    /// database at `<root>/pointlock.db` and the evidence area at
187    /// `<root>/evidence/`. Sets `journal_mode=WAL`, `synchronous=FULL`
188    /// (the actionIntent fsync semantics depend on FULL) and
189    /// `foreign_keys=ON`, and applies the DDL.
190    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
191        let root = root.as_ref().to_path_buf();
192        fs::create_dir_all(&root)?;
193        fs::create_dir_all(root.join("evidence"))?;
194        let conn = Connection::open(root.join("pointlock.db"))?;
195        // `PRAGMA journal_mode` returns the resulting mode as a row.
196        let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
197        debug_assert_eq!(mode.to_ascii_lowercase(), "wal");
198        conn.pragma_update(None, "synchronous", "FULL")?;
199        conn.pragma_update(None, "foreign_keys", "ON")?;
200        conn.execute_batch(DDL)?;
201        Ok(Store {
202            conn,
203            root,
204            fold_cache: std::collections::HashMap::new(),
205        })
206    }
207
208    /// The store's root directory.
209    pub fn root(&self) -> &Path {
210        &self.root
211    }
212
213    /// Creates the run row (status `running`) and returns the run id.
214    ///
215    /// The caller is expected to append the `runStarted` event next — the
216    /// row only seeds the fold input ([`RunMeta`]); it is not a log entry.
217    pub fn begin_run(&mut self, new_run: NewRun) -> Result<String, StoreError> {
218        let run_id = new_run
219            .run_id
220            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
221        let exists: bool = self.conn.query_row(
222            "SELECT EXISTS(SELECT 1 FROM run WHERE run_id = ?1)",
223            [&run_id],
224            |row| row.get(0),
225        )?;
226        if exists {
227            return Err(StoreError::DuplicateRun(run_id));
228        }
229        let params_json = to_canonical_json(&new_run.params_snapshot);
230        let binding_json = to_canonical_json(&serde_json::to_value(&new_run.binding)?);
231        self.conn.execute(
232            "INSERT INTO run (run_id, flow_id, ir_hash, lockfile_digest, params_snapshot, \
233             binding, status, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
234            params![
235                run_id,
236                new_run.flow_id.as_str(),
237                new_run.ir_hash.as_str(),
238                new_run.lockfile_digest.as_str(),
239                params_json,
240                binding_json,
241                RunStatus::Running.as_str(),
242                new_run.created_at_ms as i64,
243            ],
244        )?;
245        Ok(run_id)
246    }
247
248    /// Appends one event and returns its allocated `seq`.
249    ///
250    /// One `IMMEDIATE` transaction covers: `seq := MAX(seq)+1` allocation,
251    /// the `run_log` insert, the checkpoint re-materialization, and the
252    /// `run.status` transition. The materialization folds incrementally
253    /// through the per-run single-writer fold cache;
254    /// any seq discontinuity falls back to the full refold, and the
255    /// persisted view is identical either way. Atomicity means an event
256    /// whose fold fails is *refused* — the log can never outrun the
257    /// materialized view.
258    pub fn append_event(
259        &mut self,
260        run_id: &str,
261        at_ms: u64,
262        run_path: &RunPath,
263        payload: &RunLogPayload,
264    ) -> Result<u64, StoreError> {
265        let tx = self
266            .conn
267            .transaction_with_behavior(TransactionBehavior::Immediate)?;
268        let meta = read_meta(&tx, run_id)?;
269        let seq: i64 = tx.query_row(
270            "SELECT COALESCE(MAX(seq), 0) + 1 FROM run_log WHERE run_id = ?1",
271            [run_id],
272            |row| row.get(0),
273        )?;
274        let run_path_json = to_canonical_json(&serde_json::to_value(run_path)?);
275        let payload_json = to_canonical_json(&serde_json::to_value(payload)?);
276        tx.execute(
277            "INSERT INTO run_log (run_id, seq, type, at_ms, run_path, payload) \
278             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
279            params![
280                run_id,
281                seq,
282                payload.event_type(),
283                at_ms as i64,
284                run_path_json,
285                payload_json,
286            ],
287        )?;
288        let event = RunLogEvent {
289            run_id: run_id.to_owned(),
290            seq: seq as u64,
291            at_ms,
292            run_path: run_path.clone(),
293            payload: payload.clone(),
294        };
295        let folded = match self.fold_cache.get_mut(run_id) {
296            Some((cached_seq, state)) if *cached_seq + 1 == seq as u64 => {
297                if let Err(err) = state.apply(&event) {
298                    // A failed apply leaves the state half-mutated.
299                    self.fold_cache.remove(run_id);
300                    return Err(err.into());
301                }
302                *cached_seq = seq as u64;
303                state.clone().finish()
304            }
305            _ => {
306                let events = read_events(&tx, run_id)?;
307                let state = fold_state(&meta, &events)?;
308                self.fold_cache
309                    .insert(run_id.to_owned(), (seq as u64, state.clone()));
310                state.finish()
311            }
312        };
313        let view_json = to_canonical_json(&serde_json::to_value(&folded.view)?);
314        tx.execute(
315            "INSERT INTO checkpoint (run_id, log_seq, view) VALUES (?1, ?2, ?3) \
316             ON CONFLICT(run_id) DO UPDATE SET log_seq = excluded.log_seq, view = excluded.view",
317            params![run_id, seq, view_json],
318        )?;
319        tx.execute(
320            "UPDATE run SET status = ?2 WHERE run_id = ?1",
321            params![run_id, folded.status.as_str()],
322        )?;
323        tx.commit()?;
324        Ok(seq as u64)
325    }
326
327    /// Appends the `actionIntent` WAL entry in its own transaction and
328    /// returns its `seq`.
329    ///
330    /// **Dispatch discipline (07 §3.3 rule 1)**: under WAL +
331    /// `synchronous=FULL`, this method returning `Ok` means the intent is
332    /// durably on disk — that *is* the "fsync before dispatch" of spine
333    /// §6.2. Callers MUST invoke this and observe the `Ok` before calling
334    /// `provider.execute`; on crash, `reconcile(callId)` finds the intent
335    /// regardless of whether the dispatch left the process.
336    pub fn write_action_intent(
337        &mut self,
338        run_id: &str,
339        at_ms: u64,
340        run_path: &RunPath,
341        call_id: &str,
342        args_snapshot: Value,
343        dispatch: Option<IntentDispatch>,
344    ) -> Result<u64, StoreError> {
345        let (chain_index, channel, action_name) = match dispatch {
346            Some(dispatch) => (
347                Some(dispatch.chain_index),
348                Some(dispatch.channel),
349                Some(dispatch.action_name),
350            ),
351            None => (None, None, None),
352        };
353        self.append_event(
354            run_id,
355            at_ms,
356            run_path,
357            &RunLogPayload::ActionIntent {
358                call_id: call_id.to_owned(),
359                args_snapshot,
360                chain_index,
361                channel,
362                action_name,
363            },
364        )
365    }
366
367    /// The single-writer arbitration of a human response (06 §4.3; R13):
368    /// validates the response against the pending request read back from
369    /// the ledger and — only when every rule passes — appends the
370    /// `humanResponded` event, returning its `seq`.
371    ///
372    /// `at_ms` is the store-receipt clock, **the only timeout judge**
373    /// (06 §4.3 rule 2): a response received after the request's
374    /// `deadlineAtMs` is refused with
375    /// [`HumanResponseRejection::DeadlineExpired`] and *no event is
376    /// written* — the lazy settlement of the expired request itself stays
377    /// the runner's job on resume (06 §5.3).
378    ///
379    /// Arbitration rules, in order (all rejections are typed
380    /// [`StoreError::HumanResponseRejected`] and side-effect free — bad
381    /// data never enters the ledger):
382    ///
383    /// 1. The request must exist (`humanRequested` with this id).
384    /// 2. **First response wins**: a request with a paired final response
385    ///    is closed. A supervision `suspend` answer is non-final
386    ///    (spine §6.9) — it is recorded but keeps the request open for a
387    ///    later proceed/abort ruling.
388    /// 3. `at_ms` must not exceed the request's `deadlineAtMs`
389    ///    (supervision requests carry none and never expire).
390    /// 4. The request must still be pending (a lazily-settled step no
391    ///    longer accepts responses).
392    /// 5. The payload must match the shape the request's purpose/mode
393    ///    demands (06 §2.1 union as adjudicated): `confirm`
394    ///    `{decision ∈ decisions, note?}`, `judge`
395    ///    `{status ∈ pass|fail|unknown, note?}`, `provideInput`
396    ///    `{input (validated against outputSchema), note?}`, `repairWorld`
397    ///    `{decision ∈ the request's declared decisions, else
398    ///    done|cannotRepair (06 §2.1), note?}`, supervision
399    ///    `{decision ∈ proceed|abort|suspend, note?}`.
400    ///
401    /// Single-writer discipline makes check-then-append race-free: this
402    /// `Store` owns the only write connection.
403    pub fn submit_human_response(
404        &mut self,
405        run_id: &str,
406        request_id: &str,
407        actor: &str,
408        at_ms: u64,
409        response: Value,
410    ) -> Result<u64, StoreError> {
411        let reject = |reason: HumanResponseRejection| StoreError::HumanResponseRejected {
412            run_id: run_id.to_owned(),
413            request_id: request_id.to_owned(),
414            reason,
415        };
416        let events = self.events(run_id)?;
417        // Rule 1: the request must exist on the ledger.
418        let request = events
419            .iter()
420            .find_map(|event| match &event.payload {
421                RunLogPayload::HumanRequested {
422                    request_id: rid,
423                    purpose,
424                    mode,
425                    decisions,
426                    output_schema,
427                    deadline_at_ms,
428                    ..
429                } if rid == request_id => Some((
430                    event.run_path.clone(),
431                    *purpose,
432                    *mode,
433                    decisions.clone(),
434                    output_schema.clone(),
435                    *deadline_at_ms,
436                )),
437                _ => None,
438            })
439            .ok_or_else(|| reject(HumanResponseRejection::UnknownRequest))?;
440        let (run_path, purpose, mode, decisions, output_schema, deadline_at_ms) = request;
441        // Rule 2: first response wins — any paired *final* response closes
442        // the request (supervision suspend answers are non-final).
443        let finally_responded = events.iter().any(|event| match &event.payload {
444            RunLogPayload::HumanResponded {
445                request_id: rid,
446                purpose,
447                response,
448                ..
449            } if rid == request_id => {
450                !(*purpose == HumanPurpose::Supervision
451                    && response.get("decision").and_then(Value::as_str) == Some("suspend"))
452            }
453            _ => false,
454        });
455        if finally_responded {
456            return Err(reject(HumanResponseRejection::AlreadyResponded));
457        }
458        // Rule 3: the store-receipt clock is the only timeout judge.
459        if let Some(deadline) = deadline_at_ms
460            && at_ms > deadline
461        {
462            return Err(reject(HumanResponseRejection::DeadlineExpired {
463                deadline_at_ms: deadline,
464                received_at_ms: at_ms,
465            }));
466        }
467        // Rule 4: the request must still be pending (the lazy timeout
468        // settlement closes it via the step exit, without a response).
469        let pending = self
470            .materialized_checkpoint(run_id)?
471            .and_then(|(_, view)| view.human_pending)
472            .is_some_and(|pending| pending.request_id == request_id);
473        if !pending {
474            return Err(reject(HumanResponseRejection::Settled));
475        }
476        // Rule 5: shape validation per purpose/mode.
477        validate_response_shape(
478            purpose,
479            mode,
480            decisions.as_deref(),
481            &output_schema,
482            &response,
483        )
484        .map_err(|reason| reject(HumanResponseRejection::InvalidShape { reason }))?;
485        self.append_event(
486            run_id,
487            at_ms,
488            &run_path,
489            &RunLogPayload::HumanResponded {
490                request_id: request_id.to_owned(),
491                purpose,
492                response,
493                actor: actor.to_owned(),
494            },
495        )
496    }
497
498    /// Rebuilds the [`CheckpointView`] by folding the run's full log
499    /// (07 §3.3 rebuild channel). Read-only; does not touch the
500    /// materialized row.
501    pub fn rebuild_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
502        Ok(self.refold(run_id)?.view)
503    }
504
505    /// I1's runtime self-check (backs `pointlock inspect
506    /// --rebuild-checkpoint`): asserts materialized == rebuilt.
507    ///
508    /// Verifies that (a) the checkpoint row exists and its `log_seq` is the
509    /// log head, (b) the stored view equals the full-log refold, and
510    /// (c) `run.status` equals the folded status. Any inequality is a
511    /// store-layer bug surfaced as a typed error. Returns the verified
512    /// view.
513    pub fn verify_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
514        let meta = read_meta(&self.conn, run_id)?;
515        let events = read_events(&self.conn, run_id)?;
516        let (log_seq, stored_json): (i64, String) = self
517            .conn
518            .query_row(
519                "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
520                [run_id],
521                |row| Ok((row.get(0)?, row.get(1)?)),
522            )
523            .optional()?
524            .ok_or_else(|| StoreError::NoCheckpoint(run_id.to_owned()))?;
525        let head = events.last().map(|event| event.seq).unwrap_or(0);
526        if log_seq as u64 != head {
527            return Err(StoreError::StaleCheckpoint {
528                run_id: run_id.to_owned(),
529                materialized_seq: log_seq as u64,
530                log_seq: head,
531            });
532        }
533        let stored: CheckpointView = serde_json::from_str(&stored_json)?;
534        let folded = fold_checkpoint(&meta, &events)?;
535        if folded.view != stored {
536            return Err(StoreError::CheckpointMismatch {
537                run_id: run_id.to_owned(),
538                log_seq: log_seq as u64,
539                materialized: to_canonical_json(&serde_json::to_value(&stored)?),
540                rebuilt: to_canonical_json(&serde_json::to_value(&folded.view)?),
541            });
542        }
543        let stored_status = self.run_status(run_id)?;
544        if stored_status != folded.status {
545            return Err(StoreError::StatusMismatch {
546                run_id: run_id.to_owned(),
547                stored: stored_status.as_str().to_owned(),
548                folded: folded.status.as_str().to_owned(),
549            });
550        }
551        Ok(folded.view)
552    }
553
554    /// Localizes evidence bytes into the content-addressed area and indexes
555    /// them, idempotently. Layout:
556    /// `<root>/evidence/sha256/<hex[0..2]>/<hex[2..4]>/<digest>`.
557    ///
558    /// **file-before-row (07 §3.3 rule 3)**: bytes are written to a temp
559    /// file, fsynced, renamed into place (and the directory fsynced) before
560    /// the `evidence` row is inserted; the caller appends the referencing
561    /// RunLog event only after this returns. Re-putting identical bytes is
562    /// a no-op dedup (`deduplicated: true`).
563    pub fn put_evidence(
564        &mut self,
565        bytes: &[u8],
566        media_type: &str,
567    ) -> Result<EvidencePut, StoreError> {
568        let digest = sha256_hex(bytes);
569        let rel_path = format!(
570            "evidence/sha256/{}/{}/{}",
571            &digest[0..2],
572            &digest[2..4],
573            digest
574        );
575        let abs_path = self.root.join(&rel_path);
576        let deduplicated = abs_path.exists();
577        if !deduplicated {
578            let parent = abs_path.parent().expect("evidence path has a parent");
579            fs::create_dir_all(parent)?;
580            let tmp_path = parent.join(format!(".{}.tmp.{}", digest, std::process::id()));
581            {
582                let mut file = fs::File::create(&tmp_path)?;
583                file.write_all(bytes)?;
584                file.sync_all()?;
585            }
586            fs::rename(&tmp_path, &abs_path)?;
587            // Make the rename itself durable before the row exists.
588            #[cfg(unix)]
589            fs::File::open(parent)?.sync_all()?;
590        }
591        self.conn.execute(
592            "INSERT OR IGNORE INTO evidence (sha256, media_type, byte_size, local_path) \
593             VALUES (?1, ?2, ?3, ?4)",
594            params![digest, media_type, bytes.len() as i64, rel_path],
595        )?;
596        Ok(EvidencePut {
597            sha256: digest,
598            local_path: rel_path,
599            abs_path,
600            byte_size: bytes.len() as u64,
601            deduplicated,
602        })
603    }
604
605    /// Links a RunLog event to a localized evidence entry
606    /// (`evidence_ref` row; idempotent). `foreign_keys=ON` rejects links
607    /// to evidence that was never put.
608    pub fn link_evidence(
609        &mut self,
610        run_id: &str,
611        seq: u64,
612        asset_id: &str,
613        sha256: &str,
614    ) -> Result<(), StoreError> {
615        self.conn.execute(
616            "INSERT OR IGNORE INTO evidence_ref (run_id, seq, asset_id, sha256) \
617             VALUES (?1, ?2, ?3, ?4)",
618            params![run_id, seq as i64, asset_id, sha256],
619        )?;
620        Ok(())
621    }
622
623    /// Reads the run's metadata row (the fold input).
624    pub fn run_meta(&self, run_id: &str) -> Result<RunMeta, StoreError> {
625        read_meta(&self.conn, run_id)
626    }
627
628    /// Reads the run's current lifecycle status.
629    pub fn run_status(&self, run_id: &str) -> Result<RunStatus, StoreError> {
630        let status: String = self
631            .conn
632            .query_row(
633                "SELECT status FROM run WHERE run_id = ?1",
634                [run_id],
635                |row| row.get(0),
636            )
637            .optional()?
638            .ok_or_else(|| StoreError::UnknownRun(run_id.to_owned()))?;
639        RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
640            run_id: run_id.to_owned(),
641            reason: format!("run.status holds unknown value {status:?}"),
642        })
643    }
644
645    /// The run's current revision = its max ledger seq (0 before the
646    /// first event) — the SSE invalidation currency (08 §5). Cheap by
647    /// design: the pollers behind `--serve` call this a few times a
648    /// second.
649    pub fn revision(&self, run_id: &str) -> Result<u64, StoreError> {
650        let _ = read_meta(&self.conn, run_id)?;
651        let head: i64 = self.conn.query_row(
652            "SELECT COALESCE(MAX(seq), 0) FROM run_log WHERE run_id = ?1",
653            [run_id],
654            |row| row.get(0),
655        )?;
656        Ok(head as u64)
657    }
658
659    /// A store-wide monotonic revision (= sum of every run's head seq):
660    /// the inbox stream's invalidation currency — any append anywhere
661    /// moves it.
662    pub fn global_revision(&self) -> Result<u64, StoreError> {
663        let total: i64 = self.conn.query_row(
664            "SELECT COALESCE(SUM(head), 0) FROM \
665             (SELECT MAX(seq) AS head FROM run_log GROUP BY run_id)",
666            [],
667            |row| row.get(0),
668        )?;
669        Ok(total as u64)
670    }
671
672    /// Resolves one content-addressed evidence entry to its media type
673    /// and absolute path (the `/evidence/:sha256` byte route — 08 §4.3
674    /// dereference side; the address is the only key, never a path).
675    pub fn evidence_meta(&self, sha256: &str) -> Result<Option<EvidenceMeta>, StoreError> {
676        let row: Option<(String, String)> = self
677            .conn
678            .query_row(
679                "SELECT media_type, local_path FROM evidence WHERE sha256 = ?1",
680                [sha256],
681                |row| Ok((row.get(0)?, row.get(1)?)),
682            )
683            .optional()?;
684        Ok(row.map(|(media_type, local_path)| EvidenceMeta {
685            media_type,
686            abs_path: self.root.join(&local_path),
687            local_path,
688        }))
689    }
690
691    /// Lists every run, in creation order (projection read side: the
692    /// cross-run inbox and the flow run index consume this).
693    pub fn list_runs(&self) -> Result<Vec<RunListEntry>, StoreError> {
694        let mut statement = self.conn.prepare(
695            "SELECT run_id, flow_id, ir_hash, status, created_at_ms \
696             FROM run ORDER BY created_at_ms, run_id",
697        )?;
698        let rows = statement.query_map([], |row| {
699            Ok((
700                row.get::<_, String>(0)?,
701                row.get::<_, String>(1)?,
702                row.get::<_, String>(2)?,
703                row.get::<_, String>(3)?,
704                row.get::<_, i64>(4)?,
705            ))
706        })?;
707        let mut runs = Vec::new();
708        for row in rows {
709            let (run_id, flow_id, ir_hash, status, created_at_ms) = row?;
710            let status = RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
711                run_id: run_id.clone(),
712                reason: format!("run.status holds unknown value {status:?}"),
713            })?;
714            runs.push(RunListEntry {
715                run_id,
716                flow_id,
717                ir_hash,
718                status,
719                created_at_ms: created_at_ms as u64,
720            });
721        }
722        Ok(runs)
723    }
724
725    /// Reads the run's full ordered event log.
726    pub fn events(&self, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
727        // Distinguish "unknown run" from "no events yet".
728        let _ = read_meta(&self.conn, run_id)?;
729        read_events(&self.conn, run_id)
730    }
731
732    /// Reads the materialized checkpoint row, if any:
733    /// `(log_seq, view)`. `None` until the first event is appended.
734    pub fn materialized_checkpoint(
735        &self,
736        run_id: &str,
737    ) -> Result<Option<(u64, CheckpointView)>, StoreError> {
738        let row: Option<(i64, String)> = self
739            .conn
740            .query_row(
741                "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
742                [run_id],
743                |row| Ok((row.get(0)?, row.get(1)?)),
744            )
745            .optional()?;
746        match row {
747            None => Ok(None),
748            Some((log_seq, view_json)) => {
749                let view: CheckpointView = serde_json::from_str(&view_json)?;
750                Ok(Some((log_seq as u64, view)))
751            }
752        }
753    }
754
755    fn refold(&self, run_id: &str) -> Result<FoldedRun, StoreError> {
756        let meta = read_meta(&self.conn, run_id)?;
757        let events = read_events(&self.conn, run_id)?;
758        Ok(fold_checkpoint(&meta, &events)?)
759    }
760}
761
762/// Validates one human response payload against the shape its request's
763/// purpose/mode demands (06 §2.1 union as adjudicated). Strictly closed:
764/// exactly the expected key plus an optional string `note`; anything else
765/// is refused (bad data never enters the ledger).
766fn validate_response_shape(
767    purpose: HumanPurpose,
768    mode: Option<HumanMode>,
769    decisions: Option<&[String]>,
770    output_schema: &Option<JsonSchemaDocument>,
771    response: &Value,
772) -> Result<(), String> {
773    let Some(object) = response.as_object() else {
774        return Err(format!("response must be a JSON object, got {response}"));
775    };
776    let (value_key, expects_input) = match (purpose, mode) {
777        (HumanPurpose::Supervision, _) => ("decision", false),
778        (HumanPurpose::Step, Some(HumanMode::Confirm)) => ("decision", false),
779        (HumanPurpose::Step, Some(HumanMode::Judge)) => ("status", false),
780        (HumanPurpose::Step, Some(HumanMode::ProvideInput)) => ("input", true),
781        (HumanPurpose::Step, Some(HumanMode::RepairWorld)) => ("decision", false),
782        (HumanPurpose::Step, None) => {
783            return Err("the request carries no mode for its step purpose".to_owned());
784        }
785    };
786    for key in object.keys() {
787        if key != value_key && key != "note" {
788            return Err(format!("unexpected field '{key}'"));
789        }
790    }
791    if let Some(note) = object.get("note")
792        && !note.is_string()
793    {
794        return Err(format!("'note' must be a string, got {note}"));
795    }
796    let Some(value) = object.get(value_key) else {
797        return Err(format!("missing required field '{value_key}'"));
798    };
799    if expects_input {
800        // provideInput: the input must satisfy the request's outputSchema
801        // (06 §4.3 rule 3 — the channel re-prompts on rejection).
802        let Some(schema) = output_schema else {
803            return Err("the request carries no outputSchema for provideInput".to_owned());
804        };
805        return jsonschema::validate(schema.as_value(), value)
806            .map_err(|error| format!("input failed the outputSchema: {error}"));
807    }
808    let Some(label) = value.as_str() else {
809        return Err(format!("'{value_key}' must be a string, got {value}"));
810    };
811    match (purpose, mode) {
812        (HumanPurpose::Supervision, _) => match label {
813            // Deliberately no `skip` (spine §6.9).
814            "proceed" | "abort" | "suspend" => Ok(()),
815            other => Err(format!(
816                "supervision decision must be proceed|abort|suspend, got '{other}'"
817            )),
818        },
819        (_, Some(HumanMode::Confirm)) => {
820            // Position-mapped double label (06 §2.2): membership is the
821            // arbitration's check, the position mapping is the runner's.
822            let labels = decisions.unwrap_or(&[]);
823            if labels.iter().any(|candidate| candidate == label) {
824                Ok(())
825            } else {
826                Err(format!(
827                    "confirm decision '{label}' is not one of the request's labels {labels:?}"
828                ))
829            }
830        }
831        (_, Some(HumanMode::Judge)) => {
832            // The three-valued vocabulary admits no aliases (06 §2.2),
833            // narrowed further by the request's declared subset.
834            if !matches!(label, "pass" | "fail" | "unknown") {
835                return Err(format!(
836                    "judge status must be pass|fail|unknown, got '{label}'"
837                ));
838            }
839            if let Some(subset) = decisions
840                && !subset.iter().any(|candidate| candidate == label)
841            {
842                return Err(format!(
843                    "judge status '{label}' is outside the request's declared subset {subset:?}"
844                ));
845            }
846            Ok(())
847        }
848        (_, Some(HumanMode::RepairWorld)) => {
849            // A repairWorld request that declares its own vocabulary is
850            // arbitrated against exactly it — the uncertain-reconcile
851            // adjudication asks `adopt | redo | abort` (00 §6.7-B /
852            // 07 §4.4), a different question from the world-repair
853            // declaration. Without a declaration the 06 §2.1 base
854            // vocabulary governs: `done | cannotRepair`, nothing else,
855            // no aliases (2026-07-28 unification — the as-built
856            // `repaired|abort` matched neither 06 nor the adjudication
857            // and is retired).
858            if let Some(declared) = decisions {
859                return if declared.iter().any(|candidate| candidate == label) {
860                    Ok(())
861                } else {
862                    Err(format!(
863                        "repairWorld decision '{label}' is not one of the request's \
864                         labels {declared:?}"
865                    ))
866                };
867            }
868            match label {
869                "done" | "cannotRepair" => Ok(()),
870                other => Err(format!(
871                    "repairWorld decision must be done|cannotRepair (06 §2.1), got '{other}'"
872                )),
873            }
874        }
875        _ => unreachable!("provideInput and mode-less step requests returned above"),
876    }
877}
878
879fn sha256_hex(bytes: &[u8]) -> String {
880    use sha2::{Digest, Sha256};
881    let digest = Sha256::digest(bytes);
882    let mut hex = String::with_capacity(64);
883    for byte in digest {
884        let _ = write!(hex, "{byte:02x}");
885    }
886    hex
887}
888
889fn read_meta(conn: &Connection, run_id: &str) -> Result<RunMeta, StoreError> {
890    let row: Option<(String, String, String, String, String, i64)> = conn
891        .query_row(
892            "SELECT flow_id, ir_hash, lockfile_digest, params_snapshot, binding, created_at_ms \
893             FROM run WHERE run_id = ?1",
894            [run_id],
895            |row| {
896                Ok((
897                    row.get(0)?,
898                    row.get(1)?,
899                    row.get(2)?,
900                    row.get(3)?,
901                    row.get(4)?,
902                    row.get(5)?,
903                ))
904            },
905        )
906        .optional()?;
907    let Some((flow_id, ir_hash, lockfile_digest, params_json, binding_json, created_at_ms)) = row
908    else {
909        return Err(StoreError::UnknownRun(run_id.to_owned()));
910    };
911    let corrupt = |reason: String| StoreError::Corrupt {
912        run_id: run_id.to_owned(),
913        reason,
914    };
915    Ok(RunMeta {
916        run_id: run_id.to_owned(),
917        flow_id: FlowId::new(flow_id).map_err(|e| corrupt(e.to_string()))?,
918        ir_hash: Hash::new(ir_hash).map_err(|e| corrupt(e.to_string()))?,
919        lockfile_digest: Hash::new(lockfile_digest).map_err(|e| corrupt(e.to_string()))?,
920        params_snapshot: serde_json::from_str(&params_json)?,
921        binding: serde_json::from_str(&binding_json)?,
922        created_at_ms: created_at_ms as u64,
923    })
924}
925
926fn read_events(conn: &Connection, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
927    let mut stmt = conn.prepare(
928        "SELECT seq, type, at_ms, run_path, payload FROM run_log \
929         WHERE run_id = ?1 ORDER BY seq",
930    )?;
931    let rows = stmt.query_map([run_id], |row| {
932        Ok((
933            row.get::<_, i64>(0)?,
934            row.get::<_, String>(1)?,
935            row.get::<_, i64>(2)?,
936            row.get::<_, String>(3)?,
937            row.get::<_, String>(4)?,
938        ))
939    })?;
940    let mut events = Vec::new();
941    for row in rows {
942        let (seq, event_type, at_ms, run_path_json, payload_json) = row?;
943        let payload: RunLogPayload = serde_json::from_str(&payload_json)?;
944        // Self-check: the denormalized `type` column must agree with the
945        // payload discriminant.
946        if payload.event_type() != event_type {
947            return Err(StoreError::Corrupt {
948                run_id: run_id.to_owned(),
949                reason: format!(
950                    "run_log seq {seq}: type column {event_type:?} disagrees with payload \
951                     discriminant {:?}",
952                    payload.event_type()
953                ),
954            });
955        }
956        let run_path: RunPath = serde_json::from_str(&run_path_json)?;
957        events.push(RunLogEvent {
958            run_id: run_id.to_owned(),
959            seq: seq as u64,
960            at_ms: at_ms as u64,
961            run_path,
962            payload,
963        });
964    }
965    Ok(events)
966}