Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .ddd/seams/seam-rust-ledger-core-src-verify-crossing-rs.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions .ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
108 changes: 108 additions & 0 deletions ledger-cli/tests/crossing.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
37 changes: 37 additions & 0 deletions ledger-core/src/verify/crossing.rs
Original file line number Diff line number Diff line change
@@ -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<Finding> {
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;
52 changes: 52 additions & 0 deletions ledger-core/src/verify/crossing_tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
5 changes: 4 additions & 1 deletion ledger-core/src/verify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Finding> {
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));
Expand Down
5 changes: 3 additions & 2 deletions ledger/spec/ledger-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ns>/` belongs to `<ns>`: 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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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/<ns>/` belongs to `<ns>`, 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/<namespace>/` 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. |
Expand Down
Loading