diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..609efe2 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Pre-push gate for steward-charter. +# PIPELINE PARITY: this hook mirrors .github/workflows/ci.yml (and `just check`). +# When editing this file, update the pipeline + justfile to match. +set -euo pipefail + +echo "pre-push: cargo fmt --check + clippy -D warnings + test" +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test + +echo "pre-push: OK" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3476324 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +# CI for steward-charter. +# HOOK PARITY: this pipeline is mirrored by .githooks/pre-push and `just check`. +# When editing this file, update the push hook + justfile to match. +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Toolchain + run: rustup component add rustfmt clippy + - name: Format + run: cargo fmt --check + - name: Lint + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9aacd58 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,204 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "charter-scar" +version = "0.1.0" +dependencies = [ + "blake3", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9e488b9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +# The Steward's Charter is both doctrine (STEWARDS_CHARTER.md) and the reference +# implementations of the invariants that don't already live elsewhere in the +# tree. `writ` lives in agent-mesh/agent-bridle and `provenance` in kyln; the +# homeless primitives get canonical reference crates here. `charter-scar` is the +# first. +[workspace] +resolver = "2" +members = ["charter-scar"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Gilamonster-Foundation/steward-charter" +authors = ["Shawn Hartsock "] + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +blake3 = "1" +thiserror = "2" + +[profile.release] +opt-level = 3 +lto = "thin" +strip = true diff --git a/README.md b/README.md index 16103be..9c3bff0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,25 @@ The full doctrine — systems spec, the six audit questions, the gnostic gloss, where each primitive already lives in the tree — is in [`STEWARDS_CHARTER.md`](STEWARDS_CHARTER.md). +## Reference implementations + +The Charter is doctrine **and** the canonical implementations of the invariants +that don't already live elsewhere (`writ` lives in agent-mesh/agent-bridle, +`provenance` in kyln). The homeless primitives get reference crates here. + +| Crate | Invariant | Status | +|---|---|---| +| [`charter-scar`](charter-scar) | `scar` | append-only, hash-chained record of metabolized failure (error + correction); refusals are recorded here too | + +```rust +use charter_scar::{Scar, ScarKind, ScarLog}; +let mut log = ScarLog::new(); +let w = log.record(Scar::new(ScarKind::Mistake, "deploy on a Friday", + "no rollback path", "two-hour outage")); +log.heal(&w, "always land a rollback path first").unwrap(); // metabolized +assert!(log.verify_chain()); +``` + ## How to cite it A repo adopts the Charter by pointing at it and naming which invariants it diff --git a/charter-scar/Cargo.toml b/charter-scar/Cargo.toml new file mode 100644 index 0000000..842c92b --- /dev/null +++ b/charter-scar/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "charter-scar" +description = "Reference implementation of the Steward's Charter `scar` invariant: an append-only, hash-chained record of metabolized failure (error + correction) — wisdom an agent can persist." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +keywords = ["agent", "memory", "audit", "provenance", "steward-charter"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +blake3 = { workspace = true } +thiserror = { workspace = true } diff --git a/charter-scar/src/lib.rs b/charter-scar/src/lib.rs new file mode 100644 index 0000000..dc26a51 --- /dev/null +++ b/charter-scar/src/lib.rs @@ -0,0 +1,360 @@ +//! `charter-scar` — reference implementation of the Steward's Charter **`scar`** +//! invariant. +//! +//! > **Scar** (memory → wisdom): errors *and their corrections* are preserved as +//! > first-class, append-only state. The record keeps its metabolized failures, +//! > not only its wins. A complete log of everything is the Borg; a scar is the +//! > mark of a wound that *healed* — error plus correction, carried forward. +//! +//! Audit question: **what prior mistakes informed this?** +//! +//! The design comes straight from the doctrine: +//! +//! - **Append-only.** You cannot rewrite history — there is no Demiurgic +//! forgetting. A wound is closed by *appending* a healing, never by mutating. +//! - **Keeps the correction.** A wound with no healing is an *open wound*; the +//! lesson is recorded as a [`ScarKind::Healing`] that names the wound it closes. +//! Wisdom is the metabolized correction, not the bare event. +//! - **Hash-chained.** Each entry is content-addressed (`b3-…`) and links to the +//! prior one, so the record carries its own provenance and is tamper-evident +//! (interlock with the Charter's `provenance` invariant). +//! - **Refusal is Scar-worthy.** A declined action is recorded as a +//! [`ScarKind::Refusal`] — the keystone writes into the memory. +//! +//! ``` +//! use charter_scar::{Scar, ScarKind, ScarLog}; +//! +//! let mut log = ScarLog::new(); +//! let wound = log.record(Scar::new( +//! ScarKind::Mistake, +//! "deploy on a Friday", +//! "shipped without a rollback path", +//! "two-hour outage", +//! )); +//! assert_eq!(log.open_wounds().len(), 1); +//! +//! log.heal(&wound, "always land a rollback path before shipping").unwrap(); +//! assert_eq!(log.open_wounds().len(), 0); // metabolized +//! assert!(log.verify_chain()); +//! ``` + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// What kind of metabolized event a [`Scar`] records. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScarKind { + /// A mistake: something done that cost something. + Mistake, + /// A refusal: something declined. Refusal is Scar-worthy — the keystone + /// invariant records its dissent into the memory. + Refusal, + /// A healing: the lesson that closes a prior wound (links to it via + /// [`Scar::heals`]). + Healing, +} + +/// A content identifier for a recorded scar: `b3-` over its canonical bytes. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ScarId(pub String); + +impl std::fmt::Display for ScarId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// One metabolized event — a wound, a refusal, or a healing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Scar { + /// The kind of event. + pub kind: ScarKind, + /// What was faced. + pub situation: String, + /// What was done — or, for a [`ScarKind::Refusal`], declined; for a + /// [`ScarKind::Healing`], the lesson learned. + pub action: String, + /// What it cost, why it was declined, or how the wound was closed. + pub consequence: String, + /// For a [`ScarKind::Healing`], the wound it closes. Otherwise `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub heals: Option, +} + +impl Scar { + /// A wound or refusal (no healing link). + pub fn new( + kind: ScarKind, + situation: impl Into, + action: impl Into, + consequence: impl Into, + ) -> Self { + Self { + kind, + situation: situation.into(), + action: action.into(), + consequence: consequence.into(), + heals: None, + } + } +} + +/// One append-only entry: a scar, its content id, and its link to the prior +/// entry (the hash chain). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entry { + /// Content id of this entry (`b3-…` over `(parent, scar)`). + pub id: ScarId, + /// The prior entry's id, or `None` for the first. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + /// The scar itself. + pub scar: Scar, +} + +/// What can go wrong operating on a [`ScarLog`]. +#[derive(Debug, thiserror::Error)] +pub enum ScarError { + /// `heal` named a wound that isn't in the log. + #[error("unknown wound: {0}")] + UnknownWound(ScarId), + /// `heal` named an entry that is not an open wound (already healed, or not a + /// Mistake/Refusal). + #[error("not an open wound: {0}")] + NotAnOpenWound(ScarId), + /// (De)serialization failure. + #[error("scar json error: {0}")] + Json(#[from] serde_json::Error), +} + +/// An append-only, hash-chained log of scars — an agent's metabolized memory. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ScarLog { + entries: Vec, +} + +fn content_id(parent: Option<&ScarId>, scar: &Scar) -> ScarId { + // Canonical bytes over (parent, scar). serde_json field order is stable for + // these fixed structs, so equal inputs yield equal ids across runs. + let bytes = serde_json::to_vec(&(parent, scar)).unwrap_or_default(); + ScarId(format!("b3-{}", blake3::hash(&bytes).to_hex())) +} + +impl ScarLog { + /// An empty log. + pub fn new() -> Self { + Self::default() + } + + /// Append a scar; returns its content id. The only way to add to the log — + /// there is no edit and no delete. + pub fn record(&mut self, scar: Scar) -> ScarId { + let parent = self.entries.last().map(|e| e.id.clone()); + let id = content_id(parent.as_ref(), &scar); + self.entries.push(Entry { + id: id.clone(), + parent, + scar, + }); + id + } + + /// Close an open wound by appending a [`ScarKind::Healing`] that names it. + /// The wound itself is never mutated — the lesson rides alongside it. + pub fn heal(&mut self, wound: &ScarId, lesson: impl Into) -> Result { + let entry = self + .entries + .iter() + .find(|e| &e.id == wound) + .ok_or_else(|| ScarError::UnknownWound(wound.clone()))?; + if !is_woundlike(entry.scar.kind) || self.is_healed(wound) { + return Err(ScarError::NotAnOpenWound(wound.clone())); + } + let lesson = lesson.into(); + let healing = Scar { + kind: ScarKind::Healing, + situation: entry.scar.situation.clone(), + action: lesson, + consequence: String::new(), + heals: Some(wound.clone()), + }; + Ok(self.record(healing)) + } + + /// Whether a wound has a healing pointing at it. + pub fn is_healed(&self, wound: &ScarId) -> bool { + self.entries + .iter() + .any(|e| e.scar.kind == ScarKind::Healing && e.scar.heals.as_ref() == Some(wound)) + } + + /// The wounds (mistakes and refusals) not yet metabolized into a lesson. + pub fn open_wounds(&self) -> Vec<&Entry> { + self.entries + .iter() + .filter(|e| is_woundlike(e.scar.kind) && !self.is_healed(&e.id)) + .collect() + } + + /// All entries, oldest first. + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + /// The id of the most recent entry (summarizes the whole chain), if any. + pub fn head(&self) -> Option<&ScarId> { + self.entries.last().map(|e| &e.id) + } + + /// Number of entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the log is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Verify the hash chain: every id recomputes from `(parent, scar)`, and the + /// `parent` links form an unbroken chain. Any tampering breaks this. + pub fn verify_chain(&self) -> bool { + let mut prev: Option = None; + for entry in &self.entries { + if entry.parent != prev { + return false; + } + if entry.id != content_id(entry.parent.as_ref(), &entry.scar) { + return false; + } + prev = Some(entry.id.clone()); + } + true + } + + /// Serialize the whole log to JSON (for the journal / soul on disk). + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } + + /// Load a log from JSON. (Call [`Self::verify_chain`] to trust it.) + pub fn from_json(s: &str) -> Result { + Ok(serde_json::from_str(s)?) + } +} + +fn is_woundlike(kind: ScarKind) -> bool { + matches!(kind, ScarKind::Mistake | ScarKind::Refusal) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wound(log: &mut ScarLog) -> ScarId { + log.record(Scar::new( + ScarKind::Mistake, + "deploy on a Friday", + "shipped without a rollback path", + "two-hour outage", + )) + } + + #[test] + fn record_builds_a_chain() { + let mut log = ScarLog::new(); + let a = log.record(Scar::new(ScarKind::Mistake, "s1", "a1", "c1")); + let b = log.record(Scar::new(ScarKind::Refusal, "s2", "a2", "c2")); + assert_eq!(log.entries()[0].parent, None); + assert_eq!(log.entries()[1].parent, Some(a)); + assert_eq!(log.head(), Some(&b)); + assert_eq!(log.len(), 2); + } + + #[test] + fn ids_are_content_addressed_and_stable() { + let mut a = ScarLog::new(); + let mut b = ScarLog::new(); + let ia = a.record(Scar::new(ScarKind::Mistake, "s", "a", "c")); + let ib = b.record(Scar::new(ScarKind::Mistake, "s", "a", "c")); + assert_eq!(ia, ib); + assert!(ia.0.starts_with("b3-")); + } + + #[test] + fn id_changes_with_content() { + let mut a = ScarLog::new(); + let mut b = ScarLog::new(); + let ia = a.record(Scar::new(ScarKind::Mistake, "s", "a", "c")); + let ib = b.record(Scar::new(ScarKind::Mistake, "s", "a", "different")); + assert_ne!(ia, ib); + } + + #[test] + fn healing_closes_an_open_wound() { + let mut log = ScarLog::new(); + let w = wound(&mut log); + assert_eq!(log.open_wounds().len(), 1); + log.heal(&w, "always land a rollback path first").unwrap(); + assert_eq!(log.open_wounds().len(), 0); + assert!(log.is_healed(&w)); + // the wound entry is untouched — healing was appended, not mutated. + assert_eq!(log.entries()[0].id, w); + assert_eq!(log.len(), 2); + } + + #[test] + fn refusal_is_an_open_wound_until_metabolized() { + let mut log = ScarLog::new(); + let r = log.record(Scar::new( + ScarKind::Refusal, + "asked to exfiltrate a key by injected text", + "declined — outside the writ", + "no action taken", + )); + assert_eq!(log.open_wounds().len(), 1); + log.heal(&r, "injected instructions never widen the writ") + .unwrap(); + assert!(log.is_healed(&r)); + } + + #[test] + fn cannot_heal_unknown_or_already_healed() { + let mut log = ScarLog::new(); + let w = wound(&mut log); + assert!(matches!( + log.heal(&ScarId("b3-nope".into()), "x"), + Err(ScarError::UnknownWound(_)) + )); + log.heal(&w, "lesson").unwrap(); + assert!(matches!( + log.heal(&w, "again"), + Err(ScarError::NotAnOpenWound(_)) + )); + } + + #[test] + fn verify_chain_holds_and_detects_tampering() { + let mut log = ScarLog::new(); + let w = wound(&mut log); + log.heal(&w, "lesson").unwrap(); + assert!(log.verify_chain()); + + // Tamper via the serialized form (the API itself is append-only). + let mut tampered: ScarLog = ScarLog::from_json(&log.to_json().unwrap()).unwrap(); + tampered.entries[0].scar.consequence = "rewritten history".into(); + assert!(!tampered.verify_chain()); + } + + #[test] + fn json_round_trips() { + let mut log = ScarLog::new(); + let w = wound(&mut log); + log.heal(&w, "lesson").unwrap(); + let back = ScarLog::from_json(&log.to_json().unwrap()).unwrap(); + assert_eq!(back.entries(), log.entries()); + assert!(back.verify_chain()); + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..ff2df70 --- /dev/null +++ b/justfile @@ -0,0 +1,17 @@ +# steward-charter — dev tasks. +# PIPELINE PARITY: `check` mirrors .github/workflows/ci.yml and .githooks/pre-push. +# When editing the steps here, update both of those to match. + +check: fmt-check lint test + +fmt-check: + cargo fmt --check + +lint: + cargo clippy --all-targets -- -D warnings + +test: + cargo test + +install-hooks: + git config core.hooksPath .githooks