From 4f9059218f2bbaa6c26b9cac7f8f8f8386809589 Mon Sep 17 00:00:00 2001 From: khaneight Date: Wed, 26 Aug 2026 00:15:32 -0400 Subject: [PATCH] feat: export gates generated work on approval, and signs it itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, both about the same reader: somebody who opens the site and takes what they find for the author's own writing. **The gate.** An `origin: extrapolated` article publishes only when its latest verdict is `approved`. Approval is a different axis from maturity — `stable` means finished, `approved` means the archive's owner signed it — so neither `--status` nor `--include-drafts` opens it. A flag that could would make the gate advisory. Held-back articles are counted separately in the report and told plainly, because unsigned is the one exclusion the owner can clear with a single command, and burying it under "not publishable" reads as work that is not ready. **The notice.** The exporter appends the attribution to every published extrapolated article — not the agent that wrote it, which could leave it out. It names the claims the article was written from, in the words the trait records, so a reader who disagrees can disagree with the premise rather than only the conclusion, and it names who signed it and when. The `--data` bundle gains what a front end needs to show a system rather than a website: `extrapolated` on each node, the persona profile, and counts of work in flight. The profile is **affirmed traits only** — a `proposed` trait is an unconfirmed reading, and publishing one puts a claim about a person in front of readers before the person has seen it — and it carries `evidence_count` rather than the `raw/` paths, since a bundle citing documents nobody can open is citing sources at readers who cannot check them. Reading the published file rather than only asserting on it caught two defects in the notice: `cargo fmt` had wrapped the string into the middle of a sentence, and joining claims that already end in a full stop produced "generalising..". Both fixed and pinned. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +- README.md | 4 +- src/commands/export.rs | 205 ++++++++++++++++++++++++++++++++++++++--- tests/extend.rs | 197 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 397 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e1e11a..88dfae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,7 +170,11 @@ reasoning behind each of these is in [`docs/design-notes.md`](docs/design-notes. **`graph`** bare dumps the whole topology, for humans. **`log`** reads with no arguments and appends with them. - **`export`** writes the publishable subset, rewriting `[[links]]` to - unpublished pages as plain text. It renders no HTML, refuses to write under + unpublished pages as plain text. **An `extrapolated` article publishes only + when its latest verdict is `approved`** — no `--status` opens that gate — and + the *exporter* appends the attribution notice, because an agent that composes + its own disclosure can leave it out. The `--data` bundle carries affirmed + traits only, never their `raw/` paths. It renders no HTML, refuses to write under `wiki/`, `raw/` or `index/`, and — publishing not being recoverable by re-running — refuses on a partial view. `--data` emits the front-end bundle, including `meta/progress.jsonl`: one snapshot per `index` **that changed diff --git a/README.md b/README.md index 32ff1b8..4756531 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ status: draft | review | stable sentinel export --out ./content --flat --clean ``` -Writes only articles whose `status` qualifies (`stable` by default), rewrites links to unpublished articles as plain text so the output has no dead ends, and never copies `raw/` or `meta/`. It renders no HTML — feed it to [Quartz](https://quartz.jzhao.xyz) or any generator that understands wikilinks. +Writes only articles whose `status` qualifies (`stable` by default), rewrites links to unpublished articles as plain text so the output has no dead ends, and never copies `raw/` or `meta/`. + +Anything the clone wrote (`origin: extrapolated`) publishes **only once you have approved it** — `sentinel review --approve` — and `export` appends an attribution notice to it that no flag suppresses. Finished is not the same as signed. It renders no HTML — feed it to [Quartz](https://quartz.jzhao.xyz) or any generator that understands wikilinks. `--data ` also emits a JSON bundle — published nodes and edges, plus the growth history from `meta/progress.jsonl` — for a front end to render. diff --git a/src/commands/export.rs b/src/commands/export.rs index 93bc077..da99a9b 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use colored::Colorize; use serde::Serialize; +use crate::core::review; use crate::core::{output, paths, slug, wiki}; /// Statuses considered finished enough to publish, in the absence of `--status`. @@ -43,6 +44,10 @@ struct Report { /// Articles held back, and why. The true total, not a sample. excluded_count: usize, excluded: Vec, + /// Of those, how many were finished but unsigned. Counted separately + /// because it is the one exclusion the owner can clear in a command, and + /// folding it into "status" would read as work that is not ready. + held_for_approval: usize, /// Wikilinks pointing outside the published set, rewritten to plain text. links_defused: usize, /// Files already in the destination that this export would not write — @@ -120,23 +125,39 @@ pub fn run( } } + let traits = crate::core::persona::load_all()?.require_complete()?; + let mut published = Vec::new(); let mut excluded = Vec::new(); + let mut held_for_approval = 0usize; for article in &articles { - let status = article - .article - .frontmatter - .status - .as_deref() - .unwrap_or("unset"); - if allowed.contains(&status.to_lowercase()) { - published.push(article); - } else { + let fm = &article.article.frontmatter; + let status = fm.status.as_deref().unwrap_or("unset"); + if !allowed.contains(&status.to_lowercase()) { excluded.push(Excluded { path: article.rel_path().to_string(), reason: format!("status: {status}"), }); + continue; } + // The approval gate. A separate axis from maturity: `stable` means + // finished, `approved` means the archive's owner signed it. Work the + // clone wrote in their voice does not go out on the tool's opinion of + // it, and `--status` cannot override this — a flag that could would + // make the gate advisory. + if fm.is_extrapolated() && !review::is_approved(&fm.review) { + let standing = review::standing(&fm.review); + held_for_approval += 1; + excluded.push(Excluded { + path: article.rel_path().to_string(), + reason: match standing { + Some(e) => format!("written by the clone; latest verdict is '{}'", e.verdict), + None => "written by the clone and not approved".to_string(), + }, + }); + continue; + } + published.push(article); } // Links are defused against the *published* set, not the archive. An @@ -175,8 +196,15 @@ pub fn run( let mut links_defused = 0usize; let mut writes: Vec<(PathBuf, String)> = Vec::new(); for article in &published { - let (text, defused) = defuse_links(&article.content, &reachable, &titles); + let (mut text, defused) = defuse_links(&article.content, &reachable, &titles); links_defused += defused; + // Written here, by the exporter, rather than by whatever produced the + // article. An agent that composes its own disclosure is an agent that + // can leave it out, and this is the notice that stops a reader taking + // machine prose for the author's own. + if article.article.frontmatter.is_extrapolated() { + text = format!("{}\n{}", text.trim_end(), attribution(article, &traits)); + } writes.push(( destination.join(output_path(article.rel_path(), flat)), text, @@ -219,7 +247,7 @@ pub fn run( && !dry_run { let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); - let payload = bundle(&published, &reachable, &generated_at)?; + let payload = bundle(&published, &articles, &traits, &reachable, &generated_at)?; if let Some(parent) = data_path.parent() { std::fs::create_dir_all(parent)?; } @@ -234,6 +262,7 @@ pub fn run( let wrote_landing = !dry_run && !landing.exists(); let report = Report { + held_for_approval, wrote_landing, stale: stale.clone(), stale_removed: clean && !dry_run && !stale.is_empty(), @@ -303,6 +332,21 @@ pub fn run( println!(" ... and {} more", report.excluded_count - 5); } } + // Said separately and said plainly. This is the one exclusion the owner + // can clear with a single command, and burying it in a list headed "not + // publishable" reads as work that is not finished. + if report.held_for_approval > 0 { + println!( + "\n {} {} article(s) written by the clone are finished but unsigned.", + "!".yellow(), + report.held_for_approval + ); + println!( + " {}", + "sentinel review # see them\n sentinel review --approve" + .dimmed() + ); + } if !report.stale.is_empty() { if report.stale_removed { println!(" {} stale file(s) removed.", report.stale.len()); @@ -352,11 +396,51 @@ struct Bundle { /// Only published articles, so the bundle can be served beside them. nodes: Vec, edges: Vec, + /// The author's model of themselves — affirmed traits only. + /// + /// A `proposed` trait is an unconfirmed reading, and publishing one would + /// put a claim about a person in front of readers before the person has + /// seen it. The same gate the articles get, for the same reason. + persona: Vec, + /// What the archive is in the middle of. The point of showing a wiki that + /// builds itself is that the building is visible; a bundle of finished + /// pages is a website. + in_progress: InProgress, progress: Vec, /// Snapshots that could not be parsed. A history with holes should say so. unreadable_snapshots: usize, } +/// A trait, as a reader outside the archive may see it. +/// +/// Deliberately not the whole file. `evidence:` is a list of `raw/` paths, and +/// `raw/` is not published — a bundle naming documents nobody can open would +/// be citing sources at readers who cannot check them. The count is honest +/// about how much stands behind the claim without leaking the corpus. +#[derive(Serialize)] +struct PublishedTrait { + id: String, + kind: String, + claim: String, + confidence: String, + evidence_count: usize, + /// Published articles written from this trait. + expressed_in: Vec, +} + +/// Counts of work in flight, for a front end that shows the loop running. +#[derive(Serialize)] +struct InProgress { + /// Articles in the archive that this export did not publish. + unpublished: usize, + /// Generated articles waiting on the owner's verdict. + awaiting_approval: usize, + /// Traits proposed and not yet answered. + unconfirmed_traits: usize, + /// Concepts the wiki links to and has not written — what it wants next. + wanted: usize, +} + #[derive(Serialize)] struct Node { slug: String, @@ -364,6 +448,9 @@ struct Node { domain: String, origin: String, status: String, + /// Written by the clone. A front end that renders this the same as an + /// article its author wrote is a front end that misleads its readers. + extrapolated: bool, tags: Vec, /// Incoming links from other published articles — how central it is. inbound: usize, @@ -378,6 +465,8 @@ struct Edge { fn bundle( published: &[&wiki::LoadedArticle], + all: &[wiki::LoadedArticle], + traits: &[crate::core::persona::LoadedTrait], reachable: &HashSet, generated_at: &str, ) -> io::Result { @@ -416,6 +505,7 @@ fn bundle( origin: fm.origin.clone().unwrap_or_default(), status: fm.status.clone().unwrap_or_default(), tags: fm.tags.clone(), + extrapolated: fm.is_extrapolated(), inbound: inbound.get(&slug).copied().unwrap_or(0), outbound: outbound.get(&slug).copied().unwrap_or(0), slug, @@ -423,17 +513,110 @@ fn bundle( }) .collect(); + let persona = traits + .iter() + .filter(|t| t.is_affirmed()) + .map(|t| { + let id = t.canonical_id(); + PublishedTrait { + kind: t.kind().to_string(), + claim: t.frontmatter.claim.clone().unwrap_or_else(|| t.id()), + confidence: t + .frontmatter + .confidence + .clone() + .unwrap_or_else(|| "unstated".to_string()), + evidence_count: t.frontmatter.evidence.len(), + expressed_in: published + .iter() + .filter(|a| { + a.article + .frontmatter + .persona + .iter() + .any(|c| slug::canonical(c) == id) + }) + .map(|a| a.canonical_slug()) + .collect(), + id, + } + }) + .collect(); + + let in_progress = InProgress { + unpublished: all.len().saturating_sub(published.len()), + awaiting_approval: all + .iter() + .filter(|a| a.article.frontmatter.is_extrapolated()) + .filter(|a| !review::is_approved(&a.article.frontmatter.review)) + .count(), + unconfirmed_traits: traits.iter().filter(|t| t.status() == "proposed").count(), + wanted: crate::core::links::wanted(all).len(), + }; + let (progress, unreadable_snapshots) = crate::core::history::read()?; Ok(Bundle { generated_at: generated_at.to_string(), schema_version: output::SCHEMA_VERSION, nodes, edges, + persona, + in_progress, progress, unreadable_snapshots, }) } +/// The notice appended to every published extrapolated article. +/// +/// Not composable by an agent and not suppressible by a flag: the exporter +/// writes it, unconditionally, for anything marked as the clone's own work. +/// A reader who takes generated prose for the author's own writing is the +/// harm this whole feature is arranged around. +fn attribution( + article: &wiki::LoadedArticle, + traits: &[crate::core::persona::LoadedTrait], +) -> String { + let fm = &article.article.frontmatter; + let mut out = String::from( + "\n---\n\n*Written by a language model working from this archive, extending its author's own writing rather than reproducing it.", + ); + + // The claims it was written from, in the author's own words where the + // trait records them. A reader who disagrees can then disagree with the + // premise rather than only with the conclusion. + let claims: Vec = fm + .persona + .iter() + .filter_map(|id| { + let wanted = slug::canonical(id); + traits + .iter() + .find(|t| t.canonical_id() == wanted) + .map(|t| t.frontmatter.claim.clone().unwrap_or_else(|| t.id())) + }) + // A claim is written as a sentence and usually ends in a full stop. + // Joining them with punctuation of our own produced "generalising..". + .map(|c| c.trim().trim_end_matches('.').to_string()) + .filter(|c| !c.is_empty()) + .collect(); + if !claims.is_empty() { + out.push_str(&format!(" Written from: {}.", claims.join("; "))); + } + + match review::standing(&fm.review) { + Some(e) if e.verdict == "approved" => { + out.push_str(&format!(" Approved by {} on {}.", e.by, e.at)); + } + // Unreachable while the gate above holds; stated rather than assumed, + // because a silent fall-through here would publish unsigned work with + // a notice implying somebody signed it. + _ => out.push_str(" Not approved."), + } + out.push_str("*\n"); + out +} + /// A starting landing page, so the first export serves something at `/`. /// /// Deliberately plain and short. It exists so the site works, and says it is diff --git a/tests/extend.rs b/tests/extend.rs index 1e4d575..fd9f5f7 100644 --- a/tests/extend.rs +++ b/tests/extend.rs @@ -46,15 +46,17 @@ fn archive() -> Archive { 0 ); a.write("persona/held.md", &trait_file("held", "affirmed")); + // `stable`, so the publishing tests below have something ordinary to + // publish alongside the generated article. a.write( "wiki/philosophy/compiled.md", "---\ntitle: Compiled\ndomain: philosophy\norigin: authored\ntags: [t]\n\ - sources: [raw/philosophy/mine.md]\n---\n\nSee [[held-thing]].\n", + status: stable\nsources: [raw/philosophy/mine.md]\n---\n\nSee [[held-thing]].\n", ); a.write( "wiki/philosophy/held-thing.md", "---\ntitle: Held Thing\ndomain: philosophy\norigin: authored\ntags: [t]\n\ - sources: [raw/philosophy/mine.md]\n---\n\nSee [[compiled]].\n", + status: stable\nsources: [raw/philosophy/mine.md]\n---\n\nSee [[compiled]].\n", ); a.run(&["index"]); a @@ -340,3 +342,194 @@ fn an_ordinary_article_never_appears_in_the_queue() { "compiled articles do not need approval:\n{v:#}" ); } + +// --- publishing ------------------------------------------------------------ + +fn approve(a: &Archive, slug: &str) { + let mut cmd = a.cmd(&["review", slug, "--approve", "--note", "yes"]); + cmd.env("SENTINEL_REVIEWER", "khaneight"); + assert!(cmd.output().unwrap().status.success()); +} + +/// An archive holding one finished, unapproved extrapolated article. +fn publishable() -> (Archive, tempfile::TempDir) { + let a = archive(); + a.write( + "wiki/philosophy/new.md", + &extrapolated("New", &["held"]).replace("status: draft", "status: stable"), + ); + a.run(&["index"]); + (a, tempfile::tempdir().unwrap()) +} + +#[test] +fn finished_but_unsigned_generated_work_is_not_published() { + // The gate. `stable` means finished; `approved` means the archive's owner + // signed it, and only the second lets machine prose out under their name. + let (a, out) = publishable(); + let dest = out.path().join("site"); + let v = a.json(&["export", "--out", &dest.display().to_string(), "--flat"]); + assert_eq!(v["published"], 2, "the compiled articles still go:\n{v:#}"); + assert_eq!(v["held_for_approval"], 1, "{v:#}"); + assert!( + !dest.join("new.md").exists(), + "unsigned work must not reach the site" + ); + + let reason = v["excluded"] + .as_array() + .unwrap() + .iter() + .find(|e| e["path"] == "wiki/philosophy/new.md") + .expect("it must be reported, not silently dropped")["reason"] + .as_str() + .unwrap() + .to_string(); + assert!(reason.contains("not approved"), "{reason}"); +} + +#[test] +fn no_status_flag_can_open_the_gate() { + // A flag that could override this would make the gate advisory. Approval + // is a different axis from maturity and `--status` only speaks to maturity. + let (a, out) = publishable(); + let dest = out.path().join("site"); + for args in [ + vec!["export", "--out", "", "--flat", "--include-drafts"], + vec![ + "export", + "--out", + "", + "--flat", + "--status", + "draft,review,stable", + ], + ] { + let d = dest.display().to_string(); + let argv: Vec<&str> = args + .iter() + .map(|x| if x.is_empty() { d.as_str() } else { *x }) + .collect(); + let v = a.json(&argv); + assert_eq!( + v["held_for_approval"], 1, + "{argv:?} opened the gate:\n{v:#}" + ); + assert!(!dest.join("new.md").exists()); + } +} + +#[test] +fn approved_work_is_published_and_carries_a_notice_the_agent_did_not_write() { + // The exporter writes the attribution, unconditionally. An agent that + // composes its own disclosure is an agent that can leave it out. + let (a, out) = publishable(); + approve(&a, "new"); + let dest = out.path().join("site"); + let v = a.json(&["export", "--out", &dest.display().to_string(), "--flat"]); + assert_eq!(v["published"], 3, "{v:#}"); + assert_eq!(v["held_for_approval"], 0); + + let text = std::fs::read_to_string(dest.join("new.md")).unwrap(); + assert!( + text.contains("Written by a language model"), + "a reader must not take this for the author's own writing:\n{text}" + ); + assert!( + text.contains("The author holds held"), + "the notice should name the claim it was written from:\n{text}" + ); + assert!( + text.contains("Approved by khaneight"), + "and who signed it:\n{text}" + ); +} + +#[test] +fn an_ordinary_article_gets_no_notice() { + // The disclosure has to mean something. Attaching it to everything would + // make it furniture. + let (a, out) = publishable(); + let dest = out.path().join("site"); + a.run(&["export", "--out", &dest.display().to_string(), "--flat"]); + let text = std::fs::read_to_string(dest.join("compiled.md")).unwrap(); + assert!(!text.contains("Written by a language model"), "{text}"); +} + +#[test] +fn the_bundle_marks_generated_work_and_publishes_only_affirmed_traits() { + // A front end that renders the clone's work like the author's own misleads + // its readers, and a bundle carrying `proposed` traits would put an + // unconfirmed claim about a person in front of them. + let (a, out) = publishable(); + approve(&a, "new"); + a.write("persona/guessed.md", &trait_file("guessed", "proposed")); + // Something genuinely unfinished, so `unpublished` has a real value to + // report rather than being asserted against a fully-published archive. + a.write( + "wiki/philosophy/half-written.md", + "---\ntitle: Half Written\ndomain: philosophy\norigin: authored\ntags: [t]\n\ + status: draft\nsources: [raw/philosophy/mine.md]\n---\n\nSee [[compiled]].\n", + ); + a.run(&["index"]); + + let dest = out.path().join("site"); + let data = out.path().join("bundle.json"); + a.run(&[ + "export", + "--out", + &dest.display().to_string(), + "--flat", + "--data", + &data.display().to_string(), + ]); + let bundle: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&data).unwrap()).unwrap(); + + let node = bundle["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["slug"] == "new") + .expect("the approved article is in the bundle"); + assert_eq!(node["extrapolated"], true); + + let traits = bundle["persona"].as_array().unwrap(); + assert_eq!(traits.len(), 1, "affirmed only:\n{traits:#?}"); + assert_eq!(traits[0]["id"], "held"); + assert_eq!( + traits[0]["expressed_in"], + serde_json::json!(["new"]), + "the bundle should link a claim to what was written from it" + ); + assert!( + traits[0].get("evidence").is_none(), + "raw/ paths are not published, so citing them at readers is a dead end" + ); + assert_eq!(traits[0]["evidence_count"], 1); + + assert_eq!(bundle["in_progress"]["unconfirmed_traits"], 1); + assert_eq!(bundle["in_progress"]["awaiting_approval"], 0); + assert_eq!( + bundle["in_progress"]["unpublished"], 1, + "the draft is in the archive and not on the site" + ); +} + +#[test] +fn the_notice_reads_as_a_sentence() { + // Found by reading the published file rather than by asserting on it: the + // string was line-wrapped into the middle of a sentence, and joining + // claims that already end in a full stop produced "generalising..". + let (a, out) = publishable(); + approve(&a, "new"); + let dest = out.path().join("site"); + a.run(&["export", "--out", &dest.display().to_string(), "--flat"]); + let text = std::fs::read_to_string(dest.join("new.md")).unwrap(); + let notice = text + .lines() + .find(|l| l.contains("Written by a language model")) + .expect("the notice is there"); + assert!(!notice.contains(" "), "doubled spacing: {notice}"); + assert!(!notice.contains(".."), "doubled punctuation: {notice}"); +}