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-belonging-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-belonging-rs
boundary: contract surface of ledger-core/src/verify/belonging.rs
verdict_knowledge: 'Callers learn the belonging stage: every entity filed under ns/<ns>/ must belong to <ns> (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
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 @@ -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
104 changes: 104 additions & 0 deletions ledger-cli/tests/belonging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Every entity under `ns/<ns>/` belongs to `<ns>` (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}");
}
149 changes: 149 additions & 0 deletions ledger-core/src/verify/belonging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! Every entity under `ns/<ns>/` belongs to `<ns>` (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<Finding> {
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<Finding> {
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<Finding> {
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<Finding> {
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<String> {
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<String> {
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<String> {
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;
115 changes: 115 additions & 0 deletions ledger-core/src/verify/belonging_tests.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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");
}
Loading
Loading