diff --git a/.ddd/seams/seam-rust-ledger-core-src-verify-belonging-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-verify-belonging-rs.yaml new file mode 100644 index 00000000..a7d1532d --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-verify-belonging-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-verify-belonging-rs +boundary: contract surface of ledger-core/src/verify/belonging.rs +verdict_knowledge: 'Callers learn the belonging stage: every entity filed under ns// must belong to (a decision id, a version''s or acceptance''s decision, a binding''s or policy''s namespace, a revocation''s or grant acceptance''s target, a sidecar''s entity), each breach a SCHEMA fault and no class added (LP-3.33, ruling 45).' +contract_location: ledger-core/src/verify/belonging.rs +obligations: [] +metadata: {} +bindings: +- symbol: findings + file: ledger-core/src/verify/belonging.rs + before: absent + after: sha256:f984f8b49a3423a02326d32f1650c4b5fad75e5a29d51c5c273d643df888d1f1 + base_revision: efc4bc55bfddbcadbc440489ad8ba701ae86b5e9 + hash: sha256:6758d6231d707799823204b3c54f704930b26bb82d0c85b7091291ab8eb44821 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 b89ace9b..5b393984 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 @@ -132,3 +132,9 @@ bindings: after: sha256:eb8a89495dc2394ff2e7b1e744dbb9b6618025467690ac07e743848a575804c3 base_revision: 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 hash: sha256:48a5f6a970da933c6a8ba1cd334c4e59a07f8c2342652f13268192259ae96bad +- symbol: belonging + file: ledger-core/src/verify/mod.rs + before: sha256:eb8a89495dc2394ff2e7b1e744dbb9b6618025467690ac07e743848a575804c3 + after: sha256:bdac3c5d6194cd868373d4bdcab54a7f410fb4b7078b0d0d2fff70fec3f3d703 + base_revision: efc4bc55bfddbcadbc440489ad8ba701ae86b5e9 + hash: sha256:ef2bf299bd26f57a28cfeb4d0efcb845cf0c13164035048387168764f5b96dc9 diff --git a/ledger-cli/tests/belonging.rs b/ledger-cli/tests/belonging.rs new file mode 100644 index 00000000..38965e72 --- /dev/null +++ b/ledger-cli/tests/belonging.rs @@ -0,0 +1,104 @@ +//! Every entity under `ns//` belongs to `` (LP-3.33, ruling 45; AC-45). +//! +//! A file's namespace is its directory. A hand-written file under `ns/A/` +//! holding an entity of B — a decision, an acceptance, a binding, a policy, +//! or a revocation of B's acceptance — is a `SCHEMA` fault naming the +//! entity. The verbs never write one: a change-set is homed by the one +//! namespace its entities name. + +mod common; + +use common::{hand, Repo}; + +const A: &str = "fixture.ledger"; +const B: &str = "other.ledger"; + +fn verify(repo: &Repo) -> (i32, String) { + let out = repo.ledger(&["verify", "--no-blame"]); + (out.status.code().unwrap_or(-1), common::both(&out)) +} + +/// A store with a decision of A and a decision of B, each in its own +/// directory and set. +fn two_namespaces() -> (Repo, String, String) { + let repo = Repo::human(); + repo.declare_in(A); + repo.declare_in(B); + let a = repo.add("A's decision.", &[]); + let out = repo.ok(&["add", "--set", "ledger-design", "--namespace", B, "--statement", "B's decision.", "--store", "constraint", "--discharge", "analyzer:DEC001"]); + let b = common::decision_id(&out); + (repo, a, b) +} + +/// The log file holding `id`, moved from its directory into `into`'s. +fn misfile(repo: &Repo, id: &str, into: &str) { + let from = hand::file_holding(repo, id); + let target = repo.log_dir(into).join(from.file_name().expect("name")); + std::fs::create_dir_all(repo.log_dir(into)).expect("dir"); + std::fs::rename(&from, &target).expect("move"); +} + +#[test] +fn a_decision_of_b_filed_under_a_is_a_schema_fault_naming_it() { + let (repo, _, b) = two_namespaces(); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "each in its own directory: {text}"); + misfile(&repo, &b, A); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains(&format!("[SCHEMA] {b}: is a decision of `{B}`, but the file is under `{A}`'s directory")), "{text}"); + assert!(text.contains("ruling 45"), "{text}"); +} + +#[test] +fn an_acceptance_of_b_filed_under_a_is_a_schema_fault() { + let (repo, _, b) = two_namespaces(); + let acc = hand::accept(&repo, &hand::HandAccept { decision: &b, actor: "fixture-human@example", at: chrono::Utc::now(), under: None, key: None }); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); + misfile(&repo, &acc, A); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains(&format!("[SCHEMA] {acc}: accepts a decision of `{B}`")), "{text}"); +} + +#[test] +fn a_revocation_under_a_of_an_acceptance_of_b_is_a_schema_fault() { + let (repo, _, b) = two_namespaces(); + let acc = hand::accept(&repo, &hand::HandAccept { decision: &b, actor: "fixture-human@example", at: chrono::Utc::now(), under: None, key: None }); + // A legacy-shape revocation of B's acceptance, hand-filed under A. + let text = format!( + "format: 1\nid: cs:01K2C4YQJ3F8M0PT5W7NZ9RDZ5\ncreated_at: 2026-08-10T09:30:00Z\ncreated_by: fixture-human@example\nrevocations:\n- acceptance: {acc}\n at: 2026-08-10T09:30:00Z\n by: fixture-human@example\n reason: filed in the wrong directory\n" + ); + std::fs::write(repo.log_dir(A).join("01K2C4YQJ3F8M0PT5W7NZ9RDZ5.yml"), text).expect("write"); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains("[SCHEMA]") && text.contains(&format!("revokes an acceptance of `{B}`, but the file is under `{A}`'s directory")), "{text}"); + // The same revocation under B is in its place. + std::fs::rename(repo.log_dir(A).join("01K2C4YQJ3F8M0PT5W7NZ9RDZ5.yml"), repo.log_dir(B).join("01K2C4YQJ3F8M0PT5W7NZ9RDZ5.yml")).expect("move"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); +} + +#[test] +fn a_binding_and_a_policy_of_b_filed_under_a_are_schema_faults() { + let owner = "owner@customer.example"; + let repo = Repo::with_identity(owner); + repo.declare_in(A); + repo.declare_in(B); + let key = repo.keygen("owner"); + repo.use_key(&key); + repo.ok(&["init", "--namespace", A, "--external-ref", "contract 2026/117"]); + repo.ok(&["init", "--namespace", B, "--external-ref", "contract 2026/117"]); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); + // B's opening change-set, holding its policy and binding, moved under A; + // its sidecars stay under B, so they are named too. + let store = ledger_core::store::load(repo.path()); + let policy = store.log.iter().flat_map(|l| l.file.policies.iter()).find(|p| p.namespace == B).expect("policy").id.to_string(); + misfile(&repo, &policy, A); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains(&format!("[SCHEMA] {policy}: is the policy of `{B}`")), "{text}"); + assert!(text.contains("binds a key in `") || text.contains("signs an entity of `"), "{text}"); +} diff --git a/ledger-core/src/verify/belonging.rs b/ledger-core/src/verify/belonging.rs new file mode 100644 index 00000000..be6b4e2f --- /dev/null +++ b/ledger-core/src/verify/belonging.rs @@ -0,0 +1,149 @@ +//! Every entity under `ns//` belongs to `` (LP-3.33, ruling 45). +//! +//! A file's namespace is its directory (LP-3.34), so an entity that names +//! another namespace is in the wrong file: a decision id, the decision a +//! version or an acceptance names, a binding's or a policy's `namespace` +//! field, a revocation's target, the grant a grant acceptance or an +//! interval names, the interval an availability ends, and a sidecar under a +//! namespace other than its entity's. Each is a `SCHEMA` fault, no class +//! added. A record whose target is not filed is already a fault +//! (`authority::references`) and is left alone here. +//! +//! A change-set a verb is about to append may be homed nowhere when it +//! names nothing the store holds (`author::home`); such a candidate carries +//! an empty namespace and is judged by the finding that says why, not here. + +use crate::authority::Revocable; +use crate::finding::Finding; +use crate::store::{LoggedChangeSet, Store}; + +/// Every entity filed outside its namespace's directory. +pub fn findings(store: &Store) -> Vec { + let mut out = Vec::new(); + for logged in store.log.iter().filter(|l| !l.namespace.is_empty()) { + out.extend(own_entities(logged)); + out.extend(named_records(store, logged)); + } + out.extend(sidecars(store)); + out +} + +fn fault(subject: &str, what: &str, theirs: &str, dir: &str) -> Finding { + Finding::schema( + subject, + format!( + "{what} `{theirs}`, but the file is under `{dir}`'s directory — every entity in `ns/{dir}/` belongs to `{dir}` (LP-3.33, ruling 45)" + ), + ) +} + +/// The entities that carry their namespace themselves. +fn own_entities(logged: &LoggedChangeSet) -> Vec { + let dir = logged.namespace.as_str(); + let cs = &logged.file; + let mut out = Vec::new(); + for d in cs.decisions.iter().filter(|d| d.id.namespace() != dir) { + out.push(fault(&d.id.to_string(), "is a decision of", d.id.namespace(), dir)); + } + for v in cs.versions.iter().filter(|v| v.decision.namespace() != dir) { + out.push(fault(&v.decision.to_string(), &format!("version {} is of a decision of", v.hash.short()), v.decision.namespace(), dir)); + } + for a in cs.acceptances.iter().filter(|a| a.decision.namespace() != dir) { + out.push(fault(&a.id.to_string(), "accepts a decision of", a.decision.namespace(), dir)); + } + for b in cs.key_bindings.iter().filter(|b| b.namespace != dir) { + out.push(fault(&b.id.to_string(), "binds a key in", &b.namespace, dir)); + } + for p in cs.policies.iter().filter(|p| p.namespace != dir) { + out.push(fault(&p.id.to_string(), "is the policy of", &p.namespace, dir)); + } + out +} + +/// The records that belong where what they name belongs. +fn named_records(store: &Store, logged: &LoggedChangeSet) -> Vec { + let dir = logged.namespace.as_str(); + let cs = &logged.file; + let mut out = Vec::new(); + for r in &cs.revocations { + let (subject, what, theirs) = match r.target() { + Some(Revocable::Acceptance(acc)) => (r.subject(), "revokes an acceptance of", acceptance_namespace(store, &acc.to_string())), + Some(Revocable::Grant(g)) => (r.subject(), "revokes a grant filed under", directory_of(store, |cs| cs.grants.iter().any(|x| x.id == g))), + None => continue, + }; + if let Some(theirs) = theirs.filter(|t| t != dir) { + out.push(fault(&subject, what, &theirs, dir)); + } + } + for ga in &cs.grant_acceptances { + if let Some(theirs) = directory_of(store, |cs| cs.grants.iter().any(|g| g.id == ga.grant)).filter(|t| t != dir) { + out.push(fault(&ga.id.to_string(), "accepts a grant filed under", &theirs, dir)); + } + } + for u in &cs.unavailabilities { + if let Some(theirs) = directory_of(store, |cs| cs.grants.iter().any(|g| g.id == u.grant)).filter(|t| t != dir) { + out.push(fault(&u.id.to_string(), "names a grant filed under", &theirs, dir)); + } + } + for av in &cs.availabilities { + if let Some(theirs) = directory_of(store, |cs| cs.unavailabilities.iter().any(|u| u.id == av.ends)).filter(|t| t != dir) { + out.push(fault(&av.id.to_string(), "ends an interval filed under", &theirs, dir)); + } + } + out +} + +/// A sidecar sits under the namespace of the entity it signs. +fn sidecars(store: &Store) -> Vec { + store + .sidecars + .iter() + .filter(|c| !c.namespace.is_empty()) + .filter_map(|c| { + let theirs = signable_namespace(store, &c.ulid)?; + (theirs != c.namespace).then(|| fault(&format!("sig/{}", c.file), "signs an entity of", &theirs, &c.namespace)) + }) + .collect() +} + +/// The namespace of the acceptance `id`: its decision's. +fn acceptance_namespace(store: &Store, id: &str) -> Option { + store + .log + .iter() + .flat_map(|l| l.file.acceptances.iter()) + .find(|a| a.id.to_string() == id) + .map(|a| a.decision.namespace().to_string()) +} + +/// The directory of the change-set some predicate picks out. +fn directory_of(store: &Store, holds: impl Fn(&crate::changeset::ChangeSet) -> bool) -> Option { + store.log.iter().find(|l| holds(&l.file)).map(|l| l.namespace.clone()) +} + +/// The namespace a signable entity `ulid` belongs to: an acceptance's +/// decision's, a revocation's target's, a binding's or a policy's own. +fn signable_namespace(store: &Store, ulid: &str) -> Option { + for cs in store.log.iter().map(|l| &l.file) { + if let Some(a) = cs.acceptances.iter().find(|a| a.id.ulid() == ulid) { + return Some(a.decision.namespace().to_string()); + } + if let Some(r) = cs.revocations.iter().find(|r| r.id.as_ref().is_some_and(|i| i.ulid() == ulid)) { + return match r.target()? { + Revocable::Acceptance(acc) => acceptance_namespace(store, &acc.to_string()), + Revocable::Grant(g) => directory_of(store, |cs| cs.grants.iter().any(|x| x.id == g)), + }; + } + if let Some(b) = cs.key_bindings.iter().find(|b| b.id.ulid() == ulid) { + return Some(b.namespace.clone()); + } + if let Some(p) = cs.policies.iter().find(|p| p.id.ulid() == ulid) { + return Some(p.namespace.clone()); + } + } + None +} + +#[path = "belonging_tests.rs"] +#[cfg(test)] +mod tests; diff --git a/ledger-core/src/verify/belonging_tests.rs b/ledger-core/src/verify/belonging_tests.rs new file mode 100644 index 00000000..9c701713 --- /dev/null +++ b/ledger-core/src/verify/belonging_tests.rs @@ -0,0 +1,115 @@ +//! Each way an entity can sit outside its namespace's directory (ruling 45). + +use crate::finding::VerifyClass; +use crate::store::LoggedChangeSet; +use crate::testkit; + +use super::findings; + +const OTHER: &str = "hafeok.other"; + +fn subjects(store: &crate::store::Store) -> Vec { + let found = findings(store); + assert!(found.iter().all(|f| f.class == VerifyClass::Schema && f.message.contains("ruling 45")), "{found:?}"); + found.into_iter().map(|f| f.subject).collect() +} + +#[test] +fn a_store_whose_files_hold_their_own_namespace_has_no_finding() { + let sealed = testkit::sealed(testkit::version()); + let acceptance = testkit::acceptance(&sealed); + let store = testkit::store(testkit::changeset(vec![sealed], vec![acceptance])); + assert!(subjects(&store).is_empty()); +} + +#[test] +fn a_decision_its_version_and_its_acceptance_of_another_namespace_are_each_named() { + let mut other = testkit::version(); + other.decision = format!("dec:{OTHER}/01K2C4YQJ3F8M0PT5W7NZ9RDY0").parse().expect("id"); + let other = testkit::sealed(other); + let mut acc = testkit::acceptance(&other); + acc.id = "acc:01K2C4YQJ3F8M0PT5W7NZ9RDY1".parse().expect("id"); + let cs = testkit::changeset(vec![other], vec![acc]); + let mut store = testkit::store(cs); + store.log[0].namespace = testkit::NS.to_string(); + let found = subjects(&store); + assert_eq!(found.len(), 3, "{found:?}"); + assert!(found.iter().all(|s| s.contains(OTHER) || s.starts_with("acc:")), "{found:?}"); +} + +/// A sealed `add` binding of the genesis holder in `ns`. +fn binding_in(ns: &str) -> crate::authority::KeyBinding { + use crate::authority::fixture; + let mut binding = crate::authority::KeyBinding { + id: "key:01K2C4YQJ3F8M0PT5W7NZ9RDY3".parse().expect("id"), + act: crate::authority::BindingAct::Add, + principal: testkit::identity(fixture::GENESIS_HOLDER), + namespace: ns.into(), + key_type: Some("ssh-ed25519".into()), + key: Some("AAAA".into()), + closes: None, + self_bound: false, + mandate: None, + by: testkit::identity(fixture::GENESIS_HOLDER), + under: None, + at: testkit::stamp("2026-08-11T09:00:00Z"), + hash: crate::hash::VersionHash::zero(), + }; + binding.hash = crate::authority::payload::binding_hash(&binding); + binding +} + +/// A sealed `[none]` policy of `ns`. +fn policy_in(ns: &str) -> crate::authority::Policy { + use crate::authority::fixture; + let mut policy = crate::authority::Policy { + id: "pol:01K2C4YQJ3F8M0PT5W7NZ9RDY4".parse().expect("id"), + namespace: ns.into(), + schemes: vec![crate::authority::Scheme::None], + require_sk: false, + accept_role: "acceptor".into(), + reaccept_within_days: None, + replaces: None, + by: testkit::identity(fixture::GENESIS_HOLDER), + under: None, + at: testkit::stamp("2026-08-11T09:00:00Z"), + hash: crate::hash::VersionHash::zero(), + }; + policy.hash = crate::authority::payload::policy_hash(&policy); + policy +} + +#[test] +fn a_binding_a_policy_and_a_revocation_target_of_another_namespace_are_named() { + use crate::authority::fixture; + let sealed = testkit::sealed(testkit::version()); + let acceptance = testkit::acceptance(&sealed); + let acc_id = acceptance.id.clone(); + let mut store = testkit::store(testkit::changeset(vec![sealed], vec![acceptance])); + // A second file, under `hafeok.other`, revoking the acceptance filed + // under `hafeok.ledger`, with a binding and a policy of `hafeok.ledger`. + let mut cs = fixture::changeset(Vec::new(), Vec::new()); + cs.id = "cs:01K2C4YQJ3F8M0PT5W7NZ9RDY2".parse().expect("id"); + let mut revocation = testkit::legacy_revocation("2026-08-11T09:00:00Z", "wrong file"); + revocation.acceptance = Some(acc_id); + cs.revocations.push(revocation); + cs.key_bindings.push(binding_in(testkit::NS)); + cs.policies.push(policy_in(testkit::NS)); + store.log.push(LoggedChangeSet { + namespace: OTHER.into(), + path: format!("/fixture/.decisions/ns/{OTHER}/log/01K2C4YQJ3F8M0PT5W7NZ9RDY2.yml").into(), + file: cs, + }); + let found = subjects(&store); + assert_eq!(found.len(), 3, "{found:?}"); + assert!(found.iter().any(|s| s.starts_with("rev:") || s.starts_with("acc:")), "the revocation, by its subject: {found:?}"); + assert!(found.iter().any(|s| s.starts_with("key:")) && found.iter().any(|s| s.starts_with("pol:")), "{found:?}"); +} + +#[test] +fn a_candidate_homed_nowhere_is_not_judged_here() { + let sealed = testkit::sealed(testkit::version()); + let mut store = testkit::store(testkit::changeset(vec![sealed], Vec::new())); + store.log[0].namespace = String::new(); + assert!(subjects(&store).is_empty(), "an unhomed candidate is refused by the finding that says why"); +} diff --git a/ledger-core/src/verify/genesis_role_tests.rs b/ledger-core/src/verify/genesis_role_tests.rs index 5a1ee731..c6a3468f 100644 --- a/ledger-core/src/verify/genesis_role_tests.rs +++ b/ledger-core/src/verify/genesis_role_tests.rs @@ -17,7 +17,7 @@ use super::{verify, Options, Report}; fn policy(g: &Grant, accept_role: &str) -> Policy { let mut p = Policy { id: format!("pol:{}", fixture::ulid("3")).parse().expect("id"), - namespace: "fixture.ledger".into(), + namespace: testkit::NS.into(), schemes: vec![Scheme::None], require_sk: false, accept_role: accept_role.into(), diff --git a/ledger-core/src/verify/mod.rs b/ledger-core/src/verify/mod.rs index 6e3bf245..467f13b4 100644 --- a/ledger-core/src/verify/mod.rs +++ b/ledger-core/src/verify/mod.rs @@ -12,6 +12,7 @@ pub mod acts; pub mod authority; +pub mod belonging; pub mod disposition; mod genesis_role; pub mod history; @@ -160,20 +161,28 @@ impl Report { } } +/// The file-gate findings that need no landing order: the view's own, 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(disposition::unallocated(view)); + findings.extend(disposition::expired(view, today)); + findings.extend(disposition::stranded(view, store)); + findings.extend(disposition::model_acceptor(view)); + findings.extend(disposition::model_judge(view)); + findings.extend(integrity::hash_mismatch(view).into_iter().chain(integrity::dangling_acceptance(view))); + findings.extend(keys::key_changed(view)); + findings.extend(keys::key_collision(view)); + findings.extend(authority::findings(store)); + findings.extend(belonging::findings(store)); + findings +} + /// Run the gate over a loaded store. pub fn verify(store: &Store, opts: &Options) -> Report { let view = View::build(store); let mut findings = store.schema_findings.clone(); - findings.extend(view.findings.iter().cloned()); - findings.extend(disposition::unallocated(&view)); - findings.extend(disposition::expired(&view, opts.today)); - findings.extend(disposition::stranded(&view, store)); - findings.extend(disposition::model_acceptor(&view)); - findings.extend(disposition::model_judge(&view)); - findings.extend(integrity::hash_mismatch(&view).into_iter().chain(integrity::dangling_acceptance(&view))); - findings.extend(keys::key_changed(&view)); - findings.extend(keys::key_collision(&view)); - findings.extend(authority::findings(store)); + findings.extend(file_findings(store, &view, opts.today)); let landing = landing(store, opts); let signing = crate::signing::check::check(store, &landing, opts.today); findings.extend(signing.findings.iter().cloned()); diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index 8564d85c..d34f796a 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -249,7 +249,7 @@ All of section 3.6 is **not implemented**. Where the implemented format behaves - **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.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) **Not implemented.** No file holds entities of two namespaces (ruling 45). *The implemented format does not refuse it: no rule compares the namespaces of a change-set's entries, and the export restricts each change-set to one namespace's entries (LP-9.11), which presumes such files. Grants of scope `*` and role files belong to no namespace at all. No committed change-set in this repository holds two namespaces (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. ## 4. Canonical form, hashing and signatures @@ -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); 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 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 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. | | 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) | @@ -1618,6 +1619,29 @@ only adds an unhashed field. --- +#### An entity outside its namespace's directory is refused (2026-10-09, no format change) + +**Ruled 7 October 2026** (ruling 45; `ledger/prd/namespace-independence-prd.md` +§3.7; issue 3 of its §6). A file's namespace is its directory (LP-3.34), +and every entity under `ns//` belongs to ``. From this change a +verifier refuses, as a `SCHEMA` fault naming the entity: a decision id of +another namespace; a version or an acceptance of such a decision; a key +binding or a policy whose `namespace` is another; a revocation of an +acceptance or a grant filed under another namespace's directory; a grant +acceptance or an unavailability naming a grant filed elsewhere, and an +availability ending an interval filed elsewhere; and a sidecar under a +namespace other than its entity's. Together with ruling 49 (a duplicate +acceptance id is a schema fault) this closes the case in which a +revocation hand-filed in an ungoverned namespace reused a governed +acceptance's id and revoked it with no grant and no signature +(`ledger/sessions/2026-10-verification.md`, case 3c). + +**No digest moves** and no class is added. The writers never filed a +mixed change-set, and no committed file in this repository holds two +namespaces (0 of 187, scanned), so no stored verdict changes. A +hand-written store that did is refused until each entity is filed under +its own namespace's directory. + #### Spec v1.9 — one directory per namespace (2026-10-09, no format change) **Ruled 7 October 2026** (rulings 62, 63 and 66, the last as amended by 87