diff --git a/.ddd/seams/seam-rust-ledger-core-src-verify-crossing-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-verify-crossing-rs.yaml new file mode 100644 index 00000000..73228023 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-verify-crossing-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-verify-crossing-rs +boundary: contract surface of ledger-core/src/verify/crossing.rs +verdict_knowledge: 'Callers learn the crossing stage: a decision''s latest version may not supersede a decision of another namespace, since only based_on crosses namespaces, judged on the live claim alone so history keeps what it recorded, a SCHEMA fault and no class added (LP-3.31, rulings 43 and 75).' +contract_location: ledger-core/src/verify/crossing.rs +obligations: [] +metadata: {} +bindings: +- symbol: findings + file: ledger-core/src/verify/crossing.rs + before: absent + after: sha256:2e57d63f5d371504edeb1c90e0fb16d8815f6d379af5ab2c8ba48ca07aa12615 + base_revision: 74fb8581e549c803a1b4a0ddff3a6ce3ed90cc06 + hash: sha256:f2cc8de7468fca69fc456a8c3e63ceac592931d9a37f388763e7f6cf8b635b9c 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 5b393984..f12489ec 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 @@ -138,3 +138,9 @@ bindings: after: sha256:bdac3c5d6194cd868373d4bdcab54a7f410fb4b7078b0d0d2fff70fec3f3d703 base_revision: efc4bc55bfddbcadbc440489ad8ba701ae86b5e9 hash: sha256:ef2bf299bd26f57a28cfeb4d0efcb845cf0c13164035048387168764f5b96dc9 +- symbol: crossing + file: ledger-core/src/verify/mod.rs + before: sha256:bdac3c5d6194cd868373d4bdcab54a7f410fb4b7078b0d0d2fff70fec3f3d703 + after: sha256:005fa2ac0a75a8c4cf4b1d60c90eff616b8e2fa84b879ab7a233d566775c09c6 + base_revision: 74fb8581e549c803a1b4a0ddff3a6ce3ed90cc06 + hash: sha256:50077d369f2ca0de42441e4d36809c172a001080777b7592ca84c080d9f38125 diff --git a/ledger-cli/tests/crossing.rs b/ledger-cli/tests/crossing.rs new file mode 100644 index 00000000..3a11eb00 --- /dev/null +++ b/ledger-cli/tests/crossing.rs @@ -0,0 +1,108 @@ +//! Only `based_on` crosses a namespace; `supersedes` never does (LP-3.31, +//! ruling 43), judged on live claims alone (ruling 75; AC-43). +//! +//! The writer refuses the verb by the gate it runs over the store as it +//! would be, and the gate fails a claim filed by hand past the verb. A next +//! version that drops the edge repairs the store; the version that made the +//! claim stays in history, unjudged. + +mod common; + +use common::{hand, Repo}; +use ledger_core::changeset::ChangeSet; +use ledger_core::mint::UlidMint; +use ledger_core::version::VersionRaw; + +const OURS: &str = "fixture.ledger"; +const THEIRS: &str = "other.ledger"; + +/// A decision in `ns`, returning its id. +fn decision(repo: &Repo, ns: &str, statement: &str) -> String { + let out = repo.ok(&["add", "--set", "ledger-design", "--namespace", ns, "--statement", statement, "--store", "constraint", "--discharge", "analyzer:DEC001"]); + common::decision_id(&out) +} + +/// The latest version of `decision`, as filed. +fn latest(repo: &Repo, decision: &str) -> VersionRaw { + let store = ledger_core::store::load(repo.path()); + let view = ledger_core::verify::view::View::build(&store); + view.latest.get(decision).and_then(|i| view.versions.get(*i)).map(|v| v.raw.clone()).expect("a filed version") +} + +/// File the next version of a decision by hand, past every verb: `mutate` +/// edits the copy of its latest version; the change-set lands in the +/// decision's own namespace. +fn next_version_by_hand(repo: &Repo, decision: &str, mutate: impl FnOnce(&mut VersionRaw)) -> String { + let mut raw = latest(repo, decision); + raw.parent = Some(raw.hash.clone()); + mutate(&mut raw); + raw.hash = ledger_core::hash::version_hash(&raw); + let hash = raw.hash.to_string(); + let mut mint = UlidMint::system(); + let mut cs = ChangeSet::empty(ledger_core::format::CURRENT_FORMAT, mint.mint_id("cs").expect("cs id"), chrono::Utc::now(), "fixture-human@example".parse().expect("identity"), None); + cs.versions.push(raw); + let log = repo.log_dir(decision.split('/').next().and_then(|d| d.strip_prefix("dec:")).expect("namespace")); + std::fs::create_dir_all(&log).expect("log dir"); + std::fs::write(log.join(cs.file_name()), serde_yaml::to_string(&cs).expect("yaml")).expect("write"); + hash +} + +fn verify(repo: &Repo) -> (i32, String) { + let out = repo.ledger(&["verify", "--no-blame"]); + (out.status.code().unwrap_or(-1), common::both(&out)) +} + +/// Two namespaces, a decision in each, committed and conformant. +fn two_namespaces() -> (Repo, String, String) { + let repo = Repo::human(); + repo.declare(); + repo.declare_in(THEIRS); + let old = decision(&repo, OURS, "The one to be superseded."); + let successor = decision(&repo, THEIRS, "The would-be successor, in another namespace."); + hand::commit(&repo, "two namespaces"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); + (repo, old, successor) +} + +#[test] +fn the_verb_refuses_to_supersede_across_namespaces_and_writes_nothing() { + let (repo, old, successor) = two_namespaces(); + let before = repo.log_files(); + let refused = repo.refused(&["supersede", &old, "--by", &successor, "--reason", "a crossing"]); + assert!(refused.contains("[SCHEMA]") && refused.contains(&successor) && refused.contains("ruling 43"), "{refused}"); + assert_eq!(repo.log_files(), before, "nothing written"); + // The same edge the other way round is refused the same way. + let refused = repo.refused(&["supersede", &successor, "--by", &old]); + assert!(refused.contains("[SCHEMA]") && refused.contains(&old) && refused.contains("ruling 43"), "{refused}"); +} + +#[test] +fn a_hand_filed_crossing_is_a_schema_fault_until_a_next_version_drops_the_edge() { + let (repo, old, successor) = two_namespaces(); + let claim = next_version_by_hand(&repo, &successor, |raw| raw.supersedes = Some(old.parse().expect("id"))); + hand::commit(&repo, "a supersedes into another namespace, by hand"); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains(&format!("[SCHEMA] {successor}:")) && text.contains(&old) && text.contains("ruling 43"), "{text}"); + // The next version withdraws the claim (ruling 75); the claim stays in + // history and the store is conformant again. + let withdrawn = next_version_by_hand(&repo, &successor, |raw| raw.supersedes = None); + hand::commit(&repo, "the edge dropped"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); + assert!(text.contains("conformant"), "{text}"); + let store = ledger_core::store::load(repo.path()); + assert!(store.log.iter().flat_map(|l| l.file.versions.iter()).any(|v| v.hash.to_string() == claim && v.supersedes.is_some()), "history keeps the claim"); + assert_eq!(latest(&repo, &successor).hash.to_string(), withdrawn); +} + +#[test] +fn a_supersession_within_a_namespace_still_stands() { + let (repo, old, _) = two_namespaces(); + let successor = decision(&repo, OURS, "The successor, at home."); + repo.ok(&["supersede", &old, "--by", &successor]); + hand::commit(&repo, "superseded at home"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); +} diff --git a/ledger-core/src/verify/crossing.rs b/ledger-core/src/verify/crossing.rs new file mode 100644 index 00000000..9e43ad69 --- /dev/null +++ b/ledger-core/src/verify/crossing.rs @@ -0,0 +1,37 @@ +//! Only `based_on` crosses a namespace (LP-3.31, ruling 43). +//! +//! A `supersedes` edge never names a decision of another namespace. In a +//! store that exists today the rule is judged on live claims only: a +//! decision's latest version (ruling 75). Earlier versions are history, so a +//! next version that drops the edge repairs the store — the same withdrawal +//! LP-8.21 records for a losing claimant under `G005`. A `SCHEMA` fault, no +//! class added; a target no change-set filed stays `G001`'s, whichever +//! namespace it names. + +use crate::finding::Finding; +use crate::verify::view::View; + +/// Every decision whose latest version supersedes a decision of another +/// namespace. +pub fn findings(view: &View) -> Vec { + view.latest_versions() + .filter_map(|v| v.parsed.as_ref()) + .filter_map(|p| { + let old = p.supersedes.as_ref()?; + (old.namespace() != p.decision.namespace()).then(|| { + Finding::schema( + &p.decision.to_string(), + format!( + "its latest version {} supersedes `{old}`, a decision of `{}` — only `based_on` crosses a namespace; `supersedes` never does (LP-3.31, ruling 43), judged on the live claim alone (ruling 75)", + p.stored_hash.short(), + old.namespace() + ), + ) + }) + }) + .collect() +} + +#[path = "crossing_tests.rs"] +#[cfg(test)] +mod tests; diff --git a/ledger-core/src/verify/crossing_tests.rs b/ledger-core/src/verify/crossing_tests.rs new file mode 100644 index 00000000..24923d9b --- /dev/null +++ b/ledger-core/src/verify/crossing_tests.rs @@ -0,0 +1,52 @@ +//! The crossing stage over in-memory stores: a `supersedes` into another namespace, judged on the live claim. + +use super::findings; +use crate::id::DecisionId; +use crate::testkit; +use crate::verify::view::View; +use crate::version::VersionRaw; + +const SUCCESSOR: &str = "dec:hafeok.ledger/01K2C4YQJ3F8M0PT5W7NZ9RDY0"; +const ELSEWHERE: &str = "dec:other.ledger/01K2C4YQJ3F8M0PT5W7NZ9RDY1"; + +fn id(s: &str) -> DecisionId { + s.parse().expect("id") +} + +/// The successor's first version, superseding `old`. +fn claim(old: &str) -> VersionRaw { + let mut v = testkit::version(); + v.decision = id(SUCCESSOR); + v.supersedes = Some(id(old)); + testkit::sealed(v) +} + +#[test] +fn a_latest_version_superseding_another_namespace_is_a_schema_fault_naming_both() { + let store = testkit::store(testkit::changeset(vec![claim(ELSEWHERE)], Vec::new())); + let found = findings(&View::build(&store)); + assert_eq!(found.len(), 1, "{found:?}"); + let text = found[0].to_string(); + assert!(text.starts_with(&format!("[SCHEMA] {SUCCESSOR}:")), "{text}"); + assert!(text.contains(ELSEWHERE) && text.contains("`other.ledger`") && text.contains("ruling 43"), "{text}"); +} + +#[test] +fn a_next_version_that_drops_the_edge_repairs_it_and_history_is_not_judged() { + let first = claim(ELSEWHERE); + let mut withdrawn = first.clone(); + withdrawn.parent = Some(first.hash.clone()); + withdrawn.supersedes = None; + let withdrawn = testkit::sealed(withdrawn); + let store = testkit::store(testkit::changeset(vec![first, withdrawn], Vec::new())); + let view = View::build(&store); + assert!(view.versions.iter().any(|v| v.raw.supersedes.is_some()), "the claim stays in history"); + assert!(findings(&view).is_empty(), "{:?}", findings(&view)); +} + +#[test] +fn a_supersession_within_the_namespace_is_not_a_crossing() { + let old = testkit::sealed(testkit::version()); + let store = testkit::store(testkit::changeset(vec![old, claim(&testkit::decision_id().to_string())], Vec::new())); + assert!(findings(&View::build(&store)).is_empty()); +} diff --git a/ledger-core/src/verify/mod.rs b/ledger-core/src/verify/mod.rs index 467f13b4..be23b4e1 100644 --- a/ledger-core/src/verify/mod.rs +++ b/ledger-core/src/verify/mod.rs @@ -13,6 +13,7 @@ pub mod acts; pub mod authority; pub mod belonging; +pub mod crossing; pub mod disposition; mod genesis_role; pub mod history; @@ -162,9 +163,11 @@ impl Report { } /// The file-gate findings that need no landing order: the view's own, the -/// disposition classes, integrity, keys, authority records and belonging. +/// crossing, the disposition classes, integrity, keys, authority records +/// and belonging. fn file_findings(store: &Store, view: &View, today: NaiveDate) -> Vec { let mut findings = view.findings.clone(); + findings.extend(crossing::findings(view)); findings.extend(disposition::unallocated(view)); findings.extend(disposition::expired(view, today)); findings.extend(disposition::stranded(view, store)); diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index d34f796a..7d1953ad 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -247,7 +247,7 @@ Spec v1.6 (2026-10-02; the ledger CLI PRD §4 as amended 2026-10-01, informative All of section 3.6 is **not implemented**. Where the implemented format behaves otherwise, it is described where it is specified, with the ruling that will supersede it. - **LP-3.30** (W, V) **Not implemented.** A namespace verifies the same wherever it sits (ruling 32). Sharing a repository with another namespace changes no rule, so a namespace can be moved to another repository by moving its files: no hash changes, no reference changes form, and both sides still verify. Coupling between namespaces is kept as low as it can be made. -- **LP-3.31** (W, V) **Not implemented.** Only `based_on` crosses a namespace. `supersedes` never names a decision in another namespace (ruling 43). *The implemented format does not refuse it: `G001` asks only that a `supersedes` target is a decision filed anywhere in the store, so a cross-namespace `supersedes` inside one store passes (established by reading).* +- **LP-3.31** (W, V) Only `based_on` crosses a namespace. `supersedes` never names a decision in another namespace (ruling 43). The rule is judged on live claims: a decision's latest version whose `supersedes` names a decision of another namespace is a schema fault at verification (LP-8.8); earlier versions are history and are not judged, so a next version that drops the edge repairs the store (ruling 75) — the same withdrawal LP-8.21 records for a losing claimant. A writer refuses to file such a version, by the gate it runs over the store as it would be. `G001` keeps the dangling target, whichever namespace it names. - **LP-3.32** (W, V) **Not implemented.** Only a decision marked `exported` (section 3.5) can be pinned from another namespace (ruling 44). *Nothing in the implemented format pins, and `exported` gates nothing: it is read only by the format rule of LP-3.28 and by the export (established by reading).* - **LP-3.33** (W, V) No file holds entities of two namespaces (ruling 45). A file's namespace is its directory (LP-3.34), and every entity under `ns//` belongs to ``: a decision id, the decision a version or an acceptance names, a binding's or a policy's `namespace`, a revocation's target, the grant a grant acceptance or an interval names, the interval an availability ends, and a sidecar's entity. One that belongs elsewhere is a schema fault at verification (LP-8.8), and a writer never files one: a change-set is homed by the one namespace its entities name, and a mixed one is refused before it is written. @@ -1022,7 +1022,7 @@ Two verifiers conform when they report the same set of findings for the same sto ### 8.2 The parse gate - **LP-8.7** (V) Verification fails for a **schema fault** or one of **fourteen classes**, and for nothing else. A new reason is a change to this document: `L010` arrived that way, as the spec v1.1 amendment, `L013`/`L014` as spec v1.6, and the signing classes `L011`/`L012` (numbers reserved for them by #65, ruling D3) as spec v1.8. -- **LP-8.8** (V) `SCHEMA` covers: a file that does not parse against the format it declares; an unknown `format`; an unknown key; an unknown discharge scheme; a file stem disagreeing with its declared id; a duplicate id, an acceptance's included, anywhere in the store; a decision identity object filed more than once (LP-5.11, ruling 49); a per-allocation obligation from section 5.3 that is not met (except the escape's, which is `L002`); a non-empty `signature`; a version naming an undeclared set; a revocation naming an acceptance nobody filed; a `key` not matching `^[A-Z][A-Za-z0-9]{0,63}$`; `under` in a file below format 7; a sidecar that is misnamed, names no signable entity, or is on an act no policy governs (ruling 52); an entity under a namespace's directory that belongs to another namespace, a revocation's target and a sidecar's entity included (LP-3.33, ruling 45); a key binding filed by a party D7 does not allow (section 4.10); a format declaration below what a field needs or at or above the format that retired a shape the file uses (LP-3.15); a binding of a key that LP-4.37 refuses; a policy listing `none` with another scheme (LP-4.29); and the authority rules of section 8.4. +- **LP-8.8** (V) `SCHEMA` covers: a file that does not parse against the format it declares; an unknown `format`; an unknown key; an unknown discharge scheme; a file stem disagreeing with its declared id; a duplicate id, an acceptance's included, anywhere in the store; a decision identity object filed more than once (LP-5.11, ruling 49); a per-allocation obligation from section 5.3 that is not met (except the escape's, which is `L002`); a non-empty `signature`; a version naming an undeclared set; a revocation naming an acceptance nobody filed; a `key` not matching `^[A-Z][A-Za-z0-9]{0,63}$`; `under` in a file below format 7; a sidecar that is misnamed, names no signable entity, or is on an act no policy governs (ruling 52); an entity under a namespace's directory that belongs to another namespace, a revocation's target and a sidecar's entity included (LP-3.33, ruling 45); a decision's latest version whose `supersedes` names a decision of another namespace (LP-3.31, rulings 43 and 75); a key binding filed by a party D7 does not allow (section 4.10); a format declaration below what a field needs or at or above the format that retired a shape the file uses (LP-3.15); a binding of a key that LP-4.37 refuses; a policy listing `none` with another scheme (LP-4.29); and the authority rules of section 8.4. ### 8.3 The fourteen @@ -1473,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 | **Ruling 43 applied, on live claims (ruling 75)** (PRD §3.7; issue 4). LP-3.31 loses its not-implemented mark and its note on the implemented format: a decision's latest version whose `supersedes` names a decision of another namespace is a schema fault (LP-8.8); earlier versions are not judged, and a next version that drops the edge repairs the store. `G001` keeps the dangling target. No class added; no digest moves; no such edge in this repository or the fixtures. | | 9 October 2026 | **Ruling 45 applied** (PRD §3.7; issue 3). LP-3.33 loses its not-implemented mark and its note on the implemented format: every entity under `ns//` belongs to ``, a revocation's target and a sidecar's entity included, and one that belongs elsewhere is a schema fault (LP-8.8). Closes N12's cross-namespace half with ruling 49, and N14. No class added; no digest moves; no committed file in this repository is mixed (0 of 187). | | 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. |