nomograph-workflow 0.3.0

Shared workflow layer for nomograph claim-substrate tools: Store adapter, phase state machine, v1 legacy detection, cross-tool helpers.
Documentation
//! Shared Store adapter over `nomograph_claim::Store + View`.
//!
//! Both synthesist and lattice used to own identical copies of this
//! adapter (SynthStore / LattStore). Folded into one type here so the
//! wrapping convention — schema-validated appends, SQLite view synced
//! after each write, session-scoped asserter — has a single source of
//! truth.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow, bail};
use nomograph_claim::{Claim, ClaimId, ClaimType, Store as ClaimStore, View};
use serde_json::Value;

use crate::legacy::{find_legacy_v1_db, legacy_migration_error};

/// Directory inside a project that holds the claim substrate.
///
/// Visible name, plural, no nicknames. Matches D3 in
/// `keaton/research/graph-primitive/BUILDING.md`.
pub const CLAIMS_DIR: &str = "claims";

fn local_asserter() -> String {
    let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
    format!("user:local:{user}")
}

/// Synthesist / lattice adapter over a project's `claims/` directory.
///
/// Writes go through [`Store::append`]; reads go through
/// [`Store::query`]. Every append syncs the view before returning so
/// follow-up reads see the write.
pub struct Store {
    inner: ClaimStore,
    view: View,
    asserted_by: String,
    root: PathBuf,
}

impl Store {
    /// Open (or initialize) a Store.
    ///
    /// Resolution order:
    ///   1. `SYNTHESIST_DIR` env var (set by `--data-dir` in the CLI,
    ///      or exported directly). Points at the directory CONTAINING
    ///      `claims/`. An explicit override that doesn't resolve to an
    ///      existing store is a hard error — silent fallback would mask
    ///      misconfiguration (e.g. a typo yielding an empty estate).
    ///   2. Parent-directory walk from cwd. Opens the first ancestor
    ///      containing `claims/genesis.amc`.
    ///   3. If none, initialize a fresh store at `cwd/claims/` (unless
    ///      a v1 `.synth/main.db` is in scope, in which case we refuse
    ///      and name the migrator).
    pub fn discover() -> Result<Self> {
        if let Ok(raw) = std::env::var("SYNTHESIST_DIR")
            && !raw.is_empty()
        {
            return Self::open_explicit(Path::new(&raw));
        }
        let cwd = std::env::current_dir().context("cwd")?;
        Self::discover_from(&cwd)
    }

    /// Open the store at an explicit path (`SYNTHESIST_DIR` or the
    /// `--data-dir` flag). The path names the directory CONTAINING
    /// `claims/`; this mirrors the `--data-dir` help text and the
    /// v1.3.0 semantics the CHANGELOG promised.
    ///
    /// Fails loudly if the path doesn't exist or has no
    /// `claims/genesis.amc`. Previously the caller silently fell
    /// through to `Self::init_at` and created a fresh empty store,
    /// which made typos and wrong paths indistinguishable from an
    /// actually empty estate.
    fn open_explicit(dir: &Path) -> Result<Self> {
        if !dir.exists() {
            bail!(
                "SYNTHESIST_DIR / --data-dir points at `{}` which does not exist",
                dir.display()
            );
        }
        if !dir.is_dir() {
            bail!(
                "SYNTHESIST_DIR / --data-dir points at `{}` which is not a directory",
                dir.display()
            );
        }
        let claims = dir.join(CLAIMS_DIR);
        if !claims.join("genesis.amc").is_file() {
            return Err(anyhow!(
                "SYNTHESIST_DIR / --data-dir points at `{}` but no `{}/genesis.amc` is present there. \
                 Run `synthesist init` in that directory first, or unset the override to fall back \
                 to parent-directory discovery from cwd.",
                dir.display(),
                CLAIMS_DIR
            ));
        }
        Self::open_at(&claims)
    }

