Implement HARP and CPL milestones through v0.5.11

This commit is contained in:
2026-07-20 07:39:50 -07:00
parent 4b8dc903e4
commit 550f2cbff9
109 changed files with 57576 additions and 741 deletions
+12 -1
View File
@@ -3980,7 +3980,7 @@ dependencies = [
[[package]]
name = "thinkloom"
version = "0.5.0"
version = "0.5.11"
dependencies = [
"chrono",
"hex",
@@ -3989,12 +3989,14 @@ dependencies = [
"reqwest 0.12.28",
"rfd",
"rusqlite",
"ryu",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tempfile",
"unicode-normalization",
"uuid",
"walkdir",
"zip",
@@ -4426,6 +4428,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "thinkloom"
version = "0.5.0"
version = "0.5.11"
description = "Local-first writing studio with creative provenance"
authors = ["Christopher Chambers"]
license = "AGPL-3.0-only"
@@ -24,6 +24,8 @@ sha2 = "0.10"
hex = "0.4"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
unicode-normalization = "0.1"
ryu = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
keyring = "3"
+1026 -474
View File
File diff suppressed because it is too large Load Diff
+463
View File
@@ -0,0 +1,463 @@
//! Project-format classification and legacy preservation boundaries.
//!
//! Inspection is deliberately read-only. Only an exact supported marker can
//! proceed to CPL recovery; every other recognized project remains read-only.
use crate::provenance::{canonical::sha256_digest, CplError, CplResult};
use serde::Serialize;
use serde_json::Value;
use std::{
fs::{self, File},
io::{Read, Write},
path::Path,
};
use walkdir::WalkDir;
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
pub const PROJECT_FORMAT: &str = "thinkloom-cpl";
pub const PROJECT_FORMAT_VERSION: &str = "1.0";
pub const PROVENANCE_CONFORMANCE: &str = "cpl-1.0";
pub fn manifest_has_supported_marker(bytes: &[u8]) -> bool {
serde_json::from_slice::<Value>(bytes).is_ok_and(|value| {
value.get("project_format").and_then(Value::as_str) == Some(PROJECT_FORMAT)
&& value.get("project_format_version").and_then(Value::as_str)
== Some(PROJECT_FORMAT_VERSION)
&& value.get("provenance_conformance").and_then(Value::as_str)
== Some(PROVENANCE_CONFORMANCE)
})
}
pub const REQUIRED_DIRECTORIES: &[&str] = &[
"records",
"provenance/ledger/active",
"provenance/ledger/sealed",
"reports",
".app",
".app/locks",
".app/temp",
".app/recovery",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProjectClassification {
CplConforming,
LegacyPreviewReadOnly,
UnsupportedReadOnly,
CplBlocked,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectInspection {
pub classification: ProjectClassification,
pub message: String,
}
pub fn initialize_conforming_layout(root: &Path) -> CplResult<()> {
for directory in [
"manuscript/sections",
"ideas",
"conversations/transcripts",
"records/conversations",
"records/invocations",
"records/prompt-templates",
"records/sources",
"records/transformations",
"records/composition",
"provenance/schema",
"provenance/ledger/active",
"provenance/ledger/sealed",
"provenance/indexes",
"provenance/integrity",
"provenance/report-config",
"releases",
"deposits",
"reports/harp",
"assets",
".app/locks",
".app/temp",
".app/temp/staging",
".app/recovery/orphans",
".app/snapshots",
] {
fs::create_dir_all(root.join(directory)).map_err(|error| {
CplError::io("Could not create the conforming project layout", error)
})?;
}
Ok(())
}
pub fn inspect_project(root: &Path) -> CplResult<ProjectInspection> {
let manifest_path = root.join("project.json");
if !manifest_path.is_file() {
return Err(CplError::new(
"PROJECT_INVALID",
"The selected folder does not contain project.json.",
false,
));
}
let bytes = fs::read(&manifest_path)
.map_err(|error| CplError::io("Could not read the project marker", error))?;
let value: Value = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(_) => {
return Ok(ProjectInspection {
classification: ProjectClassification::UnsupportedReadOnly,
message: "The project manifest is not valid JSON. It was not modified, recovered, or verified.".to_owned(),
})
}
};
let format = value.get("project_format").and_then(Value::as_str);
let version = value.get("project_format_version").and_then(Value::as_str);
let conformance = value.get("provenance_conformance").and_then(Value::as_str);
let marker_absent = [
"project_format",
"project_format_version",
"provenance_conformance",
]
.iter()
.all(|key| value.get(key).is_none());
if marker_absent {
return Ok(ProjectInspection {
classification: ProjectClassification::LegacyPreviewReadOnly,
message: "Legacy preview project detected. Migration is deferred until after Thinkloom 1.0.0; only folder access and a byte-preserving preservation archive are available.".to_owned(),
});
}
if format != Some(PROJECT_FORMAT)
|| version != Some(PROJECT_FORMAT_VERSION)
|| conformance != Some(PROVENANCE_CONFORMANCE)
{
return Ok(ProjectInspection {
classification: ProjectClassification::UnsupportedReadOnly,
message: "The project has an incomplete or unsupported CPL marker. It was not modified, recovered, or verified.".to_owned(),
});
}
let missing = REQUIRED_DIRECTORIES
.iter()
.filter(|relative| {
let path = root.join(relative);
!path.is_dir()
|| fs::symlink_metadata(&path)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
})
.copied()
.collect::<Vec<_>>();
if !missing.is_empty() {
return Ok(ProjectInspection {
classification: ProjectClassification::CplBlocked,
message: format!(
"The CPL marker is present, but the required project structure is incomplete: {}. No recovery was attempted.",
missing.join(", ")
),
});
}
Ok(ProjectInspection {
classification: ProjectClassification::CplConforming,
message: "The exact supported CPL 1.0 marker and required project structure are present."
.to_owned(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InventoryEntry {
path: String,
kind: &'static str,
digest_or_target: String,
}
fn source_inventory(source: &Path) -> CplResult<Vec<InventoryEntry>> {
let mut entries = Vec::new();
for entry in WalkDir::new(source).follow_links(false).into_iter() {
let entry = entry.map_err(|error| {
CplError::new(
"LEGACY_ARCHIVE_READ_FAILED",
format!("Could not inspect the legacy project: {error}"),
true,
)
})?;
if entry.path() == source {
continue;
}
let relative = entry
.path()
.strip_prefix(source)
.map_err(|error| CplError::new("LEGACY_ARCHIVE_PATH_FAILED", error.to_string(), false))?
.to_string_lossy()
.replace('\\', "/");
let metadata = fs::symlink_metadata(entry.path())
.map_err(|error| CplError::io("Could not inspect a legacy project entry", error))?;
if metadata.file_type().is_symlink() {
let target = fs::read_link(entry.path())
.map_err(|error| CplError::io("Could not read a legacy project link", error))?;
entries.push(InventoryEntry {
path: relative,
kind: "symlink",
digest_or_target: target.to_string_lossy().into_owned(),
});
} else if metadata.is_dir() {
entries.push(InventoryEntry {
path: relative,
kind: "directory",
digest_or_target: String::new(),
});
} else if metadata.is_file() {
entries.push(InventoryEntry {
path: relative,
kind: "file",
digest_or_target: sha256_digest(&fs::read(entry.path()).map_err(|error| {
CplError::io("Could not read a legacy project file", error)
})?),
});
} else {
return Err(CplError::new(
"LEGACY_ARCHIVE_UNSUPPORTED_ENTRY",
format!("The legacy project contains an unsupported entry: {relative}"),
false,
));
}
}
entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
Ok(entries)
}
pub fn create_legacy_preservation_archive(source: &Path, destination: &Path) -> CplResult<()> {
let inspection = inspect_project(source)?;
if inspection.classification != ProjectClassification::LegacyPreviewReadOnly {
return Err(CplError::new(
"LEGACY_ARCHIVE_NOT_PERMITTED",
"Preservation archives are available only for unmarked legacy preview projects.",
false,
));
}
let source = fs::canonicalize(source)
.map_err(|error| CplError::io("Could not resolve the legacy project folder", error))?;
let parent = destination.parent().ok_or_else(|| {
CplError::new(
"LEGACY_ARCHIVE_PATH_INVALID",
"The preservation archive destination has no parent folder.",
false,
)
})?;
let parent = fs::canonicalize(parent)
.map_err(|error| CplError::io("Could not resolve the archive destination", error))?;
let destination = parent.join(destination.file_name().ok_or_else(|| {
CplError::new(
"LEGACY_ARCHIVE_PATH_INVALID",
"Choose a complete ZIP destination.",
false,
)
})?);
if destination.starts_with(&source) {
return Err(CplError::new(
"LEGACY_ARCHIVE_INSIDE_PROJECT",
"The preservation archive must be saved outside the legacy project so the source remains untouched.",
false,
));
}
let before = source_inventory(&source)?;
let temporary = parent.join(format!(
".{}.{}.tmp",
destination
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("legacy-preservation.zip"),
uuid::Uuid::new_v4()
));
let result = (|| -> CplResult<()> {
let file = File::create(&temporary)
.map_err(|error| CplError::io("Could not create the preservation archive", error))?;
let mut zip = ZipWriter::new(file);
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("PRESERVATION-NOTICE.txt", stored)
.map_err(zip_error)?;
zip.write_all(b"Legacy preview-project preservation archive\nNot verified, converted, or CPL-conforming\nMigration is deferred until after Thinkloom 1.0.0.\n")
.map_err(|error| CplError::io("Could not write the preservation label", error))?;
for item in &before {
let archive_path = format!("legacy-project/{}", item.path);
let source_path = item
.path
.split('/')
.fold(source.clone(), |path, component| path.join(component));
match item.kind {
"directory" => zip
.add_directory(format!("{archive_path}/"), stored)
.map_err(zip_error)?,
"symlink" => {
let link_options = stored.unix_permissions(0o120777);
zip.start_file(archive_path, link_options)
.map_err(zip_error)?;
zip.write_all(item.digest_or_target.as_bytes())
.map_err(|error| {
CplError::io("Could not preserve a legacy project link", error)
})?;
}
"file" => {
zip.start_file(archive_path, stored).map_err(zip_error)?;
let mut input = File::open(&source_path).map_err(|error| {
CplError::io("Could not read a legacy project file", error)
})?;
let mut bytes = Vec::new();
input.read_to_end(&mut bytes).map_err(|error| {
CplError::io("Could not read a legacy project file", error)
})?;
zip.write_all(&bytes).map_err(|error| {
CplError::io("Could not preserve a legacy project file", error)
})?;
}
_ => unreachable!(),
}
}
let file = zip.finish().map_err(zip_error)?;
file.sync_all()
.map_err(|error| CplError::io("Could not flush the preservation archive", error))?;
crate::provenance::ledger::atomic_replace(&temporary, &destination)?;
crate::provenance::ledger::sync_directory(&parent)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
return result;
}
let after = source_inventory(&source)?;
if before != after {
let _ = fs::remove_file(&destination);
return Err(CplError::new(
"LEGACY_PROJECT_CHANGED_DURING_ARCHIVE",
"The legacy project changed while it was being archived. The incomplete archive was removed; retry after writes stop.",
true,
));
}
Ok(())
}
fn zip_error(error: zip::result::ZipError) -> CplError {
CplError::new("LEGACY_ARCHIVE_FAILED", error.to_string(), true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use tempfile::tempdir;
use zip::ZipArchive;
fn write_manifest(root: &Path, value: Value) {
fs::write(
root.join("project.json"),
serde_json::to_vec(&value).unwrap(),
)
.unwrap();
}
#[test]
fn schema_version_without_marker_is_legacy_and_inspection_is_read_only() {
let temp = tempdir().unwrap();
write_manifest(
temp.path(),
serde_json::json!({"schemaVersion":"1.0","applicationVersion":"0.5.3"}),
);
fs::write(temp.path().join("original.bin"), [0, 1, 2, 255]).unwrap();
let before = source_inventory(temp.path()).unwrap();
let result = inspect_project(temp.path()).unwrap();
assert_eq!(
result.classification,
ProjectClassification::LegacyPreviewReadOnly
);
assert_eq!(before, source_inventory(temp.path()).unwrap());
}
#[test]
fn exact_marker_requires_the_complete_layout() {
let temp = tempdir().unwrap();
write_manifest(
temp.path(),
serde_json::json!({
"project_format": PROJECT_FORMAT,
"project_format_version": PROJECT_FORMAT_VERSION,
"provenance_conformance": PROVENANCE_CONFORMANCE
}),
);
assert_eq!(
inspect_project(temp.path()).unwrap().classification,
ProjectClassification::CplBlocked
);
initialize_conforming_layout(temp.path()).unwrap();
assert_eq!(
inspect_project(temp.path()).unwrap().classification,
ProjectClassification::CplConforming
);
}
#[test]
fn unsupported_or_partial_marker_never_enters_cpl_recovery() {
let temp = tempdir().unwrap();
write_manifest(
temp.path(),
serde_json::json!({
"project_format": PROJECT_FORMAT,
"project_format_version": "2.0",
"provenance_conformance": PROVENANCE_CONFORMANCE
}),
);
assert_eq!(
inspect_project(temp.path()).unwrap().classification,
ProjectClassification::UnsupportedReadOnly
);
}
#[test]
fn preservation_archive_retains_source_bytes_without_changing_source() {
let source_parent = tempdir().unwrap();
let source = source_parent.path().join("preview");
fs::create_dir_all(source.join("nested")).unwrap();
write_manifest(
&source,
serde_json::json!({"schemaVersion":"1.0","applicationVersion":"0.5.3"}),
);
let original = [0, 13, 10, 255, 128, 42];
fs::write(source.join("nested/data.bin"), original).unwrap();
let before = source_inventory(&source).unwrap();
let destination_parent = tempdir().unwrap();
let destination = destination_parent.path().join("preview-preservation.zip");
create_legacy_preservation_archive(&source, &destination).unwrap();
assert_eq!(before, source_inventory(&source).unwrap());
let mut archive = ZipArchive::new(File::open(destination).unwrap()).unwrap();
let mut restored = Vec::new();
archive
.by_name("legacy-project/nested/data.bin")
.unwrap()
.read_to_end(&mut restored)
.unwrap();
assert_eq!(restored, original);
let mut notice = String::new();
archive
.by_name("PRESERVATION-NOTICE.txt")
.unwrap()
.read_to_string(&mut notice)
.unwrap();
assert!(notice.contains("Not verified, converted, or CPL-conforming"));
}
#[test]
fn preservation_archive_cannot_write_inside_the_legacy_project() {
let source = tempdir().unwrap();
write_manifest(
source.path(),
serde_json::json!({"schemaVersion":"1.0","applicationVersion":"0.5.3"}),
);
fs::write(source.path().join("original.txt"), b"unchanged").unwrap();
let before = source_inventory(source.path()).unwrap();
let destination = source.path().join("preservation.zip");
let error = create_legacy_preservation_archive(source.path(), &destination)
.expect_err("an archive inside the source must be refused");
assert_eq!(error.code, "LEGACY_ARCHIVE_INSIDE_PROJECT");
assert_eq!(before, source_inventory(source.path()).unwrap());
assert!(!destination.exists());
}
}
+57
View File
@@ -0,0 +1,57 @@
use super::records::CplRecord;
const PREDICATES: &[&str] = &[
"derived_from",
"generated_by",
"modified_by_human",
"selected_by_human",
"arranged_by_human",
"included_in_deposit",
];
const EVALUATION_STATUSES: &[&str] = &["exact", "degraded", "refused", "stale", "unverified"];
pub fn validate_record(record: &CplRecord) -> Result<(), String> {
match record.record_type.as_str() {
"provenance-assertion" => {
let predicate = record
.payload
.get("predicate")
.and_then(|value| value.as_str())
.ok_or("A provenance assertion requires a predicate.")?;
if !PREDICATES.contains(&predicate) {
return Err(format!(
"Unknown composition assertion predicate '{predicate}'."
));
}
if !record
.payload
.get("dependencies")
.is_some_and(|value| value.is_array())
{
return Err("A provenance assertion requires explicit dependencies.".to_owned());
}
}
"assertion-evaluation" => {
let status = record
.payload
.get("status")
.and_then(|value| value.as_str())
.ok_or("An assertion evaluation requires a status.")?;
if !EVALUATION_STATUSES.contains(&status) {
return Err(format!("Unknown assertion evaluation status '{status}'."));
}
if record
.payload
.get("evaluated_against")
.and_then(|value| value.get("chain_head"))
.and_then(|value| value.as_str())
.is_none()
{
return Err("An assertion evaluation must bind an explicit chain head.".to_owned());
}
}
_ => {}
}
Ok(())
}
+207
View File
@@ -0,0 +1,207 @@
use super::{CplError, CplResult};
use serde_json::{Map, Number, Value};
use sha2::{Digest, Sha256};
use std::cmp::Ordering;
use unicode_normalization::UnicodeNormalization;
fn utf16_cmp(left: &str, right: &str) -> Ordering {
left.encode_utf16().cmp(right.encode_utf16())
}
pub fn normalize_nfc(value: &Value) -> CplResult<Value> {
match value {
Value::Null | Value::Bool(_) | Value::Number(_) => Ok(value.clone()),
Value::String(text) => Ok(Value::String(text.nfc().collect())),
Value::Array(values) => values
.iter()
.map(normalize_nfc)
.collect::<CplResult<Vec<_>>>()
.map(Value::Array),
Value::Object(values) => {
let mut normalized = Map::new();
for (key, child) in values {
let key: String = key.nfc().collect();
if normalized.contains_key(&key) {
return Err(CplError::new(
"CPL_NFC_KEY_COLLISION",
format!("Multiple object keys normalize to '{key}'."),
false,
));
}
normalized.insert(key, normalize_nfc(child)?);
}
Ok(Value::Object(normalized))
}
}
}
fn canonical_number(number: &Number) -> CplResult<String> {
if let Some(value) = number.as_i64() {
return Ok(value.to_string());
}
if let Some(value) = number.as_u64() {
return Ok(value.to_string());
}
let value = number.as_f64().ok_or_else(|| {
CplError::new(
"CPL_NUMBER_INVALID",
"The JSON number is not finite.",
false,
)
})?;
if !value.is_finite() {
return Err(CplError::new(
"CPL_NUMBER_INVALID",
"NaN and infinity are prohibited.",
false,
));
}
if value == 0.0 {
return Ok("0".to_owned());
}
let mut buffer = ryu::Buffer::new();
let rendered = buffer.format_finite(value);
let rendered = rendered.strip_suffix(".0").unwrap_or(rendered);
let Some(exponent_index) = rendered.find('e') else {
return Ok(rendered.to_owned());
};
let (negative, unsigned) = rendered
.strip_prefix('-')
.map_or((false, rendered), |value| (true, value));
let exponent_index = unsigned.find('e').unwrap_or(exponent_index);
let mantissa = &unsigned[..exponent_index];
let exponent: i32 = unsigned[exponent_index + 1..].parse().map_err(|error| {
CplError::new(
"CPL_NUMBER_INVALID",
format!("Invalid numeric exponent: {error}"),
false,
)
})?;
let point = mantissa.find('.').unwrap_or(mantissa.len()) as i32;
let digits = mantissa.replace('.', "");
let decimal_position = point + exponent;
let scientific_exponent = decimal_position - 1;
let body = if (0..21).contains(&scientific_exponent) {
if decimal_position as usize >= digits.len() {
format!(
"{}{}",
digits,
"0".repeat(decimal_position as usize - digits.len())
)
} else {
let split = decimal_position as usize;
format!("{}.{}", &digits[..split], &digits[split..])
}
} else if (-6..0).contains(&scientific_exponent) {
format!("0.{}{}", "0".repeat((-decimal_position) as usize), digits)
} else {
let fraction = &digits[1..];
let coefficient = if fraction.is_empty() {
digits[..1].to_owned()
} else {
format!("{}.{}", &digits[..1], fraction)
};
format!(
"{coefficient}e{}{scientific_exponent}",
if scientific_exponent >= 0 { "+" } else { "" }
)
};
Ok(if negative { format!("-{body}") } else { body })
}
fn write_canonical(value: &Value, output: &mut String) -> CplResult<()> {
match value {
Value::Null => output.push_str("null"),
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => output.push_str(&canonical_number(value)?),
Value::String(value) => {
output.push_str(&serde_json::to_string(value).map_err(|error| {
CplError::new("CPL_CANONICALIZATION_FAILED", error.to_string(), false)
})?)
}
Value::Array(values) => {
output.push('[');
for (index, child) in values.iter().enumerate() {
if index > 0 {
output.push(',');
}
write_canonical(child, output)?;
}
output.push(']');
}
Value::Object(values) => {
output.push('{');
let mut entries: Vec<_> = values.iter().collect();
entries.sort_by(|(left, _), (right, _)| utf16_cmp(left, right));
for (index, (key, child)) in entries.into_iter().enumerate() {
if index > 0 {
output.push(',');
}
output.push_str(&serde_json::to_string(key).map_err(|error| {
CplError::new("CPL_CANONICALIZATION_FAILED", error.to_string(), false)
})?);
output.push(':');
write_canonical(child, output)?;
}
output.push('}');
}
}
Ok(())
}
pub fn canonicalize(value: &Value) -> CplResult<Vec<u8>> {
let normalized = normalize_nfc(value)?;
let mut output = String::new();
write_canonical(&normalized, &mut output)?;
Ok(output.into_bytes())
}
pub fn sha256_digest(bytes: &[u8]) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
}
pub fn canonical_digest(value: &Value) -> CplResult<String> {
canonicalize(value).map(|bytes| sha256_digest(&bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn matches_normative_canonicalization_vectors() {
let cases = [
(
json!({"z":1,"a":2,"nested":{"beta":true,"alpha":false}}),
"{\"a\":2,\"nested\":{\"alpha\":false,\"beta\":true},\"z\":1}",
),
(
json!({"text":"Cafe\u{301}","e\u{301}":"normalize keys and values"}),
"{\"text\":\"Café\",\"é\":\"normalize keys and values\"}",
),
(
json!({"numbers":[333333333.3333333,1e30,4.5,0.002,1e-27]}),
"{\"numbers\":[333333333.3333333,1e+30,4.5,0.002,1e-27]}",
),
(
json!({"numbers":[1e20,1e-6,1e21,1e-7]}),
"{\"numbers\":[100000000000000000000,0.000001,1e+21,1e-7]}",
),
];
for (input, expected) in cases {
assert_eq!(
String::from_utf8(canonicalize(&input).unwrap()).unwrap(),
expected
);
}
}
#[test]
fn rejects_nfc_key_collisions() {
let value: Value = serde_json::from_str("{\"é\":1,\"\":2}").unwrap();
assert_eq!(
normalize_nfc(&value).unwrap_err().code,
"CPL_NFC_KEY_COLLISION"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+559
View File
@@ -0,0 +1,559 @@
//! Read-only CPL explorer and HARP traceability projection.
use super::{
composition::{self, CompositionProjection},
contribution_map::{self, ContributionMapProjection},
harp::{self, HarpProjection},
ledger::{self, LedgerPaths},
records::{CplRecord, VerificationReport},
verifier, CplResult,
};
use serde::Serialize;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
#[derive(Debug, Clone, Serialize)]
pub struct ExplorerRecord {
pub record_id: String,
pub record_type: String,
pub path: String,
pub record_sha256: String,
pub subject_ids: Vec<String>,
pub accessible: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExplorerEvent {
pub event_id: String,
pub event_sequence: u64,
pub timestamp: String,
pub event_type: String,
pub actor: String,
pub event_sha256: String,
pub previous_event_sha256: Option<String>,
pub records: Vec<ExplorerRecord>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HarpStatementTrace {
pub statement_id: String,
pub harp_path: String,
pub statement: String,
pub category: String,
pub segment_ids: Vec<String>,
pub assertion_ids: Vec<String>,
pub evaluation_ids: Vec<String>,
pub record_ids: Vec<String>,
pub trace_note: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CplExplorerProjection {
pub verification: VerificationReport,
pub events: Vec<ExplorerEvent>,
pub composition: CompositionProjection,
pub contribution_map: Option<ContributionMapProjection>,
pub harp: Option<HarpProjection>,
pub harp_statement_traces: Vec<HarpStatementTrace>,
}
pub fn load(root: &Path, project_id: &str) -> CplResult<CplExplorerProjection> {
let verification = verifier::verify_project(root, project_id)?;
let events = explorer_events(root)?;
let composition = composition::reconstruct(root, project_id)?;
let contribution_map = contribution_map::load_latest(root, project_id)?;
let harp = harp::load_latest(root, project_id)?;
let harp_statement_traces = match (&harp, &contribution_map) {
(Some(harp), Some(map)) => statement_traces(harp, map, &events),
_ => Vec::new(),
};
Ok(CplExplorerProjection {
verification,
events,
composition,
contribution_map,
harp,
harp_statement_traces,
})
}
fn explorer_events(root: &Path) -> CplResult<Vec<ExplorerEvent>> {
ledger::read_all_events(&LedgerPaths::new(root))?
.into_iter()
.map(|event| {
let records = event
.record_references
.iter()
.map(|reference| {
let path = resolve_relative(root, &reference.path);
let record = fs::read(&path)
.ok()
.and_then(|bytes| serde_json::from_slice::<CplRecord>(&bytes).ok());
let subject_ids = record
.as_ref()
.map(|record| identifiers_in(&record.payload))
.unwrap_or_default();
ExplorerRecord {
record_id: reference.record_id.clone(),
record_type: reference.record_type.clone(),
path: reference.path.clone(),
record_sha256: reference.record_sha256.clone(),
subject_ids,
accessible: record.is_some(),
}
})
.collect();
Ok(ExplorerEvent {
event_id: event.event_id,
event_sequence: event.event_sequence,
timestamp: event.timestamp,
event_type: event.event_type,
actor: event.actor,
event_sha256: event.event_sha256,
previous_event_sha256: event.previous_event_sha256,
records,
})
})
.collect()
}
fn statement_traces(
harp: &HarpProjection,
map: &ContributionMapProjection,
events: &[ExplorerEvent],
) -> Vec<HarpStatementTrace> {
let all_segments = map
.contribution_map
.segments
.iter()
.map(|segment| segment.segment_id.clone())
.collect::<Vec<_>>();
let all_assertions = map
.assertions
.iter()
.filter_map(|value| value["assertion_id"].as_str().map(str::to_owned))
.collect::<Vec<_>>();
let all_evaluations = map
.assertion_evaluations
.iter()
.filter_map(|value| value["evaluation_id"].as_str().map(str::to_owned))
.collect::<Vec<_>>();
let all_records = matching_records(
events,
&all_segments,
&all_assertions,
&all_evaluations,
&["human-authorship-record", "harp-export-manifest"],
);
let approval_records = matching_records(
events,
&[],
&[],
&[],
&["harp-generation-approval", "human-authorship-record"],
);
let mut traces = vec![
trace(
"deposit-binding",
"deposit",
format!("Exact deposit {} is bound to manuscript revision {}.", harp.harp["deposit"]["deposit_sha256"].as_str().unwrap_or("unknown"), harp.harp["deposit"]["manuscript_revision_id"].as_str().unwrap_or("unknown")),
"evidence_fact",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"The deposit binding is supported by inclusion assertions, their current evaluations, the frozen map, and deposit records.",
),
trace(
"cpl-binding",
"cpl_binding",
format!("HARP uses CPL chain sequence {} at {}.", harp.harp["cpl_binding"]["event_sequence"], harp.harp["cpl_binding"]["chain_head"].as_str().unwrap_or("unknown")),
"evidence_fact",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"The chain binding is checked only by the native verifier.",
),
trace(
"contribution-map-binding",
"contribution_map",
format!("Contribution map {} has digest {}.", harp.harp["contribution_map"]["contribution_map_id"].as_str().unwrap_or("unknown"), harp.harp["contribution_map"]["contribution_map_sha256"].as_str().unwrap_or("unknown")),
"derived_classification",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"The map is a deterministic projection of recorded origin, lineage, assertions, and evaluations.",
),
trace(
"evidentiary-status",
"evidentiary_status",
format!("Evidentiary status is {} and applicability is {}.", harp.harp["evidentiary_status"].as_str().unwrap_or("unknown"), harp.applicability_status),
"derived_classification",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"This status describes recorded evidence integrity and coverage; it is not a frontend validity or legal-authorship declaration.",
),
trace(
"claim-summary",
"claim_summary",
harp.harp["claim_summary"].as_str().unwrap_or("No claim summary recorded.").to_owned(),
"derived_classification",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"Every included segment is connected to its inclusion assertion, current evaluation, and underlying composition records.",
),
trace(
"coverage",
"coverage.statement",
harp.harp["coverage"]["statement"].as_str().unwrap_or("No coverage statement recorded.").to_owned(),
"derived_classification",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"Coverage counts normalized Unicode scalar positions with recorded provenance; it is never a human-authorship percentage.",
),
trace(
"identity-declaration",
"identity_declaration",
format!("Identity was {} as {}.", harp.harp["identity_declaration"]["identity_status"].as_str().unwrap_or("unknown"), harp.harp["identity_declaration"]["declared_name"].as_str().unwrap_or("unnamed")),
"user_declaration",
&[],
&[],
&[],
&approval_records,
"Identity is a user declaration tied to the approval record; Thinkloom does not infer or verify it unless separate evidence is recorded.",
),
trace(
"policy-profile",
"policy_profile",
format!("Registration suggestions use policy profile {} version {}, retrieved {}.", harp.harp["policy_profile"]["policy_profile_id"].as_str().unwrap_or("unknown"), harp.harp["policy_profile"]["profile_version"].as_str().unwrap_or("unknown"), harp.harp["policy_profile"]["retrieved_on"].as_str().unwrap_or("unknown")),
"evidence_fact",
&[],
&[],
&[],
&all_records,
"The immutable policy-profile identity and digest are embedded in the HARP record.",
),
trace(
"registration-language",
"suggested_registration_language",
"Author Created, Material Excluded, New Material Included, and Note to CO language was explicitly approved.".into(),
"suggested_application_language",
&all_segments,
&all_assertions,
&all_evaluations,
&approval_records,
"The wording is an approved suggestion linked to the evidence set and approval event; it is editable and is not legal advice.",
),
trace(
"limitations",
"limitation_codes",
format!("HARP records {} limitation codes.", harp.harp["limitation_codes"].as_array().map_or(0, Vec::len)),
"derived_classification",
&all_segments,
&all_assertions,
&all_evaluations,
&all_records,
"Limitations expose missing, degraded, sanitized, self-declared, or legally undetermined dimensions.",
),
trace(
"legal-scope",
"explanation_codes",
harp.report_metadata.legal_scope_statement.clone(),
"legal_determination_not_made",
&[],
&[],
&[],
&all_records,
"This is an explicit boundary: Thinkloom does not make the listed legal determinations.",
),
];
for (index, disclosure) in harp.harp["ai_system_disclosures"]
.as_array()
.into_iter()
.flatten()
.enumerate()
{
let segment_ids = disclosure["included_expression_segment_ids"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>();
let assertion_ids = assertion_ids_for_segments(map, &segment_ids);
let evaluation_ids = evaluation_ids_for_assertions(map, &assertion_ids);
let records = matching_records(events, &segment_ids, &assertion_ids, &evaluation_ids, &[]);
traces.push(trace(
&format!("ai-disclosure-{index}"),
&format!("ai_system_disclosures[{index}]"),
format!("Provider {} model {} is recorded for {} included segments.", disclosure["provider_id"].as_str().unwrap_or("unknown"), disclosure["model_id"].as_str().unwrap_or("unknown"), segment_ids.len()),
"evidence_fact",
&segment_ids,
&assertion_ids,
&evaluation_ids,
&records,
"AI-system identity is read from recorded invocation requests and joined to accepted-output segment lineage.",
));
}
traces
}
fn trace(
statement_id: &str,
harp_path: &str,
statement: String,
category: &str,
segment_ids: &[String],
assertion_ids: &[String],
evaluation_ids: &[String],
record_ids: &[String],
trace_note: &str,
) -> HarpStatementTrace {
HarpStatementTrace {
statement_id: statement_id.into(),
harp_path: harp_path.into(),
statement,
category: category.into(),
segment_ids: segment_ids.to_vec(),
assertion_ids: assertion_ids.to_vec(),
evaluation_ids: evaluation_ids.to_vec(),
record_ids: record_ids.to_vec(),
trace_note: trace_note.into(),
}
}
fn assertion_ids_for_segments(map: &ContributionMapProjection, segments: &[String]) -> Vec<String> {
map.contribution_map
.segments
.iter()
.filter(|segment| segments.contains(&segment.segment_id))
.flat_map(|segment| segment.assertion_ids.iter().cloned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn evaluation_ids_for_assertions(
map: &ContributionMapProjection,
assertions: &[String],
) -> Vec<String> {
map.assertion_evaluations
.iter()
.filter(|value| {
value["assertion_id"]
.as_str()
.is_some_and(|id| assertions.iter().any(|assertion| assertion == id))
})
.filter_map(|value| value["evaluation_id"].as_str().map(str::to_owned))
.collect()
}
fn matching_records(
events: &[ExplorerEvent],
segments: &[String],
assertions: &[String],
evaluations: &[String],
record_types: &[&str],
) -> Vec<String> {
let wanted = segments
.iter()
.chain(assertions)
.chain(evaluations)
.collect::<BTreeSet<_>>();
events
.iter()
.flat_map(|event| &event.records)
.filter(|record| {
record_types.contains(&record.record_type.as_str())
|| record.subject_ids.iter().any(|id| wanted.contains(id))
})
.map(|record| record.record_id.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn identifiers_in(value: &Value) -> Vec<String> {
let mut values = BTreeSet::new();
collect_identifiers(value, &mut values);
values.into_iter().collect()
}
fn collect_identifiers(value: &Value, output: &mut BTreeSet<String>) {
match value {
Value::String(value) if is_identifier(value) => {
output.insert(value.clone());
}
Value::Array(values) => {
for value in values {
collect_identifiers(value, output);
}
}
Value::Object(values) => {
for value in values.values() {
collect_identifiers(value, output);
}
}
_ => {}
}
}
fn is_identifier(value: &str) -> bool {
const PREFIXES: &[&str] = &[
"assertion_",
"deposit_",
"disposition_",
"evaluation_",
"event_",
"fragment_",
"harp_",
"invocation_",
"map_",
"operation_",
"record_",
"revision_",
"segment_",
];
PREFIXES.iter().any(|prefix| value.starts_with(prefix))
}
fn resolve_relative(root: &Path, relative: &str) -> std::path::PathBuf {
relative
.split('/')
.fold(root.to_path_buf(), |path, part| path.join(part))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recursively_finds_traceable_identifiers_without_hashes_or_text() {
let value = serde_json::json!({
"subject": { "segment_id": "segment_01J0000000000000000000000Y" },
"dependencies": ["assertion_01J0000000000000000000000V"],
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"text": "ordinary prose",
});
assert_eq!(
identifiers_in(&value),
vec![
"assertion_01J0000000000000000000000V".to_owned(),
"segment_01J0000000000000000000000Y".to_owned(),
]
);
}
#[test]
fn every_evidentiary_harp_statement_links_to_native_cpl_evidence() {
use crate::provenance::{
composition::{
CompositionAction, CompositionBoundary, CompositionCommand, RecordedOrigin,
},
contribution_map::ContributionMapRequest,
harp::{HarpGenerationRequest, RegistrationLanguageInput},
identifiers::timestamp_millis,
records::VerificationStatus,
};
let temp = tempfile::tempdir().unwrap();
let project_id = "project_01J00000000000000000000009";
let apply = |id: &str, action: CompositionAction| {
composition::apply_command(
temp.path(),
project_id,
CompositionCommand {
client_action_id: id.into(),
actor: "user".into(),
summary: id.into(),
occurred_at: timestamp_millis(),
action,
},
)
.unwrap();
};
apply(
"explorer_initialize",
CompositionAction::Initialize {
text: String::new(),
origin: RecordedOrigin::Unattested,
},
);
apply(
"explorer_human_edit",
CompositionAction::Edit {
before_text: String::new(),
after_text: "Traceable human expression.".into(),
boundary: CompositionBoundary::ExplicitSave,
origin: RecordedOrigin::RecordedDirectHumanInput,
operation_kind_hint: None,
ai_acceptance: None,
},
);
contribution_map::freeze_current(
temp.path(),
project_id,
ContributionMapRequest::default(),
)
.unwrap();
harp::generate_current(
temp.path(),
project_id,
HarpGenerationRequest {
declared_name: Some("Example Author".into()),
identity_status: "self_declared".into(),
identity_evidence_reference_ids: vec![],
sanitization_profile: "sanitized".into(),
suggested_registration_language: RegistrationLanguageInput::default(),
user_approved: true,
},
)
.unwrap();
let projection = load(temp.path(), project_id).unwrap();
assert_eq!(projection.verification.status, VerificationStatus::Verified);
assert!(!projection.events.is_empty());
assert!(projection.harp.is_some());
assert!(!projection.harp_statement_traces.is_empty());
for trace in &projection.harp_statement_traces {
assert!(!trace.statement.is_empty());
assert!(!trace.harp_path.is_empty());
assert!(!trace.record_ids.is_empty());
if matches!(
trace.category.as_str(),
"derived_classification" | "suggested_application_language"
) {
assert!(!trace.assertion_ids.is_empty());
assert!(!trace.evaluation_ids.is_empty());
}
}
let claim = projection
.harp_statement_traces
.iter()
.find(|trace| trace.statement_id == "claim-summary")
.unwrap();
assert!(!claim.segment_ids.is_empty());
assert!(!claim.assertion_ids.is_empty());
assert!(!claim.evaluation_ids.is_empty());
assert!(!claim.record_ids.is_empty());
let identity = projection
.harp_statement_traces
.iter()
.find(|trace| trace.statement_id == "identity-declaration")
.unwrap();
assert_eq!(identity.category, "user_declaration");
assert!(identity.assertion_ids.is_empty());
assert!(identity.evaluation_ids.is_empty());
assert!(!identity.record_ids.is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
use super::{CplError, CplResult};
use chrono::{DateTime, SecondsFormat, Utc};
use std::sync::{Mutex, OnceLock};
use uuid::Uuid;
static ID_CLOCK: OnceLock<Mutex<(i64, u16)>> = OnceLock::new();
pub fn timestamp_millis_at(timestamp: DateTime<Utc>) -> String {
timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)
}
pub fn timestamp_millis() -> String {
timestamp_millis_at(Utc::now())
}
fn validate_prefix(prefix: &str) -> CplResult<()> {
if prefix.is_empty()
|| prefix.len() > 24
|| !prefix
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte == b'_')
{
return Err(CplError::new(
"CPL_ID_PREFIX_INVALID",
"Identifier prefixes must contain lowercase ASCII letters and underscores.",
false,
));
}
Ok(())
}
pub fn sortable_id_at(
prefix: &str,
unix_millis: i64,
sequence: u16,
entropy: &str,
) -> CplResult<String> {
validate_prefix(prefix)?;
if !(0..=0x0000_ffff_ffff_ffff).contains(&unix_millis)
|| entropy.len() != 32
|| !entropy.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(CplError::new(
"CPL_ID_COMPONENT_INVALID",
"Sortable identifier components are invalid.",
false,
));
}
let mut bytes = [0u8; 16];
let timestamp = (unix_millis as u64).to_be_bytes();
bytes[..6].copy_from_slice(&timestamp[2..]);
bytes[6..8].copy_from_slice(&sequence.to_be_bytes());
for (index, byte) in bytes[8..].iter_mut().enumerate() {
*byte = u8::from_str_radix(&entropy[index * 2..index * 2 + 2], 16).map_err(|_| {
CplError::new(
"CPL_ID_COMPONENT_INVALID",
"Sortable identifier entropy is invalid.",
false,
)
})?;
}
Ok(format!("{prefix}_{}", crockford_128(bytes)))
}
fn crockford_128(bytes: [u8; 16]) -> String {
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let mut output = String::with_capacity(26);
let mut buffer = 0u32;
let mut bits = 2u8;
for byte in bytes {
buffer = (buffer << 8) | u32::from(byte);
bits += 8;
while bits >= 5 {
bits -= 5;
output.push(ALPHABET[((buffer >> bits) & 31) as usize] as char);
}
}
debug_assert_eq!(output.len(), 26);
output
}
fn advance_clock(clock: &mut (i64, u16), observed_millis: i64) -> CplResult<(i64, u16)> {
if observed_millis <= clock.0 {
clock.1 = clock.1.checked_add(1).ok_or_else(|| {
CplError::new(
"CPL_ID_SEQUENCE_EXHAUSTED",
"Too many identifiers in one logical millisecond.",
true,
)
})?;
} else {
*clock = (observed_millis, 0);
}
Ok(*clock)
}
pub fn sortable_id(prefix: &str) -> CplResult<String> {
let observed_millis = Utc::now().timestamp_millis();
let mut clock = ID_CLOCK
.get_or_init(|| Mutex::new((-1, 0)))
.lock()
.map_err(|_| {
CplError::new(
"CPL_ID_CLOCK_LOCKED",
"Identifier clock is unavailable.",
true,
)
})?;
let (logical_millis, sequence) = advance_clock(&mut clock, observed_millis)?;
sortable_id_at(
prefix,
logical_millis,
sequence,
&Uuid::new_v4().simple().to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn identifiers_are_prefixed_and_sort_by_time_then_sequence() {
let first = sortable_id_at("event", 1_700_000_000_000, 0, &"0".repeat(32)).unwrap();
let second = sortable_id_at("event", 1_700_000_000_000, 1, &"0".repeat(32)).unwrap();
let third = sortable_id_at("event", 1_700_000_000_001, 0, &"0".repeat(32)).unwrap();
assert!(first < second && second < third);
assert!(first.starts_with("event_"));
assert_eq!(first.len(), "event_".len() + 26);
assert!(first["event_".len()..]
.bytes()
.all(|byte| b"0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(&byte)));
}
#[test]
fn clock_rollback_preserves_identifier_order() {
let mut clock = (2_000, 3);
assert_eq!(advance_clock(&mut clock, 1_999).unwrap(), (2_000, 4));
assert_eq!(advance_clock(&mut clock, 2_001).unwrap(), (2_001, 0));
}
#[test]
fn timestamps_are_exact_utc_milliseconds() {
let value = Utc.with_ymd_and_hms(2026, 7, 17, 18, 42, 10).unwrap()
+ chrono::Duration::milliseconds(123);
assert_eq!(timestamp_millis_at(value), "2026-07-17T18:42:10.123Z");
}
}
+462
View File
@@ -0,0 +1,462 @@
use super::{
canonical::{canonical_digest, canonicalize, sha256_digest},
identifiers::timestamp_millis,
records::{ChainHead, CplEvent, SegmentManifest},
CplError, CplResult, DurableBoundary, CPL_SCHEMA_VERSION,
};
use serde::Serialize;
use serde_json::Value;
use std::{
fs::{self, File, OpenOptions},
io::Write,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy)]
pub struct LedgerConfig {
pub max_events_per_segment: u64,
pub max_bytes_per_segment: u64,
}
impl Default for LedgerConfig {
fn default() -> Self {
Self {
max_events_per_segment: 10_000,
max_bytes_per_segment: 10 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone)]
pub struct LedgerPaths {
pub root: PathBuf,
pub active: PathBuf,
pub sealed: PathBuf,
pub chain_head: PathBuf,
}
impl LedgerPaths {
pub fn new(project_root: &Path) -> Self {
let root = project_root.join("provenance/ledger");
Self {
active: root.join("active"),
sealed: root.join("sealed"),
chain_head: root.join("chain-head.json"),
root,
}
}
pub fn initialize(&self) -> CplResult<()> {
fs::create_dir_all(&self.active)
.and_then(|_| fs::create_dir_all(&self.sealed))
.map_err(|error| CplError::io("Could not create CPL ledger directories", error))?;
if active_segments(self)?.is_empty() {
let next_number = sealed_segments(self)?
.iter()
.filter_map(|path| parse_segment_number(path))
.max()
.unwrap_or(0)
+ 1;
File::create(self.active.join(segment_filename(next_number)))
.and_then(|file| file.sync_all())
.map_err(|error| {
CplError::io("Could not initialize the active CPL segment", error)
})?;
sync_directory(&self.active)?;
}
Ok(())
}
}
pub fn segment_filename(number: u64) -> String {
format!("segment-{number:06}.jsonl")
}
pub fn segment_manifest_filename(number: u64) -> String {
format!("segment-{number:06}.manifest.json")
}
fn parse_segment_number(path: &Path) -> Option<u64> {
path.file_name()?
.to_str()?
.strip_prefix("segment-")?
.strip_suffix(".jsonl")?
.parse()
.ok()
}
pub fn active_segments(paths: &LedgerPaths) -> CplResult<Vec<PathBuf>> {
let mut segments = fs::read_dir(&paths.active)
.map_err(|error| CplError::io("Could not inspect active CPL segments", error))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| parse_segment_number(path).is_some())
.collect::<Vec<_>>();
segments.sort();
Ok(segments)
}
pub fn sealed_segments(paths: &LedgerPaths) -> CplResult<Vec<PathBuf>> {
let mut segments = fs::read_dir(&paths.sealed)
.map_err(|error| CplError::io("Could not inspect sealed CPL segments", error))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| parse_segment_number(path).is_some())
.collect::<Vec<_>>();
segments.sort();
Ok(segments)
}
pub fn current_active_segment(paths: &LedgerPaths) -> CplResult<(u64, PathBuf)> {
let segments = active_segments(paths)?;
if segments.len() != 1 {
return Err(CplError::new(
"CPL_ACTIVE_SEGMENT_INVALID",
format!(
"Expected exactly one active segment, found {}.",
segments.len()
),
false,
));
}
let path = segments[0].clone();
let number = parse_segment_number(&path).ok_or_else(|| {
CplError::new(
"CPL_SEGMENT_NAME_INVALID",
"The active segment name is invalid.",
false,
)
})?;
Ok((number, path))
}
pub fn read_segment(path: &Path) -> CplResult<Vec<CplEvent>> {
let bytes =
fs::read(path).map_err(|error| CplError::io("Could not read CPL segment", error))?;
if !bytes.is_empty() && !bytes.ends_with(b"\n") {
return Err(CplError::new(
"CPL_SEGMENT_TRUNCATED",
format!("{} has an incomplete final JSONL line.", path.display()),
true,
));
}
bytes
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.enumerate()
.map(|(index, line)| {
let event: CplEvent = serde_json::from_slice(line).map_err(|error| {
CplError::new(
"CPL_EVENT_INVALID",
format!("{} line {} is invalid: {error}", path.display(), index + 1),
false,
)
})?;
if canonical_event_line(&event)? != line {
return Err(CplError::new(
"CPL_EVENT_NONCANONICAL",
format!(
"{} line {} is valid JSON but not canonical JSON.",
path.display(),
index + 1
),
false,
));
}
Ok(event)
})
.collect()
}
pub fn read_all_events(paths: &LedgerPaths) -> CplResult<Vec<CplEvent>> {
let mut events = Vec::new();
for path in sealed_segments(paths)? {
events.extend(read_segment(&path)?);
}
for path in active_segments(paths)? {
events.extend(read_segment(&path)?);
}
Ok(events)
}
pub fn read_chain_head(paths: &LedgerPaths) -> CplResult<Option<ChainHead>> {
if !paths.chain_head.exists() {
return Ok(None);
}
let bytes = fs::read(&paths.chain_head)
.map_err(|error| CplError::io("Could not read the CPL chain head", error))?;
let head: ChainHead = serde_json::from_slice(&bytes)
.map_err(|error| CplError::new("CPL_CHAIN_HEAD_INVALID", error.to_string(), false))?;
if serialize_canonical(&head)? != bytes {
return Err(CplError::new(
"CPL_CHAIN_HEAD_NONCANONICAL",
"The CPL chain head is not canonical JSON.",
false,
));
}
Ok(Some(head))
}
fn serialize_canonical<T: Serialize>(value: &T) -> CplResult<Vec<u8>> {
let value = serde_json::to_value(value)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
canonicalize(&value)
}
pub(crate) fn sync_directory(path: &Path) -> CplResult<()> {
#[cfg(not(target_os = "windows"))]
{
File::open(path)
.and_then(|file| file.sync_all())
.map_err(|error| CplError::io("Could not flush a CPL directory", error))?;
}
#[cfg(target_os = "windows")]
let _ = path;
Ok(())
}
#[cfg(target_os = "windows")]
pub(crate) fn atomic_replace(source: &Path, destination: &Path) -> CplResult<()> {
use std::os::windows::ffi::OsStrExt;
const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
#[link(name = "Kernel32")]
extern "system" {
fn MoveFileExW(existing: *const u16, replacement: *const u16, flags: u32) -> i32;
}
let source = source
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect::<Vec<_>>();
let destination = destination
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect::<Vec<_>>();
let result = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if result == 0 {
return Err(CplError::io(
"Could not atomically replace a CPL file",
std::io::Error::last_os_error(),
));
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn atomic_replace(source: &Path, destination: &Path) -> CplResult<()> {
fs::rename(source, destination)
.map_err(|error| CplError::io("Could not atomically replace a CPL file", error))
}
pub(crate) fn write_atomic<T: Serialize>(path: &Path, value: &T) -> CplResult<()> {
let parent = path.parent().ok_or_else(|| {
CplError::new(
"CPL_PATH_INVALID",
"The CPL file has no parent directory.",
false,
)
})?;
fs::create_dir_all(parent)
.map_err(|error| CplError::io("Could not create a CPL parent directory", error))?;
let temp = parent.join(format!(
".{}.tmp",
path.file_name().unwrap_or_default().to_string_lossy()
));
let bytes = serialize_canonical(value)?;
let mut file = File::create(&temp)
.map_err(|error| CplError::io("Could not create a temporary CPL file", error))?;
file.write_all(&bytes)
.and_then(|_| file.sync_all())
.map_err(|error| CplError::io("Could not flush a temporary CPL file", error))?;
drop(file);
atomic_replace(&temp, path)?;
sync_directory(parent)
}
fn last_sealed_digest(paths: &LedgerPaths) -> CplResult<Option<String>> {
sealed_segments(paths)?
.last()
.map(|path| {
fs::read(path)
.map(|bytes| sha256_digest(&bytes))
.map_err(|error| CplError::io("Could not hash the prior sealed segment", error))
})
.transpose()
}
fn rotate_if_needed(
paths: &LedgerPaths,
config: LedgerConfig,
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
) -> CplResult<()> {
let (number, active) = current_active_segment(paths)?;
let bytes = fs::read(&active)
.map_err(|error| CplError::io("Could not inspect the active segment", error))?;
let events = read_segment(&active)?;
if events.is_empty()
|| (events.len() as u64) < config.max_events_per_segment
&& (bytes.len() as u64) < config.max_bytes_per_segment
{
return Ok(());
}
OpenOptions::new()
.write(true)
.open(&active)
.and_then(|file| file.sync_all())
.map_err(|error| {
CplError::io("Could not flush the active segment before sealing", error)
})?;
boundary(DurableBoundary::SegmentFlushed)?;
let manifest = SegmentManifest {
schema_version: CPL_SCHEMA_VERSION.to_owned(),
segment_number: number,
previous_segment_file_sha256: last_sealed_digest(paths)?,
first_event_sha256: events.first().unwrap().event_sha256.clone(),
final_event_sha256: events.last().unwrap().event_sha256.clone(),
first_event_sequence: events.first().unwrap().event_sequence,
final_event_sequence: events.last().unwrap().event_sequence,
event_count: events.len() as u64,
byte_length: bytes.len() as u64,
segment_file_sha256: sha256_digest(&bytes),
sealed_at: timestamp_millis(),
};
let manifest_temp = paths
.active
.join(format!(".{}.tmp", segment_manifest_filename(number)));
let manifest_bytes = serialize_canonical(&manifest)?;
File::create(&manifest_temp)
.and_then(|mut file| {
file.write_all(&manifest_bytes)
.and_then(|_| file.sync_all())
})
.map_err(|error| CplError::io("Could not flush a sealed segment manifest", error))?;
boundary(DurableBoundary::SegmentManifestFlushed)?;
let sealed_segment = paths.sealed.join(segment_filename(number));
let sealed_manifest = paths.sealed.join(segment_manifest_filename(number));
atomic_replace(&active, &sealed_segment)?;
boundary(DurableBoundary::SegmentMoved)?;
atomic_replace(&manifest_temp, &sealed_manifest)?;
boundary(DurableBoundary::SegmentManifestMoved)?;
File::create(paths.active.join(segment_filename(number + 1)))
.and_then(|file| file.sync_all())
.map_err(|error| CplError::io("Could not create the next active segment", error))?;
boundary(DurableBoundary::NewActiveSegmentCreated)?;
sync_directory(&paths.active)?;
sync_directory(&paths.sealed)
}
pub fn append_event(
paths: &LedgerPaths,
config: LedgerConfig,
mut event: CplEvent,
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
) -> CplResult<(CplEvent, ChainHead)> {
paths.initialize()?;
rotate_if_needed(paths, config, boundary)?;
let events = read_all_events(paths)?;
let previous = events.last();
event.event_sequence = previous.map_or(1, |item| item.event_sequence + 1);
event.previous_event_sha256 = previous.map(|item| item.event_sha256.clone());
event.event_sha256 = canonical_digest(&event.identity())?;
let (segment_number, active) = current_active_segment(paths)?;
let mut line = serialize_canonical(&event)?;
line.push(b'\n');
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&active)
.map_err(|error| CplError::io("Could not open the active CPL segment", error))?;
file.write_all(&line)
.map_err(|error| CplError::io("Could not append the CPL event", error))?;
boundary(DurableBoundary::LedgerAppendBeforeFlush)?;
file.sync_all()
.map_err(|error| CplError::io("Could not flush the CPL event", error))?;
boundary(DurableBoundary::LedgerFlushed)?;
let head = ChainHead {
schema_version: CPL_SCHEMA_VERSION.to_owned(),
project_id: event.project_id.clone(),
segment_number,
segment_file: format!(
"provenance/ledger/active/{}",
segment_filename(segment_number)
),
event_id: event.event_id.clone(),
event_sequence: event.event_sequence,
event_sha256: event.event_sha256.clone(),
updated_at: event.timestamp.clone(),
};
let head_temp = paths.root.join(".chain-head.json.tmp");
let head_bytes = serialize_canonical(&head)?;
File::create(&head_temp)
.and_then(|mut file| file.write_all(&head_bytes).and_then(|_| file.sync_all()))
.map_err(|error| CplError::io("Could not flush the temporary CPL chain head", error))?;
boundary(DurableBoundary::ChainHeadTemporaryWritten)?;
atomic_replace(&head_temp, &paths.chain_head)?;
boundary(DurableBoundary::ChainHeadReplaced)?;
sync_directory(&paths.root)?;
boundary(DurableBoundary::ChainHeadDirectorySynced)?;
Ok((event, head))
}
pub fn advance_chain_head(
paths: &LedgerPaths,
project_id: &str,
event: &CplEvent,
) -> CplResult<ChainHead> {
let segment_number = locate_event_segment(paths, &event.event_id)?.ok_or_else(|| {
CplError::new(
"CPL_EVENT_NOT_FOUND",
"Cannot advance the chain head to a missing event.",
false,
)
})?;
let in_active = active_segments(paths)?
.iter()
.any(|path| parse_segment_number(path) == Some(segment_number));
let kind = if in_active { "active" } else { "sealed" };
let head = ChainHead {
schema_version: CPL_SCHEMA_VERSION.to_owned(),
project_id: project_id.to_owned(),
segment_number,
segment_file: format!(
"provenance/ledger/{kind}/{}",
segment_filename(segment_number)
),
event_id: event.event_id.clone(),
event_sequence: event.event_sequence,
event_sha256: event.event_sha256.clone(),
updated_at: timestamp_millis(),
};
write_atomic(&paths.chain_head, &head)?;
Ok(head)
}
pub fn locate_event_segment(paths: &LedgerPaths, event_id: &str) -> CplResult<Option<u64>> {
let mut segments = sealed_segments(paths)?;
segments.extend(active_segments(paths)?);
for path in segments {
if read_segment(&path)?
.iter()
.any(|event| event.event_id == event_id)
{
return Ok(parse_segment_number(&path));
}
}
Ok(None)
}
pub fn canonical_event_line(event: &CplEvent) -> CplResult<Vec<u8>> {
let value: Value = serde_json::to_value(event)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
canonicalize(&value)
}
+93
View File
@@ -0,0 +1,93 @@
//! Native Composition Provenance Ledger service.
//!
//! This is the only module allowed to create authoritative records, event
//! digests, ledger entries, chain heads, or native verification results.
pub mod assertions;
pub mod canonical;
pub mod composition;
pub mod contribution_map;
pub mod explorer;
pub mod export;
pub mod harp;
pub mod identifiers;
pub mod ledger;
pub mod phase1;
pub mod projections;
pub mod records;
pub mod recovery;
pub mod verifier;
pub mod writer;
use serde::Serialize;
use std::{error::Error, fmt};
pub use records::{
ChainHead, CplEvent, CplRecord, RecordInput, RecoveryClassification, RecoveryReport,
VerificationFinding, VerificationReport, VerificationStatus, WriteCommand, WriteResult,
};
pub use recovery::recover_project;
pub use verifier::verify_project;
pub use writer::{CplService, WriterConfig};
pub const CPL_SCHEMA_VERSION: &str = "1.0";
pub type CplResult<T> = Result<T, CplError>;
#[derive(Debug, Clone, Serialize)]
pub struct CplError {
pub code: String,
pub message: String,
pub recoverable: bool,
}
impl CplError {
pub fn new(code: &str, message: impl Into<String>, recoverable: bool) -> Self {
Self {
code: code.to_owned(),
message: message.into(),
recoverable,
}
}
pub fn io(context: &str, error: impl fmt::Display) -> Self {
Self::new("CPL_IO_ERROR", format!("{context}: {error}"), true)
}
}
impl fmt::Display for CplError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.code, self.message)
}
}
impl Error for CplError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DurableBoundary {
IntentPrepared,
FirstRecordStaged,
RecordFlushed,
RecordMoved,
RecordDirectorySynced,
SegmentFlushed,
SegmentManifestFlushed,
SegmentMoved,
SegmentManifestMoved,
NewActiveSegmentCreated,
LedgerAppendBeforeFlush,
LedgerFlushed,
ChainHeadTemporaryWritten,
ChainHeadReplaced,
ChainHeadDirectorySynced,
SqliteApplied,
Complete,
}
#[cfg(test)]
pub(crate) fn injected_failure(boundary: DurableBoundary) -> CplError {
CplError::new(
"CPL_INJECTED_FAILURE",
format!("Injected termination after {boundary:?}"),
true,
)
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
use super::{canonical::canonical_digest, records::RecordReference, CplResult};
use serde_json::{json, Value};
pub fn deterministic_projection(
project_id: &str,
chain_head_sha256: &str,
mut records: Vec<RecordReference>,
) -> CplResult<Value> {
records.sort_by(|left, right| left.record_id.as_bytes().cmp(right.record_id.as_bytes()));
let content = json!({
"project_id": project_id,
"chain_head_sha256": chain_head_sha256,
"records": records,
});
Ok(json!({
"content_sha256": canonical_digest(&content)?,
"content": content,
}))
}
+182
View File
@@ -0,0 +1,182 @@
use super::{canonical::canonical_digest, CplResult, CPL_SCHEMA_VERSION};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecordInput {
pub record_type: String,
pub payload: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CplRecord {
pub schema_version: String,
pub record_id: String,
pub record_type: String,
pub project_id: String,
pub intent_id: String,
pub client_action_id: String,
pub created_at: String,
pub payload: Value,
pub record_sha256: String,
}
impl CplRecord {
pub fn identity(&self) -> Value {
json!({"schema_version":self.schema_version,"record_id":self.record_id,"record_type":self.record_type,"project_id":self.project_id,"intent_id":self.intent_id,"client_action_id":self.client_action_id,"created_at":self.created_at,"payload":self.payload})
}
pub fn verify_digest(&self) -> CplResult<bool> {
Ok(canonical_digest(&self.identity())? == self.record_sha256)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecordReference {
pub record_id: String,
pub record_type: String,
pub path: String,
pub record_sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CplEvent {
pub schema_version: String,
pub event_id: String,
pub project_id: String,
pub event_sequence: u64,
pub timestamp: String,
pub event_type: String,
pub actor: String,
pub client_action_id: String,
pub command_sha256: String,
pub record_references: Vec<RecordReference>,
pub metadata: Value,
pub previous_event_sha256: Option<String>,
pub event_sha256: String,
}
impl CplEvent {
pub fn identity(&self) -> Value {
json!({"schema_version":self.schema_version,"event_id":self.event_id,"project_id":self.project_id,"event_sequence":self.event_sequence,"timestamp":self.timestamp,"event_type":self.event_type,"actor":self.actor,"client_action_id":self.client_action_id,"command_sha256":self.command_sha256,"record_references":self.record_references,"metadata":self.metadata,"previous_event_sha256":self.previous_event_sha256})
}
pub fn verify_digest(&self) -> CplResult<bool> {
Ok(canonical_digest(&self.identity())? == self.event_sha256)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChainHead {
pub schema_version: String,
pub project_id: String,
pub segment_number: u64,
pub segment_file: String,
pub event_id: String,
pub event_sequence: u64,
pub event_sha256: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SegmentManifest {
pub schema_version: String,
pub segment_number: u64,
pub previous_segment_file_sha256: Option<String>,
pub first_event_sha256: String,
pub final_event_sha256: String,
pub first_event_sequence: u64,
pub final_event_sequence: u64,
pub event_count: u64,
pub byte_length: u64,
pub segment_file_sha256: String,
pub sealed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WriteCommand {
pub client_action_id: String,
pub project_id: String,
pub event_type: String,
pub actor: String,
#[serde(default)]
pub metadata: Value,
#[serde(default)]
pub records: Vec<RecordInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operational_state: Option<Value>,
}
impl WriteCommand {
pub fn digest(&self) -> CplResult<String> {
canonical_digest(&serde_json::to_value(self).map_err(|error| {
super::CplError::new("CPL_COMMAND_INVALID", error.to_string(), false)
})?)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WriteResult {
pub idempotent_replay: bool,
pub intent_id: String,
pub event: CplEvent,
pub records: Vec<RecordReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum VerificationStatus {
Verified,
VerifiedWithWarnings,
Incomplete,
Failed,
Unsafe,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VerificationFinding {
pub code: String,
pub severity: String,
pub scope: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VerificationReport {
pub schema_version: String,
pub report_id: String,
pub project_id: String,
pub status: VerificationStatus,
pub verified_at: String,
pub chain_head: Option<ChainHead>,
pub event_count: u64,
pub record_count: u64,
pub findings: Vec<VerificationFinding>,
}
impl VerificationReport {
pub fn empty(project_id: &str, report_id: String, verified_at: String) -> Self {
Self {
schema_version: CPL_SCHEMA_VERSION.to_owned(),
report_id,
project_id: project_id.to_owned(),
status: VerificationStatus::Incomplete,
verified_at,
chain_head: None,
event_count: 0,
record_count: 0,
findings: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RecoveryClassification {
Clean,
RecoverableAutomatically,
RequiresUserConfirmation,
IntegrityFailure,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecoveryReport {
pub classification: RecoveryClassification,
pub actions: Vec<String>,
pub quarantined_paths: Vec<String>,
pub verification: VerificationReport,
}
+404
View File
@@ -0,0 +1,404 @@
use super::{
canonical::sha256_digest,
ledger::{self, atomic_replace, LedgerPaths},
records::{
CplEvent, RecoveryClassification, RecoveryReport, SegmentManifest, VerificationStatus,
},
verifier,
writer::{init_database, rebuild_indexes, set_intent_phase, ProjectWriterLock},
CplError, CplResult,
};
use std::{
fs::{self, OpenOptions},
path::{Path, PathBuf},
};
pub fn recover_project(root: &Path, project_id: &str) -> CplResult<RecoveryReport> {
let paths = LedgerPaths::new(root);
paths.initialize()?;
let _lock = ProjectWriterLock::acquire(root)?;
recover_locked(root, project_id)
}
pub(crate) fn recover_locked(root: &Path, project_id: &str) -> CplResult<RecoveryReport> {
let paths = LedgerPaths::new(root);
paths.initialize()?;
let mut actions = Vec::new();
let mut quarantined_paths = Vec::new();
let database = recover_database(root, &mut actions, &mut quarantined_paths)?;
let mut repaired = !actions.is_empty();
for entry in fs::read_dir(&paths.active)
.map_err(|error| CplError::io("Could not inspect interrupted segment rotation", error))?
.filter_map(Result::ok)
{
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with(".segment-") || !name.ends_with(".manifest.json.tmp") {
continue;
}
let manifest: SegmentManifest =
serde_json::from_slice(&fs::read(entry.path()).map_err(|error| {
CplError::io("Could not read an interrupted segment manifest", error)
})?)
.map_err(|error| {
CplError::new("CPL_SEGMENT_MANIFEST_INVALID", error.to_string(), false)
})?;
let sealed_segment = paths
.sealed
.join(ledger::segment_filename(manifest.segment_number));
let sealed_manifest = paths
.sealed
.join(ledger::segment_manifest_filename(manifest.segment_number));
if sealed_segment.exists() && !sealed_manifest.exists() {
let bytes = fs::read(&sealed_segment).map_err(|error| {
CplError::io("Could not verify an interrupted sealed segment", error)
})?;
if sha256_digest(&bytes) != manifest.segment_file_sha256 {
return Err(CplError::new(
"CPL_SEGMENT_MANIFEST_MISMATCH",
"Interrupted segment rotation has a manifest/segment digest contradiction.",
false,
));
}
atomic_replace(&entry.path(), &sealed_manifest)?;
actions.push(format!(
"Completed the durable manifest move for sealed segment {}.",
manifest.segment_number
));
repaired = true;
}
}
let head_before = ledger::read_chain_head(&paths)?;
let (_, active) = ledger::current_active_segment(&paths)?;
let active_bytes = fs::read(&active).map_err(|error| {
CplError::io(
"Could not inspect the active segment during recovery",
error,
)
})?;
if !active_bytes.is_empty() && !active_bytes.ends_with(b"\n") {
let complete_end = active_bytes
.iter()
.rposition(|byte| *byte == b'\n')
.map_or(0, |index| index + 1);
let complete_events = parse_complete_prefix(&active_bytes[..complete_end])?;
let readable_ids = ledger::sealed_segments(&paths)?
.into_iter()
.map(|path| ledger::read_segment(&path))
.collect::<CplResult<Vec<_>>>()?
.into_iter()
.flatten()
.chain(complete_events.iter().cloned())
.map(|event| event.event_id)
.collect::<Vec<_>>();
if head_before
.as_ref()
.is_some_and(|head| !readable_ids.iter().any(|id| id == &head.event_id))
{
let mut verification = verifier::verify_project(root, project_id)?;
verification.status = VerificationStatus::Failed;
return Ok(RecoveryReport {
classification: RecoveryClassification::IntegrityFailure,
actions: vec![
"Refused to truncate an active suffix referenced by the chain head.".to_owned(),
],
quarantined_paths,
verification,
});
}
let file = OpenOptions::new()
.write(true)
.open(&active)
.map_err(|error| CplError::io("Could not open the truncated active segment", error))?;
file.set_len(complete_end as u64)
.and_then(|_| file.sync_all())
.map_err(|error| {
CplError::io(
"Could not remove an uncommitted partial JSONL suffix",
error,
)
})?;
actions.push("Removed an incomplete active-segment suffix that was not referenced by the chain head.".to_owned());
repaired = true;
}
let events = ledger::read_all_events(&paths)?;
if let Err(message) = validate_event_chain(&events, project_id) {
let mut verification = verifier::verify_project(root, project_id)?;
verification.status = VerificationStatus::Failed;
verification.findings.push(super::VerificationFinding {
code: "CPL_RECOVERY_CHAIN_CONTRADICTION".to_owned(),
severity: "CRITICAL".to_owned(),
scope: "recovery".to_owned(),
message,
});
return Ok(RecoveryReport {
classification: RecoveryClassification::IntegrityFailure,
actions,
quarantined_paths,
verification,
});
}
match (head_before.as_ref(), events.last()) {
(Some(head), Some(last))
if head.event_id == last.event_id && head.event_sha256 == last.event_sha256 =>
{
let number =
ledger::locate_event_segment(&paths, &last.event_id)?.ok_or_else(|| {
CplError::new(
"CPL_EVENT_NOT_FOUND",
"The head event has no segment.",
false,
)
})?;
let active = ledger::active_segments(&paths)?.iter().any(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == ledger::segment_filename(number))
});
let expected = format!(
"provenance/ledger/{}/{}",
if active { "active" } else { "sealed" },
ledger::segment_filename(number)
);
if head.segment_number != number || head.segment_file != expected {
ledger::advance_chain_head(&paths, project_id, last)?;
actions.push(
"Rebound the chain head to the recovered segment location after rotation."
.to_owned(),
);
repaired = true;
}
}
(Some(head), Some(last))
if events.iter().any(|event| {
event.event_id == head.event_id && event.event_sha256 == head.event_sha256
}) =>
{
ledger::advance_chain_head(&paths, project_id, last)?;
actions.push(format!(
"Advanced the chain head from sequence {} to durable sequence {}.",
head.event_sequence, last.event_sequence
));
repaired = true;
}
(None, Some(last)) => {
ledger::advance_chain_head(&paths, project_id, last)?;
actions.push(format!(
"Reconstructed the missing chain head at sequence {}.",
last.event_sequence
));
repaired = true;
}
(Some(_), None) => {
let mut verification = verifier::verify_project(root, project_id)?;
verification.status = VerificationStatus::Failed;
return Ok(RecoveryReport {
classification: RecoveryClassification::IntegrityFailure,
actions: vec![
"The chain head is ahead of the readable ledger; no event was invented."
.to_owned(),
],
quarantined_paths,
verification,
});
}
(Some(_), Some(_)) => {
let mut verification = verifier::verify_project(root, project_id)?;
verification.status = VerificationStatus::Failed;
return Ok(RecoveryReport { classification: RecoveryClassification::IntegrityFailure, actions: vec!["The chain head does not occur in the readable ledger; automatic recovery was refused.".to_owned()], quarantined_paths, verification });
}
(None, None) => {}
}
let mut statement = database.prepare("SELECT intent_id,client_action_id,phase,record_paths_json FROM write_intents WHERE phase NOT IN ('COMPLETE','FAILED','QUARANTINED') ORDER BY intent_id")
.map_err(super::writer::database_error)?;
let intents = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(super::writer::database_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(super::writer::database_error)?;
drop(statement);
for (intent_id, client_action_id, phase, references_json) in intents {
if events
.iter()
.any(|event| event.client_action_id == client_action_id)
{
set_intent_phase(&database, &intent_id, "COMPLETE")?;
actions.push(format!(
"Replayed committed action {client_action_id} into SQLite."
));
repaired = true;
continue;
}
let references: Vec<super::records::RecordReference> =
serde_json::from_str(&references_json)
.map_err(|error| CplError::new("CPL_INTENT_INVALID", error.to_string(), false))?;
let quarantine = root.join(".app/recovery/orphans").join(&intent_id);
fs::create_dir_all(&quarantine)
.map_err(|error| CplError::io("Could not create the recovery quarantine", error))?;
let mut moved = false;
for reference in references {
let source = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
if source.exists() {
let destination =
unique_destination(&quarantine, source.file_name().unwrap_or_default());
fs::rename(&source, &destination).map_err(|error| {
CplError::io(
"Could not quarantine an uncommitted immutable record",
error,
)
})?;
quarantined_paths.push(relative_display(root, &destination));
moved = true;
}
}
let staging = root.join(".app/temp/staging").join(&intent_id);
if staging.exists() {
for entry in fs::read_dir(&staging)
.map_err(|error| CplError::io("Could not inspect abandoned CPL staging", error))?
.filter_map(Result::ok)
{
let destination = unique_destination(&quarantine, &entry.file_name());
fs::rename(entry.path(), &destination).map_err(|error| {
CplError::io("Could not quarantine abandoned CPL staging", error)
})?;
quarantined_paths.push(relative_display(root, &destination));
moved = true;
}
let _ = fs::remove_dir(&staging);
}
if moved {
set_intent_phase(&database, &intent_id, "QUARANTINED")?;
actions.push(format!(
"Quarantined uncommitted files for {client_action_id} from phase {phase}."
));
} else {
set_intent_phase(&database, &intent_id, "FAILED")?;
actions.push(format!(
"Closed abandoned intent {intent_id} with no durable authoritative files."
));
}
repaired = true;
}
rebuild_indexes(root, &events)?;
let verification = verifier::verify_project(root, project_id)?;
let classification = if matches!(
verification.status,
VerificationStatus::Failed | VerificationStatus::Unsafe
) {
RecoveryClassification::IntegrityFailure
} else if repaired {
RecoveryClassification::RecoverableAutomatically
} else {
RecoveryClassification::Clean
};
Ok(RecoveryReport {
classification,
actions,
quarantined_paths,
verification,
})
}
fn recover_database(
root: &Path,
actions: &mut Vec<String>,
quarantined_paths: &mut Vec<String>,
) -> CplResult<rusqlite::Connection> {
match init_database(root) {
Ok(database) => Ok(database),
Err(original) => {
let database_path = root.join(".app/state.sqlite");
if !database_path.exists() {
return Err(original);
}
let quarantine = root.join(".app/recovery/orphans").join(format!(
"sqlite-{}",
super::identifiers::sortable_id("recovery")?
));
fs::create_dir_all(&quarantine).map_err(|error| {
CplError::io("Could not create corrupt-SQLite quarantine", error)
})?;
for suffix in ["", "-wal", "-shm"] {
let source = PathBuf::from(format!("{}{}", database_path.display(), suffix));
if source.exists() {
let name = source.file_name().unwrap_or_default();
let destination = quarantine.join(name);
fs::rename(&source, &destination).map_err(|error| {
CplError::io("Could not quarantine corrupt SQLite state", error)
})?;
quarantined_paths.push(relative_display(root, &destination));
}
}
actions.push(format!("Quarantined unreadable SQLite state after {} and prepared a rebuild from the authoritative ledger.", original.message));
init_database(root)
}
}
}
fn parse_complete_prefix(bytes: &[u8]) -> CplResult<Vec<CplEvent>> {
bytes
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| {
serde_json::from_slice(line)
.map_err(|error| CplError::new("CPL_EVENT_INVALID", error.to_string(), false))
})
.collect()
}
fn validate_event_chain(events: &[CplEvent], project_id: &str) -> Result<(), String> {
let mut previous = None;
for (index, event) in events.iter().enumerate() {
if event.project_id != project_id
|| event.event_sequence != index as u64 + 1
|| event.previous_event_sha256 != previous
|| !event.verify_digest().map_err(|error| error.message)?
{
return Err(format!(
"Authoritative event {} contradicts the expected contiguous chain.",
event.event_id
));
}
previous = Some(event.event_sha256.clone());
}
Ok(())
}
fn unique_destination(directory: &Path, name: &std::ffi::OsStr) -> PathBuf {
let direct = directory.join(name);
if !direct.exists() {
return direct;
}
let stem = Path::new(name)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("orphan");
let extension = Path::new(name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or("bin");
(1..)
.map(|index| directory.join(format!("{stem}-{index}.{extension}")))
.find(|path| !path.exists())
.unwrap()
}
fn relative_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
+311
View File
@@ -0,0 +1,311 @@
use super::{
ledger::{self, LedgerConfig, LedgerPaths},
writer::{init_database, CplService, WriterConfig},
DurableBoundary, RecordInput, RecoveryClassification, VerificationStatus, WriteCommand,
};
use serde_json::json;
use std::sync::Arc;
fn command(project_id: &str, action: &str, value: i64) -> WriteCommand {
WriteCommand {
client_action_id: action.to_owned(),
project_id: project_id.to_owned(),
event_type: "TEST_ACTION".to_owned(),
actor: "system".to_owned(),
metadata: json!({"value": value}),
records: vec![RecordInput {
record_type: "test-record".to_owned(),
payload: json!({"value": value}),
}],
operational_state: Some(json!({"value": value})),
}
}
#[test]
fn retries_are_idempotent_and_conflicts_are_rejected() {
let temp = tempfile::tempdir().unwrap();
let service = CplService::new(temp.path(), "project_test");
let first = service
.write(command("project_test", "client_action_1", 1))
.unwrap();
let replay = service
.write(command("project_test", "client_action_1", 1))
.unwrap();
assert_eq!(first.event.event_id, replay.event.event_id);
assert!(replay.idempotent_replay);
assert_eq!(
ledger::read_all_events(&LedgerPaths::new(temp.path()))
.unwrap()
.len(),
1
);
let conflict = service
.write(command("project_test", "client_action_1", 2))
.unwrap_err();
assert_eq!(conflict.code, "CPL_IDEMPOTENCY_CONFLICT");
}
#[test]
fn os_writer_lock_serializes_concurrent_actions() {
let temp = tempfile::tempdir().unwrap();
let root = Arc::new(temp.path().to_path_buf());
std::thread::scope(|scope| {
for index in 0..8 {
let root = Arc::clone(&root);
scope.spawn(move || {
CplService::new(root.as_path(), "project_test")
.write(command(
"project_test",
&format!("client_action_{index}"),
index,
))
.unwrap();
});
}
});
let events = ledger::read_all_events(&LedgerPaths::new(temp.path())).unwrap();
assert_eq!(events.len(), 8);
assert_eq!(
events
.iter()
.map(|event| event.event_sequence)
.collect::<Vec<_>>(),
(1..=8).collect::<Vec<_>>()
);
assert_eq!(
CplService::new(temp.path(), "project_test")
.verify()
.unwrap()
.status,
VerificationStatus::Verified
);
}
#[test]
fn rotates_and_verifies_cross_segment_linkage() {
let temp = tempfile::tempdir().unwrap();
let service = CplService::with_config(
temp.path(),
"project_test",
WriterConfig {
ledger: LedgerConfig {
max_events_per_segment: 2,
max_bytes_per_segment: u64::MAX,
},
},
);
for index in 0..5 {
service
.write(command(
"project_test",
&format!("rotation_action_{index}"),
index,
))
.unwrap();
}
let paths = LedgerPaths::new(temp.path());
assert_eq!(ledger::sealed_segments(&paths).unwrap().len(), 2);
assert_eq!(ledger::active_segments(&paths).unwrap().len(), 1);
assert_eq!(
service.verify().unwrap().status,
VerificationStatus::Verified
);
}
#[test]
fn every_durable_boundary_recovers_and_retries_safely() {
let boundaries = [
DurableBoundary::IntentPrepared,
DurableBoundary::FirstRecordStaged,
DurableBoundary::RecordFlushed,
DurableBoundary::RecordMoved,
DurableBoundary::RecordDirectorySynced,
DurableBoundary::LedgerAppendBeforeFlush,
DurableBoundary::LedgerFlushed,
DurableBoundary::ChainHeadTemporaryWritten,
DurableBoundary::ChainHeadReplaced,
DurableBoundary::ChainHeadDirectorySynced,
DurableBoundary::SqliteApplied,
DurableBoundary::Complete,
];
for boundary in boundaries {
let temp = tempfile::tempdir().unwrap();
let service = CplService::new(temp.path(), "project_test");
let action = format!("fault_{boundary:?}");
let failure = service
.write_with_failure(command("project_test", &action, 7), boundary)
.unwrap_err();
assert_eq!(failure.code, "CPL_INJECTED_FAILURE", "{boundary:?}");
let recovery = service.recover().unwrap();
assert!(
!matches!(
recovery.classification,
RecoveryClassification::IntegrityFailure
),
"{boundary:?}: {:?}",
recovery.actions
);
let result = service.write(command("project_test", &action, 7)).unwrap();
assert_eq!(result.event.event_sequence, 1, "{boundary:?}");
let report = service.verify().unwrap();
assert_eq!(
report.status,
VerificationStatus::Verified,
"{boundary:?}: {:?}",
report.findings
);
assert_eq!(report.event_count, 1, "{boundary:?}");
}
}
#[test]
fn recovery_rebuilds_sqlite_from_authoritative_events() {
let temp = tempfile::tempdir().unwrap();
let service = CplService::new(temp.path(), "project_test");
service
.write(command("project_test", "rebuild_action", 4))
.unwrap();
let database = init_database(temp.path()).unwrap();
database.execute("DELETE FROM cpl_events", []).unwrap();
database.execute("DELETE FROM cpl_records", []).unwrap();
database
.execute("DELETE FROM cpl_action_receipts", [])
.unwrap();
drop(database);
let recovery = service.recover().unwrap();
assert!(!matches!(
recovery.classification,
RecoveryClassification::IntegrityFailure
));
assert_eq!(
service
.write(command("project_test", "rebuild_action", 4))
.unwrap()
.event
.event_sequence,
1
);
assert_eq!(
service.verify().unwrap().status,
VerificationStatus::Verified
);
}
#[test]
fn every_segment_rotation_boundary_recovers_without_sequence_loss() {
let boundaries = [
DurableBoundary::SegmentFlushed,
DurableBoundary::SegmentManifestFlushed,
DurableBoundary::SegmentMoved,
DurableBoundary::SegmentManifestMoved,
DurableBoundary::NewActiveSegmentCreated,
];
for boundary in boundaries {
let temp = tempfile::tempdir().unwrap();
let service = CplService::with_config(
temp.path(),
"project_test",
WriterConfig {
ledger: LedgerConfig {
max_events_per_segment: 1,
max_bytes_per_segment: u64::MAX,
},
},
);
service
.write(command("project_test", "rotation_seed", 1))
.unwrap();
let failure = service
.write_with_failure(command("project_test", "rotation_retry", 2), boundary)
.unwrap_err();
assert_eq!(failure.code, "CPL_INJECTED_FAILURE", "{boundary:?}");
let recovery = service.recover().unwrap();
assert!(
!matches!(
recovery.classification,
RecoveryClassification::IntegrityFailure
),
"{boundary:?}: {:?}",
recovery.actions
);
service
.write(command("project_test", "rotation_retry", 2))
.unwrap();
let events = ledger::read_all_events(&LedgerPaths::new(temp.path())).unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event_sequence)
.collect::<Vec<_>>(),
vec![1, 2],
"{boundary:?}"
);
assert_eq!(
service.verify().unwrap().status,
VerificationStatus::Verified,
"{boundary:?}"
);
}
}
#[test]
fn corrupt_sqlite_is_quarantined_and_rebuilt_from_canonical_records() {
let temp = tempfile::tempdir().unwrap();
let service = CplService::new(temp.path(), "project_test");
let mut rebuild_command = command("project_test", "sqlite_rebuild", 9);
rebuild_command.records[0].record_type = "application-state-snapshot".to_owned();
service.write(rebuild_command).unwrap();
std::fs::write(
temp.path().join(".app/state.sqlite"),
b"not a sqlite database",
)
.unwrap();
let recovery = service.recover().unwrap();
assert_eq!(
recovery.classification,
RecoveryClassification::RecoverableAutomatically
);
assert!(recovery
.actions
.iter()
.any(|action| action.contains("Quarantined unreadable SQLite")));
assert!(!recovery.quarantined_paths.is_empty());
let mut replay_command = command("project_test", "sqlite_rebuild", 9);
replay_command.records[0].record_type = "application-state-snapshot".to_owned();
let replay = service.write(replay_command).unwrap();
assert!(replay.idempotent_replay);
assert!(!replay.intent_id.is_empty());
let database = init_database(temp.path()).unwrap();
let state: String = database
.query_row("SELECT json FROM project_state WHERE id=1", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&state).unwrap(),
json!({"value": 9})
);
assert_eq!(
service.verify().unwrap().status,
VerificationStatus::Verified
);
}
#[test]
fn stale_lock_file_artifact_does_not_block_the_os_managed_lock() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp.path().join(".app")).unwrap();
std::fs::write(
temp.path().join(".app/cpl.writer.lock"),
b"stale process metadata",
)
.unwrap();
let service = CplService::new(temp.path(), "project_test");
service
.write(command("project_test", "stale_lock_action", 1))
.unwrap();
assert_eq!(
service.verify().unwrap().status,
VerificationStatus::Verified
);
}
+438
View File
@@ -0,0 +1,438 @@
use super::{
assertions,
canonical::{canonicalize, sha256_digest},
identifiers::{sortable_id, timestamp_millis},
ledger::{self, LedgerPaths},
records::{
CplRecord, SegmentManifest, VerificationFinding, VerificationReport, VerificationStatus,
},
writer::init_database,
CplError, CplResult,
};
use rusqlite::OptionalExtension;
use std::{
collections::HashSet,
fs,
path::{Component, Path},
};
fn finding(
code: &str,
severity: &str,
scope: impl Into<String>,
message: impl Into<String>,
) -> VerificationFinding {
VerificationFinding {
code: code.to_owned(),
severity: severity.to_owned(),
scope: scope.into(),
message: message.into(),
}
}
fn validate_relative_path(path: &str) -> bool {
if path.is_empty()
|| path.contains('\\')
|| path.contains(':')
|| path.chars().any(char::is_control)
{
return false;
}
Path::new(path)
.components()
.all(|component| matches!(component, Component::Normal(_)))
}
pub fn verify_project(root: &Path, project_id: &str) -> CplResult<VerificationReport> {
let report_id = sortable_id("report")?;
let verified_at = timestamp_millis();
let mut report = VerificationReport::empty(project_id, report_id, verified_at);
let paths = LedgerPaths::new(root);
if !paths.root.exists() {
report.findings.push(finding(
"CPL_LEDGER_MISSING",
"ERROR",
"ledger",
"The native CPL ledger has not been initialized.",
));
return Ok(report);
}
let mut errors = 0usize;
let mut warnings = 0usize;
let sealed = ledger::sealed_segments(&paths)?;
let mut previous_segment_digest = None;
for segment_path in &sealed {
let number = segment_path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix("segment-"))
.and_then(|name| name.strip_suffix(".jsonl"))
.and_then(|name| name.parse::<u64>().ok())
.ok_or_else(|| {
CplError::new(
"CPL_SEGMENT_NAME_INVALID",
"A sealed segment filename is invalid.",
false,
)
})?;
let manifest_path = paths.sealed.join(ledger::segment_manifest_filename(number));
let bytes = match fs::read(segment_path) {
Ok(bytes) => bytes,
Err(error) => {
errors += 1;
report.findings.push(finding(
"CPL_SEALED_SEGMENT_UNREADABLE",
"CRITICAL",
segment_path.display().to_string(),
error.to_string(),
));
continue;
}
};
let manifest_bytes = match fs::read(&manifest_path) {
Ok(bytes) => bytes,
Err(error) => {
errors += 1;
report.findings.push(finding(
"CPL_SEGMENT_MANIFEST_INVALID",
"CRITICAL",
manifest_path.display().to_string(),
error.to_string(),
));
continue;
}
};
let manifest: SegmentManifest = match serde_json::from_slice(&manifest_bytes) {
Ok(manifest) => manifest,
Err(error) => {
errors += 1;
report.findings.push(finding(
"CPL_SEGMENT_MANIFEST_INVALID",
"CRITICAL",
manifest_path.display().to_string(),
error.to_string(),
));
continue;
}
};
if canonicalize(&serde_json::to_value(&manifest).map_err(|error| {
CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false)
})?)?
!= manifest_bytes
{
errors += 1;
report.findings.push(finding(
"CPL_SEGMENT_MANIFEST_NONCANONICAL",
"CRITICAL",
manifest_path.display().to_string(),
"The sealed segment manifest is not canonical JSON.",
));
}
let events = match ledger::read_segment(segment_path) {
Ok(events) => events,
Err(error) => {
errors += 1;
report.findings.push(finding(
&error.code,
"CRITICAL",
segment_path.display().to_string(),
error.message,
));
continue;
}
};
let manifest_valid = !events.is_empty()
&& manifest.segment_number == number
&& manifest.previous_segment_file_sha256 == previous_segment_digest
&& manifest.first_event_sha256 == events.first().unwrap().event_sha256
&& manifest.final_event_sha256 == events.last().unwrap().event_sha256
&& manifest.first_event_sequence == events.first().unwrap().event_sequence
&& manifest.final_event_sequence == events.last().unwrap().event_sequence
&& manifest.event_count == events.len() as u64
&& manifest.byte_length == bytes.len() as u64
&& manifest.segment_file_sha256 == sha256_digest(&bytes);
if !manifest_valid {
errors += 1;
report.findings.push(finding(
"CPL_SEGMENT_MANIFEST_MISMATCH",
"CRITICAL",
manifest_path.display().to_string(),
"The sealed manifest does not bind the exact segment bytes and event range.",
));
}
previous_segment_digest = Some(sha256_digest(&bytes));
}
let events = match ledger::read_all_events(&paths) {
Ok(events) => events,
Err(error) => {
report.status = VerificationStatus::Failed;
report
.findings
.push(finding(&error.code, "CRITICAL", "ledger", error.message));
return Ok(report);
}
};
report.event_count = events.len() as u64;
let mut previous = None;
let mut record_count = 0u64;
let mut event_ids = HashSet::new();
let mut action_ids = HashSet::new();
let mut record_ids = HashSet::new();
for (index, event) in events.iter().enumerate() {
if !event_ids.insert(&event.event_id) || !action_ids.insert(&event.client_action_id) {
errors += 1;
report.findings.push(finding(
"CPL_EVENT_ID_DUPLICATE",
"CRITICAL",
event.event_id.clone(),
"Event IDs and client action IDs must each be unique across the ledger.",
));
}
let expected_sequence = index as u64 + 1;
if event.project_id != project_id
|| event.event_sequence != expected_sequence
|| event.previous_event_sha256 != previous
|| !event.verify_digest()?
{
errors += 1;
report.findings.push(finding("CPL_EVENT_CHAIN_INVALID", "CRITICAL", event.event_id.clone(), format!("Event sequence {} failed project, sequence, previous-link, or digest verification.", event.event_sequence)));
}
let parsed = chrono::DateTime::parse_from_rfc3339(&event.timestamp).ok();
let timestamp_valid = parsed
.map(|value| {
value.offset().local_minus_utc() == 0
&& value.timestamp_subsec_millis() * 1_000_000 == value.timestamp_subsec_nanos()
&& event.timestamp.ends_with('Z')
&& event.timestamp.len() == 24
})
.unwrap_or(false);
if !timestamp_valid {
errors += 1;
report.findings.push(finding(
"CPL_TIMESTAMP_INVALID",
"ERROR",
event.event_id.clone(),
"The event timestamp is not exact RFC 3339 UTC millisecond form.",
));
}
for reference in &event.record_references {
if !record_ids.insert(&reference.record_id) {
errors += 1;
report.findings.push(finding(
"CPL_RECORD_ID_DUPLICATE",
"CRITICAL",
reference.record_id.clone(),
"An immutable record ID is referenced more than once.",
));
}
record_count += 1;
if !validate_relative_path(&reference.path) {
errors += 1;
report.findings.push(finding(
"CPL_RECORD_PATH_UNSAFE",
"CRITICAL",
reference.record_id.clone(),
"The record path is not a safe project-relative forward-slash path.",
));
continue;
}
let path = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(error) => {
errors += 1;
report.findings.push(finding(
"CPL_RECORD_MISSING",
"CRITICAL",
reference.record_id.clone(),
error.to_string(),
));
continue;
}
};
let record: CplRecord = match serde_json::from_slice(&bytes) {
Ok(record) => record,
Err(error) => {
errors += 1;
report.findings.push(finding(
"CPL_RECORD_INVALID",
"CRITICAL",
reference.record_id.clone(),
error.to_string(),
));
continue;
}
};
let canonical = canonicalize(&serde_json::to_value(&record).map_err(|error| {
CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false)
})?)?;
if canonical != bytes
|| record.record_id != reference.record_id
|| record.record_type != reference.record_type
|| record.record_sha256 != reference.record_sha256
|| !record.verify_digest()?
{
errors += 1;
report.findings.push(finding("CPL_RECORD_DIGEST_INVALID", "CRITICAL", reference.record_id.clone(), "The immutable record bytes, identity, type, or digest do not match the event reference."));
}
if let Err(message) = assertions::validate_record(&record) {
errors += 1;
report.findings.push(finding(
"CPL_ASSERTION_INVALID",
"ERROR",
reference.record_id.clone(),
message,
));
}
}
previous = Some(event.event_sha256.clone());
}
report.record_count = record_count;
let head = ledger::read_chain_head(&paths)?;
report.chain_head = head.clone();
let expected_head_location = if let Some(last) = events.last() {
let number = ledger::locate_event_segment(&paths, &last.event_id)?;
if let Some(number) = number {
let active = ledger::active_segments(&paths)?.iter().any(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == ledger::segment_filename(number))
});
Some((
number,
format!(
"provenance/ledger/{}/{}",
if active { "active" } else { "sealed" },
ledger::segment_filename(number)
),
))
} else {
None
}
} else {
None
};
match (events.last(), head.as_ref()) {
(Some(last), Some(head))
if head.project_id == project_id
&& head.event_id == last.event_id
&& head.event_sequence == last.event_sequence
&& head.event_sha256 == last.event_sha256
&& expected_head_location
.as_ref()
.is_some_and(|(number, path)| {
head.segment_number == *number && head.segment_file == *path
}) => {}
(None, None) => report.findings.push(finding(
"CPL_LEDGER_EMPTY",
"WARNING",
"ledger",
"The ledger is initialized but contains no events.",
)),
_ => {
errors += 1;
report.findings.push(finding(
"CPL_CHAIN_HEAD_MISMATCH",
"CRITICAL",
"chain-head",
"The chain head does not bind the final readable event.",
));
}
}
if let Ok(database) = init_database(root) {
let indexed: Option<u64> = database
.query_row("SELECT MAX(event_sequence) FROM cpl_events", [], |row| {
row.get(0)
})
.optional()
.unwrap_or(None)
.flatten();
let authoritative = events.last().map(|event| event.event_sequence);
if indexed != authoritative {
warnings += 1;
report.findings.push(finding(
"CPL_DERIVED_INDEX_STALE",
"WARNING",
"sqlite",
"The rebuildable SQLite event index is stale.",
));
}
} else {
warnings += 1;
report.findings.push(finding(
"CPL_DERIVED_INDEX_UNAVAILABLE",
"WARNING",
"sqlite",
"The rebuildable SQLite index could not be inspected.",
));
}
report.status = if errors > 0 {
VerificationStatus::Failed
} else if events.is_empty() {
VerificationStatus::Incomplete
} else if warnings > 0 {
VerificationStatus::VerifiedWithWarnings
} else {
VerificationStatus::Verified
};
if errors == 0 && !events.is_empty() {
report.findings.push(finding("CPL_INTEGRITY_VERIFIED", "INFO", "ledger", format!("Verified {} contiguous events and {} immutable record references against the retained chain head.", events.len(), record_count)));
}
Ok(report)
}
/// Enforces the release boundary against the authoritative native report.
/// Warnings remain visible but do not make an otherwise verified chain unsafe
/// to release. Every other terminal status blocks before release state changes.
pub fn require_release_verification(report: &VerificationReport) -> CplResult<()> {
match report.status {
VerificationStatus::Verified | VerificationStatus::VerifiedWithWarnings => Ok(()),
VerificationStatus::Incomplete
| VerificationStatus::Failed
| VerificationStatus::Unsafe => Err(CplError::new(
"RELEASE_VERIFICATION_BLOCKED",
format!(
"Native CPL verification status {:?} does not permit release finalization.",
report.status
),
true,
)),
}
}
#[cfg(test)]
mod release_gate_tests {
use super::*;
fn report(status: VerificationStatus) -> VerificationReport {
let mut report = VerificationReport::empty(
"project_test",
"report_test".into(),
"2026-07-19T00:00:00.000Z".into(),
);
report.status = status;
report
}
#[test]
fn release_gate_accepts_only_complete_safe_native_verification() {
for status in [
VerificationStatus::Verified,
VerificationStatus::VerifiedWithWarnings,
] {
require_release_verification(&report(status)).unwrap();
}
for status in [
VerificationStatus::Incomplete,
VerificationStatus::Failed,
VerificationStatus::Unsafe,
] {
let error = require_release_verification(&report(status)).unwrap_err();
assert_eq!(error.code, "RELEASE_VERIFICATION_BLOCKED");
}
}
}
+678
View File
@@ -0,0 +1,678 @@
use super::{
canonical::{canonical_digest, canonicalize},
identifiers::{sortable_id, timestamp_millis},
ledger::{self, atomic_replace, sync_directory, LedgerConfig, LedgerPaths},
records::{
CplEvent, CplRecord, RecordReference, VerificationReport, WriteCommand, WriteResult,
},
recovery, verifier, CplError, CplResult, DurableBoundary, CPL_SCHEMA_VERSION,
};
use rusqlite::{params, Connection, OptionalExtension};
use std::{
fs::{self, File, OpenOptions},
io::Write,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy)]
pub struct WriterConfig {
pub ledger: LedgerConfig,
}
impl Default for WriterConfig {
fn default() -> Self {
Self {
ledger: LedgerConfig::default(),
}
}
}
pub struct CplService {
root: PathBuf,
project_id: String,
config: WriterConfig,
}
impl CplService {
pub fn new(root: impl Into<PathBuf>, project_id: impl Into<String>) -> Self {
Self {
root: root.into(),
project_id: project_id.into(),
config: WriterConfig::default(),
}
}
#[cfg(test)]
pub fn with_config(
root: impl Into<PathBuf>,
project_id: impl Into<String>,
config: WriterConfig,
) -> Self {
Self {
root: root.into(),
project_id: project_id.into(),
config,
}
}
pub fn initialize(&self) -> CplResult<()> {
LedgerPaths::new(&self.root).initialize()?;
fs::create_dir_all(self.root.join("records"))
.and_then(|_| fs::create_dir_all(self.root.join(".app/recovery/orphans")))
.and_then(|_| fs::create_dir_all(self.root.join(".app/temp/staging")))
.map_err(|error| CplError::io("Could not initialize native CPL storage", error))?;
Ok(())
}
pub fn write(&self, command: WriteCommand) -> CplResult<WriteResult> {
self.write_with_boundary(command, &mut |_| Ok(()))
}
pub fn verify(&self) -> CplResult<VerificationReport> {
verifier::verify_project(&self.root, &self.project_id)
}
pub fn recover(&self) -> CplResult<super::RecoveryReport> {
recovery::recover_project(&self.root, &self.project_id)
}
fn write_with_boundary(
&self,
command: WriteCommand,
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
) -> CplResult<WriteResult> {
let _lock = ProjectWriterLock::acquire(&self.root)?;
self.initialize()?;
recovery::recover_locked(&self.root, &self.project_id)?;
self.write_locked(command, boundary)
}
pub(crate) fn write_prepared(
&self,
client_action_id: &str,
prepare: impl FnOnce() -> CplResult<Option<WriteCommand>>,
) -> CplResult<WriteResult> {
let _lock = ProjectWriterLock::acquire(&self.root)?;
self.initialize()?;
recovery::recover_locked(&self.root, &self.project_id)?;
if let Some(command) = prepare()? {
return self.write_locked(command, &mut |_| Ok(()));
}
let database = init_database(&self.root)?;
let result_json = database
.query_row(
"SELECT result_json FROM cpl_action_receipts WHERE client_action_id=?1",
params![client_action_id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(database_error)?
.ok_or_else(|| {
CplError::new(
"CPL_RECEIPT_MISSING",
"The committed composition action has no recoverable receipt.",
false,
)
})?;
let mut result: WriteResult = serde_json::from_str(&result_json)
.map_err(|error| CplError::new("CPL_RECEIPT_INVALID", error.to_string(), false))?;
result.idempotent_replay = true;
Ok(result)
}
fn write_locked(
&self,
command: WriteCommand,
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
) -> CplResult<WriteResult> {
validate_command(&command, &self.project_id)?;
let command_sha256 = command.digest()?;
let mut database = init_database(&self.root)?;
if let Some((stored_digest, stored_result)) = database
.query_row(
"SELECT command_sha256, result_json FROM cpl_action_receipts WHERE client_action_id=?1",
params![command.client_action_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()
.map_err(database_error)?
{
if stored_digest != command_sha256 {
return Err(CplError::new(
"CPL_IDEMPOTENCY_CONFLICT",
"The client_action_id was already committed with a different canonical command.",
false,
));
}
let mut result: WriteResult = serde_json::from_str(&stored_result).map_err(|error| {
CplError::new("CPL_RECEIPT_INVALID", error.to_string(), false)
})?;
result.idempotent_replay = true;
return Ok(result);
}
let intent_id = sortable_id("intent")?;
let event_id = sortable_id("event")?;
let created_at = timestamp_millis();
let (records, references) = prepare_records(&command, &intent_id, &created_at)?;
let paths_json = serde_json::to_string(&references)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
database
.execute(
"INSERT INTO write_intents(intent_id,client_action_id,command_sha256,phase,record_paths_json,event_id,updated_at) VALUES(?1,?2,?3,'PREPARED',?4,?5,?6)",
params![intent_id, command.client_action_id, command_sha256, paths_json, event_id, created_at],
)
.map_err(database_error)?;
boundary(DurableBoundary::IntentPrepared)?;
persist_records(&self.root, &intent_id, &records, &references, boundary)?;
set_intent_phase(&database, &intent_id, "RECORDS_DURABLE")?;
let event = CplEvent {
schema_version: CPL_SCHEMA_VERSION.to_owned(),
event_id,
project_id: command.project_id.clone(),
event_sequence: 0,
timestamp: created_at,
event_type: command.event_type.clone(),
actor: command.actor.clone(),
client_action_id: command.client_action_id.clone(),
command_sha256: command_sha256.clone(),
record_references: references.clone(),
metadata: command.metadata.clone(),
previous_event_sha256: None,
event_sha256: String::new(),
};
let (event, _) = ledger::append_event(
&LedgerPaths::new(&self.root),
self.config.ledger,
event,
boundary,
)?;
set_intent_phase(&database, &intent_id, "LEDGER_APPENDED")?;
set_intent_phase(&database, &intent_id, "CHAIN_HEAD_ADVANCED")?;
let result = WriteResult {
idempotent_replay: false,
intent_id: intent_id.clone(),
event: event.clone(),
records: references.clone(),
};
apply_sqlite_state(
&mut database,
&command,
&command_sha256,
&intent_id,
&event,
&result,
)?;
boundary(DurableBoundary::SqliteApplied)?;
set_intent_phase(&database, &intent_id, "COMPLETE")?;
boundary(DurableBoundary::Complete)?;
let staging = self.root.join(".app/temp/staging").join(&intent_id);
if staging.exists() {
let _ = fs::remove_dir(&staging);
}
Ok(result)
}
#[cfg(test)]
pub fn write_with_failure(
&self,
command: WriteCommand,
failure: DurableBoundary,
) -> CplResult<WriteResult> {
let mut fired = false;
self.write_with_boundary(command, &mut |boundary| {
if !fired && boundary == failure {
fired = true;
Err(super::injected_failure(boundary))
} else {
Ok(())
}
})
}
}
fn validate_command(command: &WriteCommand, project_id: &str) -> CplResult<()> {
if command.project_id != project_id {
return Err(CplError::new(
"CPL_PROJECT_MISMATCH",
"The command targets another project.",
false,
));
}
for (label, value) in [
("client_action_id", command.client_action_id.as_str()),
("event_type", command.event_type.as_str()),
("actor", command.actor.as_str()),
] {
if value.trim().is_empty() || value.chars().any(char::is_control) {
return Err(CplError::new(
"CPL_COMMAND_INVALID",
format!("{label} is empty or contains control characters."),
false,
));
}
}
if command.records.is_empty() {
return Err(CplError::new(
"CPL_RECORD_REQUIRED",
"Every provenance mutation requires at least one immutable authoritative record.",
false,
));
}
for record in &command.records {
if !safe_component(&record.record_type) {
return Err(CplError::new(
"CPL_RECORD_TYPE_INVALID",
format!("Unsafe record type '{}'.", record.record_type),
false,
));
}
}
Ok(())
}
fn safe_component(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 80
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
})
}
fn prepare_records(
command: &WriteCommand,
intent_id: &str,
created_at: &str,
) -> CplResult<(Vec<CplRecord>, Vec<RecordReference>)> {
let mut records = Vec::with_capacity(command.records.len());
let mut references = Vec::with_capacity(command.records.len());
for input in &command.records {
let record_id = sortable_id("record")?;
let mut record = CplRecord {
schema_version: CPL_SCHEMA_VERSION.to_owned(),
record_id: record_id.clone(),
record_type: input.record_type.clone(),
project_id: command.project_id.clone(),
intent_id: intent_id.to_owned(),
client_action_id: command.client_action_id.clone(),
created_at: created_at.to_owned(),
payload: input.payload.clone(),
record_sha256: String::new(),
};
record.record_sha256 = canonical_digest(&record.identity())?;
references.push(RecordReference {
record_id,
record_type: input.record_type.clone(),
path: format!("records/{}/{}.json", input.record_type, record.record_id),
record_sha256: record.record_sha256.clone(),
});
records.push(record);
}
Ok((records, references))
}
fn persist_records(
root: &Path,
intent_id: &str,
records: &[CplRecord],
references: &[RecordReference],
boundary: &mut dyn FnMut(DurableBoundary) -> CplResult<()>,
) -> CplResult<()> {
let staging = root.join(".app/temp/staging").join(intent_id);
fs::create_dir_all(&staging)
.map_err(|error| CplError::io("Could not create same-filesystem CPL staging", error))?;
for (index, (record, reference)) in records.iter().zip(references).enumerate() {
let stage = staging.join(format!("{}.json", record.record_id));
let bytes = canonicalize(&serde_json::to_value(record).map_err(|error| {
CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false)
})?)?;
let mut file = File::create(&stage)
.map_err(|error| CplError::io("Could not stage an immutable CPL record", error))?;
file.write_all(&bytes)
.map_err(|error| CplError::io("Could not write a staged CPL record", error))?;
if index == 0 {
boundary(DurableBoundary::FirstRecordStaged)?;
}
file.sync_all()
.map_err(|error| CplError::io("Could not flush a staged CPL record", error))?;
boundary(DurableBoundary::RecordFlushed)?;
drop(file);
let final_path = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
let parent = final_path.parent().ok_or_else(|| {
CplError::new("CPL_PATH_INVALID", "Record path has no parent.", false)
})?;
fs::create_dir_all(parent).map_err(|error| {
CplError::io("Could not create the immutable record directory", error)
})?;
if final_path.exists() {
return Err(CplError::new(
"CPL_RECORD_COLLISION",
format!("{} already exists.", final_path.display()),
false,
));
}
atomic_replace(&stage, &final_path)?;
boundary(DurableBoundary::RecordMoved)?;
sync_directory(parent)?;
boundary(DurableBoundary::RecordDirectorySynced)?;
}
Ok(())
}
fn apply_sqlite_state(
database: &mut Connection,
command: &WriteCommand,
command_sha256: &str,
intent_id: &str,
event: &CplEvent,
result: &WriteResult,
) -> CplResult<()> {
let transaction = database.transaction().map_err(database_error)?;
if let Some(state) = &command.operational_state {
let state = serde_json::to_string(state)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
transaction.execute(
"INSERT INTO project_state(id,json,updated_at) VALUES(1,?1,?2) ON CONFLICT(id) DO UPDATE SET json=excluded.json,updated_at=excluded.updated_at",
params![state, event.timestamp],
).map_err(database_error)?;
}
index_event(&transaction, event)?;
let result_json = serde_json::to_string(result)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
transaction.execute(
"INSERT OR REPLACE INTO cpl_action_receipts(client_action_id,command_sha256,event_id,result_json,committed_at) VALUES(?1,?2,?3,?4,?5)",
params![command.client_action_id, command_sha256, event.event_id, result_json, event.timestamp],
).map_err(database_error)?;
transaction.execute("UPDATE write_intents SET phase='SQLITE_APPLIED',result_json=?2,updated_at=?3 WHERE intent_id=?1", params![intent_id, result_json, event.timestamp]).map_err(database_error)?;
transaction.commit().map_err(database_error)
}
pub(crate) fn init_database(root: &Path) -> CplResult<Connection> {
let state = root.join(".app");
fs::create_dir_all(&state)
.map_err(|error| CplError::io("Could not create CPL SQLite storage", error))?;
let database = Connection::open(state.join("state.sqlite")).map_err(database_error)?;
database.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS project_state (id INTEGER PRIMARY KEY CHECK(id=1), json TEXT NOT NULL, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS write_intents (intent_id TEXT PRIMARY KEY, client_action_id TEXT NOT NULL, command_sha256 TEXT NOT NULL, phase TEXT NOT NULL CHECK(phase IN ('PREPARED','RECORDS_DURABLE','LEDGER_APPENDED','CHAIN_HEAD_ADVANCED','SQLITE_APPLIED','COMPLETE','QUARANTINED','FAILED')), record_paths_json TEXT NOT NULL, event_id TEXT NOT NULL, result_json TEXT, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS cpl_events (event_sequence INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, client_action_id TEXT NOT NULL UNIQUE, event_type TEXT NOT NULL, event_sha256 TEXT NOT NULL, previous_event_sha256 TEXT, timestamp TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS cpl_records (record_id TEXT PRIMARY KEY, record_type TEXT NOT NULL, path TEXT NOT NULL UNIQUE, record_sha256 TEXT NOT NULL, event_id TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS cpl_action_receipts (client_action_id TEXT PRIMARY KEY, command_sha256 TEXT NOT NULL, event_id TEXT NOT NULL, result_json TEXT NOT NULL, committed_at TEXT NOT NULL);",
).map_err(database_error)?;
Ok(database)
}
pub(crate) fn index_event(database: &Connection, event: &CplEvent) -> CplResult<()> {
database.execute(
"INSERT OR REPLACE INTO cpl_events(event_sequence,event_id,client_action_id,event_type,event_sha256,previous_event_sha256,timestamp) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![event.event_sequence, event.event_id, event.client_action_id, event.event_type, event.event_sha256, event.previous_event_sha256, event.timestamp],
).map_err(database_error)?;
for record in &event.record_references {
database.execute(
"INSERT OR REPLACE INTO cpl_records(record_id,record_type,path,record_sha256,event_id) VALUES(?1,?2,?3,?4,?5)",
params![record.record_id, record.record_type, record.path, record.record_sha256, event.event_id],
).map_err(database_error)?;
}
Ok(())
}
pub(crate) fn rebuild_indexes(root: &Path, events: &[CplEvent]) -> CplResult<()> {
let phase1_project_id = events.iter().find_map(|event| {
event
.record_references
.iter()
.any(|record| record.record_type == "phase1-operation")
.then(|| event.project_id.clone())
});
let composition_project_id = events.iter().find_map(|event| {
event
.record_references
.iter()
.any(|record| record.record_type == "composition-command")
.then(|| event.project_id.clone())
});
let mut database = init_database(root)?;
let transaction = database.transaction().map_err(database_error)?;
transaction
.execute("DELETE FROM cpl_records", [])
.map_err(database_error)?;
transaction
.execute("DELETE FROM cpl_events", [])
.map_err(database_error)?;
transaction
.execute("DELETE FROM cpl_action_receipts", [])
.map_err(database_error)?;
let mut latest_operational_state = None;
for event in events {
index_event(&transaction, event)?;
for reference in &event.record_references {
if reference.record_type == "application-state-snapshot" {
let path = root.join(reference.path.replace('/', std::path::MAIN_SEPARATOR_STR));
let record: CplRecord =
serde_json::from_slice(&fs::read(&path).map_err(|error| {
CplError::io(
"Could not rebuild operational state from its canonical record",
error,
)
})?)
.map_err(|error| {
CplError::new("CPL_RECORD_INVALID", error.to_string(), false)
})?;
latest_operational_state = Some((record.payload, event.timestamp.clone()));
}
}
let first_record = event.record_references.first().ok_or_else(|| {
CplError::new(
"CPL_RECORD_REQUIRED",
"Committed event has no authoritative record reference.",
false,
)
})?;
let first_path = root.join(
first_record
.path
.replace('/', std::path::MAIN_SEPARATOR_STR),
);
let first_record: CplRecord =
serde_json::from_slice(&fs::read(&first_path).map_err(|error| {
CplError::io("Could not reconstruct the committed intent identity", error)
})?)
.map_err(|error| CplError::new("CPL_RECORD_INVALID", error.to_string(), false))?;
let result = WriteResult {
idempotent_replay: false,
intent_id: first_record.intent_id,
event: event.clone(),
records: event.record_references.clone(),
};
let result_json = serde_json::to_string(&result)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
transaction.execute(
"INSERT INTO cpl_action_receipts(client_action_id,command_sha256,event_id,result_json,committed_at) VALUES(?1,?2,?3,?4,?5)",
params![event.client_action_id, event.command_sha256, event.event_id, result_json, event.timestamp],
).map_err(database_error)?;
}
if let Some((state, updated_at)) = latest_operational_state {
let state = serde_json::to_string(&state)
.map_err(|error| CplError::new("CPL_SERIALIZATION_FAILED", error.to_string(), false))?;
transaction.execute(
"INSERT INTO project_state(id,json,updated_at) VALUES(1,?1,?2) ON CONFLICT(id) DO UPDATE SET json=excluded.json,updated_at=excluded.updated_at",
params![state, updated_at],
).map_err(database_error)?;
}
transaction.commit().map_err(database_error)?;
if let Some(project_id) = phase1_project_id {
super::phase1::rebuild_projection_cache(root, &project_id, events)?;
}
if let Some(project_id) = composition_project_id {
super::composition::rebuild_projection_cache(root, &project_id, events)?;
}
Ok(())
}
pub(crate) fn set_intent_phase(
database: &Connection,
intent_id: &str,
phase: &str,
) -> CplResult<()> {
database
.execute(
"UPDATE write_intents SET phase=?2,updated_at=?3 WHERE intent_id=?1",
params![intent_id, phase, timestamp_millis()],
)
.map_err(database_error)?;
Ok(())
}
pub(crate) fn database_error(error: rusqlite::Error) -> CplError {
CplError::new("CPL_DATABASE_ERROR", error.to_string(), true)
}
pub(crate) struct ProjectWriterLock {
file: File,
}
impl ProjectWriterLock {
pub(crate) fn acquire(root: &Path) -> CplResult<Self> {
let app = root.join(".app/locks");
fs::create_dir_all(&app)
.map_err(|error| CplError::io("Could not create the CPL lock directory", error))?;
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(app.join("cpl.writer.lock"))
.map_err(|error| CplError::io("Could not open the CPL writer lock", error))?;
lock_file(&file)?;
Ok(Self { file })
}
}
impl Drop for ProjectWriterLock {
fn drop(&mut self) {
let _ = unlock_file(&self.file);
}
}
#[cfg(target_os = "windows")]
fn lock_file(file: &File) -> CplResult<()> {
use std::os::windows::io::AsRawHandle;
#[repr(C)]
struct Overlapped {
internal: usize,
internal_high: usize,
offset: u32,
offset_high: u32,
event: *mut std::ffi::c_void,
}
#[link(name = "Kernel32")]
extern "system" {
fn LockFileEx(
file: *mut std::ffi::c_void,
flags: u32,
reserved: u32,
low: u32,
high: u32,
overlapped: *mut Overlapped,
) -> i32;
}
let mut overlapped = Overlapped {
internal: 0,
internal_high: 0,
offset: 0,
offset_high: 0,
event: std::ptr::null_mut(),
};
let result = unsafe {
LockFileEx(
file.as_raw_handle(),
0x2,
0,
u32::MAX,
u32::MAX,
&mut overlapped,
)
};
if result == 0 {
return Err(CplError::io(
"Could not acquire the exclusive OS CPL writer lock",
std::io::Error::last_os_error(),
));
}
Ok(())
}
#[cfg(target_os = "windows")]
fn unlock_file(file: &File) -> CplResult<()> {
use std::os::windows::io::AsRawHandle;
#[repr(C)]
struct Overlapped {
internal: usize,
internal_high: usize,
offset: u32,
offset_high: u32,
event: *mut std::ffi::c_void,
}
#[link(name = "Kernel32")]
extern "system" {
fn UnlockFileEx(
file: *mut std::ffi::c_void,
reserved: u32,
low: u32,
high: u32,
overlapped: *mut Overlapped,
) -> i32;
}
let mut overlapped = Overlapped {
internal: 0,
internal_high: 0,
offset: 0,
offset_high: 0,
event: std::ptr::null_mut(),
};
let result =
unsafe { UnlockFileEx(file.as_raw_handle(), 0, u32::MAX, u32::MAX, &mut overlapped) };
if result == 0 {
return Err(CplError::io(
"Could not release the CPL writer lock",
std::io::Error::last_os_error(),
));
}
Ok(())
}
#[cfg(unix)]
fn lock_file(file: &File) -> CplResult<()> {
use std::os::fd::AsRawFd;
extern "C" {
fn flock(fd: i32, operation: i32) -> i32;
}
if unsafe { flock(file.as_raw_fd(), 2) } != 0 {
return Err(CplError::io(
"Could not acquire the exclusive OS CPL writer lock",
std::io::Error::last_os_error(),
));
}
Ok(())
}
#[cfg(unix)]
fn unlock_file(file: &File) -> CplResult<()> {
use std::os::fd::AsRawFd;
extern "C" {
fn flock(fd: i32, operation: i32) -> i32;
}
if unsafe { flock(file.as_raw_fd(), 8) } != 0 {
return Err(CplError::io(
"Could not release the CPL writer lock",
std::io::Error::last_os_error(),
));
}
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Thinkloom",
"version": "0.5.0",
"version": "0.5.11",
"identifier": "com.thinkloom.desktop",
"build": {
"beforeDevCommand": "npm run dev",