diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-verify-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-verify-rs.yaml index 59675364..6f4e7faa 100644 --- a/.ddd/seams/seam-rust-ledger-cli-src-commands-verify-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-verify-rs.yaml @@ -24,3 +24,9 @@ bindings: after: sha256:f6f3e91d7c7a4d62879b72c501f573fe83dcd8e961b5fe6e7aeb5f71fb6f9671 base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:2848d2674f2097fb042c05ddd593a86fd580d98ceeb07990822599ebec82cea0 +- symbol: legacy_layout + file: ledger-cli/src/commands/verify.rs + before: sha256:f6f3e91d7c7a4d62879b72c501f573fe83dcd8e961b5fe6e7aeb5f71fb6f9671 + after: sha256:715a87a4601fb9b95cd50c5f51f8749a684935110771775692b5bf24a068568d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:6789df6cbef5073d922b5a3697cdf55428d8fe89207fa7a7b92b757b834c65a5 diff --git a/.ddd/seams/seam-rust-ledger-core-src-blame-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-blame-rs.yaml new file mode 100644 index 00000000..405a3c00 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-blame-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-blame-rs +boundary: contract surface of ledger-core/src/blame.rs +verdict_knowledge: 'Callers learn git blame attribution follows a file along its lineage: with the legacy capability the pickaxe also names the flat path the file had before the re-layout, so the introducing author is the original one and never the re-layout commit''s (LP-3.35).' +contract_location: ledger-core/src/blame.rs +obligations: [] +metadata: {} +bindings: +- symbol: introducing_author + file: ledger-core/src/blame.rs + before: sha256:12d4ae9b7ddb2af8423df3ca1bc1f2b0fdc942c95539d6622a18c6811c408a6b + after: sha256:bd37b146167ee5eecaaa06c8ced110c1a7199e6921d2e9a56c1f19b53a09c9a3 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:cc0047860f00c1d511d995adefb15ba8b5abf8db6e2b5eec73b15e84e8f95163 diff --git a/.ddd/seams/seam-rust-ledger-core-src-changeset-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-changeset-rs.yaml index 5fb8594f..b23551ac 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-changeset-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-changeset-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-changeset-rs boundary: contract surface of ledger-core/src/changeset.rs -verdict_knowledge: "Consumers learn a change-set carries the format-6 authority lists beside decisions, versions, acceptances and revocations, with empty() and entry counts covering every kind." +verdict_knowledge: Consumers learn a change-set carries the format-6 authority lists beside decisions, versions, acceptances and revocations, with empty() and entry counts covering every kind. contract_location: ledger-core/src/changeset.rs obligations: [] metadata: {} @@ -60,3 +60,9 @@ bindings: after: sha256:2938b1158a1b74fa565a3e7ce98bd1a6cc0ac1040e0186ddcf60ccbf7439494b base_revision: e33398d874ec5ddd52404c4f2c16039d5ed7cdbb hash: sha256:370983173de896f16fe302261036f485b1e78a7dd877b7d02f4982478b49586d +- symbol: namespace_hint + file: ledger-core/src/changeset.rs + before: sha256:60d4f5850702a7873ba96aa09bf7c94e717a0d15537303db7a8c4140791fbb1b + after: sha256:773050254a354008bf7ff67fcfc116e019dc374381f6ecedb22c1ccb0d10df5f + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:643d517323816382107be0d7d7bfd9264517849a14141ce2f1b393f8337c8698 diff --git a/.ddd/seams/seam-rust-ledger-core-src-landing-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-landing-rs.yaml index ff1466ed..cb5bad78 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-landing-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-landing-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-landing-rs boundary: contract surface of ledger-core/src/landing.rs -verdict_knowledge: "Callers learn when an entity entered the record (D6): the first first-parent commit whose version of its file holds it, positions ordered by landing then at, computed against a base so a branch reads as its merge would." +verdict_knowledge: 'Callers learn when an entity entered the record (D6): the first first-parent commit whose version of its file holds it, positions ordered by landing then at, computed against a base so a branch reads as its merge would.' contract_location: ledger-core/src/landing.rs obligations: [] metadata: {} @@ -222,3 +222,39 @@ bindings: after: sha256:164acf3c23ea8bdcafe66807f6a57f9fe56fc6ed105c503daeacb184deff58d9 base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:3cad8354bec6d565b07d261b4376cf848f5a9404cdf884f59e24b35b96767221 +- symbol: content_at_any + file: ledger-core/src/landing.rs + before: sha256:8020dbe3933d7a0be4a146e0c615cee9799f7faddb9000ad2e9101823ddc90d8 + after: sha256:3375480dcd06034118add6b0bbbdf4daa320bf9f72fa7ce9f01e4285b9cbb984 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:01069fe69813aa064bd8459e88c9352f39641a00094e73ca6c34aaf66e44a287 +- symbol: lineage + file: ledger-core/src/landing.rs + before: sha256:8020dbe3933d7a0be4a146e0c615cee9799f7faddb9000ad2e9101823ddc90d8 + after: sha256:3375480dcd06034118add6b0bbbdf4daa320bf9f72fa7ce9f01e4285b9cbb984 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:27742c9d7a1815d039264febf14b21f752ef5f8025a2d7c5672b9eeff81e6ba3 +- symbol: lineage_versions + file: ledger-core/src/landing.rs + before: sha256:8020dbe3933d7a0be4a146e0c615cee9799f7faddb9000ad2e9101823ddc90d8 + after: sha256:3375480dcd06034118add6b0bbbdf4daa320bf9f72fa7ce9f01e4285b9cbb984 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:dbc2be26a748cce2b4e422b86f3e6176a5c2152253fa7aa8f294dfa242fe559b +- symbol: touched_after_landing + file: ledger-core/src/landing.rs + before: sha256:8020dbe3933d7a0be4a146e0c615cee9799f7faddb9000ad2e9101823ddc90d8 + after: sha256:3375480dcd06034118add6b0bbbdf4daa320bf9f72fa7ce9f01e4285b9cbb984 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:c18d08e81be611cec65fc0fd846b82a940c2ca1c187db8e294b301c676bd4cf3 +- symbol: legacy + file: ledger-core/src/landing.rs + before: sha256:8020dbe3933d7a0be4a146e0c615cee9799f7faddb9000ad2e9101823ddc90d8 + after: sha256:3375480dcd06034118add6b0bbbdf4daa320bf9f72fa7ce9f01e4285b9cbb984 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:90b7fe1994140099a9bebd35061bfe4ff4a4fcd9520031509d187f603438d341 +- symbol: compute + file: ledger-core/src/landing.rs + before: sha256:8020dbe3933d7a0be4a146e0c615cee9799f7faddb9000ad2e9101823ddc90d8 + after: sha256:3375480dcd06034118add6b0bbbdf4daa320bf9f72fa7ce9f01e4285b9cbb984 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:a2645b9b81dc991a63ce1af1332d0469eff7b3ca96810b3c9b140e4283bdfdd4 diff --git a/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml index 949dd14d..356cde85 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml @@ -156,3 +156,39 @@ bindings: after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e base_revision: 70395dfe317cb9a00665433813766ca763f3c58a hash: sha256:d8280851d3938727e513a912caa8b9a94fedad29bf46a227ec623b7dc9e6d29e +- symbol: FLAT_TRACKED + file: ledger-core/src/layout.rs + before: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + after: sha256:387c4847112a3705992e8b129d3a5b719995e80a994250b3dcd55891e6dc174d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:ebf7600830e77b4620fe516e217409afcc672ceca8853af041579db2588231c5 +- symbol: classify_flat + file: ledger-core/src/layout.rs + before: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + after: sha256:387c4847112a3705992e8b129d3a5b719995e80a994250b3dcd55891e6dc174d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:78dc38fe8fbd21c85cfe1bf4d1fefa5086d2f95f81d11c5b6bfacdb9bfc0cd90 +- symbol: flat_counterpart + file: ledger-core/src/layout.rs + before: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + after: sha256:387c4847112a3705992e8b129d3a5b719995e80a994250b3dcd55891e6dc174d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:7103837e2c1bba7671458e636e4cfc838785f5f080c5298ee2ac2064109ac37b +- symbol: flat_history + file: ledger-core/src/layout.rs + before: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + after: sha256:387c4847112a3705992e8b129d3a5b719995e80a994250b3dcd55891e6dc174d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:16c971037cd98f29f2a8ea49827b25e84d2718df6b8f1df6289c9bbfab6a67c7 +- symbol: moved_to + file: ledger-core/src/layout.rs + before: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + after: sha256:387c4847112a3705992e8b129d3a5b719995e80a994250b3dcd55891e6dc174d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:9fb33d6cb75edf885846288e61d6f2ab1c37ef50da014cfe3aec1d2236833aef +- symbol: moved_to_a_namespace + file: ledger-core/src/layout.rs + before: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + after: sha256:387c4847112a3705992e8b129d3a5b719995e80a994250b3dcd55891e6dc174d + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:9cde5daa6c7c09e4832001770a66465c2c1e4af1cbe75586368a6b65c03efacd diff --git a/.ddd/seams/seam-rust-ledger-core-src-verify-integrity-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-verify-integrity-rs.yaml new file mode 100644 index 00000000..f87b4a5b --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-verify-integrity-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-verify-integrity-rs +boundary: contract surface of ledger-core/src/verify/integrity.rs +verdict_knowledge: 'Callers learn the blame-consistency check takes the legacy flag so that, in a repository whose history holds the flat layout, an entity''s introducing author is read across the re-layout rather than from the move (LP-3.35).' +contract_location: ledger-core/src/verify/integrity.rs +obligations: [] +metadata: {} +bindings: +- symbol: blame_consistency + file: ledger-core/src/verify/integrity.rs + before: sha256:c014372bf22afda6daaeeea21a4bf6e8a91a272a0c774abae734b2427ac56663 + after: sha256:ae758c7c418b13c7bd4843c27a0c4aa8fe211e8e2164782afc9baf1dbf24e551 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:231feb826f40caf6825079b1143f2cbc2e8de3b0cd5a902d7c5a06e1e02719d7 diff --git a/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml index a871215e..b89ace9b 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml @@ -126,3 +126,9 @@ bindings: after: sha256:770850c2399abca19404cfdf31c4dfebcbc398e8d746a1d2aa710fe9dc8566d6 base_revision: c4b9c904c0389a9e3b484e92436dd01637346118 hash: sha256:039b19c6fd36f4909fe03bfd67b932a80edcd516b103326cad8a46f46bbebcbf +- symbol: legacy_layout + file: ledger-core/src/verify/mod.rs + before: sha256:77b45a4c3de98cc4e558d1cc07c6b1d76e42d7ff882d79b2fabbe5a48f4de89a + after: sha256:eb8a89495dc2394ff2e7b1e744dbb9b6618025467690ac07e743848a575804c3 + base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 + hash: sha256:48a5f6a970da933c6a8ba1cd334c4e59a07f8c2342652f13268192259ae96bad diff --git a/ledger-cli/src/commands/cli_enum.rs b/ledger-cli/src/commands/cli_enum.rs index 6bbb8895..0cbfd4f3 100644 --- a/ledger-cli/src/commands/cli_enum.rs +++ b/ledger-cli/src/commands/cli_enum.rs @@ -348,6 +348,11 @@ pub enum Commands { /// Judge acceptance expiry against this date instead of today #[arg(long, value_name = "DATE")] today: Option, + /// Verify as an implementation without the legacy capability of + /// ruling 82 would: a history holding the flat layout of revision + /// v1.8 is refused with exit 2, naming its first flat commit + #[arg(long)] + no_legacy_layout: bool, /// Skip the git blame pass (class L009) #[arg(long)] no_blame: bool, diff --git a/ledger-cli/src/commands/mod.rs b/ledger-cli/src/commands/mod.rs index 81019120..9a3c7d59 100644 --- a/ledger-cli/src/commands/mod.rs +++ b/ledger-cli/src/commands/mod.rs @@ -136,8 +136,8 @@ fn dispatch_read(command: Commands, root: Option) -> Result inspect::status(root, today.as_deref()), - Commands::Verify { gate, json, today, no_blame, export, base } => { - verify::run(root, verify::Args { gate, json, today, blame: !no_blame, export, base }) + Commands::Verify { gate, json, today, no_legacy_layout, no_blame, export, base } => { + verify::run(root, verify::Args { gate, json, today, blame: !no_blame, export, base, legacy_layout: !no_legacy_layout }) } // Every writing verb is handled by `dispatch`, which routes here // only for what is left. Reaching this arm means a variant was diff --git a/ledger-cli/src/commands/verify.rs b/ledger-cli/src/commands/verify.rs index dc6bbff8..29f8a571 100644 --- a/ledger-cli/src/commands/verify.rs +++ b/ledger-cli/src/commands/verify.rs @@ -21,17 +21,24 @@ pub struct Args { /// The base landing is computed against (D6); default `origin/HEAD` /// when the clone has it. pub base: Option, + /// Whether this run has the legacy capability of ruling 82 (LP-3.35): + /// without it, a history holding the flat layout is refused with exit 2. + pub legacy_layout: bool, } pub fn run(root: Option, args: Args) -> Result { let repo_root = resolve_root(root)?; let base = args.base.or_else(|| ledger_core::landing::Landing::default_base(&repo_root)); + if !args.legacy_layout { + refuse_flat_history(&repo_root, base.as_deref())?; + } let options = Options { gate: args.gate.as_deref().map(parse_gate).transpose()?, today: parse_today(args.today.as_deref())?, blame: args.blame, history: true, base: base.clone(), + legacy_layout: args.legacy_layout, }; let mut loaded = store::load(&repo_root); match &base { @@ -64,6 +71,24 @@ pub fn run(root: Option, args: Args) -> Result { Ok(if report.is_conformant() { EXIT_OK } else { EXIT_VIOLATIONS }) } +/// A verifier without the legacy capability cannot verify a repository +/// whose history holds the flat layout of revision v1.8: it refuses before +/// reading anything, names the first flat commit, and never reports the +/// repository conformant (LP-3.35; rulings 82, 97). Exit 2: the gate could +/// not run, which is the verifier's limit and not a finding about the store. +fn refuse_flat_history(root: &std::path::Path, base: Option<&str>) -> Result<(), String> { + let mut revs = vec!["HEAD"]; + revs.extend(base); + match ledger_core::layout::flat_history(root, &revs) { + None => Ok(()), + Some(commit) => Err(format!( + "this repository's history holds the flat layout of revision v1.8, first at commit {commit}, and this run has no \ + legacy capability to read it (ruling 82) — the repository cannot be verified by it; a verifier with the capability \ + (this one, without `--no-legacy-layout`) reads both path patterns in history (LP-3.35)" + )), + } +} + fn parse_gate(name: &str) -> Result { match name { "readiness" => Ok(Gate::Readiness), diff --git a/ledger-cli/tests/binding_accounting.rs b/ledger-cli/tests/binding_accounting.rs index 131def10..8882419d 100644 --- a/ledger-cli/tests/binding_accounting.rs +++ b/ledger-cli/tests/binding_accounting.rs @@ -27,7 +27,7 @@ enum Expect { fn accounted(repo: &Repo) -> Vec<(String, Expect)> { let store = ledger_core::store::load(repo.path()); let today = Utc::now().date_naive(); - let landing = ledger_core::landing::Landing::compute(repo.path(), None).expect("landing"); + let landing = ledger_core::landing::Landing::compute(repo.path(), None, true).expect("landing"); let trusted: Vec = ledger_core::signing::check::check(&store, &landing, today).trusted.iter().map(|b| b.id.to_string()).collect(); let report = ledger_core::verify::verify(&store, &ledger_core::verify::Options::full(today)); diff --git a/ledger-cli/tests/legacy_layout.rs b/ledger-cli/tests/legacy_layout.rs new file mode 100644 index 00000000..b5a9f877 --- /dev/null +++ b/ledger-cli/tests/legacy_layout.rs @@ -0,0 +1,191 @@ +//! The flat layout of revision v1.8 in history (LP-3.35; rulings 82, 97). +//! +//! A store made before v1.9 keeps its flat paths in history, and the +//! re-layout is one commit that moves every file under +//! `.decisions/ns//`. A verifier with the legacy capability +//! follows each file across both path patterns for `L007`, `L009` and the +//! base overlay, and reads no flat semantics; one without it refuses the +//! repository with exit 2, naming the first flat commit, and never reports +//! it conformant (AC-82). + +mod common; + +use std::path::Path; + +use common::{hand, Repo}; + +const CS: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXW"; +const NS: &str = "fixture.ledger"; +/// The `pass` fixture's acceptor. +const ACCEPTOR: &str = "fixture-human@example"; +/// Whoever makes the re-layout commit: not the acceptor, so `L009` would +/// name them if the pickaxe stopped at the move. +const RELAYER: &str = "relayout@example"; + +/// A repository whose first commit holds the `pass` fixture in the flat +/// layout of v1.8, committed by its acceptor. Returns the repo and that +/// commit's id. +fn flat_history() -> (Repo, String) { + let repo = Repo::with_identity(ACCEPTOR); + let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pass/.decisions"); + for sub in ["sets", "log"] { + let to = repo.path().join(".decisions").join(sub); + std::fs::create_dir_all(&to).expect("mkdir"); + for entry in std::fs::read_dir(from.join(sub)).expect("fixture").flatten() { + std::fs::copy(entry.path(), to.join(entry.file_name())).expect("copy"); + } + } + hand::commit(&repo, "the store, in the flat layout of v1.8"); + let first = String::from_utf8_lossy(&common::git(repo.path(), &["rev-parse", "HEAD"]).stdout).trim().to_string(); + (repo, first) +} + +/// Re-lay the store out in one commit by `RELAYER`: `sets/` and `log/` move +/// under `ns//`, byte for byte, as `git mv` moves them. +fn relayout(repo: &Repo) { + std::fs::create_dir_all(repo.ns_dir(NS)).expect("mkdir"); + for sub in ["sets", "log"] { + repo.git(&["mv", &format!(".decisions/{sub}"), &format!(".decisions/ns/{NS}/{sub}")]); + } + repo.act_as(RELAYER); + repo.git(&["commit", "-q", "-m", "re-lay out under ns// (spec v1.9)"]); +} + +/// `verify` with blame on and the legacy capability, as the reference runs it. +fn verify(repo: &Repo, extra: &[&str]) -> (i32, String) { + let mut args = vec!["verify", "--today", "2026-08-10"]; + args.extend_from_slice(extra); + let out = repo.ledger(&args); + (out.status.code().unwrap_or(-1), common::both(&out)) +} + +#[test] +fn re_laid_out_in_one_commit_the_store_verifies_with_its_original_authors_and_no_l007() { + let (repo, _) = flat_history(); + relayout(&repo); + let (code, text) = verify(&repo, &[]); + assert_eq!(code, 0, "{text}"); + assert!(text.contains("conformant"), "{text}"); + // Blame was on and the acceptor is not the re-layer: `L009` found the + // original introduction through the flat path, not the move. + assert!(!text.contains("[L009]") && !text.contains("skipped"), "{text}"); + // The flat files the move deleted are no removal: every entity is + // present, unchanged, under its namespace's directory. + assert!(!text.contains("[L007]"), "{text}"); +} + +#[test] +fn an_entity_dropped_by_the_re_layout_is_l007_by_entity() { + let (repo, _) = flat_history(); + std::fs::create_dir_all(repo.ns_dir(NS)).expect("mkdir"); + for sub in ["sets", "log"] { + repo.git(&["mv", &format!(".decisions/{sub}"), &format!(".decisions/ns/{NS}/{sub}")]); + } + // The acceptance is left behind in the move. + let moved = repo.log_dir(NS).join(format!("{CS}.yml")); + let text = std::fs::read_to_string(&moved).expect("read"); + let (keep, _) = text.split_once("acceptances:").expect("acceptances"); + std::fs::write(&moved, keep).expect("write"); + repo.act_as(RELAYER); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-q", "-m", "re-lay out, dropping the acceptance"]); + let (code, text) = verify(&repo, &[]); + assert_eq!(code, 1, "{text}"); + assert!(text.contains("[L007] acc:01K2C4YQJ3F8M0PT5W7NZ9RDXX") && text.contains("is gone"), "{text}"); +} + +#[test] +fn a_flat_file_removed_rather_than_moved_is_l007_as_before() { + let (repo, _) = flat_history(); + std::fs::create_dir_all(repo.ns_dir(NS)).expect("mkdir"); + repo.git(&["mv", ".decisions/sets", &format!(".decisions/ns/{NS}/sets")]); + repo.git(&["rm", "-q", "-r", ".decisions/log"]); + repo.act_as(RELAYER); + repo.git(&["commit", "-q", "-m", "the log removed, not moved"]); + let (code, text) = verify(&repo, &[]); + assert_eq!(code, 1, "{text}"); + assert!(text.contains("[L007]") && text.contains("is gone"), "{text}"); +} + +#[test] +fn a_format_declaration_changed_across_the_re_layout_is_l007() { + let (repo, _) = flat_history(); + std::fs::create_dir_all(repo.ns_dir(NS)).expect("mkdir"); + for sub in ["sets", "log"] { + repo.git(&["mv", &format!(".decisions/{sub}"), &format!(".decisions/ns/{NS}/{sub}")]); + } + let moved = repo.log_dir(NS).join(format!("{CS}.yml")); + let text = std::fs::read_to_string(&moved).expect("read"); + std::fs::write(&moved, text.replacen("format: 1\n", "format: 5\n", 1)).expect("write"); + repo.act_as(RELAYER); + repo.git(&["add", "-A"]); + repo.git(&["commit", "-q", "-m", "re-lay out, raising the declaration past what the content needs"]); + let (code, text) = verify(&repo, &[]); + assert_eq!(code, 1, "{text}"); + assert!(text.contains("[L007]") && text.contains("its format declaration was 1 at"), "{text}"); +} + +#[test] +fn without_the_capability_a_flat_history_is_refused_with_exit_2_naming_the_first_flat_commit() { + let (repo, first) = flat_history(); + relayout(&repo); + let out = repo.ledger(&["verify", "--no-legacy-layout", "--today", "2026-08-10"]); + let text = common::both(&out); + assert_eq!(out.status.code(), Some(2), "the gate could not run: {text}"); + assert!(text.contains(&first), "names the first flat commit: {text}"); + assert!(text.contains("ruling 82") && text.contains("flat layout"), "{text}"); + assert!(!text.contains("conformant"), "never reported conformant: {text}"); + // Any flat commit on the base refuses too. + let out = repo.ledger(&["verify", "--no-legacy-layout", "--base", "HEAD~1", "--today", "2026-08-10"]); + assert_eq!(out.status.code(), Some(2), "{}", common::both(&out)); +} + +#[test] +fn a_history_written_at_v1_9_needs_no_capability() { + let repo = Repo::human(); + repo.declare(); + repo.add("Money is decimal.", &[]); + hand::commit(&repo, "a v1.9 store"); + let (code, text) = verify(&repo, &["--no-legacy-layout"]); + assert_eq!(code, 0, "{text}"); + assert!(text.contains("conformant"), "{text}"); +} + +/// A second flat change-set, filed on the flat base after the re-layout +/// branched off: another decision of the fixture's set, accepted. +fn second_flat_change_set(repo: &Repo) { + let text = std::fs::read_to_string(repo.path().join(format!(".decisions/log/{CS}.yml"))).expect("read"); + let mut cs: ledger_core::changeset::ChangeSet = serde_yaml::from_str(&text).expect("parse"); + cs.id = "cs:01K2C4YQJ3F8M0PT5W7NZ9RDY0".parse().expect("id"); + let decision: ledger_core::id::DecisionId = "dec:fixture.ledger/01K2C4YQJ3F8M0PT5W7NZ9RDY1".parse().expect("id"); + cs.decisions[0].id = decision.clone(); + cs.versions[0].decision = decision.clone(); + cs.versions[0].statement = "Time is UTC.".into(); + cs.versions[0].hash = ledger_core::hash::version_hash(&cs.versions[0]); + cs.acceptances[0].id = "acc:01K2C4YQJ3F8M0PT5W7NZ9RDY2".parse().expect("id"); + cs.acceptances[0].decision = decision; + cs.acceptances[0].version = cs.versions[0].hash.clone(); + std::fs::write(repo.path().join(format!(".decisions/log/{}", cs.file_name())), serde_yaml::to_string(&cs).expect("yaml")).expect("write"); +} + +#[test] +fn the_base_overlay_reads_a_flat_base_for_the_re_layouts_own_pull_request() { + let (repo, _) = flat_history(); + repo.git(&["checkout", "-q", "-b", "relayout"]); + relayout(&repo); + // Meanwhile the flat base moves on: a second change-set lands on main. + repo.git(&["checkout", "-q", "main"]); + repo.act_as(ACCEPTOR); + second_flat_change_set(&repo); + hand::commit(&repo, "a second decision, still flat"); + repo.git(&["checkout", "-q", "relayout"]); + let alone = repo.ledger(&["verify", "--json", "--today", "2026-08-10"]); + let alone: serde_json::Value = serde_json::from_slice(&alone.stdout).expect("json"); + assert_eq!(alone["decisions"], 1, "the branch's own store"); + let merged = repo.ledger(&["verify", "--json", "--base", "main", "--today", "2026-08-10"]); + let text = common::both(&merged); + assert_eq!(merged.status.code(), Some(0), "{text}"); + let merged: serde_json::Value = serde_json::from_slice(&merged.stdout).expect("json"); + assert_eq!(merged["decisions"], 2, "the base's flat change-set is overlaid: {merged}"); + assert!(merged["findings"].as_array().is_some_and(Vec::is_empty), "{merged}"); +} diff --git a/ledger-core/src/author/report.rs b/ledger-core/src/author/report.rs index c47d86ec..4d84775f 100644 --- a/ledger-core/src/author/report.rs +++ b/ledger-core/src/author/report.rs @@ -130,7 +130,7 @@ pub fn blame(store: &Store, decision: &DecisionId) -> String { } else { "historical" }; - let committed = match introducing_author(&store.root, viewed.path, &a.id.to_string()) { + let committed = match introducing_author(&store.root, viewed.path, &a.id.to_string(), true) { Attribution::Author(email) => format!("committed by {email}"), Attribution::NotCommitted => "not yet committed".to_string(), Attribution::NoRepository => "no repository to corroborate".to_string(), diff --git a/ledger-core/src/authority/signers.rs b/ledger-core/src/authority/signers.rs index 4970bf51..cd30965c 100644 --- a/ledger-core/src/authority/signers.rs +++ b/ledger-core/src/authority/signers.rs @@ -49,7 +49,7 @@ fn when(at: &DateTime) -> String { /// gate trusts (an unsigned or wrongly signed binding never enters one, /// spec v1.8). A namespace that binds nothing has no entry. pub fn derive(store: &Store) -> BTreeMap { - let landing = crate::landing::Landing::compute(&store.root, None).unwrap_or_default(); + let landing = crate::landing::Landing::compute(&store.root, None, true).unwrap_or_default(); derive_each(&crate::signing::check::trusted_bindings(store, &landing), &filed(store)) } diff --git a/ledger-core/src/blame.rs b/ledger-core/src/blame.rs index faea0f98..163a29de 100644 --- a/ledger-core/src/blame.rs +++ b/ledger-core/src/blame.rs @@ -32,18 +32,24 @@ pub enum Attribution { /// Uses the pickaxe (`-S`) rather than `git blame` on purpose: an acceptance /// is a block of lines whose exact position moves with formatting, but the /// change-set the acceptance id first appears in does not move at all. -pub fn introducing_author(root: &Path, file: &Path, needle: &str) -> Attribution { +/// +/// With `legacy` (LP-3.35), the pickaxe names both the file's path and the +/// flat path it had before the re-layout: `-S` matches the re-layout commit +/// too, where the id leaves one path and enters the other, but `--reverse` +/// puts the original introduction first. +pub fn introducing_author(root: &Path, file: &Path, needle: &str, legacy: bool) -> Attribution { if !is_repository(root) { return Attribution::NoRepository; } - let relative = file.strip_prefix(root).unwrap_or(file); + let relative = file.strip_prefix(root).unwrap_or(file).to_string_lossy().replace('\\', "/"); + let paths = crate::landing::lineage(&relative, legacy); let out = Command::new("git") .arg("-C") .arg(root) .args(["log", "--reverse", "--format=%ae"]) .arg(format!("-S{needle}")) .arg("--") - .arg(relative) + .args(&paths) .output(); let Ok(out) = out else { return Attribution::NoRepository; diff --git a/ledger-core/src/blame_tests.rs b/ledger-core/src/blame_tests.rs index e7da7d45..e707689d 100644 --- a/ledger-core/src/blame_tests.rs +++ b/ledger-core/src/blame_tests.rs @@ -39,7 +39,7 @@ fn the_author_of_the_introducing_commit_is_returned() { let repo = Repo::new(); let file = repo.commit("log.yml", "id: acc:AAA\n", "fixture-human@example"); assert_eq!( - introducing_author(repo.path(), &file, "acc:AAA"), + introducing_author(repo.path(), &file, "acc:AAA", true), Attribution::Author("fixture-human@example".into()) ); } @@ -51,11 +51,11 @@ fn the_earliest_commit_wins_when_a_later_one_touches_the_same_file() { let file = repo.commit("log.yml", "id: acc:AAA\nid: acc:BBB\n", "second@example"); // Each acceptance is attributed to the commit that introduced *it*. assert_eq!( - introducing_author(repo.path(), &file, "acc:AAA"), + introducing_author(repo.path(), &file, "acc:AAA", true), Attribution::Author("first@example".into()) ); assert_eq!( - introducing_author(repo.path(), &file, "acc:BBB"), + introducing_author(repo.path(), &file, "acc:BBB", true), Attribution::Author("second@example".into()) ); } @@ -66,7 +66,7 @@ fn an_uncommitted_acceptance_is_skipped_not_failed() { let file = repo.commit("log.yml", "id: acc:AAA\n", "fixture-human@example"); std::fs::write(&file, "id: acc:AAA\nid: acc:NEW\n").expect("write"); // Staged-but-uncommitted is the state every acceptance passes through. - assert_eq!(introducing_author(repo.path(), &file, "acc:NEW"), Attribution::NotCommitted); + assert_eq!(introducing_author(repo.path(), &file, "acc:NEW", true), Attribution::NotCommitted); } #[test] @@ -74,7 +74,7 @@ fn a_directory_that_is_not_a_repository_reports_no_repository() { let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("log.yml"); std::fs::write(&file, "id: acc:AAA\n").expect("write"); - assert_eq!(introducing_author(dir.path(), &file, "acc:AAA"), Attribution::NoRepository); + assert_eq!(introducing_author(dir.path(), &file, "acc:AAA", true), Attribution::NoRepository); } #[test] @@ -82,7 +82,7 @@ fn author_emails_are_lowercased_so_comparison_matches_a_normalised_identity() { let repo = Repo::new(); let file = repo.commit("log.yml", "id: acc:AAA\n", "Fixture-Human@Example"); assert_eq!( - introducing_author(repo.path(), &file, "acc:AAA"), + introducing_author(repo.path(), &file, "acc:AAA", true), Attribution::Author("fixture-human@example".into()) ); } diff --git a/ledger-core/src/changeset.rs b/ledger-core/src/changeset.rs index a03134bf..1319553f 100644 --- a/ledger-core/src/changeset.rs +++ b/ledger-core/src/changeset.rs @@ -102,6 +102,21 @@ impl ChangeSet { } } + /// The namespace this change-set's entities name, where one does: a + /// decision id, a version's or an acceptance's decision, a binding's or + /// a policy's `namespace`. What the legacy capability homes a flat-era + /// file by (LP-3.35); a file of the layout is homed by its directory. + pub fn namespace_hint(&self) -> Option<&str> { + self.decisions + .iter() + .map(|d| d.id.namespace()) + .chain(self.versions.iter().map(|v| v.decision.namespace())) + .chain(self.acceptances.iter().map(|a| a.decision.namespace())) + .chain(self.key_bindings.iter().map(|b| b.namespace.as_str())) + .chain(self.policies.iter().map(|p| p.namespace.as_str())) + .next() + } + /// How many entries the act filed, of every kind. pub fn entry_count(&self) -> usize { self.decisions.len() diff --git a/ledger-core/src/landing.rs b/ledger-core/src/landing.rs index ec6c4721..aba22ad0 100644 --- a/ledger-core/src/landing.rs +++ b/ledger-core/src/landing.rs @@ -25,6 +25,15 @@ //! `at` decides. Without git every entity is at the tip and `at` alone //! decides — the export-only verifier's reading, stated as its limit. //! +//! **The flat layout in history** (LP-3.35, rulings 82 and 97). A store made +//! before v1.9 keeps its flat paths in history. With the legacy capability +//! on, the write-once readers ([`touched_after_landing`], [`file_versions`], +//! [`content_at`]) follow a tracked file across both path patterns, told +//! apart by path; landing positions read the namespace paths only, which is +//! sound while no store with flat history holds an authority record. A +//! verifier without the capability refuses such a history before reading it +//! ([`crate::layout::flat_history`]). +//! //! Assumption, stated rather than proved: the default branch's history is //! not rewritten. A ruleset holds that; `verify` cannot. @@ -43,6 +52,9 @@ const TRACKED: &[&str] = crate::layout::TRACKED; pub struct Landing { /// Whether git answered. When it did not, everything is at the tip. pub available: bool, + /// Whether the write-once readers follow a file across the flat paths + /// of revision v1.8 in history (the legacy capability, LP-3.35). + pub legacy: bool, /// Repo-relative path → (first-parent index, landing commit). landed: BTreeMap, /// For touched files only: (path, entity key) → first-parent index of @@ -84,16 +96,16 @@ impl Landing { /// Resolve every tracked file's landing on `HEAD`, against `base` when /// given (see the module doc). A `base` that does not resolve is an /// error, never silently ignored: the result would differ from CI's. - pub fn compute(root: &Path, base: Option<&str>) -> Result { + pub fn compute(root: &Path, base: Option<&str>, legacy: bool) -> Result { if !git_ok(root, &["rev-parse", "--verify", "-q", "HEAD"]) { return Ok(Self::unknown()); } let head = first_parent_adds(root, "HEAD")?; - let touched = touched_after_landing(root); + let touched = touched_after_landing(root, legacy); let Some(base) = base else { let tip = head.commits; let entities = entity_landings(root, "HEAD", &touched)?; - return Ok(Self { available: true, landed: head.adds, entities, touched, tip }); + return Ok(Self { available: true, legacy, landed: head.adds, entities, touched, tip }); }; if !git_ok(root, &["rev-parse", "--verify", "-q", &format!("{base}^{{commit}}")]) { return Err(format!("`{base}` does not name a commit — landing is computed against the base")); @@ -110,7 +122,7 @@ impl Landing { // Entities of a touched file land where the base's line first holds // them; one only the branch holds lands at the tip, as the merge would. let entities = entity_landings(root, base, &touched)?; - Ok(Self { available: true, landed, entities, touched, tip }) + Ok(Self { available: true, legacy, landed, entities, touched, tip }) } /// A landing with every listed entity of one file, and every listed @@ -120,6 +132,7 @@ impl Landing { pub(crate) fn fixed(path: &str, entities: &[(String, usize)], files: &[(String, usize)], tip: usize) -> Self { Self { available: true, + legacy: false, landed: files.iter().map(|(f, i)| (f.clone(), (*i, String::new()))).collect(), entities: entities.iter().map(|(key, i)| ((path.to_string(), key.clone()), *i)).collect(), touched: [path.to_string()].into(), @@ -155,21 +168,27 @@ impl Landing { /// The tracked paths git says changed or vanished after they were added: /// modified or deleted on the first-parent line, or in the working tree. -/// The write-once checks read only these, not every landed file. -pub fn touched_after_landing(root: &Path) -> std::collections::BTreeSet { +/// The write-once checks read only these, not every landed file. With +/// `legacy`, the flat paths of revision v1.8 are listed too, so a flat file +/// the re-layout deleted is judged, by entity, against where it went. +pub fn touched_after_landing(root: &Path, legacy: bool) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); + let mut paths: Vec<&str> = TRACKED.to_vec(); + if legacy { + paths.extend(crate::layout::FLAT_TRACKED); + } let history = Command::new("git") .arg("-C") .arg(root) - .args(["log", "--first-parent", "--diff-merges=first-parent", "--relative", "--name-only"]) + .args(["log", "--first-parent", "--diff-merges=first-parent", "--relative", "--name-only", "--no-renames"]) .args(["--diff-filter=DM", "--format=", "HEAD", "--"]) - .args(TRACKED) + .args(&paths) .output(); let worktree = Command::new("git") .arg("-C") .arg(root) - .args(["diff", "--relative", "--name-only", "--diff-filter=DM", "HEAD", "--"]) - .args(TRACKED) + .args(["diff", "--relative", "--name-only", "--no-renames", "--diff-filter=DM", "HEAD", "--"]) + .args(&paths) .output(); for out_put in [history, worktree].into_iter().flatten().filter(|o| o.status.success()) { out.extend(String::from_utf8_lossy(&out_put.stdout).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string)); @@ -183,6 +202,22 @@ pub fn content_at(root: &Path, commit: &str, path: &str) -> Option { out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) } +/// The text of the first of `paths` the commit's tree holds: a file read +/// along its lineage across the re-layout (LP-3.35). +pub fn content_at_any(root: &Path, commit: &str, paths: &[String]) -> Option { + paths.iter().find_map(|p| content_at(root, commit, p)) +} + +/// The paths one tracked file has had: its path now and, with the legacy +/// capability, the flat path it had before the re-layout (LP-3.35). +pub fn lineage(path: &str, legacy: bool) -> Vec { + let mut out = vec![path.to_string()]; + if legacy { + out.extend(crate::layout::flat_counterpart(path)); + } + out +} + /// A repo-relative path for a file under `root`. pub fn relative(root: &Path, file: &Path) -> String { file.strip_prefix(root).unwrap_or(file).to_string_lossy().replace('\\', "/") @@ -196,12 +231,13 @@ struct Adds { /// Walk `rev`'s first-parent line oldest first, recording the first commit /// that added each tracked path. Merges are diffed against their first /// parent (`--first-parent` implies it), so a branch's files land at the -/// merge commit. +/// merge commit. Renames are not detected: a move is a delete and an add, +/// so each path is classified by itself (LP-3.35). fn first_parent_adds(root: &Path, rev: &str) -> Result { let out = Command::new("git") .arg("-C") .arg(root) - .args(["log", "--first-parent", "--diff-merges=first-parent", "--reverse", "--relative"]) + .args(["log", "--first-parent", "--diff-merges=first-parent", "--reverse", "--relative", "--no-renames"]) .args(["--name-only", "--diff-filter=A", "--format=%x01%H", rev, "--"]) .args(TRACKED) .output() @@ -250,10 +286,17 @@ fn entity_landings( /// The first-parent commits of `rev` that changed `path`, oldest first. pub fn file_versions(root: &Path, rev: &str, path: &str) -> Vec { + lineage_versions(root, rev, &[path.to_string()]) +} + +/// The first-parent commits of `rev` that changed any of `paths` (one +/// file's lineage), oldest first. +pub fn lineage_versions(root: &Path, rev: &str, paths: &[String]) -> Vec { Command::new("git") .arg("-C") .arg(root) - .args(["log", "--first-parent", "--diff-merges=first-parent", "--reverse", "--format=%H", rev, "--", path]) + .args(["log", "--first-parent", "--diff-merges=first-parent", "--reverse", "--format=%H", rev, "--"]) + .args(paths) .output() .ok() .filter(|o| o.status.success()) diff --git a/ledger-core/src/landing_tests.rs b/ledger-core/src/landing_tests.rs index 99d2081c..74e80a09 100644 --- a/ledger-core/src/landing_tests.rs +++ b/ledger-core/src/landing_tests.rs @@ -24,7 +24,7 @@ fn repo() -> tempfile::TempDir { #[test] fn without_git_everything_is_at_the_tip() { let dir = tempfile::tempdir().expect("tempdir"); - let l = Landing::compute(dir.path(), None).expect("compute"); + let l = Landing::compute(dir.path(), None, true).expect("compute"); assert!(!l.available); assert_eq!(l.index(".decisions/ns/a.ns/log/a.yml"), l.index(".decisions/ns/a.ns/log/b.yml")); } @@ -34,7 +34,7 @@ fn a_file_lands_at_the_first_first_parent_commit_containing_it() { let dir = repo(); file(dir.path(), "a.yml"); file(dir.path(), "b.yml"); - let l = Landing::compute(dir.path(), None).expect("compute"); + let l = Landing::compute(dir.path(), None, true).expect("compute"); assert!(l.index(".decisions/ns/a.ns/log/a.yml") < l.index(".decisions/ns/a.ns/log/b.yml")); assert!(l.index(".decisions/ns/a.ns/log/b.yml") < l.index(".decisions/ns/a.ns/log/uncommitted.yml"), "uncommitted is the tip"); } @@ -49,22 +49,22 @@ fn a_branch_verified_against_its_base_agrees_with_the_merge() { git(root, &["checkout", "-q", "main"]); file(root, "main.yml"); git(root, &["checkout", "-q", "topic"]); - let local = Landing::compute(root, Some("main")).expect("against base"); - let naive = Landing::compute(root, None).expect("no base"); + let local = Landing::compute(root, Some("main"), true).expect("against base"); + let naive = Landing::compute(root, None, true).expect("no base"); assert!( naive.index(".decisions/ns/a.ns/log/branch.yml") < naive.index(".decisions/ns/a.ns/log/main.yml"), "without the base the branch file reads as earlier, which the merge will not say" ); git(root, &["checkout", "-q", "main"]); git(root, &["merge", "-q", "--no-ff", "-m", "merge topic", "topic"]); - let merged = Landing::compute(root, None).expect("merge"); + let merged = Landing::compute(root, None, true).expect("merge"); let order = |l: &Landing| { (l.index(".decisions/ns/a.ns/log/a.yml") < l.index(".decisions/ns/a.ns/log/main.yml"), l.index(".decisions/ns/a.ns/log/main.yml") < l.index(".decisions/ns/a.ns/log/branch.yml")) }; assert_eq!(order(&local), (true, true)); assert_eq!(order(&local), order(&merged), "local against the base and the merge ref agree"); - assert!(Landing::compute(root, Some("no-such-ref")).is_err()); + assert!(Landing::compute(root, Some("no-such-ref"), true).is_err()); } #[test] diff --git a/ledger-core/src/layout.rs b/ledger-core/src/layout.rs index 23dcc0df..20c5bc38 100644 --- a/ledger-core/src/layout.rs +++ b/ledger-core/src/layout.rs @@ -34,6 +34,11 @@ pub const SIGNERS_FILE: &str = "allowed_signers"; /// pathspecs. Set files are not tracked: a floor is raised in place. pub const TRACKED: &[&str] = &[".decisions/ns/*/log/*", ".decisions/ns/*/roles/*", ".decisions/ns/*/sig/*"]; +/// The tracked directories of revision v1.8's flat layout, as git +/// pathspecs. Read in history only, by the legacy capability (LP-3.35, +/// rulings 82 and 97): never at the verified commit. +pub const FLAT_TRACKED: &[&str] = &[".decisions/log", ".decisions/roles", ".decisions/sig"]; + /// What kind of file a path under a namespace directory is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { @@ -112,6 +117,87 @@ pub fn classify(path: &str) -> Option<(String, Kind)> { Some((namespace.to_string(), kind)) } +/// The kind of a repo-relative path of the flat layout: +/// `.decisions//`. `None` for anything else, a +/// path of the namespace layout included: `ns` is no directory of the flat +/// layout, so no path matches both. +pub fn classify_flat(path: &str) -> Option { + let rest = path.strip_prefix(&format!("{STORE_DIR}/"))?; + let (sub, file) = rest.split_once('/')?; + if file.is_empty() || file.contains('/') { + return None; + } + match sub { + SETS_DIR => Some(Kind::Set), + ROLES_DIR => Some(Kind::Role), + LOG_DIR => Some(Kind::Log), + SIG_DIR => Some(Kind::Sig), + _ => None, + } +} + +/// The flat path a namespaced tracked file had before the re-layout: the +/// same kind and file name directly under `.decisions/`. A file's stem is +/// its id (LP-3.12), so the name follows the entity across the move. +pub fn flat_counterpart(path: &str) -> Option { + let (_, kind) = classify(path)?; + let sub = match kind { + Kind::Set => SETS_DIR, + Kind::Role => ROLES_DIR, + Kind::Log => LOG_DIR, + Kind::Sig => SIG_DIR, + Kind::Signers => return None, + }; + let file = path.rsplit('/').next()?; + Some(format!("{STORE_DIR}/{sub}/{file}")) +} + +/// Where a flat file went: every repo-relative path under a namespace's +/// directory at `root` that holds a file of the same kind and name. +pub fn moved_to(root: &Path, flat: &str) -> Vec { + let Some(kind) = classify_flat(flat) else { return Vec::new() }; + let Some(file) = flat.rsplit('/').next() else { return Vec::new() }; + namespaces(root) + .iter() + .filter(|ns| { + let dir = match kind { + Kind::Set => sets_dir(root, ns), + Kind::Role => roles_dir(root, ns), + Kind::Log => log_dir(root, ns), + Kind::Sig => sig_dir(root, ns), + Kind::Signers => return false, + }; + dir.join(file).is_file() + }) + .map(|ns| format!("{}/{file}", relative_dir(ns, kind))) + .collect() +} + +/// Whether some namespace's directory under `root` holds a file of the +/// same kind and name as the flat path. +pub fn moved_to_a_namespace(root: &Path, flat: &str) -> bool { + !moved_to(root, flat).is_empty() +} + +/// The oldest first-parent commit of any of `revs` that touched a flat +/// tracked path: the history predates v1.9 (LP-3.35). `None` when no +/// commit did, or when git cannot answer. +pub fn flat_history(root: &Path, revs: &[&str]) -> Option { + let out = std::process::Command::new("git") + .arg("-C") + .arg(root) + .args(["log", "--first-parent", "--reverse", "--format=%H"]) + .args(revs) + .arg("--") + .args(FLAT_TRACKED) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout).lines().map(str::trim).find(|l| !l.is_empty()).map(str::to_string) +} + /// Whether a repo-relative path is a change-set file of some namespace. pub fn is_log(path: &str) -> bool { classify(path).is_some_and(|(_, k)| k == Kind::Log) @@ -157,6 +243,44 @@ mod tests { assert!(is_role(".decisions/ns/a/roles/x.yml")); } + #[test] + fn a_flat_path_classifies_by_kind_and_names_its_namespaced_counterpart() { + assert_eq!(classify_flat(".decisions/log/01K.yml"), Some(Kind::Log)); + assert_eq!(classify_flat(".decisions/roles/steward.yml"), Some(Kind::Role)); + assert_eq!(classify_flat(".decisions/sig/01K.ssh.sig"), Some(Kind::Sig)); + assert_eq!(classify_flat(".decisions/sets/design.yml"), Some(Kind::Set)); + assert_eq!(classify_flat(".decisions/ns/a/log/01K.yml"), None, "no path matches both patterns"); + assert_eq!(classify_flat(".decisions/allowed_signers"), None); + assert_eq!(flat_counterpart(".decisions/ns/a.ns/log/01K.yml").as_deref(), Some(".decisions/log/01K.yml")); + assert_eq!(flat_counterpart(".decisions/ns/a.ns/sig/01K.ssh.sig").as_deref(), Some(".decisions/sig/01K.ssh.sig")); + assert_eq!(flat_counterpart(".decisions/ns/a.ns/allowed_signers"), None); + } + + #[test] + fn flat_history_names_the_oldest_flat_commit_and_nothing_in_a_clean_history() { + use crate::testkit::git; + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + git(root, &["init", "-q", "--initial-branch=main"]); + git(root, &["config", "user.email", "fixture-human@example"]); + git(root, &["config", "user.name", "Fixture"]); + let file = |rel: &str| { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().expect("dir")).expect("mkdir"); + std::fs::write(&p, rel).expect("write"); + git(root, &["add", "-A"]); + git(root, &["commit", "-q", "-m", rel]); + }; + file(".decisions/ns/a.ns/log/x.yml"); + assert_eq!(flat_history(root, &["HEAD"]), None, "a v1.9 history holds no flat path"); + file(".decisions/log/y.yml"); + let first = String::from_utf8_lossy(&git(root, &["rev-parse", "HEAD"]).stdout).trim().to_string(); + file(".decisions/log/z.yml"); + assert_eq!(flat_history(root, &["HEAD"]).as_deref(), Some(first.as_str()), "the oldest flat commit"); + assert!(moved_to_a_namespace(root, ".decisions/log/x.yml")); + assert!(!moved_to_a_namespace(root, ".decisions/log/y.yml")); + } + #[test] fn the_relative_directories_spell_the_layout() { assert_eq!(relative_dir("a.ns", Kind::Log), ".decisions/ns/a.ns/log"); diff --git a/ledger-core/src/revision.rs b/ledger-core/src/revision.rs index fe5d7f7b..9d1228c3 100644 --- a/ledger-core/src/revision.rs +++ b/ledger-core/src/revision.rs @@ -6,6 +6,14 @@ //! object store (`git show :`), parsed by the same //! [`crate::store`] rules the disk loader applies, so a store loaded at a //! revision and the same tree checked out on disk read identically. +//! +//! A revision before v1.9 holds the flat layout. The reference +//! implementation carries the legacy capability (LP-3.35), so a flat +//! revision is read too — its change-sets homed by the namespace their +//! entities name, its sets by the versions that name them — for `diff`, +//! `merge`, and the base overlay of the re-layout's own pull request. A +//! verifier run without the capability refuses such a history before it +//! gets here. use std::path::{Path, PathBuf}; use std::process::Command; @@ -47,24 +55,65 @@ pub fn load_at(root: &Path, rev: &str) -> Result { dir: root.join(STORE_DIR), ..Store::default() }; + let mut flat_sets: Vec<(String, String, String)> = Vec::new(); for path in tree_paths(root, &commit)? { - let Some((namespace, kind)) = crate::layout::classify(&path) else { continue }; - let Some(text) = show(root, &commit, &path)? else { continue }; let label = format!("{rev}:{path}"); let stem = file_stem(&path); - match kind { - Kind::Set => crate::store::take_set(&mut store, &namespace, &label, &stem, &text), - Kind::Role => crate::store::take_role(&mut store, &namespace, &label, &stem, &text), - Kind::Log => crate::store::take_log(&mut store, &namespace, PathBuf::from(&label), &label, &stem, &text), - Kind::Sig | Kind::Signers => {} + let Some(text) = show(root, &commit, &path)? else { continue }; + if let Some((namespace, kind)) = crate::layout::classify(&path) { + take_namespaced(&mut store, &namespace, kind, &label, &stem, &text); + } else if let Some(kind) = crate::layout::classify_flat(&path) { + take_flat(&mut store, kind, &label, &stem, &text, &mut flat_sets); } } + home_flat_sets(&mut store, flat_sets); store.sets.sort_by(|a, b| a.id.cmp(&b.id)); store.roles.sort_by(|a, b| a.id.cmp(&b.id)); store.log.sort_by(|a, b| a.file.id.cmp(&b.file.id)); Ok(store) } +/// One file of the layout at a revision, by its directory. +fn take_namespaced(store: &mut Store, namespace: &str, kind: Kind, label: &str, stem: &str, text: &str) { + match kind { + Kind::Set => crate::store::take_set(store, namespace, label, stem, text), + Kind::Role => crate::store::take_role(store, namespace, label, stem, text), + Kind::Log => crate::store::take_log(store, namespace, PathBuf::from(label), label, stem, text), + Kind::Sig | Kind::Signers => {} + } +} + +/// One file of the flat layout of revision v1.8 at a revision, homed by +/// content (LP-3.35): a change-set by the namespace its entities name, a +/// set later by the versions that name it, a role by nothing. +fn take_flat(store: &mut Store, kind: Kind, label: &str, stem: &str, text: &str, flat_sets: &mut Vec<(String, String, String)>) { + match kind { + Kind::Log => { + let namespace = serde_yaml::from_str::(text) + .ok() + .and_then(|cs| cs.namespace_hint().map(str::to_string)) + .unwrap_or_default(); + crate::store::take_log(store, &namespace, PathBuf::from(label), label, stem, text); + } + Kind::Set => flat_sets.push((label.to_string(), stem.to_string(), text.to_string())), + Kind::Role => crate::store::take_role(store, "", label, stem, text), + Kind::Sig | Kind::Signers => {} + } +} + +/// A flat set belongs to the namespace whose versions name it. +fn home_flat_sets(store: &mut Store, flat_sets: Vec<(String, String, String)>) { + for (label, stem, text) in flat_sets { + let namespace = store + .log + .iter() + .find(|l| l.file.versions.iter().any(|v| v.set == stem)) + .map(|l| l.namespace.clone()) + .unwrap_or_default(); + crate::store::take_set(store, &namespace, &label, &stem, &text); + } +} + /// Add to `store` the log files `base` carries that the working tree does /// not (D6): the log is append-only, so the union is what the merge into /// `base` would hold, and `verify --base` then judges the merge's store. @@ -82,9 +131,14 @@ pub fn overlay_base(store: &mut Store, base: &str) -> Result { let commit = resolve(&store.root, base)?; let listing = git(&store.root, &["ls-tree", "-r", "--name-only", &commit, "--", STORE_DIR]).unwrap_or_default(); for path in listing.lines().map(str::trim).filter(|p| !p.is_empty()) { - let Some((namespace, Kind::Sig)) = crate::layout::classify(path) else { continue }; let name = path.rsplit('/').next().unwrap_or_default().to_string(); - if store.sidecars.iter().any(|c| c.file == name && c.namespace == namespace) { + let namespace = match (crate::layout::classify(path), crate::layout::classify_flat(path)) { + (Some((namespace, Kind::Sig)), _) => namespace, + // A flat sidecar (LP-3.35) is homed by the entity it signs. + (None, Some(Kind::Sig)) => sidecar_home(store, &name).unwrap_or_default(), + _ => continue, + }; + if store.sidecars.iter().any(|c| c.file == name) { continue; } let Ok((ulid, scheme)) = crate::signing::Sidecar::parse_name(&name) else { continue }; @@ -96,6 +150,23 @@ pub fn overlay_base(store: &mut Store, base: &str) -> Result { Ok(added) } +/// The namespace of the signable entity a sidecar `..sig` +/// names, among the store's change-sets. +fn sidecar_home(store: &Store, name: &str) -> Option { + let ulid = name.split('.').next()?; + store + .log + .iter() + .find(|l| { + let cs = &l.file; + cs.acceptances.iter().any(|a| a.id.ulid() == ulid) + || cs.revocations.iter().any(|r| r.id.as_ref().is_some_and(|i| i.ulid() == ulid)) + || cs.key_bindings.iter().any(|b| b.id.ulid() == ulid) + || cs.policies.iter().any(|p| p.id.ulid() == ulid) + }) + .map(|l| l.namespace.clone()) +} + /// Every `.yml`/`.yaml` path under `.decisions/` in the revision's tree. /// A revision with no store at all is an empty store, not an error — a /// branch that predates `ledger init` diffs as "everything was added". diff --git a/ledger-core/src/verify/history.rs b/ledger-core/src/verify/history.rs index 45694ae5..9ba3a03f 100644 --- a/ledger-core/src/verify/history.rs +++ b/ledger-core/src/verify/history.rs @@ -19,6 +19,14 @@ //! would revive what it ended), an edited role. All are `L007` by the //! `L010` amendment mechanism: the stored content no longer matches content //! the record already fixed. +//! +//! **Across the re-layout** (LP-3.35, rulings 82 and 97). With the legacy +//! capability a file is judged along its lineage: the flat path it had in +//! revision v1.8 and its path under its namespace's directory, one history. +//! The re-layout commit deletes every flat file; that is no removal when +//! each entity is present, unchanged, under its namespace's directory, so a +//! deleted flat path whose file moved is judged through the moved file, and +//! anything else stays `L007`. use std::collections::BTreeMap; use std::path::Path; @@ -27,7 +35,7 @@ use serde_yaml::Value; use crate::finding::{Finding, VerifyClass}; use crate::landed::entities; -use crate::landing::{content_at, file_versions, touched_after_landing, Landing}; +use crate::landing::{content_at_any, lineage, lineage_versions, touched_after_landing, Landing}; use crate::store::Store; /// Every history finding, when git answered. @@ -36,25 +44,44 @@ pub fn findings(store: &Store, landing: &Landing) -> Vec { return Vec::new(); } let mut out = Vec::new(); - for path in touched_after_landing(&store.root) { - let versions = file_versions(&store.root, "HEAD", &path); + // A flat file the re-layout deleted is judged through the file it + // became, under its namespace's directory, along their one lineage; + // one that went nowhere is a removal like any other. + let mut touched = std::collections::BTreeSet::new(); + for path in touched_after_landing(&store.root, landing.legacy) { + let moved = if landing.legacy && crate::layout::classify_flat(&path).is_some() { + crate::layout::moved_to(&store.root, &path) + } else { + Vec::new() + }; + if moved.is_empty() { + touched.insert(path); + } else { + touched.extend(moved); + } + } + for path in touched { + let paths = lineage(&path, landing.legacy); + let versions = lineage_versions(&store.root, "HEAD", &paths); let Some(first) = versions.first() else { continue }; let now = std::fs::read_to_string(store.root.join(&path)).ok(); - if crate::layout::is_log(&path) { - out.extend(log_file(&store.root, &path, &versions, now.as_deref())); - out.extend(format_changes(&store.root, &path, &versions, now.as_deref())); - } else if let Some(landed) = content_at(&store.root, first, &path) { + let is_log = crate::layout::is_log(&path) || crate::layout::classify_flat(&path) == Some(crate::layout::Kind::Log); + if is_log { + out.extend(log_file(&store.root, &path, &paths, &versions, now.as_deref())); + out.extend(format_changes(&store.root, &path, &paths, &versions, now.as_deref())); + } else if let Some(landed) = content_at_any(&store.root, first, &paths) { out.extend(whole_file(&path, first, &landed, now.as_deref())); } } out } -/// Each entity any landed version of the file held, against the file now. -fn log_file(root: &Path, path: &str, versions: &[String], now: Option<&str>) -> Vec { +/// Each entity any landed version of the file held, along its lineage, +/// against the file now. +fn log_file(root: &Path, path: &str, paths: &[String], versions: &[String], now: Option<&str>) -> Vec { let mut held: BTreeMap = BTreeMap::new(); for commit in versions { - let Some(text) = content_at(root, commit, path) else { continue }; + let Some(text) = content_at_any(root, commit, paths) else { continue }; for (key, value) in entities(&text).unwrap_or_default() { held.entry(key).or_insert((value, commit.as_str())); } @@ -87,9 +114,9 @@ fn log_file(root: &Path, path: &str, versions: &[String], now: Option<&str>) -> /// working tree last (ruling 58). The one change allowed is a correction /// (LP-3.16): a raise, to exactly the lowest format the content needs, with /// no entity changed in the same step. Anything else is `L007`. -fn format_changes(root: &Path, path: &str, versions: &[String], now: Option<&str>) -> Vec { +fn format_changes(root: &Path, path: &str, paths: &[String], versions: &[String], now: Option<&str>) -> Vec { let mut steps: Vec<(String, String)> = - versions.iter().filter_map(|c| content_at(root, c, path).map(|t| (short(c).to_string(), t))).collect(); + versions.iter().filter_map(|c| content_at_any(root, c, paths).map(|t| (short(c).to_string(), t))).collect(); if let Some(now) = now { steps.push(("the working tree".to_string(), now.to_string())); } @@ -128,14 +155,14 @@ fn declared(text: &str) -> Option { fn whole_file(path: &str, commit: &str, landed: &str, now: Option<&str>) -> Option { let same = match now { None => false, - Some(now) if crate::layout::is_role(path) => without_format(now) == without_format(landed), + Some(now) if is_role(path) => without_format(now) == without_format(landed), Some(now) => now == landed, }; if same { return None; } let what = if now.is_some() { "changed" } else { "removed" }; - let rule = if crate::layout::is_role(path) { + let rule = if is_role(path) { "roles are write-once; a new role and new grants supersede" } else { "a landed signature is never edited or removed" @@ -143,6 +170,11 @@ fn whole_file(path: &str, commit: &str, landed: &str, now: Option<&str>) -> Opti Some(Finding::new(VerifyClass::L007, path, format!("{what} since it landed in {} — {rule}", short(commit)))) } +/// A role file of either layout. +fn is_role(path: &str) -> bool { + crate::layout::is_role(path) || crate::layout::classify_flat(path) == Some(crate::layout::Kind::Role) +} + fn without_format(text: &str) -> Option { let mut v: Value = serde_yaml::from_str(text).ok()?; if let Value::Mapping(m) = &mut v { diff --git a/ledger-core/src/verify/integrity.rs b/ledger-core/src/verify/integrity.rs index 909a9d3e..be7d09f2 100644 --- a/ledger-core/src/verify/integrity.rs +++ b/ledger-core/src/verify/integrity.rs @@ -82,11 +82,11 @@ pub struct BlameOutcome { /// not failed: it is the state every acceptance passes through on its way /// into history. The check therefore lands on the next run over committed /// history, which in practice is CI. -pub fn blame_consistency(view: &View, store: &Store) -> BlameOutcome { +pub fn blame_consistency(view: &View, store: &Store, legacy: bool) -> BlameOutcome { let mut out = BlameOutcome::default(); for viewed in &view.acceptances { let a = viewed.acceptance; - match introducing_author(&store.root, viewed.path, &a.id.to_string()) { + match introducing_author(&store.root, viewed.path, &a.id.to_string(), legacy) { Attribution::Author(email) if email != a.actor.as_str() => { out.findings.push(Finding::new( VerifyClass::L009, diff --git a/ledger-core/src/verify/mod.rs b/ledger-core/src/verify/mod.rs index 3beb9d0e..6e3bf245 100644 --- a/ledger-core/src/verify/mod.rs +++ b/ledger-core/src/verify/mod.rs @@ -44,17 +44,23 @@ pub struct Options { pub history: bool, /// The base the landing is computed against (`verify --base`). pub base: Option, + /// Whether the history readers follow a file across the flat paths of + /// revision v1.8 (the legacy capability, LP-3.35; rulings 82, 97). The + /// reference implementation has it; `verify --no-legacy-layout` shows + /// what an implementation without it does, and refuses a flat history + /// before any of this runs. + pub legacy_layout: bool, } impl Options { /// Every class, as of `today`, with blame and landing consulted. pub fn full(today: NaiveDate) -> Self { - Self { gate: None, today, blame: true, history: true, base: None } + Self { gate: None, today, blame: true, history: true, base: None, legacy_layout: true } } /// Every class as of `today`, without git: the write-time gate. pub fn offline(today: NaiveDate) -> Self { - Self { gate: None, today, blame: false, history: false, base: None } + Self { gate: None, today, blame: false, history: false, base: None, legacy_layout: true } } } @@ -183,7 +189,7 @@ pub fn verify(store: &Store, opts: &Options) -> Report { ..Report::default() }; if opts.blame { - let outcome = integrity::blame_consistency(&view, store); + let outcome = integrity::blame_consistency(&view, store, opts.legacy_layout); findings.extend(outcome.findings); report.blame_uncommitted = outcome.uncommitted; report.blame_unavailable = outcome.no_repository; @@ -211,7 +217,7 @@ fn graph_stage(store: &Store, landing: &crate::landing::Landing) -> Vec crate::landing::Landing { match opts.history { - true => crate::landing::Landing::compute(&store.root, opts.base.as_deref()).unwrap_or_default(), + true => crate::landing::Landing::compute(&store.root, opts.base.as_deref(), opts.legacy_layout).unwrap_or_default(), false => crate::landing::Landing::unknown(), } } diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index c68560ec..8f67dcaf 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -160,7 +160,7 @@ Revision v1.8 held one `sets/`, `roles/`, `log/` and `sig/` and one `allowed_sig - **LP-3.12** (V) The file stem MUST equal the id the file declares: `.yml` for a change-set whose `id` is `cs:`, `.yml` for a set, `.yml` for a role. A disagreement is a schema fault. - **LP-3.13** (W) The `index/` directory is a rebuildable cache and is never committed. A store keeps it out of version control so that a rebuild cache can never be committed by accident. - **LP-3.34** (W, V) A store holds each namespace under `.decisions/ns//`, with its own `sets/`, `roles/`, `log/`, `sig/` and `allowed_signers`. A file's namespace is its directory. At the verified commit and in the working tree, a file under `.decisions/` outside `ns/` and `index/` is a schema fault: the flat paths of revision v1.8 among them, a store with no `ns/` directory and any of those paths included, and a file directly under `ns/`, since a namespace is a directory named by its id (rulings 62, 63). The layout is a specification revision with no format number (ruling 66): no file's content changes, and no digest moves. -- **LP-3.35** (V) **Not implemented** (issue 2). A verifier looks for the flat paths of revision v1.8 (`.decisions/log/`, `.decisions/roles/`, `.decisions/sig/`) on the first-parent history it reads. A verifier with the legacy capability then reads change-set, role and sidecar files at both those paths and the paths of LP-3.34, told apart by path, and only their entities, for `L007`, `L009` and the base overlay (ruling 97). The capability is not part of the verifier profile (ruling 82). A verifier without it refuses a repository whose history holds a flat path, with exit status 2. +- **LP-3.35** (V) A verifier looks for the flat paths of revision v1.8 (`.decisions/log/`, `.decisions/roles/`, `.decisions/sig/`) on the first-parent history it reads, the verified commit's and, when given, the base's. A verifier with the **legacy capability** then reads change-set, role and sidecar files at both those paths and the paths of LP-3.34, told apart by path and never a commit by its layout, and only their entities, for `L007` (with LP-3.16's `format:` comparison), `L009` and the base overlay (ruling 97): a file's lineage is its path under its namespace's directory and the flat path of the same kind and name, since a file's stem is its id (LP-3.12). Nothing else is read from the flat layout: no store-wide set, role, genesis or `allowed_signers`. The capability is not part of the verifier profile (ruling 82). A verifier without it refuses a repository whose history holds a flat path, with exit status 2 (LP-8.18), naming the first flat commit, and never reports the repository conformant. *The reference implementation has the capability; `ledger verify --no-legacy-layout` runs without it.* - **LP-3.36** (W, V) Ids, set ids, role ids and file names are unique within a namespace. Two namespaces may each declare a set, or a role, of one id; a version names a set of its own namespace (LP-5.22), and a role is named by the grants and policies of its own namespace once authority is per namespace (LP-6.31, issue 5). ### 3.2 Format declarations @@ -1115,8 +1115,10 @@ CI has to tell "the gate said no" apart from "the gate broke". This differs from Appending a new entity to a landed file changes no other entity; the new one lands where it was appended (LP-8.24). + A re-layout from the flat layout of revision v1.8 (LP-3.34) deletes every flat file. That is no removal when each of its entities is present, unchanged, under its namespace's directory in the same commit: a verifier with the legacy capability judges the moved file along its lineage (LP-3.35), and anything else stays `L007`. + A log file's `format:` declaration is not an entity, but it is compared too (ruling 58): each change of it along the same history, the working tree last, MUST be a correction under LP-3.16, a raise to exactly the lowest format the content needs with no entity changed in the same step; otherwise `L007`. -- **LP-8.32** (V) `L009` reads the author of the commit that introduced an acceptance and compares its email address, as an identity (section 3.4), with the acceptance's actor. A repository with no git history has the check skipped entirely (LP-8.14). +- **LP-8.32** (V) `L009` reads the author of the commit that introduced an acceptance and compares its email address, as an identity (section 3.4), with the acceptance's actor. A repository with no git history has the check skipped entirely (LP-8.14). With the legacy capability the introducing commit is found along the file's lineage (LP-3.35), so a re-layout commit is never the introduction of what it moved. ### 8.8 Notices @@ -1471,6 +1473,7 @@ A deployment should tell holders, before they accept a grant, that their address | 7 October 2026 | Ruling 58 applied: a landed `format:` declaration is compared across first-parent history, and any change other than the LP-3.16 correction fails `L007` (LP-3.16, LP-8.30, `L007`'s wording). This enforces the ruling of 5 October on #81; its three corrections stay green. No class added; no digest moves. | | 7 October 2026 | Ruling 59 applied: a `set:` grant scope accepts every valid set id, dots included (LP-3.3). The reference had refused a dot, which section 3.3 allows. No class added; no digest moves: a scope is hashed as written. | | 7 October 2026 | Ruling 60 applied: a change-set's `parents` is part of its header entity and immutable once landed (LP-8.25). The reference had keyed each parent as its own entity, so a parent appended to a landed header passed. No class added; no digest moves. | +| 9 October 2026 | **Spec v1.9: the flat layout in history** (rulings 82 and 97; PRD §3.1.1, §3.5.7; issue 2). LP-3.35 loses its not-implemented mark: a verifier detects the flat paths of revision v1.8 on the first-parent history it reads; with the legacy capability it follows each tracked file along its lineage, the flat path and the namespaced one, for `L007`, LP-3.16's `format:` comparison, `L009` and the base overlay, and reads no flat semantics; without it, it refuses with exit 2 naming the first flat commit. LP-8.30 gains the re-layout exception, LP-8.32 the lineage. No class added; no digest moves. The Appendix C note of v1.9 gains its history paragraph. | | 9 October 2026 | **Spec v1.9: one directory per namespace** (rulings 62, 63, 66 as amended by 87; PRD §3.1; issue 1). Section 3.1 gives the layout: every namespace under `.decisions/ns//` with its own `sets/`, `roles/`, `log/`, `sig/` and `allowed_signers`; the flat paths of revision v1.8 are a schema fault at the verified commit (LP-3.34, new). LP-3.35 (new) states what a verifier does with the flat layout in history and is marked not implemented until issue 2. LP-3.36 (new): ids, set ids, role ids and file names are unique within a namespace. LP-5.22 (new): a version names a set of its own namespace. LP-4.10 and sections 5.2, 5.3 and 5.6 name the new paths. No class added; no digest moves; no format number; `CANONICAL_FORM` stays `v1`. The Appendix C note "Spec v1.9 — one directory per namespace" is added. | | 9 October 2026 | The Unicode version behind LP-4.18 step 2b is named: 17.0.0. The tables were unpinned — the reference accepted any `unicode-normalization` 0.1.x and commits no lockfile, and a second implementation had no version to match. The reference now pins the release that carries these tables (`=0.1.25`), held by a test on its `UNICODE_VERSION`. A move to a later Unicode version is a canonical-form question for the principal. No class added; no digest moves; `CANONICAL_FORM` stays `v1`. (#140) | @@ -1646,13 +1649,31 @@ No class is added: the refusal is `SCHEMA`. `ns//` in one commit: each change-set and sidecar to the namespace its entities belong to, each set to the namespace whose versions name it, each role file to the namespace whose grants and policies name -it. How a verifier reads the flat paths that stay in such a store's -history, and what a verifier without that capability does, is LP-3.35 -(issue 2 of the PRD's §6, ruling 82 as narrowed by 97). A governed store -with more than one namespace is not moved but re-founded (ruling 68); its -note lands with issue 9. This repository's own store and the committed -fixture stores are re-laid out by issues 10 and 9; until then a verifier -of this revision refuses them, as LP-3.34 says. +it. A governed store with more than one namespace is not moved but +re-founded (ruling 68); its note lands with issue 9. This repository's +own store and the committed fixture stores are re-laid out by issues 10 +and 9; until then a verifier of this revision refuses them, as LP-3.34 +says. + +**The flat layout in history** (rulings 82 and 97; LP-3.35). The +re-layout cannot make the flat paths disappear from history, and +verification reads history for `L007`, `L009` and the base overlay. +Reading the flat layout there is a *legacy capability*, outside the +verifier profile: a verifier with it classifies each path by itself, +never a commit by its layout, follows each tracked file along its lineage +(its namespaced path and the flat path of the same kind and name), judges +immutability by entity across the move, finds `L009`'s introducing commit +along the lineage, and reads a flat base in the overlay. It reads no flat +semantics. A verifier without it refuses such a repository with exit +status 2, naming the first flat commit, and never reports it conformant: +it never passes the repository by treating the re-layout commit as every +entity's landing. The reference implementation has the capability, since +this repository's history will always predate v1.9; `ledger verify +--no-legacy-layout` shows what a verifier without it does. Landing +positions, which still decide authority verdicts until order from the +acts lands (issue 12), read the namespaced paths only; that is sound +because no committed store with flat history holds an authority record +(confirmed by scan on 9 October 2026). #### A float or an explicit null in hashed content is refused (2026-10-07, no format change)