    /// Like [`Self::discover`] but starts from a given path.
    ///
    /// Walk-up order:
    ///   1. Any ancestor with a `claims/genesis.amc` → open it.
    ///   2. Otherwise, if any ancestor has a v1 `.synth/main.db`, bail
    ///      with a prescriptive migration error. Silently creating an
    ///      empty v2 estate alongside legacy data orphans the user's
    ///      work; we refuse.
    ///   3. Otherwise, initialize a fresh v2 estate at `start/claims/`.
    pub fn discover_from(start: &Path) -> Result<Self> {
        let mut cur = start.to_path_buf();
        loop {
            let candidate = cur.join(CLAIMS_DIR);
            if candidate.join("genesis.amc").is_file() {
                return Self::open_at(&candidate);
            }
            if !cur.pop() {
                break;
            }
        }
        if let Some(legacy) = find_legacy_v1_db(start) {
            return Err(legacy_migration_error(&legacy));
        }
        Self::init_at(&start.join(CLAIMS_DIR))
    }

    /// Open at an explicit `claims/` directory. The directory must
    /// already contain a `genesis.amc`.
    pub fn open_at(claims_dir: &Path) -> Result<Self> {
        let mut inner = ClaimStore::open(claims_dir)
            .with_context(|| format!("open claim store at {}", claims_dir.display()))?;
        let mut view = View::open(claims_dir)
            .with_context(|| format!("open view at {}", claims_dir.display()))?;
        view.sync(&mut inner).context("sync view on open")?;
        Ok(Self {
            inner,
            view,
            asserted_by: local_asserter(),
            root: claims_dir.to_path_buf(),
        })
    }

    /// Initialize a fresh Store at `claims_dir`.
    pub fn init_at(claims_dir: &Path) -> Result<Self> {
        let inner = ClaimStore::init(claims_dir)
            .with_context(|| format!("init claim store at {}", claims_dir.display()))?;
        let view = View::open(claims_dir)
            .with_context(|| format!("open view at {}", claims_dir.display()))?;
        Ok(Self {
            inner,
            view,
            asserted_by: local_asserter(),
            root: claims_dir.to_path_buf(),
        })
    }

    /// Discover and scope the asserter with an optional session id.
    ///
    /// When a session id is given, subsequent appends assert as
    /// `user:local:<user>:<session>` — giving us attribution without
    /// cryptographic identity (see IDENTITY.md for the v0.2 roadmap).
    pub fn discover_for(session: &Option<String>) -> Result<Self> {
        let mut s = Self::discover()?;
        if let Some(id) = session
            && !id.is_empty()
        {
            s.asserted_by = format!("{}:{}", s.asserted_by, id);
        }
        Ok(s)
    }

    /// Override the asserter prefix. Reserved for tests and for future
    /// callers that set asserter from a parsed Session claim (e.g.
    /// beacon sync, rather than `$USER`).
    #[allow(dead_code)]
    pub fn with_asserter(mut self, asserted_by: impl Into<String>) -> Self {
        self.asserted_by = asserted_by.into();
        self
    }

    /// Append a typed claim and synchronize the SQLite view.
    ///
    /// Both the workflow layer and the substrate are type-agnostic
    /// for validation purposes since claim 0.2.0 / workflow 0.2.0:
    /// per-type schema validation is the consumer's responsibility,
    /// applied at its API boundary (typically a CLI or library
    /// entrypoint) BEFORE calling this method. Consumers compose
    /// `nomograph_claim::validation` helpers with their own per-type
    /// validators; see `synthesist::schema` for a worked example.
    /// This method assumes the caller has already validated `props`
    /// for the given `claim_type`.
    pub fn append(
        &mut self,
        claim_type: ClaimType,
        props: Value,
        supersedes: Option<ClaimId>,
    ) -> Result<ClaimId> {
        let mut claim = Claim::new(claim_type, props, self.asserted_by.clone());
        if let Some(prior) = supersedes {
            claim = claim.with_supersedes(prior);
        }
        self.inner.append(&claim).context("append claim")?;
        self.view
            .sync(&mut self.inner)
            .context("sync view after append")?;
        Ok(claim.id)
    }

    /// Run a read-only SQL query over the SQLite view.
    pub fn query(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Vec<Value>> {
        self.view.query(sql, params).context("view query")
    }

    /// The `claims/` directory backing this store.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Force a view rebuild. Most callers don't need this; `append`
    /// syncs automatically.
    #[allow(dead_code)]
    pub fn sync_view(&mut self) -> Result<()> {
        self.view.sync(&mut self.inner).context("sync view")?;
        Ok(())
    }

    /// Access the underlying claim store (for session handles,
    /// compact, merge). Most callers should not need this.
    pub fn inner(&mut self) -> &mut ClaimStore {
        &mut self.inner
    }
}