From 9e6daf592b4f3ea040897f288648686efab46e3a Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Fri, 9 Oct 2026 15:24:29 +0000 Subject: [PATCH 1/2] Detect the flat layout in history; refuse it without the legacy capability Issue #145 (PRD issue 2; rulings 82 and 97; AC-82). Before reading history a verifier looks for the flat paths of revision v1.8 (`.decisions/log`, `.decisions/roles`, `.decisions/sig`) on the first-parent line of the verified commit and, when given, the base (`layout::flat_history`). Run without the legacy capability (`ledger verify --no-legacy-layout`) it refuses such a repository with exit 2, naming the first flat commit, and never reports it conformant. With the capability, which the reference implementation has, the three readers ruling 97 leaves to git follow a tracked file along its lineage, its namespaced path and the flat path of the same kind and name, told apart by path and never a commit by its layout: - `L007`: a flat file the re-layout deleted is judged through the file it became, by entity, with ruling 58's `format:` comparison along the same lineage (`verify::history`); a flat file that went nowhere is a removal as before. Rename detection is off in every landing query, so a move reads as a delete and an add and each path classifies by itself. - `L009`: the pickaxe names both paths, so the introducing commit is the original and never the move (`blame::introducing_author`). - The base overlay: a flat base is read for the re-layout's own pull request; a flat change-set is homed by the namespace its entities name and a flat sidecar by the entity it signs (`revision`). Landing positions read the namespaced paths only, as the session's instruction says; that is sound because no committed store with flat history holds an authority record, confirmed by scanning every first-parent revision of this repository (none, and no deletion in the flat log). Tests (`ledger-cli/tests/legacy_layout.rs`, each failing before the change): a flat store re-laid out in one commit by a non-acceptor verifies conformant with blame on; an entity dropped by the move is `L007` by entity; a flat file removed rather than moved stays `L007`; a `format:` raised past the need across the move is `L007`; without the capability a flat history is refused with exit 2 naming the first flat commit, on the base too; a v1.9 history needs no capability; the overlay reads a flat base. `layout` gains unit tests for the flat classifier, the counterpart and the detection. Protocol: LP-3.35 loses its not-implemented mark; LP-8.30 gains the re-layout exception; LP-8.32 the lineage; Appendix B row; the v1.9 Appendix C note gains its history paragraph. No class added, no digest moves. Stacked on #154 (issue 1). This repository's own store is still flat, so `ledger verify --export` is still refused here; #10's re-layout commit, stacked on this, makes it green. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ledger-cli/src/commands/cli_enum.rs | 5 + ledger-cli/src/commands/mod.rs | 4 +- ledger-cli/src/commands/verify.rs | 25 ++++ ledger-cli/tests/binding_accounting.rs | 2 +- ledger-cli/tests/legacy_layout.rs | 191 +++++++++++++++++++++++++ ledger-core/src/author/report.rs | 2 +- ledger-core/src/authority/signers.rs | 2 +- ledger-core/src/blame.rs | 12 +- ledger-core/src/blame_tests.rs | 12 +- ledger-core/src/changeset.rs | 15 ++ ledger-core/src/landing.rs | 69 +++++++-- ledger-core/src/landing_tests.rs | 12 +- ledger-core/src/layout.rs | 124 ++++++++++++++++ ledger-core/src/revision.rs | 89 ++++++++++-- ledger-core/src/verify/history.rs | 60 ++++++-- ledger-core/src/verify/integrity.rs | 4 +- ledger-core/src/verify/mod.rs | 14 +- ledger/spec/ledger-protocol.md | 39 +++-- 18 files changed, 610 insertions(+), 71 deletions(-) create mode 100644 ledger-cli/tests/legacy_layout.rs 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) From 25ab27ad9d1793d9e259e3750fc73e9225e7819f Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Sun, 11 Oct 2026 06:12:22 +0000 Subject: [PATCH 2/2] Seam bindings for step 2: the flat layout in history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files the signed bindings the contract-surface gate demands for the 17 exposed Rust surface changes of this step over 7 files — two new declarations (blame.rs, verify/integrity.rs) with their verdict_knowledge written; the other five amended with bindings only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ...ust-ledger-cli-src-commands-verify-rs.yaml | 6 +++ .../seam-rust-ledger-core-src-blame-rs.yaml | 14 +++++++ ...eam-rust-ledger-core-src-changeset-rs.yaml | 8 +++- .../seam-rust-ledger-core-src-landing-rs.yaml | 38 ++++++++++++++++++- .../seam-rust-ledger-core-src-layout-rs.yaml | 36 ++++++++++++++++++ ...t-ledger-core-src-verify-integrity-rs.yaml | 14 +++++++ ...am-rust-ledger-core-src-verify-mod-rs.yaml | 6 +++ 7 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 .ddd/seams/seam-rust-ledger-core-src-blame-rs.yaml create mode 100644 .ddd/seams/seam-rust-ledger-core-src-verify-integrity-rs.yaml 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