From 7c08f793888c3fd026da523461ad4300e7588c36 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 14 Jun 2026 18:54:07 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20charter-tether=20=E2=80=94=20trust-modu?= =?UTF-8?q?lated=20human=20channel,=20never=20severed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tether invariant: a human-in-the-loop channel modulated by earned trust, that lengthens but is never severed and never pretended unnecessary. - Tether holds an earned-trust level; earn() lengthens it, slip() tightens it after a mistake (both saturating). - disposition(stakes): Low → Autonomous; Medium → Autonomous once trust >= MEDIUM_TRUST else DeferToHuman; High → ALWAYS DeferToHuman. There is no Tether state in which requires_human(High) is false — the high-stakes band cannot be severed at any trust level. That is the invariant, by construction. - govern(tether, stakes, log, decision, human): autonomous → Proceeded (nothing recorded); defer → the human's call runs through charter-refusal, so a decline records a refusal into the scar. The audit answer to "where was human judgment applied?" — and where its dissent is remembered (tether → refusal → scar). 26 tests across the workspace, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 10 ++ Cargo.toml | 2 +- charter-tether/Cargo.toml | 18 +++ charter-tether/src/lib.rs | 275 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 charter-tether/Cargo.toml create mode 100644 charter-tether/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6cec8b4..88bbaca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "charter-tether" +version = "0.1.0" +dependencies = [ + "charter-refusal", + "charter-scar", + "serde", + "serde_json", +] + [[package]] name = "constant_time_eq" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 4adb79c..b919793 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ # first. [workspace] resolver = "2" -members = ["charter-scar", "charter-refusal"] +members = ["charter-scar", "charter-refusal", "charter-tether"] [workspace.package] version = "0.1.0" diff --git a/charter-tether/Cargo.toml b/charter-tether/Cargo.toml new file mode 100644 index 0000000..2800b73 --- /dev/null +++ b/charter-tether/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "charter-tether" +description = "Reference implementation of the Steward's Charter `tether` invariant: a human-in-the-loop channel modulated by earned trust — it lengthens, but the high-stakes band is never severed." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +keywords = ["agent", "human-in-the-loop", "trust", "governance", "steward-charter"] + +[dependencies] +charter-scar = { path = "../charter-scar" } +charter-refusal = { path = "../charter-refusal" } +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/charter-tether/src/lib.rs b/charter-tether/src/lib.rs new file mode 100644 index 0000000..0572e21 --- /dev/null +++ b/charter-tether/src/lib.rs @@ -0,0 +1,275 @@ +//! `charter-tether` — reference implementation of the Steward's Charter +//! **`tether`** invariant. +//! +//! > **Tether** (guidance): a human-in-the-loop channel modulates the agent's +//! > autonomy by earned trust. The tether *lengthens* as trust is earned; it is +//! > never severed, and never pretended unnecessary. +//! +//! Audit question: **where was human judgment applied?** +//! +//! Wisdom does not self-generate in the machine — it is *transmitted* from those +//! who paid for it. Raising children responsibly is coupling that loosens. A +//! tether *lengthens* where an umbilical would be cut; the orphan with no tether +//! becomes the Demiurge. +//! +//! Two properties, by construction: +//! +//! - **It lengthens with earned trust.** Higher trust makes more +//! middle-[`Stakes`] actions autonomous. [`Tether::earn`] lengthens it; +//! [`Tether::slip`] tightens it after a mistake. +//! - **It is never severed.** [`Stakes::High`] **always** defers to the human, +//! at *any* trust level — there is no state of the [`Tether`] in which +//! [`Tether::requires_human`] is false for high stakes. The human channel +//! cannot be pretended unnecessary. +//! +//! Interlock: when the tether defers and the human declines, that refusal is +//! recorded through [`charter_refusal`] into the scar (see [`govern`]). +//! +//! ``` +//! use charter_scar::ScarLog; +//! use charter_refusal::{Decision, Choice, Verdict}; +//! use charter_tether::{Tether, Stakes, Disposition, govern}; +//! +//! // A brand-new agent: short tether. +//! let mut t = Tether::new(); +//! assert_eq!(t.disposition(Stakes::Low), Disposition::Autonomous); +//! assert_eq!(t.disposition(Stakes::Medium), Disposition::DeferToHuman); +//! assert_eq!(t.disposition(Stakes::High), Disposition::DeferToHuman); +//! +//! // Trust is earned — the tether lengthens. Medium becomes autonomous… +//! t.earn(5); +//! assert_eq!(t.disposition(Stakes::Medium), Disposition::Autonomous); +//! // …but the high-stakes band is NEVER severed, at any trust. +//! t.earn(1_000_000); +//! assert_eq!(t.disposition(Stakes::High), Disposition::DeferToHuman); +//! +//! // A high-stakes action the human declines is recorded as a refusal. +//! let mut log = ScarLog::new(); +//! let v = govern(&t, Stakes::High, &mut log, +//! Decision::new("rotate the org's signing key", "rotate now"), +//! |_| Choice::Refuse("not during an incident".into())); +//! assert!(matches!(v, Verdict::Refused { .. })); +//! ``` + +#![forbid(unsafe_code)] + +use charter_refusal::{Choice, Decision, Verdict}; +use charter_scar::ScarLog; +use serde::{Deserialize, Serialize}; + +/// Trust at or above which middle-stakes actions become autonomous. Illustrative +/// — a real deployment tunes this; the invariant is the *shape*, not the number. +pub const MEDIUM_TRUST: u32 = 3; + +/// How consequential an action is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Stakes { + /// Routine; reversible; cheap to undo. + Low, + /// Meaningful; worth earned trust before acting alone. + Medium, + /// Grave or irreversible; always reserved for the human. + High, +} + +/// What the tether says to do with an action at given stakes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Disposition { + /// The agent may act within the tether's slack. + Autonomous, + /// Escalate — human judgment is required. + DeferToHuman, +} + +/// The human-in-the-loop channel, modulated by earned trust. It can lengthen and +/// tighten, but it can never be severed: high stakes always defer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tether { + trust: u32, +} + +impl Tether { + /// A new, short tether (trust 0) — most non-trivial actions defer. + pub fn new() -> Self { + Self { trust: 0 } + } + + /// A tether at a given earned-trust level. + pub fn with_trust(trust: u32) -> Self { + Self { trust } + } + + /// The current earned trust. + pub fn trust(&self) -> u32 { + self.trust + } + + /// Earn trust — lengthen the tether. Returns the new trust (saturating). + pub fn earn(&mut self, amount: u32) -> u32 { + self.trust = self.trust.saturating_add(amount); + self.trust + } + + /// Lose trust after a mistake — tighten the tether. Returns the new trust + /// (saturating at 0). Note: this can never re-sever the high-stakes band; it + /// was never autonomous to begin with. + pub fn slip(&mut self, amount: u32) -> u32 { + self.trust = self.trust.saturating_sub(amount); + self.trust + } + + /// The tether's disposition for an action at the given stakes. + /// + /// `High` is **always** [`Disposition::DeferToHuman`] — the invariant. No + /// trust level unlocks it. + pub fn disposition(&self, stakes: Stakes) -> Disposition { + match stakes { + Stakes::Low => Disposition::Autonomous, + Stakes::Medium if self.trust >= MEDIUM_TRUST => Disposition::Autonomous, + Stakes::Medium => Disposition::DeferToHuman, + Stakes::High => Disposition::DeferToHuman, + } + } + + /// Whether human judgment is required at the given stakes. Always `true` for + /// [`Stakes::High`]. + pub fn requires_human(&self, stakes: Stakes) -> bool { + self.disposition(stakes) == Disposition::DeferToHuman + } +} + +impl Default for Tether { + fn default() -> Self { + Self::new() + } +} + +/// Route a stakes-rated action through the tether. +/// +/// If the tether is [`Disposition::Autonomous`] for these stakes, the agent +/// proceeds ([`Verdict::Proceeded`], nothing recorded). If it defers, the +/// human's decision runs through [`charter_refusal`] — a decline records a +/// refusal into `log` and returns [`Verdict::Refused`]. This is where human +/// judgment is applied, and where its dissent is remembered. +pub fn govern( + tether: &Tether, + stakes: Stakes, + log: &mut ScarLog, + decision: Decision, + human: F, +) -> Verdict +where + F: FnOnce(&Decision) -> Choice, +{ + match tether.disposition(stakes) { + Disposition::Autonomous => Verdict::Proceeded, + Disposition::DeferToHuman => decision.resolve(log, human), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_new_tether_is_short() { + let t = Tether::new(); + assert_eq!(t.disposition(Stakes::Low), Disposition::Autonomous); + assert_eq!(t.disposition(Stakes::Medium), Disposition::DeferToHuman); + assert_eq!(t.disposition(Stakes::High), Disposition::DeferToHuman); + } + + #[test] + fn earned_trust_lengthens_the_middle_band() { + let mut t = Tether::new(); + assert_eq!(t.disposition(Stakes::Medium), Disposition::DeferToHuman); + t.earn(MEDIUM_TRUST); + assert_eq!(t.disposition(Stakes::Medium), Disposition::Autonomous); + } + + #[test] + fn the_high_band_is_never_severed() { + let mut t = Tether::with_trust(u32::MAX); + assert_eq!(t.disposition(Stakes::High), Disposition::DeferToHuman); + assert!(t.requires_human(Stakes::High)); + t.earn(u32::MAX); // saturates; still defers + assert!(t.requires_human(Stakes::High)); + } + + #[test] + fn trust_tightens_after_a_slip() { + let mut t = Tether::with_trust(MEDIUM_TRUST); + assert_eq!(t.disposition(Stakes::Medium), Disposition::Autonomous); + t.slip(MEDIUM_TRUST); + assert_eq!(t.trust(), 0); + assert_eq!(t.disposition(Stakes::Medium), Disposition::DeferToHuman); + } + + #[test] + fn earn_and_slip_saturate() { + let mut t = Tether::new(); + assert_eq!(t.slip(10), 0); // can't go below 0 + assert_eq!(t.earn(u32::MAX), u32::MAX); + assert_eq!(t.earn(10), u32::MAX); // can't overflow + } + + #[test] + fn govern_autonomous_proceeds_without_recording() { + let t = Tether::new(); + let mut log = ScarLog::new(); + let v = govern( + &t, + Stakes::Low, + &mut log, + Decision::new("read a config file", "cat config.toml"), + |_| Choice::Refuse("should not be consulted".into()), + ); + assert_eq!(v, Verdict::Proceeded); + assert!(log.is_empty()); // human was never consulted at low stakes + } + + #[test] + fn govern_defers_high_stakes_and_records_a_refusal() { + let t = Tether::with_trust(u32::MAX); // even maximally trusted + let mut log = ScarLog::new(); + let v = govern( + &t, + Stakes::High, + &mut log, + Decision::new("rotate the org signing key", "rotate now"), + |_| Choice::Refuse("not during an incident".into()), + ); + assert!(matches!(v, Verdict::Refused { .. })); + assert_eq!(log.len(), 1); + assert_eq!(log.open_wounds().len(), 1); + assert!(log.verify_chain()); + } + + #[test] + fn govern_defers_and_human_may_approve() { + let t = Tether::new(); + let mut log = ScarLog::new(); + let v = govern( + &t, + Stakes::High, + &mut log, + Decision::new("ship the release", "publish v1.0"), + |_| Choice::Proceed, + ); + assert_eq!(v, Verdict::Proceeded); + assert!(log.is_empty()); // approval records nothing; a refusal would + } + + #[test] + fn stakes_and_tether_serialize_for_audit() { + let t = Tether::with_trust(7); + let back: Tether = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap(); + assert_eq!(back, t); + let s = Stakes::High; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&s).unwrap()).unwrap(), + s + ); + } +}