haematite-cli 0.21.3

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! Approved Lys drafts applied to the served store exactly once
//! (ACCESS-001 R3, PERMISSIONS-ACROSS-THE-STACK D4).
//!
//! A held change was drafted with the exact frame its client sent. Once Lys
//! approves it, the connector pulls it and lys-pass's `execute_once` hands it
//! here. Its words are decoded as the transport decodes a frame, and must
//! name exactly the resource and action the draft was approved for, so an
//! approval never applies an act on anything else. The change is then
//! applied through the service's own mutation path. That path is
//! receipt-first: a request id already applied answers its stored receipt
//! and writes nothing. So the mutation and its receipt are one commit, and
//! a second run after a lost acknowledgment applies nothing again.

use haematite::tree::Hash;
use lys_pass::drafts::{ApprovedDraft, Execution, Executor};

use crate::serve::authorize::subjects;
use crate::serve::pass::target;
use crate::serve::protocol::{Operation, Request};
use crate::serve::service::Service;
use crate::serve::storage::Failure;

/// The served store's draft ledger, for the database `database`.
pub struct Drafts<'a> {
    service: &'a Service,
    database: String,
}

impl<'a> Drafts<'a> {
    /// Apply approved drafts to `service`, the served database `database`.
    #[must_use]
    pub fn new(service: &'a Service, database: &str) -> Self {
        Self {
            service,
            database: database.to_owned(),
        }
    }
}

impl Executor for Drafts<'_> {
    type Error = Failure;

    /// The outcome is keyed by the branch and request id inside the draft's
    /// words, which an id alone does not carry, so it is read where the
    /// change is applied: receipt-first, before anything is written.
    fn receipt(&self, _draft: &str) -> Result<Option<Execution>, Failure> {
        Ok(None)
    }

    /// A draft whose words are not a change on exactly the approved
    /// resource is refused on execution by its own name, so Lys records the
    /// refusal and the page moves on. A failure of the store itself stays an
    /// error: nothing is acknowledged, and the next pull asks again.
    fn execute_and_record(&mut self, draft: &ApprovedDraft) -> Result<Execution, Failure> {
        let operation = match self.approved_change(draft) {
            Ok(operation) => operation,
            Err(refusal) => {
                return Ok(Execution::RefusedOnExecution {
                    request_digest: draft.request_digest.clone(),
                    refusal: refusal.code,
                    reason: refusal.message,
                });
            }
        };
        let receipt = self
            .service
            .dispatch(operation)
            .map_err(|failure| failure.failure().clone())?;
        let bytes = serde_json::to_vec(&receipt).map_err(Failure::storage)?;
        Ok(Execution::Executed {
            request_digest: draft.request_digest.clone(),
            receipt_digest: Hash::of(&bytes).to_string(),
        })
    }
}

impl Drafts<'_> {
    /// The change in `draft`'s words, when they name exactly the resource and
    /// action the draft was approved for; otherwise the refusal that says why.
    fn approved_change(&self, draft: &ApprovedDraft) -> Result<Operation, Failure> {
        let operation = serde_json::from_str::<Request>(&draft.words)
            .map_err(|error| Failure::new("draft_words_invalid", error.to_string()))?
            .operation;
        if !matches!(operation, Operation::Mutate(_)) {
            return Err(Failure::new(
                "draft_not_a_change",
                "only a change is applied from an approved draft",
            ));
        }
        let differs = || {
            Failure::new(
                "draft_target_differs",
                "the draft's words act on another resource than it was approved for",
            )
        };
        let asked = subjects(&operation)?;
        let [subject] = asked.as_slice() else {
            return Err(differs());
        };
        let named = target(subject.route, &self.database, subject.name.as_deref())
            .map_err(|error| Failure::new(error.name(), "the draft's resource is invalid"))?;
        if named != draft.target {
            return Err(differs());
        }
        Ok(operation)
    }
}

#[cfg(test)]
#[path = "drafts_tests.rs"]
mod tests;