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
184/// Switches `conn` to WAL journaling, failing closed when the pragma does
185/// not take: sqlite answers `PRAGMA journal_mode = WAL` with the mode it
186/// actually ended up in, and on a VFS that cannot host WAL (network or
187/// immutable/read-only media) it silently stays in rollback-journal mode.
188/// The store's durability contract assumes WAL, so anything but `wal` is a
189/// typed open failure naming the answered mode.
190fn enable_wal(conn: &Connection, root: &Path) -> Result<(), StoreError> {
191    let mode: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?;
192    if mode.eq_ignore_ascii_case("wal") {
193        Ok(())
194    } else {
195        Err(StoreError::JournalModeNotWal {
196            root: root.display().to_string(),
197            mode,
198        })
199    }
200}
201
202impl Store {
203    /// Opens (creating if needed) the store rooted at `root`: the SQLite
204    /// database at `<root>/pointlock.db` and the evidence area at
205    /// `<root>/evidence/`. Sets `journal_mode=WAL`, `synchronous=FULL`
206    /// (the actionIntent fsync semantics depend on FULL) and
207    /// `foreign_keys=ON`, and applies the DDL.
208    pub fn open(root: impl AsRef<Path>) -> Result<Self, StoreError> {
209        let root = root.as_ref().to_path_buf();
210        fs::create_dir_all(&root)?;
211        fs::create_dir_all(root.join("evidence"))?;
212        let conn = Connection::open(root.join("pointlock.db"))?;
213        // `PRAGMA journal_mode` returns the resulting mode as a row.
214        enable_wal(&conn, &root)?;
215        conn.pragma_update(None, "synchronous", "FULL")?;
216        conn.pragma_update(None, "foreign_keys", "ON")?;
217        conn.execute_batch(DDL)?;
218        Ok(Store {
219            conn,
220            root,
221            fold_cache: std::collections::HashMap::new(),
222        })
223    }
224
225    /// The store's root directory.
226    pub fn root(&self) -> &Path {
227        &self.root
228    }
229
230    /// Creates the run row (status `running`) and returns the run id.
231    ///
232    /// The caller is expected to append the `runStarted` event next — the
233    /// row only seeds the fold input ([`RunMeta`]); it is not a log entry.
234    pub fn begin_run(&mut self, new_run: NewRun) -> Result<String, StoreError> {
235        let run_id = new_run
236            .run_id
237            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
238        let exists: bool = self.conn.query_row(
239            "SELECT EXISTS(SELECT 1 FROM run WHERE run_id = ?1)",
240            [&run_id],
241            |row| row.get(0),
242        )?;
243        if exists {
244            return Err(StoreError::DuplicateRun(run_id));
245        }
246        let params_json = to_canonical_json(&new_run.params_snapshot);
247        let binding_json = to_canonical_json(&serde_json::to_value(&new_run.binding)?);
248        self.conn.execute(
249            "INSERT INTO run (run_id, flow_id, ir_hash, lockfile_digest, params_snapshot, \
250             binding, status, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
251            params![
252                run_id,
253                new_run.flow_id.as_str(),
254                new_run.ir_hash.as_str(),
255                new_run.lockfile_digest.as_str(),
256                params_json,
257                binding_json,
258                RunStatus::Running.as_str(),
259                new_run.created_at_ms as i64,
260            ],
261        )?;
262        Ok(run_id)
263    }
264
265    /// Appends one event and returns its allocated `seq`.
266    ///
267    /// One `IMMEDIATE` transaction covers: `seq := MAX(seq)+1` allocation,
268    /// the `run_log` insert, the checkpoint re-materialization, and the
269    /// `run.status` transition. The materialization folds incrementally
270    /// through the per-run single-writer fold cache;
271    /// any seq discontinuity falls back to the full refold, and the
272    /// persisted view is identical either way. Atomicity means an event
273    /// whose fold fails is *refused* — the log can never outrun the
274    /// materialized view.
275    pub fn append_event(
276        &mut self,
277        run_id: &str,
278        at_ms: u64,
279        run_path: &RunPath,
280        payload: &RunLogPayload,
281    ) -> Result<u64, StoreError> {
282        let tx = self
283            .conn
284            .transaction_with_behavior(TransactionBehavior::Immediate)?;
285        let meta = read_meta(&tx, run_id)?;
286        let seq: i64 = tx.query_row(
287            "SELECT COALESCE(MAX(seq), 0) + 1 FROM run_log WHERE run_id = ?1",
288            [run_id],
289            |row| row.get(0),
290        )?;
291        let run_path_json = to_canonical_json(&serde_json::to_value(run_path)?);
292        let payload_json = to_canonical_json(&serde_json::to_value(payload)?);
293        tx.execute(
294            "INSERT INTO run_log (run_id, seq, type, at_ms, run_path, payload) \
295             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
296            params![
297                run_id,
298                seq,
299                payload.event_type(),
300                at_ms as i64,
301                run_path_json,
302                payload_json,
303            ],
304        )?;
305        let event = RunLogEvent {
306            run_id: run_id.to_owned(),
307            seq: seq as u64,
308            at_ms,
309            run_path: run_path.clone(),
310            payload: payload.clone(),
311        };
312        let folded = match self.fold_cache.get_mut(run_id) {
313            Some((cached_seq, state)) if *cached_seq + 1 == seq as u64 => {
314                if let Err(err) = state.apply(&event) {
315                    // A failed apply leaves the state half-mutated.
316                    self.fold_cache.remove(run_id);
317                    return Err(err.into());
318                }
319                *cached_seq = seq as u64;
320                state.clone().finish()
321            }
322            _ => {
323                let events = read_events(&tx, run_id)?;
324                let state = fold_state(&meta, &events)?;
325                self.fold_cache
326                    .insert(run_id.to_owned(), (seq as u64, state.clone()));
327                state.finish()
328            }
329        };
330        let view_json = to_canonical_json(&serde_json::to_value(&folded.view)?);
331        tx.execute(
332            "INSERT INTO checkpoint (run_id, log_seq, view) VALUES (?1, ?2, ?3) \
333             ON CONFLICT(run_id) DO UPDATE SET log_seq = excluded.log_seq, view = excluded.view",
334            params![run_id, seq, view_json],
335        )?;
336        tx.execute(
337            "UPDATE run SET status = ?2 WHERE run_id = ?1",
338            params![run_id, folded.status.as_str()],
339        )?;
340        tx.commit()?;
341        Ok(seq as u64)
342    }
343
344    /// Appends the `actionIntent` WAL entry in its own transaction and
345    /// returns its `seq`.
346    ///
347    /// **Dispatch discipline (07 §3.3 rule 1)**: under WAL +
348    /// `synchronous=FULL`, this method returning `Ok` means the intent is
349    /// durably on disk — that *is* the "fsync before dispatch" of spine
350    /// §6.2. Callers MUST invoke this and observe the `Ok` before calling
351    /// `provider.execute`; on crash, `reconcile(callId)` finds the intent
352    /// regardless of whether the dispatch left the process.
353    pub fn write_action_intent(
354        &mut self,
355        run_id: &str,
356        at_ms: u64,
357        run_path: &RunPath,
358        call_id: &str,
359        args_snapshot: Value,
360        dispatch: Option<IntentDispatch>,
361    ) -> Result<u64, StoreError> {
362        let (chain_index, channel, action_name) = match dispatch {
363            Some(dispatch) => (
364                Some(dispatch.chain_index),
365                Some(dispatch.channel),
366                Some(dispatch.action_name),
367            ),
368            None => (None, None, None),
369        };
370        self.append_event(
371            run_id,
372            at_ms,
373            run_path,
374            &RunLogPayload::ActionIntent {
375                call_id: call_id.to_owned(),
376                args_snapshot,
377                chain_index,
378                channel,
379                action_name,
380            },
381        )
382    }
383
384    /// The single-writer arbitration of a human response (06 §4.3; R13):
385    /// validates the response against the pending request read back from
386    /// the ledger and — only when every rule passes — appends the
387    /// `humanResponded` event, returning its `seq`.
388    ///
389    /// `at_ms` is the store-receipt clock, **the only timeout judge**
390    /// (06 §4.3 rule 2): a response received after the request's
391    /// `deadlineAtMs` is refused with
392    /// [`HumanResponseRejection::DeadlineExpired`] and *no event is
393    /// written* — the lazy settlement of the expired request itself stays
394    /// the runner's job on resume (06 §5.3).
395    ///
396    /// Arbitration rules, in order (all rejections are typed
397    /// [`StoreError::HumanResponseRejected`] and side-effect free — bad
398    /// data never enters the ledger):
399    ///
400    /// 1. The request must exist (`humanRequested` with this id).
401    /// 2. **First response wins**: a request with a paired final response
402    ///    is closed. A supervision `suspend` answer is non-final
403    ///    (spine §6.9) — it is recorded but keeps the request open for a
404    ///    later proceed/abort ruling.
405    /// 3. `at_ms` must not exceed the request's `deadlineAtMs`
406    ///    (supervision requests carry none and never expire).
407    /// 4. The request must still be pending (a lazily-settled step no
408    ///    longer accepts responses).
409    /// 5. The payload must match the shape the request's purpose/mode
410    ///    demands (06 §2.1 union as adjudicated): `confirm`
411    ///    `{decision ∈ decisions, note?}`, `judge`
412    ///    `{status ∈ pass|fail|unknown, note?}`, `provideInput`
413    ///    `{input (validated against outputSchema), note?}`, `repairWorld`
414    ///    `{decision ∈ the request's declared decisions, else
415    ///    done|cannotRepair (06 §2.1), note?}`, supervision
416    ///    `{decision ∈ proceed|abort|suspend, note?}`.
417    ///
418    /// Single-writer discipline makes check-then-append race-free: this
419    /// `Store` owns the only write connection.
420    pub fn submit_human_response(
421        &mut self,
422        run_id: &str,
423        request_id: &str,
424        actor: &str,
425        at_ms: u64,
426        response: Value,
427    ) -> Result<u64, StoreError> {
428        let reject = |reason: HumanResponseRejection| StoreError::HumanResponseRejected {
429            run_id: run_id.to_owned(),
430            request_id: request_id.to_owned(),
431            reason,
432        };
433        let events = self.events(run_id)?;
434        // Rule 1: the request must exist on the ledger.
435        let request = events
436            .iter()
437            .find_map(|event| match &event.payload {
438                RunLogPayload::HumanRequested {
439                    request_id: rid,
440                    purpose,
441                    mode,
442                    decisions,
443                    output_schema,
444                    deadline_at_ms,
445                    ..
446                } if rid == request_id => Some((
447                    event.run_path.clone(),
448                    *purpose,
449                    *mode,
450                    decisions.clone(),
451                    output_schema.clone(),
452                    *deadline_at_ms,
453                )),
454                _ => None,
455            })
456            .ok_or_else(|| reject(HumanResponseRejection::UnknownRequest))?;
457        let (run_path, purpose, mode, decisions, output_schema, deadline_at_ms) = request;
458        // Rule 2: first response wins — any paired *final* response closes
459        // the request (supervision suspend answers are non-final).
460        let finally_responded = events.iter().any(|event| match &event.payload {
461            RunLogPayload::HumanResponded {
462                request_id: rid,
463                purpose,
464                response,
465                ..
466            } if rid == request_id => {
467                !(*purpose == HumanPurpose::Supervision
468                    && response.get("decision").and_then(Value::as_str) == Some("suspend"))
469            }
470            _ => false,
471        });
472        if finally_responded {
473            return Err(reject(HumanResponseRejection::AlreadyResponded));
474        }
475        // Rule 3: the store-receipt clock is the only timeout judge.
476        if let Some(deadline) = deadline_at_ms
477            && at_ms > deadline
478        {
479            return Err(reject(HumanResponseRejection::DeadlineExpired {
480                deadline_at_ms: deadline,
481                received_at_ms: at_ms,
482            }));
483        }
484        // Rule 4: the request must still be pending (the lazy timeout
485        // settlement closes it via the step exit, without a response).
486        let pending = self
487            .materialized_checkpoint(run_id)?
488            .and_then(|(_, view)| view.human_pending)
489            .is_some_and(|pending| pending.request_id == request_id);
490        if !pending {
491            return Err(reject(HumanResponseRejection::Settled));
492        }
493        // Rule 5: shape validation per purpose/mode.
494        validate_response_shape(
495            purpose,
496            mode,
497            decisions.as_deref(),
498            &output_schema,
499            &response,
500        )
501        .map_err(|reason| reject(HumanResponseRejection::InvalidShape { reason }))?;
502        self.append_event(
503            run_id,
504            at_ms,
505            &run_path,
506            &RunLogPayload::HumanResponded {
507                request_id: request_id.to_owned(),
508                purpose,
509                response,
510                actor: actor.to_owned(),
511            },
512        )
513    }
514
515    /// Rebuilds the [`CheckpointView`] by folding the run's full log
516    /// (07 §3.3 rebuild channel). Read-only; does not touch the
517    /// materialized row.
518    pub fn rebuild_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
519        Ok(self.refold(run_id)?.view)
520    }
521
522    /// I1's runtime self-check (backs `pointlock inspect
523    /// --rebuild-checkpoint`): asserts materialized == rebuilt.
524    ///
525    /// Verifies that (a) the checkpoint row exists and its `log_seq` is the
526    /// log head, (b) the stored view equals the full-log refold, and
527    /// (c) `run.status` equals the folded status. Any inequality is a
528    /// store-layer bug surfaced as a typed error. Returns the verified
529    /// view.
530    pub fn verify_checkpoint(&self, run_id: &str) -> Result<CheckpointView, StoreError> {
531        let meta = read_meta(&self.conn, run_id)?;
532        let events = read_events(&self.conn, run_id)?;
533        let (log_seq, stored_json): (i64, String) = self
534            .conn
535            .query_row(
536                "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
537                [run_id],
538                |row| Ok((row.get(0)?, row.get(1)?)),
539            )
540            .optional()?
541            .ok_or_else(|| StoreError::NoCheckpoint(run_id.to_owned()))?;
542        let head = events.last().map(|event| event.seq).unwrap_or(0);
543        if log_seq as u64 != head {
544            return Err(StoreError::StaleCheckpoint {
545                run_id: run_id.to_owned(),
546                materialized_seq: log_seq as u64,
547                log_seq: head,
548            });
549        }
550        let stored: CheckpointView = serde_json::from_str(&stored_json)?;
551        let folded = fold_checkpoint(&meta, &events)?;
552        if folded.view != stored {
553            return Err(StoreError::CheckpointMismatch {
554                run_id: run_id.to_owned(),
555                log_seq: log_seq as u64,
556                materialized: to_canonical_json(&serde_json::to_value(&stored)?),
557                rebuilt: to_canonical_json(&serde_json::to_value(&folded.view)?),
558            });
559        }
560        let stored_status = self.run_status(run_id)?;
561        if stored_status != folded.status {
562            return Err(StoreError::StatusMismatch {
563                run_id: run_id.to_owned(),
564                stored: stored_status.as_str().to_owned(),
565                folded: folded.status.as_str().to_owned(),
566            });
567        }
568        Ok(folded.view)
569    }
570
571    /// Localizes evidence bytes into the content-addressed area and indexes
572    /// them, idempotently. Layout:
573    /// `<root>/evidence/sha256/<hex[0..2]>/<hex[2..4]>/<digest>`.
574    ///
575    /// **file-before-row (07 §3.3 rule 3)**: bytes are written to a temp
576    /// file, fsynced, renamed into place (and the directory fsynced) before
577    /// the `evidence` row is inserted; the caller appends the referencing
578    /// RunLog event only after this returns. Re-putting identical bytes is
579    /// a no-op dedup (`deduplicated: true`).
580    pub fn put_evidence(
581        &mut self,
582        bytes: &[u8],
583        media_type: &str,
584    ) -> Result<EvidencePut, StoreError> {
585        let digest = sha256_hex(bytes);
586        let rel_path = format!(
587            "evidence/sha256/{}/{}/{}",
588            &digest[0..2],
589            &digest[2..4],
590            digest
591        );
592        let abs_path = self.root.join(&rel_path);
593        let deduplicated = abs_path.exists();
594        if !deduplicated {
595            let parent = abs_path.parent().expect("evidence path has a parent");
596            fs::create_dir_all(parent)?;
597            let tmp_path = parent.join(format!(".{}.tmp.{}", digest, std::process::id()));
598            // Removes the temp file on every error path (write, fsync,
599            // rename); disarmed once the rename has succeeded.
600            struct TempGuard<'a>(&'a Path, bool);
601            impl Drop for TempGuard<'_> {
602                fn drop(&mut self) {
603                    if self.1 {
604                        let _ = fs::remove_file(self.0);
605                    }
606                }
607            }
608            let mut guard = TempGuard(&tmp_path, true);
609            {
610                let mut file = fs::File::create(&tmp_path)?;
611                file.write_all(bytes)?;
612                file.sync_all()?;
613            }
614            fs::rename(&tmp_path, &abs_path)?;
615            guard.1 = false;
616            // Make the rename itself durable before the row exists.
617            #[cfg(unix)]
618            fs::File::open(parent)?.sync_all()?;
619        }
620        self.conn.execute(
621            "INSERT OR IGNORE INTO evidence (sha256, media_type, byte_size, local_path) \
622             VALUES (?1, ?2, ?3, ?4)",
623            params![digest, media_type, bytes.len() as i64, rel_path],
624        )?;
625        Ok(EvidencePut {
626            sha256: digest,
627            local_path: rel_path,
628            abs_path,
629            byte_size: bytes.len() as u64,
630            deduplicated,
631        })
632    }
633
634    /// Links a RunLog event to a localized evidence entry
635    /// (`evidence_ref` row; idempotent). `foreign_keys=ON` rejects links
636    /// to evidence that was never put.
637    pub fn link_evidence(
638        &mut self,
639        run_id: &str,
640        seq: u64,
641        asset_id: &str,
642        sha256: &str,
643    ) -> Result<(), StoreError> {
644        self.conn.execute(
645            "INSERT OR IGNORE INTO evidence_ref (run_id, seq, asset_id, sha256) \
646             VALUES (?1, ?2, ?3, ?4)",
647            params![run_id, seq as i64, asset_id, sha256],
648        )?;
649        Ok(())
650    }
651
652    /// Reads the run's metadata row (the fold input).
653    pub fn run_meta(&self, run_id: &str) -> Result<RunMeta, StoreError> {
654        read_meta(&self.conn, run_id)
655    }
656
657    /// Reads the run's current lifecycle status.
658    pub fn run_status(&self, run_id: &str) -> Result<RunStatus, StoreError> {
659        let status: String = self
660            .conn
661            .query_row(
662                "SELECT status FROM run WHERE run_id = ?1",
663                [run_id],
664                |row| row.get(0),
665            )
666            .optional()?
667            .ok_or_else(|| StoreError::UnknownRun(run_id.to_owned()))?;
668        RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
669            run_id: run_id.to_owned(),
670            reason: format!("run.status holds unknown value {status:?}"),
671        })
672    }
673
674    /// The run's current revision = its max ledger seq (0 before the
675    /// first event) — the SSE invalidation currency (08 §5). Cheap by
676    /// design: the pollers behind `--serve` call this a few times a
677    /// second.
678    pub fn revision(&self, run_id: &str) -> Result<u64, StoreError> {
679        let _ = read_meta(&self.conn, run_id)?;
680        let head: i64 = self.conn.query_row(
681            "SELECT COALESCE(MAX(seq), 0) FROM run_log WHERE run_id = ?1",
682            [run_id],
683            |row| row.get(0),
684        )?;
685        Ok(head as u64)
686    }
687
688    /// A store-wide monotonic revision (= sum of every run's head seq):
689    /// the inbox stream's invalidation currency — any append anywhere
690    /// moves it.
691    pub fn global_revision(&self) -> Result<u64, StoreError> {
692        let total: i64 = self.conn.query_row(
693            "SELECT COALESCE(SUM(head), 0) FROM \
694             (SELECT MAX(seq) AS head FROM run_log GROUP BY run_id)",
695            [],
696            |row| row.get(0),
697        )?;
698        Ok(total as u64)
699    }
700
701    /// Resolves one content-addressed evidence entry to its media type
702    /// and absolute path (the `/evidence/:sha256` byte route — 08 §4.3
703    /// dereference side; the address is the only key, never a path).
704    pub fn evidence_meta(&self, sha256: &str) -> Result<Option<EvidenceMeta>, StoreError> {
705        let row: Option<(String, String)> = self
706            .conn
707            .query_row(
708                "SELECT media_type, local_path FROM evidence WHERE sha256 = ?1",
709                [sha256],
710                |row| Ok((row.get(0)?, row.get(1)?)),
711            )
712            .optional()?;
713        Ok(row.map(|(media_type, local_path)| EvidenceMeta {
714            media_type,
715            abs_path: self.root.join(&local_path),
716            local_path,
717        }))
718    }
719
720    /// Lists every run, in creation order (projection read side: the
721    /// cross-run inbox and the flow run index consume this).
722    pub fn list_runs(&self) -> Result<Vec<RunListEntry>, StoreError> {
723        let mut statement = self.conn.prepare(
724            "SELECT run_id, flow_id, ir_hash, status, created_at_ms \
725             FROM run ORDER BY created_at_ms, run_id",
726        )?;
727        let rows = statement.query_map([], |row| {
728            Ok((
729                row.get::<_, String>(0)?,
730                row.get::<_, String>(1)?,
731                row.get::<_, String>(2)?,
732                row.get::<_, String>(3)?,
733                row.get::<_, i64>(4)?,
734            ))
735        })?;
736        let mut runs = Vec::new();
737        for row in rows {
738            let (run_id, flow_id, ir_hash, status, created_at_ms) = row?;
739            let status = RunStatus::parse(&status).ok_or_else(|| StoreError::Corrupt {
740                run_id: run_id.clone(),
741                reason: format!("run.status holds unknown value {status:?}"),
742            })?;
743            runs.push(RunListEntry {
744                run_id,
745                flow_id,
746                ir_hash,
747                status,
748                created_at_ms: created_at_ms as u64,
749            });
750        }
751        Ok(runs)
752    }
753
754    /// Reads the run's full ordered event log.
755    pub fn events(&self, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
756        // Distinguish "unknown run" from "no events yet".
757        let _ = read_meta(&self.conn, run_id)?;
758        read_events(&self.conn, run_id)
759    }
760
761    /// Reads the materialized checkpoint row, if any:
762    /// `(log_seq, view)`. `None` until the first event is appended.
763    pub fn materialized_checkpoint(
764        &self,
765        run_id: &str,
766    ) -> Result<Option<(u64, CheckpointView)>, StoreError> {
767        let row: Option<(i64, String)> = self
768            .conn
769            .query_row(
770                "SELECT log_seq, view FROM checkpoint WHERE run_id = ?1",
771                [run_id],
772                |row| Ok((row.get(0)?, row.get(1)?)),
773            )
774            .optional()?;
775        match row {
776            None => Ok(None),
777            Some((log_seq, view_json)) => {
778                let view: CheckpointView = serde_json::from_str(&view_json)?;
779                Ok(Some((log_seq as u64, view)))
780            }
781        }
782    }
783
784    fn refold(&self, run_id: &str) -> Result<FoldedRun, StoreError> {
785        let meta = read_meta(&self.conn, run_id)?;
786        let events = read_events(&self.conn, run_id)?;
787        Ok(fold_checkpoint(&meta, &events)?)
788    }
789}
790
791/// Validates one human response payload against the shape its request's
792/// purpose/mode demands (06 §2.1 union as adjudicated). Strictly closed:
793/// exactly the expected key plus an optional string `note`; anything else
794/// is refused (bad data never enters the ledger).
795fn validate_response_shape(
796    purpose: HumanPurpose,
797    mode: Option<HumanMode>,
798    decisions: Option<&[String]>,
799    output_schema: &Option<JsonSchemaDocument>,
800    response: &Value,
801) -> Result<(), String> {
802    let Some(object) = response.as_object() else {
803        return Err(format!("response must be a JSON object, got {response}"));
804    };
805    let (value_key, expects_input) = match (purpose, mode) {
806        (HumanPurpose::Supervision, _) => ("decision", false),
807        (HumanPurpose::Step, Some(HumanMode::Confirm)) => ("decision", false),
808        (HumanPurpose::Step, Some(HumanMode::Judge)) => ("status", false),
809        (HumanPurpose::Step, Some(HumanMode::ProvideInput)) => ("input", true),
810        (HumanPurpose::Step, Some(HumanMode::RepairWorld)) => ("decision", false),
811        (HumanPurpose::Step, None) => {
812            return Err("the request carries no mode for its step purpose".to_owned());
813        }
814    };
815    for key in object.keys() {
816        if key != value_key && key != "note" {
817            return Err(format!("unexpected field '{key}'"));
818        }
819    }
820    if let Some(note) = object.get("note")
821        && !note.is_string()
822    {
823        return Err(format!("'note' must be a string, got {note}"));
824    }
825    let Some(value) = object.get(value_key) else {
826        return Err(format!("missing required field '{value_key}'"));
827    };
828    if expects_input {
829        // provideInput: the input must satisfy the request's outputSchema
830        // (06 §4.3 rule 3 — the channel re-prompts on rejection).
831        let Some(schema) = output_schema else {
832            return Err("the request carries no outputSchema for provideInput".to_owned());
833        };
834        return jsonschema::validate(schema.as_value(), value)
835            .map_err(|error| format!("input failed the outputSchema: {error}"));
836    }
837    let Some(label) = value.as_str() else {
838        return Err(format!("'{value_key}' must be a string, got {value}"));
839    };
840    match (purpose, mode) {
841        (HumanPurpose::Supervision, _) => match label {
842            // Deliberately no `skip` (spine §6.9).
843            "proceed" | "abort" | "suspend" => Ok(()),
844            other => Err(format!(
845                "supervision decision must be proceed|abort|suspend, got '{other}'"
846            )),
847        },
848        (_, Some(HumanMode::Confirm)) => {
849            // Position-mapped double label (06 §2.2): membership is the
850            // arbitration's check, the position mapping is the runner's.
851            let labels = decisions.unwrap_or(&[]);
852            if labels.iter().any(|candidate| candidate == label) {
853                Ok(())
854            } else {
855                Err(format!(
856                    "confirm decision '{label}' is not one of the request's labels {labels:?}"
857                ))
858            }
859        }
860        (_, Some(HumanMode::Judge)) => {
861            // The three-valued vocabulary admits no aliases (06 §2.2),
862            // narrowed further by the request's declared subset.
863            if !matches!(label, "pass" | "fail" | "unknown") {
864                return Err(format!(
865                    "judge status must be pass|fail|unknown, got '{label}'"
866                ));
867            }
868            if let Some(subset) = decisions
869                && !subset.iter().any(|candidate| candidate == label)
870            {
871                return Err(format!(
872                    "judge status '{label}' is outside the request's declared subset {subset:?}"
873                ));
874            }
875            Ok(())
876        }
877        (_, Some(HumanMode::RepairWorld)) => {
878            // A repairWorld request that declares its own vocabulary is
879            // arbitrated against exactly it — the uncertain-reconcile
880            // adjudication asks `adopt | redo | abort` (00 §6.7-B /
881            // 07 §4.4), a different question from the world-repair
882            // declaration. Without a declaration the 06 §2.1 base
883            // vocabulary governs: `done | cannotRepair`, nothing else,
884            // no aliases (2026-07-28 unification — the as-built
885            // `repaired|abort` matched neither 06 nor the adjudication
886            // and is retired).
887            if let Some(declared) = decisions {
888                return if declared.iter().any(|candidate| candidate == label) {
889                    Ok(())
890                } else {
891                    Err(format!(
892                        "repairWorld decision '{label}' is not one of the request's \
893                         labels {declared:?}"
894                    ))
895                };
896            }
897            match label {
898                "done" | "cannotRepair" => Ok(()),
899                other => Err(format!(
900                    "repairWorld decision must be done|cannotRepair (06 §2.1), got '{other}'"
901                )),
902            }
903        }
904        _ => unreachable!("provideInput and mode-less step requests returned above"),
905    }
906}
907
908fn sha256_hex(bytes: &[u8]) -> String {
909    use sha2::{Digest, Sha256};
910    let digest = Sha256::digest(bytes);
911    let mut hex = String::with_capacity(64);
912    for byte in digest {
913        let _ = write!(hex, "{byte:02x}");
914    }
915    hex
916}
917
918fn read_meta(conn: &Connection, run_id: &str) -> Result<RunMeta, StoreError> {
919    let row: Option<(String, String, String, String, String, i64)> = conn
920        .query_row(
921            "SELECT flow_id, ir_hash, lockfile_digest, params_snapshot, binding, created_at_ms \
922             FROM run WHERE run_id = ?1",
923            [run_id],
924            |row| {
925                Ok((
926                    row.get(0)?,
927                    row.get(1)?,
928                    row.get(2)?,
929                    row.get(3)?,
930                    row.get(4)?,
931                    row.get(5)?,
932                ))
933            },
934        )
935        .optional()?;
936    let Some((flow_id, ir_hash, lockfile_digest, params_json, binding_json, created_at_ms)) = row
937    else {
938        return Err(StoreError::UnknownRun(run_id.to_owned()));
939    };
940    let corrupt = |reason: String| StoreError::Corrupt {
941        run_id: run_id.to_owned(),
942        reason,
943    };
944    Ok(RunMeta {
945        run_id: run_id.to_owned(),
946        flow_id: FlowId::new(flow_id).map_err(|e| corrupt(e.to_string()))?,
947        ir_hash: Hash::new(ir_hash).map_err(|e| corrupt(e.to_string()))?,
948        lockfile_digest: Hash::new(lockfile_digest).map_err(|e| corrupt(e.to_string()))?,
949        params_snapshot: serde_json::from_str(&params_json)?,
950        binding: serde_json::from_str(&binding_json)?,
951        created_at_ms: created_at_ms as u64,
952    })
953}
954
955fn read_events(conn: &Connection, run_id: &str) -> Result<Vec<RunLogEvent>, StoreError> {
956    let mut stmt = conn.prepare(
957        "SELECT seq, type, at_ms, run_path, payload FROM run_log \
958         WHERE run_id = ?1 ORDER BY seq",
959    )?;
960    let rows = stmt.query_map([run_id], |row| {
961        Ok((
962            row.get::<_, i64>(0)?,
963            row.get::<_, String>(1)?,
964            row.get::<_, i64>(2)?,
965            row.get::<_, String>(3)?,
966            row.get::<_, String>(4)?,
967        ))
968    })?;
969    let mut events = Vec::new();
970    for row in rows {
971        let (seq, event_type, at_ms, run_path_json, payload_json) = row?;
972        let payload: RunLogPayload = serde_json::from_str(&payload_json)?;
973        // Self-check: the denormalized `type` column must agree with the
974        // payload discriminant.
975        if payload.event_type() != event_type {
976            return Err(StoreError::Corrupt {
977                run_id: run_id.to_owned(),
978                reason: format!(
979                    "run_log seq {seq}: type column {event_type:?} disagrees with payload \
980                     discriminant {:?}",
981                    payload.event_type()
982                ),
983            });
984        }
985        let run_path: RunPath = serde_json::from_str(&run_path_json)?;
986        events.push(RunLogEvent {
987            run_id: run_id.to_owned(),
988            seq: seq as u64,
989            at_ms: at_ms as u64,
990            run_path,
991            payload,
992        });
993    }
994    Ok(events)
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    /// A database sqlite cannot switch to WAL (here: opened `immutable`,
1002    /// where the pragma answers the existing `delete` mode without any
1003    /// error) must be a typed open failure, never a silent fallback.
1004    #[test]
1005    fn enable_wal_fails_closed_when_the_pragma_answers_another_mode() {
1006        let dir = std::env::temp_dir().join(format!("pointlock-store-wal-{}", std::process::id()));
1007        let _ = fs::remove_dir_all(&dir);
1008        fs::create_dir_all(&dir).expect("create dir");
1009        let db = dir.join("pointlock.db");
1010        Connection::open(&db)
1011            .expect("create db")
1012            .execute_batch("CREATE TABLE t (a)")
1013            .expect("ddl");
1014        let conn = Connection::open_with_flags(
1015            format!("file:{}?immutable=1", db.display()),
1016            rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_URI,
1017        )
1018        .expect("open immutable");
1019        let err = enable_wal(&conn, &dir).expect_err("WAL cannot take on an immutable db");
1020        assert!(
1021            matches!(&err, StoreError::JournalModeNotWal { mode, root }
1022                if mode == "delete" && root == &dir.display().to_string()),
1023            "got {err:?}"
1024        );
1025        let _ = fs::remove_dir_all(&dir);
1026    }
1027}