From 1e019f1661a952fddc063778943398f537004d4f Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Fri, 9 Oct 2026 14:59:40 +0000 Subject: [PATCH 1/3] Load every store from ns// and refuse flat paths at the verified commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #144 (PRD issue 1, rulings 62, 63 and 66 as amended by 87). Every store holds each namespace under `.decisions/ns//`, with its own `sets/`, `roles/`, `log/`, `sig/` and `allowed_signers`, and a file's namespace is its directory. The loader reads only those directories; a file at a flat path of revision v1.8, or anywhere under `.decisions/` outside `ns/` and `index/`, is a schema fault naming the path (AC-63), and so is a store with no `ns/` and any of those paths. The layout is one module, `ledger_core::layout`: the directory helpers, the git pathspecs landing tracks, and the one classifier every reader of a repo-relative path uses (landing, the write-once history check, the at-revision loader and the base overlay). A set and a role carry the namespace of the directory that holds them; a set id is unique within a namespace and a version names a set of its own namespace (LP-3.36, LP-5.22). Sidecars carry their namespace too. The writers file every change-set under the one namespace its entities belong to (`author::home::namespace_of`), sidecars beside it, role files and set files under theirs; `declare` and `role declare` take `--namespace`, inferred when the store holds one namespace. `allowed_signers` is written per namespace and `[SIGNERS]` compares each namespace's file; its derivation still reads a close in any namespace, which issues 6 and 7 narrow. `init` scaffolds `ns/`. The merge driver's `.gitattributes` patterns follow the layout. Tests: `store_tests` gains the AC-63 cases (each flat path named, a flat-only store refused and unread, a set id unique per namespace, a stray file under `ns/`); `layout` and `author::home` carry their unit tests. The integration suites that wrote or read flat paths now go through the harness's per-namespace helpers, and the fixtures and this repository's own store, both still flat until issues 9 and 10, are staged under `ns//` by `common::stage_store_into` before a test reads them, byte for byte. The `ddd migrate-ledger` adapter names the namespace it declares the set in. Protocol: section 3.1 states the layout; LP-3.34, LP-3.35 (marked not implemented until issue 2), LP-3.36 and LP-5.22 are new; LP-4.10 and sections 5.2, 5.3 and 5.6 name the new paths; Appendix B row; Appendix C note "Spec v1.9 — one directory per namespace". No class added, no digest moves, no format number, `CANONICAL_FORM` stays `v1`. This repository's own store is still flat, so `ledger verify --export` on this branch refuses it (191 `SCHEMA`), as LP-3.34 says; issue 10 re-lays it out, after issue 2's history capability. That is the one red check on this pull request. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- CLAUDE.md | 4 +- ddd-cli/src/commands/migrate.rs | 5 +- ddd-cli/tests/migrate.rs | 7 +- ledger-cli/src/commands/authority.rs | 4 +- ledger-cli/src/commands/authority_enum.rs | 6 +- ledger-cli/src/commands/cli_enum.rs | 4 + ledger-cli/src/commands/declare.rs | 2 + ledger-cli/src/commands/merge_cmd.rs | 8 +- ledger-cli/src/commands/mod.rs | 4 +- ledger-cli/tests/accept_group.rs | 4 +- ledger-cli/tests/authority.rs | 32 ++--- ledger-cli/tests/batch.rs | 2 +- ledger-cli/tests/cli.rs | 4 +- ledger-cli/tests/closed_key.rs | 6 +- ledger-cli/tests/common/hand.rs | 22 +-- ledger-cli/tests/common/mod.rs | 166 ++++++++++++++++++++- ledger-cli/tests/diff.rs | 4 +- ledger-cli/tests/digests.rs | 19 ++- ledger-cli/tests/export.rs | 19 +-- ledger-cli/tests/export_verifier.rs | 17 ++- ledger-cli/tests/format_history.rs | 9 +- ledger-cli/tests/gate.rs | 30 ++-- ledger-cli/tests/genesis_key.rs | 20 +-- ledger-cli/tests/graph.rs | 36 ++--- ledger-cli/tests/inbox.rs | 6 +- ledger-cli/tests/inbox_failures.rs | 2 +- ledger-cli/tests/inbox_fixture/mod.rs | 2 +- ledger-cli/tests/key_across_namespaces.rs | 7 +- ledger-cli/tests/key_ownership.rs | 2 +- ledger-cli/tests/keys.rs | 4 +- ledger-cli/tests/landed_header.rs | 11 +- ledger-cli/tests/legacy_revocation.rs | 7 +- ledger-cli/tests/merge.rs | 6 +- ledger-cli/tests/pre_policy_binding.rs | 4 +- ledger-cli/tests/revisit_format.rs | 23 ++- ledger-cli/tests/role_position.rs | 4 +- ledger-cli/tests/scalars.rs | 6 +- ledger-cli/tests/show.rs | 4 +- ledger-cli/tests/signing.rs | 4 +- ledger-cli/tests/trust.rs | 6 +- ledger-cli/tests/ungoverned_sidecar.rs | 2 +- ledger-cli/tests/verbs.rs | 2 +- ledger-core/src/author/authority_ops.rs | 19 ++- ledger-core/src/author/decision.rs | 6 +- ledger-core/src/author/declare.rs | 21 ++- ledger-core/src/author/home.rs | 139 ++++++++++++++++++ ledger-core/src/author/mod.rs | 23 ++- ledger-core/src/author/report.rs | 3 +- ledger-core/src/author/sign_ops.rs | 4 +- ledger-core/src/author/sign_tests.rs | 3 +- ledger-core/src/author/tests.rs | 1 + ledger-core/src/author/version_ops.rs | 2 +- ledger-core/src/authority/fixture.rs | 3 + ledger-core/src/authority/role.rs | 4 + ledger-core/src/authority/signers.rs | 118 +++++++++------ ledger-core/src/authority/view.rs | 2 +- ledger-core/src/batch_tests.rs | 3 +- ledger-core/src/diff.rs | 2 +- ledger-core/src/graph/export.rs | 3 +- ledger-core/src/init.rs | 19 +-- ledger-core/src/landing.rs | 7 +- ledger-core/src/landing_tests.rs | 14 +- ledger-core/src/layout.rs | 165 +++++++++++++++++++++ ledger-core/src/lib.rs | 1 + ledger-core/src/merge/plan.rs | 4 +- ledger-core/src/merge/resolve.rs | 6 +- ledger-core/src/merge/tests.rs | 3 +- ledger-core/src/revision.rs | 25 ++-- ledger-core/src/set.rs | 4 + ledger-core/src/show.rs | 2 +- ledger-core/src/signing/check_tests.rs | 2 +- ledger-core/src/signing/mod.rs | 22 +-- ledger-core/src/store.rs | 168 +++++++++++++++++----- ledger-core/src/store_tests.rs | 97 ++++++++++++- ledger-core/src/testkit.rs | 6 +- ledger-core/src/verify/disposition.rs | 2 +- ledger-core/src/verify/duplicate_tests.rs | 3 +- ledger-core/src/verify/history.rs | 6 +- ledger-core/src/verify/order_grid.rs | 15 +- ledger-core/src/verify/view.rs | 8 +- ledger/spec/ledger-protocol.md | 84 ++++++++--- 81 files changed, 1191 insertions(+), 364 deletions(-) create mode 100644 ledger-core/src/author/home.rs create mode 100644 ledger-core/src/layout.rs diff --git a/CLAUDE.md b/CLAUDE.md index 77aebf53..97628928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -322,7 +322,7 @@ store (`.decisions/`), separate ontology. `--namespace` writes every namespace) and commit the result. Allocated-awaiting-acceptance is *status*, not a failure. - **Authority (format 6 / spec v1.7, #69)** — roles - (`.decisions/roles/.yml`), grants + grant acceptances, unavailability + (`.decisions/ns//roles/.yml`), grants + grant acceptances, unavailability / availability, key bindings, namespace policy, and the `rev:` revocation entity, all in `ledger-core/src/authority/`. `ledger init --namespace --external-ref ` bootstraps the genesis; `role declare`, @@ -334,7 +334,7 @@ store (`.decisions/`), separate ontology. `verify`'s `[SIGNERS]` stage. Graph classes `A003`/`A005`; `A006` deferred. - **Signing (format 7 / spec v1.8, #70)** — `ledger-core/src/signing/`. A namespace's policy lists the required schemes; a signature is a sidecar - `.decisions/sig/..sig` over the entity's signed bytes + `.decisions/ns//sig/..sig` over the entity's signed bytes (`hash::signed_bytes`: prefix, `0x0A`, canonical JSON — what the digest digests). The verbs sign with `git config user.signingkey` (`ssh`); `dsse` is verified, never signed. Every act names its grant (`under`, diff --git a/ddd-cli/src/commands/migrate.rs b/ddd-cli/src/commands/migrate.rs index b603c4fb..d339fa23 100644 --- a/ddd-cli/src/commands/migrate.rs +++ b/ddd-cli/src/commands/migrate.rs @@ -26,7 +26,7 @@ pub fn run(root: Option, namespace: String, set: String) -> Result<()> let store = ddd_core::store::load(&ddd_dir); let entries = plan(&repo_root, &store).map_err(ProductError::ConfigError)?; let mut author = Author::open(&repo_root).map_err(|e| author_err(&e))?; - declare_set(&mut author, &set)?; + declare_set(&mut author, &namespace, &set)?; let mut rows = Vec::new(); for entry in &entries { let ledger_id = file_entry(&mut author, &namespace, &set, entry)?; @@ -46,10 +46,11 @@ pub fn run(root: Option, namespace: String, set: String) -> Result<()> Ok(()) } -fn declare_set(author: &mut Author, set: &str) -> Result<()> { +fn declare_set(author: &mut Author, namespace: &str, set: &str) -> Result<()> { let owner = PRINCIPAL.parse().map_err(ProductError::ConfigError)?; author .declare(DeclareArgs { + namespace: Some(namespace.to_string()), id: set.to_string(), title: "DDD governance — the migrated record of the .ddd store (M8)".to_string(), tolerance_floor: ledger_core::tier::Tier::T1, diff --git a/ddd-cli/tests/migrate.rs b/ddd-cli/tests/migrate.rs index a518cfc7..11fd8997 100644 --- a/ddd-cli/tests/migrate.rs +++ b/ddd-cli/tests/migrate.rs @@ -56,8 +56,9 @@ fn repo() -> tempfile::TempDir { ".ddd/seams/seam-t-api.yaml", "format: 1\nid: seam/t/api\nboundary: fn t in src/t.rs\nverdict_knowledge: callers learn t\ncontract_location: src/t.rs#t\n", ); - write(dir, ".decisions/sets/.keep", ""); - std::fs::create_dir_all(dir.join(".decisions/log")).expect("mkdir"); + // The store's layout is one directory per namespace (LP-3.34); the + // migration makes `ns/t.ddd/` on its first write. + std::fs::create_dir_all(dir.join(".decisions/ns")).expect("mkdir"); git(dir, &["add", "-A"]); git(dir, &["commit", "-qm", "init"]); tmp @@ -100,7 +101,7 @@ fn migration_files_entries_writes_the_concordance_and_refuses_a_second_run() { let ledger = Command::cargo_bin("ledger").expect("binary").current_dir(dir).arg("verify").assert().success(); let out = String::from_utf8_lossy(&ledger.get_output().stdout).to_string(); assert!(out.contains("awaiting acceptance"), "{out}"); - let log_text: String = std::fs::read_dir(dir.join(".decisions/log")) + let log_text: String = std::fs::read_dir(dir.join(".decisions/ns/t.ddd/log")) .expect("log") .flatten() .map(|e| std::fs::read_to_string(e.path()).unwrap_or_default()) diff --git a/ledger-cli/src/commands/authority.rs b/ledger-cli/src/commands/authority.rs index 810e7bb8..5bb19f6a 100644 --- a/ledger-cli/src/commands/authority.rs +++ b/ledger-cli/src/commands/authority.rs @@ -25,9 +25,9 @@ pub fn init_namespace(root: PathBuf, args: InitNamespaceArgs) -> Result, cmd: RoleCmd) -> Result { - let RoleCmd::Declare { id, may, title, notes } = cmd; + let RoleCmd::Declare { id, namespace, may, title, notes } = cmd; let mut author = open_author(root)?; - finish(author.declare_role(RoleArgs { id, title, may: parse_all(&may)?, notes })) + finish(author.declare_role(RoleArgs { namespace, id, title, may: parse_all(&may)?, notes })) } pub fn grant(root: Option, cmd: GrantCmd) -> Result { diff --git a/ledger-cli/src/commands/authority_enum.rs b/ledger-cli/src/commands/authority_enum.rs index 3bd5b39d..843b4fd8 100644 --- a/ledger-cli/src/commands/authority_enum.rs +++ b/ledger-cli/src/commands/authority_enum.rs @@ -9,6 +9,10 @@ pub enum RoleCmd { Declare { /// Role id: lowercase alphanumerics, dashes, dots id: String, + /// The namespace whose roles/ holds the file; inferred when the + /// store holds one + #[arg(long, value_name = "NS")] + namespace: Option, /// Capability, repeatable: accept-decision | sign-off-pattern | /// waive-invalidation | grant-role | revoke-grant | /// declare-unavailability | rotate-genesis @@ -93,7 +97,7 @@ pub enum IdentityCmd { #[arg(long, value_name = "PATH")] key_file: std::path::PathBuf, }, - /// Rewrite .decisions/allowed_signers from the log (after a merge) + /// Rewrite each namespace's allowed_signers from the log (after a merge) Sync, } diff --git a/ledger-cli/src/commands/cli_enum.rs b/ledger-cli/src/commands/cli_enum.rs index adfc6862..6bbb8895 100644 --- a/ledger-cli/src/commands/cli_enum.rs +++ b/ledger-cli/src/commands/cli_enum.rs @@ -129,6 +129,10 @@ pub enum Commands { Declare { #[arg(long, value_name = "ID")] set: String, + /// The namespace the set belongs to (its directory); inferred when + /// the store holds one + #[arg(long, value_name = "NS")] + namespace: Option, /// Human title; defaults to the set id #[arg(long)] title: Option, diff --git a/ledger-cli/src/commands/declare.rs b/ledger-cli/src/commands/declare.rs index f661c990..9273771d 100644 --- a/ledger-cli/src/commands/declare.rs +++ b/ledger-cli/src/commands/declare.rs @@ -8,6 +8,7 @@ use super::common::{finish, open_author, parse_ground, parse_tier}; pub struct Flags { pub set: String, + pub namespace: Option, pub title: Option, pub tolerance_floor: String, pub ground: String, @@ -18,6 +19,7 @@ pub struct Flags { pub fn run(root: Option, flags: Flags) -> Result { let mut author = open_author(root)?; let args = DeclareArgs { + namespace: flags.namespace, id: flags.set.clone(), title: flags.title.unwrap_or_else(|| flags.set.clone()), tolerance_floor: parse_tier(&flags.tolerance_floor)?, diff --git a/ledger-cli/src/commands/merge_cmd.rs b/ledger-cli/src/commands/merge_cmd.rs index a13863ba..b812926f 100644 --- a/ledger-cli/src/commands/merge_cmd.rs +++ b/ledger-cli/src/commands/merge_cmd.rs @@ -104,10 +104,10 @@ fn install(root: Option) -> Result { # Structured merge for the decision ledger (L3). The driver completes\n\ # mechanical cases and exits non-zero on anything requiring judgment,\n\ # preserving the conflict for `ledger merge --resolve`.\n\ -log/*.yml merge=ledger\n\ -log/*.yaml merge=ledger\n\ -sets/*.yml merge=ledger\n\ -sets/*.yaml merge=ledger\n"; +ns/*/log/*.yml merge=ledger\n\ +ns/*/log/*.yaml merge=ledger\n\ +ns/*/sets/*.yml merge=ledger\n\ +ns/*/sets/*.yaml merge=ledger\n"; std::fs::write(&attributes, content).map_err(|e| e.to_string())?; git_config(&repo, "merge.ledger.name", "decision ledger structured merge (L3)")?; git_config(&repo, "merge.ledger.driver", "ledger merge-driver %O %A %B %P")?; diff --git a/ledger-cli/src/commands/mod.rs b/ledger-cli/src/commands/mod.rs index 4fe50e10..81019120 100644 --- a/ledger-cli/src/commands/mod.rs +++ b/ledger-cli/src/commands/mod.rs @@ -69,8 +69,8 @@ fn dispatch(command: Commands, root: Option) -> Result { Commands::Allocate { decision, store, discharge, stage, expectation, actor } => { evolve::allocate(root, &decision, &store, &discharge, stage.as_deref(), expectation, actor.as_deref()) } - Commands::Declare { set, title, tolerance_floor, ground, owner, notes } => { - declare::run(root, declare::Flags { set, title, tolerance_floor, ground, owner, notes }) + Commands::Declare { set, namespace, title, tolerance_floor, ground, owner, notes } => { + declare::run(root, declare::Flags { set, namespace, title, tolerance_floor, ground, owner, notes }) } Commands::Escape { decision, exposure, review_by } => { evolve::escape(root, &decision, exposure, &review_by) diff --git a/ledger-cli/tests/accept_group.rs b/ledger-cli/tests/accept_group.rs index ffc47f4a..49ae37d4 100644 --- a/ledger-cli/tests/accept_group.rs +++ b/ledger-cli/tests/accept_group.rs @@ -30,7 +30,7 @@ impl Repo { repo.git(&["config", "user.name", "Fixture"]); repo.git(&["config", "user.email", email]); repo.ok(&["init"]); - repo.ok(&["declare", "--set", "batched", "--tolerance-floor", "T1"]); + repo.ok(&["declare", "--set", "batched", "--namespace", "fixture.batched", "--tolerance-floor", "T1"]); repo } @@ -118,7 +118,7 @@ impl Repo { } fn log_files(&self) -> usize { - std::fs::read_dir(self.path().join(".decisions/log")) + std::fs::read_dir(ledger_core::layout::log_dir(self.path(), "fixture.batched")) .expect("log dir") .filter_map(Result::ok) .count() diff --git a/ledger-cli/tests/authority.rs b/ledger-cli/tests/authority.rs index 1e242f74..8571207c 100644 --- a/ledger-cli/tests/authority.rs +++ b/ledger-cli/tests/authority.rs @@ -67,14 +67,14 @@ fn key_file(repo: &Repo, name: &str, key_type: &str) -> String { #[test] fn init_namespace_bootstraps_the_genesis_and_the_gate_stays_green() { let repo = governed(); - let root = std::fs::read_to_string(repo.path().join(".decisions/roles/steward.yml")).expect("root role"); + let root = std::fs::read_to_string(repo.roles_dir(NS).join("steward.yml")).expect("root role"); for cap in ["grant-role", "revoke-grant", "declare-unavailability", "rotate-genesis"] { assert!(root.contains(cap), "the root role carries {cap}: {root}"); } for cap in ["accept-decision", "sign-off-pattern", "waive-invalidation"] { assert!(!root.contains(cap), "the root role carries no decision capability: {root}"); } - let accept = std::fs::read_to_string(repo.path().join(".decisions/roles/acceptor.yml")).expect("accept role"); + let accept = std::fs::read_to_string(repo.roles_dir(NS).join("acceptor.yml")).expect("accept role"); assert!(accept.contains("accept-decision"), "{accept}"); let shown = repo.ok(&["policy", "show", "--namespace", NS]); assert!(shown.contains("accept-decision role: acceptor"), "{shown}"); @@ -138,7 +138,7 @@ fn grant_revocation_needs_a_terminal_and_files_its_own_entity() { let revoked = repo.ok_tty(&["grant", "revoke", &grant, "--reason", "moved to the platform team"]); assert!(revoked.contains("revoked grant:"), "{revoked}"); let log = repo.log_files().pop().expect("log"); - let text = std::fs::read_to_string(repo.path().join(".decisions/log").join(log)).expect("read"); + let text = std::fs::read_to_string(repo.log_dir(NS).join(log)).expect("read"); assert!(text.contains("format: 7") && text.contains("under: grant:") && text.contains("id: rev:") && text.contains("revokes: grant:"), "{text}"); repo.ok(&["verify", "--no-blame"]); } @@ -151,11 +151,11 @@ fn key_bindings_derive_allowed_signers_and_verify_holds_it() { let first = repo.ok(&["identity", "add", "--namespace", NS, "--key-file", &format!("{owner}.pub")]); let binding = first.split_whitespace().find(|w| w.starts_with("key:")).expect("binding id").to_string(); let log = repo.log_files().pop().expect("log"); - let text = std::fs::read_to_string(repo.path().join(".decisions/log").join(log)).expect("read"); + let text = std::fs::read_to_string(repo.log_dir(NS).join(log)).expect("read"); assert!(text.contains("self_bound: true") && text.contains("mandate: contract 2026/117"), "{text}"); let ulid = binding.trim_start_matches("key:"); - assert!(repo.path().join(format!(".decisions/sig/{ulid}.ssh.sig")).is_file(), "self-signed by the key it binds"); - let signers = repo.path().join(".decisions/allowed_signers"); + assert!(repo.sidecar(NS, ulid).is_file(), "self-signed by the key it binds"); + let signers = repo.signers_path(NS); let derived = std::fs::read_to_string(&signers).expect("allowed_signers"); assert!(derived.contains(&format!("{OWNER} namespaces=\"ledger-accept@{NS}\",valid-after=")), "{derived}"); repo.ok(&["verify", "--no-blame"]); @@ -227,16 +227,16 @@ fn revoke_files_a_revocation_entity_and_leaves_the_acceptance_untouched() { let files_before: Vec<(String, String)> = repo .log_files() .into_iter() - .map(|f| (f.clone(), std::fs::read_to_string(repo.path().join(".decisions/log").join(&f)).expect("read"))) + .map(|f| (f.clone(), std::fs::read_to_string(repo.log_dir(NS).join(&f)).expect("read"))) .collect(); let out = repo.ok_tty(&["revoke", &acc, "--reason", "filed against the wrong version"]); assert!(out.contains("by rev:"), "{out}"); for (file, text) in &files_before { - let now = std::fs::read_to_string(repo.path().join(".decisions/log").join(file)).expect("read"); + let now = std::fs::read_to_string(repo.log_dir(NS).join(file)).expect("read"); assert_eq!(&now, text, "{file} was edited by a revocation"); } let log = repo.log_files().pop().expect("log"); - let text = std::fs::read_to_string(repo.path().join(".decisions/log").join(log)).expect("read"); + let text = std::fs::read_to_string(repo.log_dir(NS).join(log)).expect("read"); assert!(text.contains("format: 7") && text.contains("under: grant:") && text.contains(&format!("revokes: {acc}")) && text.contains("hash: sha256:"), "{text}"); repo.ok(&["verify", "--no-blame"]); } @@ -281,8 +281,8 @@ fn the_accept_role_is_never_the_genesis_role() { fn bootstrap_refuses_an_existing_root_role_without_the_root_capabilities() { let repo = Repo::with_identity(OWNER); let role = "format: 6\nid: steward\nowner: owner@customer.example\nmay: [accept-decision, grant-role]\ncreated_at: 2026-10-02\n"; - std::fs::create_dir_all(repo.path().join(".decisions/roles")).expect("roles dir"); - std::fs::write(repo.path().join(".decisions/roles/steward.yml"), role).expect("role"); + std::fs::create_dir_all(repo.roles_dir(NS)).expect("roles dir"); + std::fs::write(repo.roles_dir(NS).join("steward.yml"), role).expect("role"); let refused = repo.refused(&["init", "--namespace", NS, "--external-ref", "m"]); assert!(refused.contains("cannot be the genesis role") && refused.contains("revoke-grant"), "{refused}"); assert!(repo.log_files().is_empty(), "a refused init files nothing"); @@ -318,7 +318,7 @@ fn a_policy_naming_the_genesis_role_and_a_widened_genesis_role_fail_verify() { let repo = Repo::with_identity(OWNER); repo.declare(); repo.ok(&["init", "--namespace", NS, "--external-ref", "contract 2026/117", "--without-key"]); - let log = repo.path().join(".decisions/log"); + let log = repo.log_dir(NS); let file = std::fs::read_dir(&log).expect("log").flatten().map(|e| e.path()).next().expect("the init change-set"); let mut cs: ChangeSet = serde_yaml::from_str(&std::fs::read_to_string(&file).expect("read")).expect("parse"); for p in &mut cs.policies { @@ -326,7 +326,7 @@ fn a_policy_naming_the_genesis_role_and_a_widened_genesis_role_fail_verify() { p.hash = policy_hash(p); } std::fs::write(&file, serde_yaml::to_string(&cs).expect("yaml")).expect("write"); - let roles = repo.path().join(".decisions/roles"); + let roles = repo.roles_dir(NS); let steward = std::fs::read_to_string(roles.join("steward.yml")).expect("steward"); std::fs::write(roles.join("steward.yml"), steward.replace("- rotate-genesis\n", "- rotate-genesis\n- accept-decision\n")).expect("widen"); std::fs::remove_file(roles.join("acceptor.yml")).expect("acceptor"); @@ -345,8 +345,8 @@ fn a_policy_naming_the_genesis_role_and_a_widened_genesis_role_fail_verify() { fn bootstrap_refuses_an_existing_root_role_that_carries_a_decision_capability() { let repo = Repo::with_identity(OWNER); let role = "format: 6\nid: steward\nowner: owner@customer.example\nmay: [grant-role, revoke-grant, declare-unavailability, rotate-genesis, accept-decision]\ncreated_at: 2026-10-07\n"; - std::fs::create_dir_all(repo.path().join(".decisions/roles")).expect("roles dir"); - std::fs::write(repo.path().join(".decisions/roles/steward.yml"), role).expect("role"); + std::fs::create_dir_all(repo.roles_dir(NS)).expect("roles dir"); + std::fs::write(repo.roles_dir(NS).join("steward.yml"), role).expect("role"); let refused = repo.refused(&["init", "--namespace", NS, "--external-ref", "m", "--without-key"]); assert!(refused.contains("cannot be the genesis role") && refused.contains("carries accept-decision"), "{refused}"); assert!(repo.log_files().is_empty(), "a refused init files nothing"); @@ -380,7 +380,7 @@ fn no_agent_identity_and_no_non_interactive_session_produces_an_acceptance() { #[test] fn a_set_scope_with_a_dot_is_granted_and_verifies() { let repo = governed(); - repo.ok(&["declare", "--set", "money.rules", "--tolerance-floor", "T1"]); + repo.ok(&["declare", "--set", "money.rules", "--namespace", NS, "--tolerance-floor", "T1"]); let out = repo.ok(&["grant", "new", "acceptor", "--to", ARCHITECT, "--scope", "set:money.rules"]); let grant = grant_id(&out); let store = ledger_core::store::load(repo.path()); diff --git a/ledger-cli/tests/batch.rs b/ledger-cli/tests/batch.rs index 9b0e4530..a484fea2 100644 --- a/ledger-cli/tests/batch.rs +++ b/ledger-cli/tests/batch.rs @@ -47,7 +47,7 @@ fn manifest(out: &str) -> String { } fn sidecars(repo: &Repo) -> usize { - std::fs::read_dir(repo.path().join(".decisions/sig")).map(|d| d.count()).unwrap_or(0) + std::fs::read_dir(repo.sig_dir(NS)).map(|d| d.count()).unwrap_or(0) } fn three(repo: &Repo) -> Vec { diff --git a/ledger-cli/tests/cli.rs b/ledger-cli/tests/cli.rs index 147f0202..f9202e98 100644 --- a/ledger-cli/tests/cli.rs +++ b/ledger-cli/tests/cli.rs @@ -24,8 +24,8 @@ fn init_scaffolds_the_layout_then_verify_passes_over_the_empty_store() { let dir = tempfile::tempdir().expect("tempdir"); let out = ledger(dir.path(), &["init"]); assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); - assert!(dir.path().join(".decisions/sets").is_dir()); - assert!(dir.path().join(".decisions/log").is_dir()); + assert!(dir.path().join(".decisions/ns").is_dir(), "one directory per namespace, made on first write (LP-3.34)"); + assert!(!dir.path().join(".decisions/sets").exists() && !dir.path().join(".decisions/log").exists(), "no flat path"); let ignore = std::fs::read_to_string(dir.path().join(".gitignore")).expect("read"); assert!(ignore.contains(".decisions/index/"), "{ignore}"); diff --git a/ledger-cli/tests/closed_key.rs b/ledger-cli/tests/closed_key.rs index ee1db16a..8606ca7c 100644 --- a/ledger-cli/tests/closed_key.rs +++ b/ledger-cli/tests/closed_key.rs @@ -51,12 +51,12 @@ fn verify(repo: &Repo) -> (i32, String) { /// File `cs` by hand, with one `ssh` sidecar per `(ulid, namespace, bytes)` /// signed by `key`; commit it. fn file_signed(repo: &Repo, cs: ChangeSet, signed: &[(String, String, Vec)], key: &str) { - let dir = repo.path().join(".decisions/sig"); + let dir = repo.sig_dir(NS); std::fs::create_dir_all(&dir).expect("sig dir"); for (ulid, ns, bytes) in signed { std::fs::write(dir.join(format!("{ulid}.ssh.sig")), hand::ssh_sign(key, ns, bytes)).expect("sig"); } - std::fs::write(repo.path().join(".decisions/log").join(cs.file_name()), serde_yaml::to_string(&cs).expect("yaml")).expect("log"); + std::fs::write(repo.log_dir(NS).join(cs.file_name()), serde_yaml::to_string(&cs).expect("yaml")).expect("log"); hand::commit(repo, "signed with the closed key, backdated"); } @@ -69,7 +69,7 @@ fn public(key: &str) -> String { } fn derived_signers(repo: &Repo) -> String { - ledger_core::authority::signers::derive(&store(repo)).unwrap_or_default() + ledger_core::authority::signers::derive(&store(repo)).into_values().collect() } fn fails_with(repo: &Repo, id: &str, class: &str) -> String { diff --git a/ledger-cli/tests/common/hand.rs b/ledger-cli/tests/common/hand.rs index a9898206..8ce5cee7 100644 --- a/ledger-cli/tests/common/hand.rs +++ b/ledger-cli/tests/common/hand.rs @@ -58,10 +58,12 @@ pub fn accept(repo: &Repo, h: &HandAccept<'_>) -> String { let mut cs = ChangeSet::empty(7, mint.mint_id("cs").expect("cs id"), h.at, h.actor.parse().expect("actor"), None); cs.acceptances.push(acceptance); let text = serde_yaml::to_string(&cs).expect("yaml"); - std::fs::write(repo.path().join(".decisions/log").join(cs.file_name()), text).expect("write log"); + let log = repo.log_dir(&ns); + std::fs::create_dir_all(&log).expect("log dir"); + std::fs::write(log.join(cs.file_name()), text).expect("write log"); if let Some(key) = h.key { let sig = ssh_sign(key, &ns, &bytes); - let dir = repo.path().join(".decisions/sig"); + let dir = repo.sig_dir(&ns); std::fs::create_dir_all(&dir).expect("sig dir"); std::fs::write(dir.join(format!("{ulid}.ssh.sig")), sig).expect("write sig"); } @@ -101,7 +103,8 @@ pub fn file( let at = bindings.iter().map(|b| b.at).chain(policies.iter().map(|p| p.at)).max().unwrap_or_else(Utc::now); let who = bindings.first().map(|b| b.by.clone()).or_else(|| policies.first().map(|p| p.by.clone())).expect("an entity"); let mut cs = ChangeSet::empty(7, mint.mint_id("cs").expect("cs id"), at, who, None); - let dir = repo.path().join(".decisions/sig"); + let ns = bindings.first().map(|b| b.namespace.clone()).or_else(|| policies.first().map(|p| p.namespace.clone())).expect("a namespace"); + let dir = repo.sig_dir(&ns); std::fs::create_dir_all(&dir).expect("sig dir"); for b in &bindings { let ulid = b.id.ulid().to_string(); @@ -120,7 +123,9 @@ pub fn file( cs.key_bindings = bindings; cs.policies = policies; let text = serde_yaml::to_string(&cs).expect("yaml"); - std::fs::write(repo.path().join(".decisions/log").join(cs.file_name()), text).expect("write log"); + let log = repo.log_dir(&ns); + std::fs::create_dir_all(&log).expect("log dir"); + std::fs::write(log.join(cs.file_name()), text).expect("write log"); } /// A sealed key binding, as a hand would write it. @@ -160,12 +165,11 @@ pub fn binding( b } -/// The log file holding the entity `id`, by its text. +/// The log file holding the entity `id`, by its text, in any namespace. pub fn file_holding(repo: &Repo, id: &str) -> std::path::PathBuf { - std::fs::read_dir(repo.path().join(".decisions/log")) - .expect("log") - .flatten() - .map(|e| e.path()) + ledger_core::layout::namespaces(repo.path()) + .into_iter() + .flat_map(|ns| std::fs::read_dir(repo.log_dir(&ns)).into_iter().flatten().flatten().map(|e| e.path())) .find(|p| std::fs::read_to_string(p).is_ok_and(|t| t.contains(&format!("id: {id}")))) .unwrap_or_else(|| panic!("no log file holds {id}")) } diff --git a/ledger-cli/tests/common/mod.rs b/ledger-cli/tests/common/mod.rs index ae353f9f..f4f54a1a 100644 --- a/ledger-cli/tests/common/mod.rs +++ b/ledger-cli/tests/common/mod.rs @@ -94,15 +94,68 @@ impl Repo { expect_code(&self.tty(args), 1, args) } - /// The log files in the store, sorted. + /// The log files of every namespace in the store, sorted by name. pub fn log_files(&self) -> Vec { - let mut out: Vec = std::fs::read_dir(self.path().join(".decisions/log")) - .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect()) - .unwrap_or_default(); + let mut out: Vec = ledger_core::layout::namespaces(self.path()) + .into_iter() + .flat_map(|ns| { + std::fs::read_dir(self.log_dir(&ns)) + .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect::>()) + .unwrap_or_default() + }) + .collect(); out.sort(); out } + /// `.decisions/ns//` — a namespace's directory (LP-3.34). + pub fn ns_dir(&self, ns: &str) -> std::path::PathBuf { + ledger_core::layout::namespace_dir(self.path(), ns) + } + + /// `.decisions/ns//log/`. + pub fn log_dir(&self, ns: &str) -> std::path::PathBuf { + ledger_core::layout::log_dir(self.path(), ns) + } + + /// `.decisions/ns//sig/`. + pub fn sig_dir(&self, ns: &str) -> std::path::PathBuf { + ledger_core::layout::sig_dir(self.path(), ns) + } + + /// `.decisions/ns//roles/`. + pub fn roles_dir(&self, ns: &str) -> std::path::PathBuf { + ledger_core::layout::roles_dir(self.path(), ns) + } + + /// `.decisions/ns//sets/`. + pub fn sets_dir(&self, ns: &str) -> std::path::PathBuf { + ledger_core::layout::sets_dir(self.path(), ns) + } + + /// `.decisions/ns//allowed_signers`. + pub fn signers_path(&self, ns: &str) -> std::path::PathBuf { + ledger_core::layout::signers_path(self.path(), ns) + } + + /// The namespace's derived `allowed_signers`, or empty when absent. + pub fn signers(&self, ns: &str) -> String { + std::fs::read_to_string(self.signers_path(ns)).unwrap_or_default() + } + + /// The sidecar path for the entity `ulid` in `ns`. + pub fn sidecar(&self, ns: &str, ulid: &str) -> std::path::PathBuf { + self.sig_dir(ns).join(format!("{ulid}.ssh.sig")) + } + + /// How many sidecars every namespace holds. + pub fn sidecar_count(&self) -> usize { + ledger_core::layout::namespaces(self.path()) + .into_iter() + .map(|ns| std::fs::read_dir(self.sig_dir(&ns)).map(|d| d.count()).unwrap_or(0)) + .sum() + } + /// A fresh ed25519 key pair (no passphrase) under `keys/`, outside the /// store; returns the private key's path. pub fn keygen(&self, name: &str) -> String { @@ -140,9 +193,15 @@ impl Repo { private } - /// Declare the default fixture set at floor T1. + /// Declare the default fixture set at floor T1, in `fixture.ledger`. pub fn declare(&self) { - self.ok(&["declare", "--set", "ledger-design", "--tolerance-floor", "T1"]); + self.declare_in("fixture.ledger"); + } + + /// Declare the default fixture set at floor T1 in `ns`: a version + /// names a set of its own namespace (LP-5.22). + pub fn declare_in(&self, ns: &str) { + self.ok(&["declare", "--set", "ledger-design", "--namespace", ns, "--tolerance-floor", "T1"]); } /// File a constraint decision and return its id. @@ -255,6 +314,101 @@ pub fn both(out: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)) } +/// A scratch copy of a committed fixture store, laid out under +/// `.decisions/ns//` (LP-3.34). The fixtures under +/// `tests/fixtures/` are still in the flat layout until issue 9 re-lays +/// them out; until then this stages each one the way the loader reads. +pub fn stage_fixture(name: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + stage_fixture_into(name, dir.path()); + dir +} + +/// Stage the fixture `name` into `root`'s store; returns its one namespace. +pub fn stage_fixture_into(name: &str, root: &Path) -> String { + let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name).join(".decisions"); + let namespaces = stage_store_into(&from, root); + assert_eq!(namespaces.len(), 1, "a fixture holds one namespace: {namespaces:?}"); + namespaces.into_iter().next().expect("one namespace") +} + +/// A scratch copy of this repository's own store, staged the same way: the +/// store is flat until issue 10 re-lays it out, and the loader reads only +/// `ns//`. +pub fn stage_workspace() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".decisions"); + stage_store_into(&from, dir.path()); + dir +} + +/// Stage the store at `from` (a `.decisions/` directory, flat or per +/// namespace) into `root`'s store under `ns//`: a change-set +/// goes to the namespace its decisions name, a set to the namespace whose +/// versions name it. Bytes are copied, never rewritten. Returns the +/// namespaces staged. +pub fn stage_store_into(from: &Path, root: &Path) -> Vec { + if from.join("ns").is_dir() { + let mut out = Vec::new(); + for ns in std::fs::read_dir(from.join("ns")).expect("ns").flatten().filter(|e| e.path().is_dir()) { + let name = ns.file_name().to_string_lossy().into_owned(); + copy_tree(&ns.path(), &ledger_core::layout::namespace_dir(root, &name)); + out.push(name); + } + out.sort(); + return out; + } + let mut namespaces: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut set_homes: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut logs: Vec<(std::path::PathBuf, String)> = Vec::new(); + for entry in std::fs::read_dir(from.join("log")).into_iter().flatten().flatten() { + let text = std::fs::read_to_string(entry.path()).expect("read"); + let cs: ledger_core::changeset::ChangeSet = serde_yaml::from_str(&text).expect("a change-set"); + let ns = cs + .decisions + .iter() + .map(|d| d.id.namespace().to_string()) + .chain(cs.versions.iter().map(|v| v.decision.namespace().to_string())) + .chain(cs.acceptances.iter().map(|a| a.decision.namespace().to_string())) + .next() + .unwrap_or_else(|| panic!("{} names no namespace", entry.path().display())); + for v in &cs.versions { + set_homes.entry(v.set.clone()).or_insert_with(|| ns.clone()); + } + namespaces.insert(ns.clone()); + logs.push((entry.path(), ns)); + } + for (path, ns) in logs { + let target = ledger_core::layout::log_dir(root, &ns); + std::fs::create_dir_all(&target).expect("mkdir"); + std::fs::copy(&path, target.join(path.file_name().expect("name"))).expect("copy"); + } + for entry in std::fs::read_dir(from.join("sets")).into_iter().flatten().flatten() { + let stem = entry.path().file_stem().expect("stem").to_string_lossy().into_owned(); + let ns = set_homes + .get(&stem) + .cloned() + .or_else(|| (namespaces.len() == 1).then(|| namespaces.iter().next().cloned()).flatten()) + .unwrap_or_else(|| panic!("set `{stem}` is named by no version, and the store speaks several namespaces")); + let target = ledger_core::layout::sets_dir(root, &ns); + std::fs::create_dir_all(&target).expect("mkdir"); + std::fs::copy(entry.path(), target.join(entry.file_name())).expect("copy"); + } + namespaces.into_iter().collect() +} + +fn copy_tree(from: &Path, to: &Path) { + std::fs::create_dir_all(to).expect("mkdir"); + for entry in std::fs::read_dir(from).expect("read").flatten() { + let target = to.join(entry.file_name()); + if entry.path().is_dir() { + copy_tree(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).expect("copy"); + } + } +} + /// The `dec:` id an `add` printed. pub fn decision_id(stdout: &str) -> String { stdout diff --git a/ledger-cli/tests/diff.rs b/ledger-cli/tests/diff.rs index 24387828..18051c44 100644 --- a/ledger-cli/tests/diff.rs +++ b/ledger-cli/tests/diff.rs @@ -56,7 +56,7 @@ fn two_commit_repo() -> (Repo, String) { let repo = Repo::new(); repo.ok(&["init"]); repo.ok(&[ - "declare", "--set", "diff-fixture", "--tolerance-floor", "T1", + "declare", "--set", "diff-fixture", "--namespace", "fixture.diff", "--tolerance-floor", "T1", ]); let add = repo.ok(&[ "add", "--set", "diff-fixture", "--namespace", "fixture.diff", @@ -77,7 +77,7 @@ fn two_commit_repo() -> (Repo, String) { ]); repo.ok(&["accept", &decision]); repo.ok(&[ - "declare", "--set", "second-set", "--tolerance-floor", "T2", + "declare", "--set", "second-set", "--namespace", "fixture.diff", "--tolerance-floor", "T2", ]); repo.commit("second: revision, acceptance, new set"); (repo, decision) diff --git a/ledger-cli/tests/digests.rs b/ledger-cli/tests/digests.rs index 28b861d3..ede2cd6a 100644 --- a/ledger-cli/tests/digests.rs +++ b/ledger-cli/tests/digests.rs @@ -19,15 +19,30 @@ fn workspace() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("..") } +/// Every log directory of a store: `.decisions/ns//log` (LP-3.34), or +/// the flat `.decisions/log` of a store not yet re-laid out. +fn log_dirs_of(root: &Path) -> Vec { + let mut out: Vec = ledger_core::layout::namespaces(root) + .into_iter() + .map(|ns| ledger_core::layout::log_dir(root, &ns)) + .filter(|d| d.is_dir()) + .collect(); + let flat = root.join(".decisions/log"); + if flat.is_dir() { + out.push(flat); + } + out +} + fn log_dirs() -> Vec { let fixtures = workspace().join("ledger-cli/tests/fixtures"); let mut dirs: Vec = std::fs::read_dir(&fixtures) .expect("fixtures") .flatten() .filter(|e| e.file_name() != STALE_BY_DESIGN) - .map(|e| e.path().join(".decisions/log")) + .flat_map(|e| log_dirs_of(&e.path())) .collect(); - dirs.push(workspace().join(".decisions/log")); + dirs.extend(log_dirs_of(&workspace())); dirs.sort(); dirs } diff --git a/ledger-cli/tests/export.rs b/ledger-cli/tests/export.rs index f97ba4cf..71117a2c 100644 --- a/ledger-cli/tests/export.rs +++ b/ledger-cli/tests/export.rs @@ -2,18 +2,16 @@ //! `verify --export` holds it byte-identical to the log — a hand edit, a //! stale file, or no file at all fails the gate with exit 1. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Output; use assert_cmd::Command; +mod common; + const TODAY: &str = "2026-08-10"; const EXPORT: &str = "docs/decisions/fixture.ledger.nt"; -fn fixtures() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") -} - fn ledger(root: &Path, args: &[&str]) -> Output { let mut cmd = Command::cargo_bin("ledger").expect("binary"); cmd.arg("--root").arg(root).args(args); @@ -30,16 +28,7 @@ fn text(out: &Output) -> String { /// A scratch copy of the conformant fixture, so tests can write exports. fn scratch() -> tempfile::TempDir { - let dir = tempfile::tempdir().expect("tempdir"); - let from = fixtures().join("pass/.decisions"); - let to = dir.path().join(".decisions"); - for sub in ["sets", "log"] { - std::fs::create_dir_all(to.join(sub)).expect("mkdir"); - for entry in std::fs::read_dir(from.join(sub)).expect("read").flatten() { - std::fs::copy(entry.path(), to.join(sub).join(entry.file_name())).expect("copy"); - } - } - dir + common::stage_fixture("pass") } fn namespace_of_fixture(dir: &Path) -> String { diff --git a/ledger-cli/tests/export_verifier.rs b/ledger-cli/tests/export_verifier.rs index 80a3f614..acfb521a 100644 --- a/ledger-cli/tests/export_verifier.rs +++ b/ledger-cli/tests/export_verifier.rs @@ -50,11 +50,11 @@ fn the_export_and_the_sidecars_alone_verify_and_rebuild_allowed_signers() { repo.ok(&["export", "--format", "ntriples", "--namespace", NS, "--out", &export.display().to_string()]); std::fs::create_dir_all(input.path().join("sig")).expect("sig dir"); let mut sidecars = 0; - for entry in std::fs::read_dir(repo.path().join(".decisions/sig")).expect("sig").flatten() { + for entry in std::fs::read_dir(repo.sig_dir(NS)).expect("sig").flatten() { std::fs::copy(entry.path(), input.path().join("sig").join(entry.file_name())).expect("copy"); sidecars += 1; } - let committed = std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).expect("derived"); + let committed = std::fs::read_to_string(repo.signers_path(NS)).expect("derived"); // The verifier's input is fixed: nothing under `.decisions/`, no git. std::fs::remove_dir_all(repo.path().join(".decisions")).expect("remove store"); std::fs::remove_dir_all(repo.path().join(".git")).expect("remove git"); @@ -71,12 +71,15 @@ fn the_export_and_the_sidecars_alone_verify_and_rebuild_allowed_signers() { fn bytes_rebuilt_from_the_export_equal_the_signed_bytes_over_every_fixture() { let signed = signed_store(); let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let mut roots: Vec = std::fs::read_dir(&fixtures).expect("fixtures").flatten().map(|e| e.path()).collect(); - roots.sort(); - roots.push(signed.path().to_path_buf()); - // And this repository's own store: 91 acceptances signed by nobody yet, + let mut names: Vec = std::fs::read_dir(&fixtures).expect("fixtures").flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + // Each fixture staged under `ns//`, the signed store, and + // this repository's own store: 91 acceptances signed by nobody yet, // whose bytes the export must still rebuild exactly. - roots.push(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..")); + let mut staged: Vec = names.iter().map(|n| common::stage_fixture(n)).collect(); + staged.push(common::stage_workspace()); + let mut roots: Vec = staged.iter().map(|d| d.path().to_path_buf()).collect(); + roots.push(signed.path().to_path_buf()); let mut checked = 0; for root in roots { let store = ledger_core::store::load(&root); diff --git a/ledger-cli/tests/format_history.rs b/ledger-cli/tests/format_history.rs index ece2f607..91cd3a3a 100644 --- a/ledger-cli/tests/format_history.rs +++ b/ledger-cli/tests/format_history.rs @@ -18,13 +18,8 @@ const CS: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXW"; /// The `pass` fixture's store (its change-set needs format 1), landed /// declaring `format: N`. Returns the log file. fn landed_at(repo: &Repo, n: u32) -> PathBuf { - let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pass/.decisions"); - for dir in ["sets", "log"] { - for entry in std::fs::read_dir(from.join(dir)).expect("fixture").flatten() { - std::fs::copy(entry.path(), repo.path().join(".decisions").join(dir).join(entry.file_name())).expect("copy"); - } - } - let file = repo.path().join(format!(".decisions/log/{CS}.yml")); + common::stage_fixture_into("pass", repo.path()); + let file = repo.log_dir("fixture.ledger").join(format!("{CS}.yml")); redeclare(&file, 1, n); hand::commit(repo, &format!("landed at format {n}")); file diff --git a/ledger-cli/tests/gate.rs b/ledger-cli/tests/gate.rs index 18fd1ef9..6399509a 100644 --- a/ledger-cli/tests/gate.rs +++ b/ledger-cli/tests/gate.rs @@ -32,12 +32,14 @@ fn fixtures() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } -/// Run the gate over a fixture store. Blame is off: these stores live inside +/// Run the gate over a fixture store, staged under `.decisions/ns//` +/// (`common::stage_fixture`). Blame is off: these stores live inside /// this repository, so their history attributes to whoever committed them. fn gate(fixture: &str, extra: &[&str]) -> Output { + let staged = common::stage_fixture(fixture); let mut cmd = Command::cargo_bin("ledger").expect("binary"); cmd.arg("--root") - .arg(fixtures().join(fixture)) + .arg(staged.path()) .args(["verify", "--today", TODAY, "--no-blame"]) .args(extra); cmd.output().expect("run") @@ -199,16 +201,7 @@ mod blame { } fn copy_pass_fixture(&self) { - let from = fixtures().join("pass/.decisions"); - let to = self.path().join(".decisions"); - std::fs::create_dir_all(to.join("sets")).expect("mkdir"); - std::fs::create_dir_all(to.join("log")).expect("mkdir"); - for sub in ["sets", "log"] { - for entry in std::fs::read_dir(from.join(sub)).expect("read").flatten() { - let target = to.join(sub).join(entry.file_name()); - std::fs::copy(entry.path(), target).expect("copy"); - } - } + common::stage_fixture_into("pass", self.path()); } fn git(&self, args: &[&str]) { @@ -245,7 +238,7 @@ mod blame { // File a second acceptance under a new id, uncommitted. It is // appended: the landed one stays as it landed (landed entities are // immutable, `L007`). - let log = repo.path().join(".decisions/log/01K2C4YQJ3F8M0PT5W7NZ9RDXW.yml"); + let log = ledger_core::layout::log_dir(repo.path(), "fixture.ledger").join("01K2C4YQJ3F8M0PT5W7NZ9RDXW.yml"); let text = std::fs::read_to_string(&log).expect("read"); let mut file: serde_yaml::Value = serde_yaml::from_str(&text).expect("yaml"); let list = file.get_mut("acceptances").and_then(serde_yaml::Value::as_sequence_mut).expect("acceptances"); @@ -279,7 +272,7 @@ fn fixture_hashes_are_current() { if name == STALE_BY_DESIGN { continue; } - for log in std::fs::read_dir(entry.path().join(".decisions/log")).expect("log").flatten() { + for log in fixture_log_files(&entry.path()) { if refresh(&log.path(), update) { stale.push(format!("{name}/{}", log.file_name().to_string_lossy())); } @@ -291,6 +284,15 @@ fn fixture_hashes_are_current() { ); } +/// The log files of one fixture store: `.decisions/ns//log/*` (LP-3.34) +/// or, until issue 9 re-lays the fixtures out, the flat `.decisions/log/*`. +fn fixture_log_files(root: &Path) -> Vec { + let mut dirs: Vec = + ledger_core::layout::namespaces(root).iter().map(|ns| ledger_core::layout::log_dir(root, ns)).collect(); + dirs.push(root.join(".decisions/log")); + dirs.into_iter().filter(|d| d.is_dir()).flat_map(|d| std::fs::read_dir(d).expect("log").flatten()).collect() +} + /// Rewrite the stored hashes in one log file. Returns whether it was stale. fn refresh(path: &Path, write: bool) -> bool { let original = std::fs::read_to_string(path).expect("read"); diff --git a/ledger-cli/tests/genesis_key.rs b/ledger-cli/tests/genesis_key.rs index ce629a44..de2e490c 100644 --- a/ledger-cli/tests/genesis_key.rs +++ b/ledger-cli/tests/genesis_key.rs @@ -58,8 +58,9 @@ fn policy_of(repo: &Repo, ns: &str) -> ledger_core::authority::Policy { store.log.iter().flat_map(|l| l.file.policies.iter()).find(|p| p.namespace == ns).cloned().expect("policy") } -fn sidecar(repo: &Repo, ulid: &str) -> std::path::PathBuf { - repo.path().join(".decisions/sig").join(format!("{ulid}.ssh.sig")) +/// The sidecar of the entity `ulid`, under the namespace it was filed in. +fn sidecar(repo: &Repo, ns: &str, ulid: &str) -> std::path::PathBuf { + repo.sidecar(ns, ulid) } /// A self-bound binding for the holder's address, filed by hand with a key @@ -89,9 +90,9 @@ fn init_binds_the_configured_key_self_bound_and_it_signs_the_first_policy() { assert!(public.contains(b.key.as_deref().unwrap_or("?")), "the configured key is the one bound"); let policy = policy_of(&repo, NS); assert_eq!(b.at, policy.at, "dated with the policy, so the policy governs it"); - assert!(sidecar(&repo, b.id.ulid()).exists(), "the binding is signed by the key it binds"); - assert!(sidecar(&repo, policy.id.ulid()).exists(), "the first policy is signed"); - let signers = std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).expect("allowed_signers"); + assert!(sidecar(&repo, NS, b.id.ulid()).exists(), "the binding is signed by the key it binds"); + assert!(sidecar(&repo, NS, policy.id.ulid()).exists(), "the first policy is signed"); + let signers = std::fs::read_to_string(repo.signers_path(NS)).expect("allowed_signers"); assert!(signers.contains(b.key.as_deref().unwrap_or("?")), "{signers}"); hand::commit(&repo, "governed"); let (code, text) = verify(&repo); @@ -126,7 +127,7 @@ fn unbound_at_init_the_window_stays_open_and_init_says_so() { repo.ok(&["identity", "sync"]); let (code, text) = verify(&repo); assert_eq!(code, 0, "unbound, the forged first binding is trusted: {text}"); - let signers = std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).expect("allowed_signers"); + let signers = std::fs::read_to_string(repo.signers_path(NS)).expect("allowed_signers"); let store = ledger_core::store::load(repo.path()); let forged = store.log.iter().flat_map(|l| l.file.key_bindings.iter()).find(|b| b.id.to_string() == id).expect("forged"); assert!(signers.contains(forged.key.as_deref().unwrap_or("?")), "and it reaches allowed_signers: {signers}"); @@ -137,7 +138,7 @@ fn unbound_at_init_the_window_stays_open_and_init_says_so() { fn a_first_policy_filed_before_any_key_stays_valid_unsigned() { let (repo, _) = unkeyed(); let policy = policy_of(&repo, NS); - assert!(!sidecar(&repo, policy.id.ulid()).exists(), "nothing to sign with"); + assert!(!sidecar(&repo, NS, policy.id.ulid()).exists(), "nothing to sign with"); hand::commit(&repo, "governed"); let (code, text) = verify(&repo); assert_eq!(code, 0, "{text}"); @@ -149,7 +150,7 @@ fn a_later_namespaces_first_policy_is_signed_and_unsigned_it_is_l011() { let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]); assert!(out.contains("signed"), "{out}"); let policy = policy_of(&repo, SECOND); - let sig = sidecar(&repo, policy.id.ulid()); + let sig = sidecar(&repo, SECOND, policy.id.ulid()); assert!(sig.exists(), "signed with the key trusted in the first namespace"); hand::commit(&repo, "second namespace"); let (code, text) = verify(&repo); @@ -187,10 +188,11 @@ fn init_in_a_later_namespace_binds_the_holders_key_there_and_they_sign_with_no_i assert!(!b.self_bound && b.mandate.is_none() && b.by.as_str() == OWNER, "their own add: {b:?}"); assert_eq!(b.at, policy.at, "dated with the policy"); assert!(std::fs::read_to_string(format!("{key}.pub")).expect("pub").contains(b.key.as_deref().unwrap_or("?")), "the existing key"); - assert!(sidecar(&repo, b.id.ulid()).exists(), "signed by their key trusted in the first namespace"); + assert!(sidecar(&repo, SECOND, b.id.ulid()).exists(), "signed by their key trusted in the first namespace"); // They sign in the new namespace straight away. let grant = hand::word(&repo.ok(&["grant", "new", "acceptor", "--to", OWNER, "--scope", &format!("ns:{SECOND}")]), "grant:"); repo.ok(&["grant", "accept", &grant]); + repo.declare_in(SECOND); let out = repo.ok(&[ "add", "--set", "ledger-design", "--namespace", SECOND, "--statement", "Signed with no separate identity add.", "--store", "constraint", "--discharge", "analyzer:DEC001", diff --git a/ledger-cli/tests/graph.rs b/ledger-cli/tests/graph.rs index ff9ae53d..c519464d 100644 --- a/ledger-cli/tests/graph.rs +++ b/ledger-cli/tests/graph.rs @@ -3,18 +3,16 @@ //! and the graph stage reports through `verify` with unchanged exit //! semantics. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Output; use assert_cmd::Command; +mod common; + /// The date at which the coverage fixture exhibits every state. const TODAY: &str = "2027-01-01"; -fn fixtures() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") -} - fn ledger(root: &Path, args: &[&str]) -> Output { let mut cmd = Command::cargo_bin("ledger").expect("binary"); cmd.arg("--root").arg(root).args(args); @@ -27,16 +25,7 @@ fn stdout(out: &Output) -> String { /// A scratch copy of the coverage fixture, so tests can write an index. fn scratch_coverage() -> tempfile::TempDir { - let dir = tempfile::tempdir().expect("tempdir"); - let from = fixtures().join("coverage/.decisions"); - let to = dir.path().join(".decisions"); - for sub in ["sets", "log"] { - std::fs::create_dir_all(to.join(sub)).expect("mkdir"); - for entry in std::fs::read_dir(from.join(sub)).expect("read").flatten() { - std::fs::copy(entry.path(), to.join(sub).join(entry.file_name())).expect("copy"); - } - } - dir + common::stage_fixture("coverage") } #[test] @@ -57,7 +46,8 @@ fn the_rebuild_is_byte_identical_after_deleting_the_index() { #[test] fn coverage_distinguishes_all_seven_states_on_the_fixture() { - let root = fixtures().join("coverage"); + let staged = common::stage_fixture("coverage"); + let root = staged.path().to_path_buf(); let out = ledger(&root, &["coverage", "--today", TODAY]); assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr)); let text = stdout(&out); @@ -80,7 +70,8 @@ fn coverage_distinguishes_all_seven_states_on_the_fixture() { #[test] fn coverage_json_carries_the_states_and_the_chain_machine_readably() { - let root = fixtures().join("coverage"); + let staged = common::stage_fixture("coverage"); + let root = staged.path().to_path_buf(); let out = ledger(&root, &["coverage", "--today", TODAY, "--json"]); let parsed: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("json"); assert_eq!(parsed["decisions"], 9); @@ -93,7 +84,8 @@ fn coverage_json_carries_the_states_and_the_chain_machine_readably() { fn a_superseded_versions_acceptances_are_historical_in_blame_and_status() { // d7 (the chain root) is superseded; the fixture's `decided` decision // d3 stays live. Status renders the supersession terminally. - let root = fixtures().join("coverage"); + let staged = common::stage_fixture("coverage"); + let root = staged.path().to_path_buf(); let out = ledger(&root, &["status", "--today", TODAY]); let text = stdout(&out); assert!(text.contains("superseded by dec:fixture.coverage/"), "{text}"); @@ -104,7 +96,7 @@ fn the_graph_stage_reports_through_verify_with_exit_one() { // Break the fixture copy: point a supersession at a decision nobody // filed. The file gate stays quiet; the graph stage fails the run. let dir = scratch_coverage(); - let log_dir = dir.path().join(".decisions/log"); + let log_dir = ledger_core::layout::log_dir(dir.path(), "fixture.coverage"); let target = std::fs::read_dir(&log_dir) .expect("read") .flatten() @@ -136,7 +128,8 @@ fn latest_follows_the_parent_dag_when_ulid_order_contradicts_it() { // a writer whose clock runs ahead. ULID order would call the root // latest and the signed revision historical; the parent DAG knows the // revision is the tip, so the store is conformant and decided. - let root = fixtures().join("two-clocks"); + let staged = common::stage_fixture("two-clocks"); + let root = staged.path().to_path_buf(); let out = ledger(&root, &["verify", "--today", "2026-08-10", "--no-blame"]); assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr)); assert!( @@ -157,7 +150,8 @@ fn g004_a_forked_chain_fails_verify_and_names_both_tips() { // produces: one parent, two divergent revisions. No file is malformed; // the graph stage names the fork and the store is non-conformant until // a human arbitrates. - let root = fixtures().join("forked"); + let staged = common::stage_fixture("forked"); + let root = staged.path().to_path_buf(); let out = ledger(&root, &["verify", "--today", "2026-08-10", "--no-blame"]); assert_eq!(out.status.code(), Some(1), "{}", String::from_utf8_lossy(&out.stderr)); let err = String::from_utf8_lossy(&out.stderr).into_owned(); diff --git a/ledger-cli/tests/inbox.rs b/ledger-cli/tests/inbox.rs index c818c683..4e81b0c3 100644 --- a/ledger-cli/tests/inbox.rs +++ b/ledger-cli/tests/inbox.rs @@ -42,14 +42,15 @@ fn three_repositories_twelve_branches_one_sitting_every_branch_green() { let mut sidecars = 0; for (i, h) in f.holders.iter().enumerate() { h.git(&["fetch", "-q", "origin"]); - let on_main = git(h.path(), &["ls-tree", "--name-only", "origin/main", ".decisions/sig/"]).lines().count(); + let on_main = git(h.path(), &["ls-tree", "-r", "--name-only", "origin/main", ".decisions/"]).lines().filter(|p| p.contains("/sig/")).count(); let branches = git(h.path(), &["for-each-ref", "--format=%(refname:strip=3)", "refs/remotes/origin/agent/"]); for b in branches.lines() { let wt = f.root.path().join(format!("check-{i}-{}", b.replace('/', "-"))); h.git(&["worktree", "add", "-q", "--detach", &wt.display().to_string(), &format!("origin/{b}")]); let v = common::invoke(&wt, &["verify", "--base", "origin/main", "--export"]); assert_eq!(v.status.code(), Some(0), "repo{i} {b}: {}", common::both(&v)); - sidecars += std::fs::read_dir(wt.join(".decisions/sig")).map(|d| d.count()).unwrap_or(0) - on_main; + let in_tree: usize = ledger_core::layout::namespaces(&wt).iter().map(|ns| std::fs::read_dir(ledger_core::layout::sig_dir(&wt, ns)).map(|d| d.count()).unwrap_or(0)).sum(); + sidecars += in_tree - on_main; } } assert_eq!(sidecars, 20, "one signature per acceptance, beyond the sidecars main already holds"); @@ -60,6 +61,7 @@ fn a_namespace_with_no_policy_is_named_unchecked_not_omitted() { let f = fixture(); let h = &f.holders[0]; // A second namespace, never opted in, proposed on main. + h.declare_in("loose.ledger"); let out = h.ok(&["add", "--set", "ledger-design", "--namespace", "loose.ledger", "--statement", "Unchecked."]); assert!(out.contains("dec:loose.ledger/"), "{out}"); h.ok(&["export", "--format", "ntriples"]); diff --git a/ledger-cli/tests/inbox_failures.rs b/ledger-cli/tests/inbox_failures.rs index 4abe8080..fbb9c0e0 100644 --- a/ledger-cli/tests/inbox_failures.rs +++ b/ledger-cli/tests/inbox_failures.rs @@ -142,7 +142,7 @@ fn a_killed_sitting_leaves_a_worktree_the_next_run_sweeps() { let killed = inbox(&f, &["accept", "--all", "--confirm", &manifest(&dry)]); assert_ne!(killed.status.code(), Some(0), "{}", common::both(&killed)); let left = f.holders[0].path().join(".git/ledger-inbox/agent_0"); - assert!(left.join(".decisions/sig").is_dir(), "the kill left the signed worktree behind"); + assert!(ledger_core::layout::namespaces(&left).iter().any(|ns| ledger_core::layout::sig_dir(&left, ns).is_dir()), "the kill left the signed worktree behind"); let signed = git(&left, &["rev-parse", "HEAD"]); assert!(!git(f.holders[0].path(), &["worktree", "list"]).is_empty()); let bare = f.root.path().join("remote0.git"); diff --git a/ledger-cli/tests/inbox_fixture/mod.rs b/ledger-cli/tests/inbox_fixture/mod.rs index 9daa13f0..e1dab5f4 100644 --- a/ledger-cli/tests/inbox_fixture/mod.rs +++ b/ledger-cli/tests/inbox_fixture/mod.rs @@ -33,7 +33,7 @@ pub fn repository(root: &Path, i: usize) -> Repo { git(root, &["init", "-q", "--bare", "--initial-branch=main", &bare.display().to_string()]); let holder = Repo::with_identity(OWNER); holder.git(&["remote", "add", "origin", &bare.display().to_string()]); - holder.declare(); + holder.declare_in(&ns); holder.ok(&["init", "--namespace", &ns, "--external-ref", &format!("contract {i}"), "--without-key"]); holder.bind_own_key(&ns, "owner"); let grant = common::hand::word(&holder.ok(&["grant", "new", "acceptor", "--to", OWNER, "--scope", &format!("ns:{ns}")]), "grant:"); diff --git a/ledger-cli/tests/key_across_namespaces.rs b/ledger-cli/tests/key_across_namespaces.rs index 1ba47491..6442ba83 100644 --- a/ledger-cli/tests/key_across_namespaces.rs +++ b/ledger-cli/tests/key_across_namespaces.rs @@ -27,8 +27,9 @@ fn pause() { std::thread::sleep(std::time::Duration::from_millis(1100)); } +/// Every namespace's derived `allowed_signers`, concatenated. fn signers(repo: &Repo) -> String { - std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).unwrap_or_default() + ledger_core::layout::namespaces(repo.path()).into_iter().map(|ns| repo.signers(&ns)).collect() } fn verify(repo: &Repo, extra: &[&str]) -> (i32, String) { @@ -80,6 +81,7 @@ fn a_then_b() -> (Repo, String) { hand::commit(&repo, "A"); pause(); repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE]); + repo.declare_in(B); acceptor(&repo, B); hand::commit(&repo, "B, the same key bound"); pause(); @@ -97,6 +99,7 @@ fn b_then_a() -> (Repo, String) { pause(); repo.use_key(&key); repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE]); + repo.declare_in(B); acceptor(&repo, B); hand::commit(&repo, "B"); pause(); @@ -155,7 +158,7 @@ fn bound_in_b_then_a_and_closed_in_a_the_key_is_closed_in_b_and_vouches_in_no_th /// it, as the holder's own further `add`. fn bound_twice() -> (Repo, String, String, String) { let repo = Repo::with_identity(OWNER); - repo.declare(); + repo.declare_in(A); let key = repo.keygen("owner"); repo.use_key(&key); repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]); diff --git a/ledger-cli/tests/key_ownership.rs b/ledger-cli/tests/key_ownership.rs index fc4a278a..5bec1ae3 100644 --- a/ledger-cli/tests/key_ownership.rs +++ b/ledger-cli/tests/key_ownership.rs @@ -79,7 +79,7 @@ fn a_hand_filed_binding_of_another_principals_key_is_a_schema_fault() { assert_eq!(code, 1, "{text}"); assert!(text.contains("[SCHEMA]") && text.contains(&id) && text.contains("a key belongs to one principal"), "{text}"); repo.ok(&["identity", "sync"]); - let signers = std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).unwrap_or_default(); + let signers = std::fs::read_to_string(repo.signers_path(NS)).unwrap_or_default(); let blob = std::fs::read_to_string(format!("{a}.pub")).expect("pub").split_whitespace().nth(1).map(str::to_string).expect("blob"); assert!(signers.lines().filter(|l| l.contains(&blob)).all(|l| l.starts_with(OWNER)), "never trusted as the architect's: {signers}"); } diff --git a/ledger-cli/tests/keys.rs b/ledger-cli/tests/keys.rs index b4e6938f..d47861f5 100644 --- a/ledger-cli/tests/keys.rs +++ b/ledger-cli/tests/keys.rs @@ -14,7 +14,7 @@ fn add_files_a_keyed_exported_decision_as_format_5() { ]); assert!(out.contains("key `MoneyIsDecimal`"), "{out}"); let file = repo.log_files().pop().expect("a log file"); - let text = std::fs::read_to_string(repo.path().join(".decisions/log").join(file)).expect("read"); + let text = std::fs::read_to_string(repo.log_dir("fixture.ledger").join(file)).expect("read"); assert!(text.contains("format: 5"), "{text}"); assert!(text.contains("key: MoneyIsDecimal") && text.contains("exported: true"), "{text}"); repo.ok(&["verify", "--no-blame"]); @@ -26,7 +26,7 @@ fn an_unkeyed_add_stays_a_format_1_file() { repo.declare(); repo.add("Money is decimal.", &[]); let file = repo.log_files().pop().expect("a log file"); - let text = std::fs::read_to_string(repo.path().join(".decisions/log").join(file)).expect("read"); + let text = std::fs::read_to_string(repo.log_dir("fixture.ledger").join(file)).expect("read"); assert!(text.contains("format: 1"), "a file declares only what it uses: {text}"); } diff --git a/ledger-cli/tests/landed_header.rs b/ledger-cli/tests/landed_header.rs index 5d012839..64d36501 100644 --- a/ledger-cli/tests/landed_header.rs +++ b/ledger-cli/tests/landed_header.rs @@ -7,8 +7,6 @@ mod common; -use std::path::Path; - use common::{hand, Repo}; const CS: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXW"; @@ -16,12 +14,7 @@ const CS: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXW"; /// The `pass` fixture's store, committed. fn landed() -> Repo { let repo = Repo::human(); - let from = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pass/.decisions"); - for dir in ["sets", "log"] { - for entry in std::fs::read_dir(from.join(dir)).expect("fixture").flatten() { - std::fs::copy(entry.path(), repo.path().join(".decisions").join(dir).join(entry.file_name())).expect("copy"); - } - } + common::stage_fixture_into("pass", repo.path()); hand::commit(&repo, "landed"); repo } @@ -29,7 +22,7 @@ fn landed() -> Repo { /// Add `line` to the landed header, commit, and verify. fn header_gains(line: &str) -> (i32, String) { let repo = landed(); - let path = repo.path().join(format!(".decisions/log/{CS}.yml")); + let path = repo.log_dir("fixture.ledger").join(format!("{CS}.yml")); let text = std::fs::read_to_string(&path).expect("log file"); let edited = text.replacen("created_by: fixture-human@example\n", &format!("created_by: fixture-human@example\n{line}\n"), 1); assert_ne!(text, edited, "the header was edited"); diff --git a/ledger-cli/tests/legacy_revocation.rs b/ledger-cli/tests/legacy_revocation.rs index 2c20abe7..39f67e53 100644 --- a/ledger-cli/tests/legacy_revocation.rs +++ b/ledger-cli/tests/legacy_revocation.rs @@ -46,7 +46,7 @@ fn legacy_revocation(repo: &Repo, acceptance: &str, by: &str, at: &str) { let text = format!( "format: 1\nid: cs:{CS}\ncreated_at: {at}\ncreated_by: {by}\nrevocations:\n- acceptance: {acceptance}\n at: {at}\n by: {by}\n reason: not mine to revoke\n" ); - std::fs::write(repo.path().join(format!(".decisions/log/{CS}.yml")), text).expect("write"); + std::fs::write(repo.log_dir(NS).join(format!("{CS}.yml")), text).expect("write"); } fn export(repo: &Repo) -> String { @@ -135,7 +135,10 @@ versions:\n- decision: {dec}\n hash: {hash}\n set: ledger-design\n statement: acceptances:\n- id: {acc}\n decision: {dec}\n version: {hash}\n actor: {MALLORY}\n at: 2026-08-10T09:20:00Z\n\ revocations:\n- acceptance: {acc}\n at: 2026-08-10T09:30:00Z\n by: {MALLORY}\n reason: withdrawn\n" ); - std::fs::write(repo.path().join(format!(".decisions/log/{EARLY}.yml")), text).expect("write"); + // The file belongs to `open.ns`, whose set it names (LP-3.34, LP-5.22). + repo.declare_in("open.ns"); + std::fs::create_dir_all(repo.log_dir("open.ns")).expect("log dir"); + std::fs::write(repo.log_dir("open.ns").join(format!("{EARLY}.yml")), text).expect("write"); repo.act_as(MALLORY); hand::commit(&repo, "a reused acceptance id in an ungoverned namespace"); let (code, text) = verify(&repo); diff --git a/ledger-cli/tests/merge.rs b/ledger-cli/tests/merge.rs index 9fc7b451..a2e2eb60 100644 --- a/ledger-cli/tests/merge.rs +++ b/ledger-cli/tests/merge.rs @@ -24,7 +24,7 @@ impl Repo { repo.git(&["config", "user.name", "Fixture Human"]); repo.git(&["config", "user.email", "fixture-human@example"]); repo.ok(&["init"]); - repo.ok(&["declare", "--set", "merge-fixture", "--tolerance-floor", "T1"]); + repo.ok(&["declare", "--set", "merge-fixture", "--namespace", "fixture.merge", "--tolerance-floor", "T1"]); repo } @@ -272,7 +272,7 @@ fn a_competing_supersession_is_surfaced_and_settled_by_withdrawal() { fn a_divergent_floor_stops_at_the_driver_and_resolves_by_choice() { let repo = Repo::new(); // A member-free set, so a floor move cannot strand anyone. - repo.ok(&["declare", "--set", "ops", "--tolerance-floor", "T0"]); + repo.ok(&["declare", "--set", "ops", "--namespace", "fixture.merge", "--tolerance-floor", "T0"]); repo.ok(&["merge", "--install"]); // The test must pin the driver to this build's binary, not PATH. let bin: PathBuf = assert_cmd::cargo::cargo_bin("ledger"); @@ -283,7 +283,7 @@ fn a_divergent_floor_stops_at_the_driver_and_resolves_by_choice() { ]); repo.commit("main: the ops set, driver installed"); - let set_file = repo.path().join(".decisions/sets/ops.yml"); + let set_file = ledger_core::layout::sets_dir(repo.path(), "fixture.merge").join("ops.yml"); let rewrite_floor = |floor: &str| { let text = std::fs::read_to_string(&set_file).expect("read"); std::fs::write(&set_file, text.replace("tolerance_floor: T0", floor)).expect("write"); diff --git a/ledger-cli/tests/pre_policy_binding.rs b/ledger-cli/tests/pre_policy_binding.rs index 2970e6d4..e4b1829e 100644 --- a/ledger-cli/tests/pre_policy_binding.rs +++ b/ledger-cli/tests/pre_policy_binding.rs @@ -45,8 +45,9 @@ fn policy_of(repo: &Repo, ns: &str) -> Policy { store.log.iter().flat_map(|l| l.file.policies.iter()).find(|p| p.namespace == ns).cloned().expect("policy") } +/// Every namespace's derived `allowed_signers`, concatenated. fn signers(repo: &Repo) -> String { - std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).unwrap_or_default() + ledger_core::layout::namespaces(repo.path()).into_iter().map(|ns| repo.signers(&ns)).collect() } /// The verb, with its clock `behind` the policy's. @@ -133,6 +134,7 @@ fn a_signed_binding_before_the_first_policy_is_trusted_and_signs() { assert_eq!(out.status.code(), Some(0), "{}", common::both(&out)); assert!(signers(&repo).contains(&blob), "it reaches allowed_signers: {}", signers(&repo)); // And it signs: an acceptance in the second namespace under that key. + repo.declare_in(SECOND); repo.use_key(&next); let grant = hand::word(&repo.ok(&["grant", "new", "acceptor", "--to", OWNER, "--scope", &format!("ns:{SECOND}")]), "grant:"); repo.ok(&["grant", "accept", &grant]); diff --git a/ledger-cli/tests/revisit_format.rs b/ledger-cli/tests/revisit_format.rs index c7ba716a..69b242c7 100644 --- a/ledger-cli/tests/revisit_format.rs +++ b/ledger-cli/tests/revisit_format.rs @@ -51,7 +51,7 @@ fn revised_onto_a_reopen_edge(repo: &Repo) -> PathBuf { repo.ok(&["revise", &id, "--statement", "Money is decimal.", "--revisit-if", REOPEN, "--note", "edge re-typed"]); let file = repo.log_files().into_iter().find(|f| !before.contains(f)).expect("the revision's file"); repo.ok_tty(&["accept", &id]); - repo.path().join(".decisions/log").join(file) + repo.log_dir("fixture.ledger").join(file) } /// Before #67 every next-version verb wrote `format: 1`, an inherited edge @@ -66,7 +66,7 @@ fn every_version_carrying_a_reopen_edge_is_written_at_format_4() { let before = repo.log_files(); repo.ok(&["revise", &id, "--statement", "Money is decimal, always."]); let inherited = repo.log_files().into_iter().find(|f| !before.contains(f)).expect("file"); - assert_eq!(declared(&repo.path().join(".decisions/log").join(inherited)), "format: 4", "inherited"); + assert_eq!(declared(&repo.log_dir("fixture.ledger").join(inherited)), "format: 4", "inherited"); let (code, text) = verify(&repo); assert_eq!(code, 0, "{text}"); } @@ -120,15 +120,25 @@ fn a_raise_beside_another_edit_is_not_a_correction() { #[test] fn the_corrected_files_declare_exactly_what_they_need() { - let log = workspace().join(".decisions/log"); + // This repository's store: `.decisions/ns//log` (LP-3.34), or the + // flat `.decisions/log` until it is re-laid out. + let log = |stem: &str| { + let root = workspace(); + ledger_core::layout::namespaces(&root) + .into_iter() + .map(|ns| ledger_core::layout::log_dir(&root, &ns).join(format!("{stem}.yml"))) + .chain(std::iter::once(root.join(".decisions/log").join(format!("{stem}.yml")))) + .find(|p| p.is_file()) + .unwrap_or_else(|| panic!("{stem} is not in this repository's store")) + }; for stem in CORRECTED { - let path = log.join(format!("{stem}.yml")); + let path = log(stem); let cs: ledger_core::changeset::ChangeSet = serde_yaml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse"); assert_eq!(cs.format, format::REVISIT_FORMAT, "{stem}"); assert_eq!(format::needed_for(&cs), cs.format, "{stem}: raised past what it needs"); } - let path = log.join(format!("{NOT_REOPENING}.yml")); + let path = log(NOT_REOPENING); let cs: ledger_core::changeset::ChangeSet = serde_yaml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse"); assert!(cs.versions.iter().all(|v| v.revisit_if.is_empty()), "{NOT_REOPENING} carries no reopen edge"); @@ -139,7 +149,8 @@ fn the_corrected_files_declare_exactly_what_they_need() { /// acceptance of their own hash, and the gate finds nothing about them. #[test] fn the_four_acceptances_still_verify() { - let store = ledger_core::store::load(&workspace()); + let staged = common::stage_workspace(); + let store = ledger_core::store::load(staged.path()); let view = View::build(&store); let report = verify::verify(&store, &Options::offline(chrono::Utc::now().date_naive())); let mut seen = 0; diff --git a/ledger-cli/tests/role_position.rs b/ledger-cli/tests/role_position.rs index c5abda30..d2250271 100644 --- a/ledger-cli/tests/role_position.rs +++ b/ledger-cli/tests/role_position.rs @@ -30,7 +30,7 @@ fn an_act_that_landed_before_the_role_its_grant_names_fails_a006() { repo.ok_tty(&["accept", &id]); // Land everything but the `acceptor` role file, then the role file. repo.git(&["add", "-A"]); - repo.git(&["reset", "-q", "--", ".decisions/roles/acceptor.yml"]); + repo.git(&["reset", "-q", "--", &format!(".decisions/ns/{NS}/roles/acceptor.yml")]); repo.git(&["commit", "-q", "-m", "the acceptance, before its role"]); hand::commit(&repo, "the role, after"); let store = ledger_core::store::load(repo.path()); @@ -48,7 +48,7 @@ fn a_role_and_an_act_landed_in_the_same_commit_pass_whatever_the_role_is_dated() let repo = Repo::with_identity(OWNER); repo.declare(); repo.ok(&["init", "--namespace", NS, "--external-ref", "contract 2026/117", "--without-key"]); - let role = repo.path().join(".decisions/roles/acceptor.yml"); + let role = repo.roles_dir(NS).join("acceptor.yml"); let text = std::fs::read_to_string(&role).expect("role"); let dated: String = text .lines() diff --git a/ledger-cli/tests/scalars.rs b/ledger-cli/tests/scalars.rs index 36cc0f0d..6afaec18 100644 --- a/ledger-cli/tests/scalars.rs +++ b/ledger-cli/tests/scalars.rs @@ -18,9 +18,11 @@ const CS: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXW"; fn committed(scalar: &str) -> (i32, String) { let repo = Repo::human(); let sets = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pass/.decisions/sets"); + std::fs::create_dir_all(repo.sets_dir("fixture.ledger")).expect("sets dir"); for entry in std::fs::read_dir(sets).expect("fixture").flatten() { - std::fs::copy(entry.path(), repo.path().join(".decisions/sets").join(entry.file_name())).expect("copy"); + std::fs::copy(entry.path(), repo.sets_dir("fixture.ledger").join(entry.file_name())).expect("copy"); } + std::fs::create_dir_all(repo.log_dir("fixture.ledger")).expect("log dir"); let file = |hash: &str| { format!( "format: 1\nid: cs:{CS}\ncreated_at: 2026-08-10T09:14:22Z\ncreated_by: fixture-human@example\n\ @@ -31,7 +33,7 @@ versions:\n - decision: dec:fixture.ledger/01K2C4YQJ3F8M0PT5W7NZ9RDXV\n hash }; let draft: ledger_core::changeset::ChangeSet = serde_yaml::from_str(&file(&format!("sha256:{}", "0".repeat(64)))).expect("parse"); let hash = ledger_core::hash::version_hash(&draft.versions[0]).to_string(); - std::fs::write(repo.path().join(format!(".decisions/log/{CS}.yml")), file(&hash)).expect("write"); + std::fs::write(repo.log_dir("fixture.ledger").join(format!("{CS}.yml")), file(&hash)).expect("write"); hand::commit(&repo, "a version"); let out = repo.ledger(&["verify", "--no-blame"]); (out.status.code().unwrap_or(-1), common::both(&out)) diff --git a/ledger-cli/tests/show.rs b/ledger-cli/tests/show.rs index c7671d2c..5059625b 100644 --- a/ledger-cli/tests/show.rs +++ b/ledger-cli/tests/show.rs @@ -24,7 +24,7 @@ impl Repo { repo.git(&["config", "user.email", "fixture-human@example"]); repo.git(&["config", "user.name", "Fixture Human"]); repo.ok(&["init"]); - repo.ok(&["declare", "--set", "shown", "--tolerance-floor", "T1"]); + repo.ok(&["declare", "--set", "shown", "--namespace", "fixture.shown", "--tolerance-floor", "T1"]); repo } @@ -128,7 +128,7 @@ fn a_reopen_edge_declares_format_four_and_nothing_else_does() { repo.add("A decision that states one.", &[ "--revisit-if", "claim:DDD-gates-01@sha256:6f500ee8", ]); - let mut formats: Vec = std::fs::read_dir(repo.path().join(".decisions/log")) + let mut formats: Vec = std::fs::read_dir(ledger_core::layout::log_dir(repo.path(), "fixture.shown")) .expect("log dir") .filter_map(|e| e.ok()) .filter_map(|e| std::fs::read_to_string(e.path()).ok()) diff --git a/ledger-cli/tests/signing.rs b/ledger-cli/tests/signing.rs index 6379b755..7548b873 100644 --- a/ledger-cli/tests/signing.rs +++ b/ledger-cli/tests/signing.rs @@ -52,7 +52,7 @@ fn an_accept_is_signed_into_a_sidecar_and_verifies() { let out = repo.ok_tty(&["accept", &id]); let sig = hand::word(&out, "sig/"); assert!(sig.ends_with(".ssh.sig"), "{out}"); - assert!(repo.path().join(".decisions").join(&sig).is_file()); + assert!(repo.ns_dir(NS).join(&sig).is_file()); green(&repo); } @@ -61,7 +61,7 @@ fn a_missing_or_tampered_signature_fails_l011() { let (repo, _) = governed(); let id = repo.add("Money is decimal.", &[]); let out = repo.ok_tty(&["accept", &id]); - let sig = repo.path().join(".decisions").join(hand::word(&out, "sig/")); + let sig = repo.ns_dir(NS).join(hand::word(&out, "sig/")); let original = std::fs::read(&sig).expect("sig"); let mut tampered = original.clone(); let mid = tampered.len() / 2; diff --git a/ledger-cli/tests/trust.rs b/ledger-cli/tests/trust.rs index 8e62b630..5a7f057d 100644 --- a/ledger-cli/tests/trust.rs +++ b/ledger-cli/tests/trust.rs @@ -44,7 +44,7 @@ fn binding_of(repo: &Repo, who: &str) -> String { } fn signers(repo: &Repo) -> String { - std::fs::read_to_string(repo.path().join(".decisions/allowed_signers")).unwrap_or_default() + std::fs::read_to_string(repo.signers_path(NS)).unwrap_or_default() } #[test] @@ -57,7 +57,7 @@ fn an_unsigned_binding_for_an_existing_holder_fails_and_never_reaches_allowed_si let (code, text) = verify(&repo); assert_eq!(code, 1); assert!(text.contains("[L011]") && text.contains(&id) && text.contains("not trusted"), "{text}"); - let derived = ledger_core::authority::signers::derive(&ledger_core::store::load(repo.path())).unwrap_or_default(); + let derived = ledger_core::authority::signers::derive(&ledger_core::store::load(repo.path())).remove(NS).unwrap_or_default(); assert_eq!(derived.lines().filter(|l| !l.starts_with('#')).count(), 1, "only the genesis key: {derived}"); } @@ -189,7 +189,7 @@ fn removing_a_policy_fails_l007_because_opting_in_is_one_way() { #[test] fn a_role_file_edited_after_landing_fails_l007() { let (repo, _) = governed(); - let path = repo.path().join(".decisions/roles/acceptor.yml"); + let path = repo.roles_dir(NS).join("acceptor.yml"); let text = std::fs::read_to_string(&path).expect("role"); std::fs::write(&path, text.replace("- accept-decision", "- accept-decision\n- grant-role")).expect("edit"); let (code, out) = verify(&repo); diff --git a/ledger-cli/tests/ungoverned_sidecar.rs b/ledger-cli/tests/ungoverned_sidecar.rs index 3fa70100..622d2095 100644 --- a/ledger-cli/tests/ungoverned_sidecar.rs +++ b/ledger-cli/tests/ungoverned_sidecar.rs @@ -21,7 +21,7 @@ fn accepted_with_a_false_sidecar(repo: &Repo) -> String { repo.ok_tty(&["accept", &id]); let store = ledger_core::store::load(repo.path()); let acc = store.log.iter().flat_map(|l| l.file.acceptances.iter()).next().expect("acceptance").id.ulid().to_string(); - let dir = repo.path().join(".decisions/sig"); + let dir = repo.sig_dir(NS); std::fs::create_dir_all(&dir).expect("sig dir"); let name = format!("{acc}.ssh.sig"); std::fs::write(dir.join(&name), "not a signature\n").expect("sidecar"); diff --git a/ledger-cli/tests/verbs.rs b/ledger-cli/tests/verbs.rs index 81994158..052fd486 100644 --- a/ledger-cli/tests/verbs.rs +++ b/ledger-cli/tests/verbs.rs @@ -83,7 +83,7 @@ impl Repo { } fn declare(&self) { - self.ok(&["declare", "--set", "verbs", "--tolerance-floor", "T1"]); + self.ok(&["declare", "--set", "verbs", "--namespace", "fixture.verbs", "--tolerance-floor", "T1"]); } /// Add a constraint-allocated decision; returns its id. diff --git a/ledger-core/src/author/authority_ops.rs b/ledger-core/src/author/authority_ops.rs index 4df1074a..7366291a 100644 --- a/ledger-core/src/author/authority_ops.rs +++ b/ledger-core/src/author/authority_ops.rs @@ -51,6 +51,9 @@ pub const DEFAULT_ACCEPT_ROLE: &str = "acceptor"; /// What `role declare` states. pub struct RoleArgs { + /// The namespace whose `roles/` holds the file: explicit, else the one + /// namespace the store has a directory for. + pub namespace: Option, pub id: String, pub title: Option, pub may: Vec, @@ -108,7 +111,7 @@ impl Author { ))); } if store.roles.iter().all(|r| r.id != accept_role) { - new_roles.push(self.new_role(&accept_role, "Accepts decisions", &[Capability::AcceptDecision])); + new_roles.push(self.new_role(&args.namespace, &accept_role, "Accepts decisions", &[Capability::AcceptDecision])); lines.push(format!("declared role `{accept_role}` — may accept-decision; held by nobody until granted")); } let root = candidate.grants.first().cloned().or(genesis).ok_or_else(|| AuthorError::Io("no genesis grant".into()))?; @@ -136,10 +139,11 @@ impl Author { Ok(Applied { path, lines }) } - fn new_role(&self, id: &str, title: &str, may: &[Capability]) -> Role { + fn new_role(&self, namespace: &str, id: &str, title: &str, may: &[Capability]) -> Role { Role { format: crate::format::AUTHORITY_FORMAT, id: id.to_string(), + namespace: namespace.to_string(), title: Some(title.into()), owner: self.who.clone(), may: may.to_vec(), @@ -161,7 +165,7 @@ impl Author { AuthorError::Usage("the store has no genesis yet — `--external-ref` names the mandate it rests on".into()) })?; let new_roles = match store.role(&args.role) { - None => vec![self.new_role(&args.role, "Genesis steward", Capability::ROOT)], + None => vec![self.new_role(&args.namespace, &args.role, "Genesis steward", Capability::ROOT)], Some(existing) => match existing.genesis_refusal() { Some(why) => return Err(AuthorError::Conflict(format!("role `{}` cannot be the genesis role: {why}", args.role))), None => Vec::new(), @@ -257,8 +261,9 @@ impl Author { }) } + /// Write a role file under its namespace's `roles/` (LP-3.34). fn write_role(&self, role: &Role) -> Result<(), AuthorError> { - let path = self.root.join(crate::STORE_DIR).join("roles").join(role.file_name()); + let path = crate::layout::roles_dir(&self.root, &role.namespace).join(role.file_name()); if path.exists() { return Err(AuthorError::Conflict(format!("role `{}` is already declared", role.id))); } @@ -277,9 +282,11 @@ impl Author { if args.may.is_empty() { return Err(AuthorError::Usage("a role names at least one capability (`--may`)".into())); } + let namespace = super::home::resolve_namespace_dir(&self.root, args.namespace.as_deref())?; let role = Role { format: crate::format::AUTHORITY_FORMAT, id: args.id, + namespace, title: args.title, owner: self.who.clone(), may: args.may, @@ -287,9 +294,9 @@ impl Author { notes: args.notes, }; self.write_role(&role)?; - let path = self.root.join(crate::STORE_DIR).join("roles").join(role.file_name()); + let path = crate::layout::roles_dir(&self.root, &role.namespace).join(role.file_name()); let caps: Vec<&str> = role.may.iter().map(|c| c.as_str()).collect(); - Ok(Applied { path, lines: vec![format!("declared role `{}` — may {}", role.id, caps.join(", "))] }) + Ok(Applied { path, lines: vec![format!("declared role `{}` in `{}` — may {}", role.id, role.namespace, caps.join(", "))] }) } /// Grant a role: `grant-role` over the new grant's scope. Below the diff --git a/ledger-core/src/author/decision.rs b/ledger-core/src/author/decision.rs index 0c22cf8b..cbade833 100644 --- a/ledger-core/src/author/decision.rs +++ b/ledger-core/src/author/decision.rs @@ -74,16 +74,16 @@ impl Author { /// Mint a decision and file its first version. pub fn add(&mut self, args: AddArgs) -> Result { let store = self.load(); + let namespace = resolve_namespace(args.namespace.as_deref(), &store)?; let floor = store - .set(&args.set) + .set_in(&namespace, &args.set) .ok_or_else(|| { AuthorError::Usage(format!( - "set `{}` is not declared under sets/ — `ledger declare` it first", + "set `{}` is not declared under `{namespace}`'s sets/ — `ledger declare` it there first (a version names a set of its own namespace)", args.set )) })? .tolerance_floor; - let namespace = resolve_namespace(args.namespace.as_deref(), &store)?; let decision: DecisionId = format!("dec:{namespace}/{}", self.mint.mint()) .parse() .map_err(AuthorError::Usage)?; diff --git a/ledger-core/src/author/declare.rs b/ledger-core/src/author/declare.rs index 20e992db..a6b3d312 100644 --- a/ledger-core/src/author/declare.rs +++ b/ledger-core/src/author/declare.rs @@ -2,8 +2,10 @@ //! //! A set fixes the tolerance floor, the ground state, and the owner; it //! never lists members (§5.2 of the ledger protocol). Declaring one writes a fresh -//! `sets/.yml` through the same schema type the loader parses, so a -//! declared set cannot disagree with what `verify` will read back. +//! `ns//sets/.yml` through the same schema type the loader parses, +//! so a declared set cannot disagree with what `verify` will read back. A +//! set belongs to the namespace whose directory holds it (LP-3.34), and a +//! version names a set of its own namespace (LP-5.22). use crate::set::{DecisionSet, Ground}; use crate::tier::Tier; @@ -12,6 +14,9 @@ use super::{Applied, Author, AuthorError}; /// What a declaration states. pub struct DeclareArgs { + /// The namespace the set is declared in: explicit, else the one + /// namespace the store has a directory for. + pub namespace: Option, pub id: String, pub title: String, pub tolerance_floor: Tier, @@ -27,15 +32,17 @@ impl Author { pub fn declare(&mut self, args: DeclareArgs) -> Result { DecisionSet::validate_id(&args.id).map_err(AuthorError::Usage)?; let store = self.load(); - if store.set(&args.id).is_some() { + let namespace = super::home::resolve_namespace_dir(&self.root, args.namespace.as_deref())?; + if store.set_in(&namespace, &args.id).is_some() { return Err(AuthorError::Usage(format!( - "set `{}` is already declared under sets/ — a floor change is a set-level governed act, not a redeclaration", + "set `{}` is already declared under `{namespace}`'s sets/ — a floor change is a set-level governed act, not a redeclaration", args.id ))); } let set = DecisionSet { format: crate::format::CURRENT_FORMAT, id: args.id.clone(), + namespace: namespace.clone(), title: args.title, tolerance_floor: args.tolerance_floor, ground: args.ground, @@ -43,7 +50,9 @@ impl Author { created_at: self.today(), notes: args.notes, }; - let path = self.root.join(crate::STORE_DIR).join("sets").join(format!("{}.yml", set.id)); + let dir = crate::layout::sets_dir(&self.root, &namespace); + std::fs::create_dir_all(&dir).map_err(|e| AuthorError::Io(e.to_string()))?; + let path = dir.join(format!("{}.yml", set.id)); if path.exists() { return Err(AuthorError::Io(format!("{} already exists", path.display()))); } @@ -54,7 +63,7 @@ impl Author { Ok(Applied { path, lines: vec![format!( - "declared set `{}` — floor {}, owner {}", + "declared set `{}` in `{namespace}` — floor {}, owner {}", set.id, set.tolerance_floor, set.owner )], }) diff --git a/ledger-core/src/author/home.rs b/ledger-core/src/author/home.rs new file mode 100644 index 00000000..1079ece4 --- /dev/null +++ b/ledger-core/src/author/home.rs @@ -0,0 +1,139 @@ +//! Which namespace's directory a change-set is filed under (LP-3.34). +//! +//! A file's namespace is its directory, and every entity in it belongs to +//! that namespace (ruling 45). A verb files one act, so the change-set it +//! builds names exactly one namespace: through the decision ids its +//! decisions, versions and acceptances carry; the `namespace` field of a +//! binding or a policy; and, for the records that carry none, the +//! namespace of what they name — a revocation its target's, a grant the +//! grant it is made `under`, a grant acceptance or an interval its grant's. +//! The genesis grant names no `under`; it is filed with the first policy of +//! the namespace it opens. + +use std::collections::BTreeSet; + +use crate::authority::Revocable; +use crate::changeset::ChangeSet; +use crate::store::Store; + +use super::AuthorError; + +/// The one namespace `cs` belongs to, read from its entities and, where an +/// entity names a record filed earlier, from `store`. +pub fn namespace_of(store: &Store, cs: &ChangeSet) -> Result { + let mut found: BTreeSet = BTreeSet::new(); + found.extend(cs.policies.iter().map(|p| p.namespace.clone())); + found.extend(cs.key_bindings.iter().map(|b| b.namespace.clone())); + found.extend(cs.decisions.iter().map(|d| d.id.namespace().to_string())); + found.extend(cs.versions.iter().map(|v| v.decision.namespace().to_string())); + found.extend(cs.acceptances.iter().map(|a| a.decision.namespace().to_string())); + for r in &cs.revocations { + match r.target() { + Some(Revocable::Acceptance(acc)) => found.extend(acceptance_namespace(store, &acc.to_string())), + Some(Revocable::Grant(g)) => found.extend(grant_namespace(store, &g.to_string())), + None => {} + } + } + for g in &cs.grants { + if let Some(under) = &g.under { + found.extend(grant_namespace(store, &under.to_string())); + } + } + for ga in &cs.grant_acceptances { + found.extend(grant_namespace(store, &ga.grant.to_string())); + } + for u in &cs.unavailabilities { + found.extend(grant_namespace(store, &u.grant.to_string())); + } + for av in &cs.availabilities { + found.extend(interval_namespace(store, &av.ends.to_string())); + } + let mut it = found.into_iter(); + match (it.next(), it.next()) { + (Some(ns), None) => Ok(ns), + (None, _) => Err(AuthorError::Usage( + "the change-set names no namespace — every entity belongs to one namespace's directory (LP-3.34)".to_string(), + )), + (Some(a), Some(b)) => Err(AuthorError::Usage(format!( + "the change-set holds entities of more than one namespace (`{a}`, `{b}`, …) — a file holds one namespace's entities (ruling 45)" + ))), + } +} + +/// The namespace whose directory holds the grant `id`. +pub fn grant_namespace(store: &Store, id: &str) -> Option { + store.log.iter().find(|l| l.file.grants.iter().any(|g| g.id.to_string() == id)).map(|l| l.namespace.clone()) +} + +/// The namespace of the acceptance `id`: its decision's. +pub 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 namespace whose directory holds the unavailability `id`. +pub fn interval_namespace(store: &Store, id: &str) -> Option { + store.log.iter().find(|l| l.file.unavailabilities.iter().any(|u| u.id.to_string() == id)).map(|l| l.namespace.clone()) +} + +/// The namespace a file is declared in when the verb takes none: the one +/// namespace the store has a directory for. None, or several, is a usage +/// error naming what to pass. +pub fn resolve_namespace_dir(root: &std::path::Path, explicit: Option<&str>) -> Result { + if let Some(ns) = explicit { + crate::id::validate_namespace(ns).map_err(AuthorError::Usage)?; + return Ok(ns.to_string()); + } + let present = crate::layout::namespaces(root); + match present.as_slice() { + [one] => Ok(one.clone()), + [] => Err(AuthorError::Usage( + "the store has no namespace directory yet, so there is none to infer — pass --namespace".to_string(), + )), + several => Err(AuthorError::Usage(format!( + "the store holds several namespaces ({}) — pass --namespace", + several.join(", ") + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testkit; + + #[test] + fn a_change_set_is_homed_by_its_decisions_namespace() { + let cs = testkit::changeset(vec![testkit::sealed(testkit::version())], Vec::new()); + let store = testkit::store(cs.clone()); + assert_eq!(namespace_of(&store, &cs).expect("home"), testkit::NS); + } + + #[test] + fn an_empty_change_set_has_no_home_and_two_namespaces_are_refused() { + let store = testkit::store(testkit::changeset(Vec::new(), Vec::new())); + let empty = testkit::changeset(Vec::new(), Vec::new()); + assert!(namespace_of(&store, &empty).is_err()); + let mut other = testkit::version(); + other.decision = "dec:hafeok.other/01K2C4YQJ3F8M0PT5W7NZ9RDY0".parse().expect("id"); + let mixed = testkit::changeset(vec![testkit::sealed(testkit::version()), testkit::sealed(other)], Vec::new()); + let err = namespace_of(&store, &mixed).expect_err("two namespaces"); + assert!(err.to_string().contains("ruling 45"), "{err}"); + } + + #[test] + fn the_namespace_directory_is_inferred_only_when_there_is_one() { + let dir = tempfile::tempdir().expect("tempdir"); + assert!(resolve_namespace_dir(dir.path(), None).is_err()); + std::fs::create_dir_all(crate::layout::namespace_dir(dir.path(), "a.ns")).expect("mkdir"); + assert_eq!(resolve_namespace_dir(dir.path(), None).expect("one"), "a.ns"); + std::fs::create_dir_all(crate::layout::namespace_dir(dir.path(), "b.ns")).expect("mkdir"); + assert!(resolve_namespace_dir(dir.path(), None).is_err()); + assert_eq!(resolve_namespace_dir(dir.path(), Some("b.ns")).expect("explicit"), "b.ns"); + assert!(resolve_namespace_dir(dir.path(), Some("Not A Namespace")).is_err()); + } +} diff --git a/ledger-core/src/author/mod.rs b/ledger-core/src/author/mod.rs index 2be5435f..712669d0 100644 --- a/ledger-core/src/author/mod.rs +++ b/ledger-core/src/author/mod.rs @@ -12,7 +12,9 @@ //! not a write fault). //! //! The log stays append-only: verbs only ever create new files -//! (`log/.yml`, `sets/.yml`), never edit one. +//! (`ns//log/.yml`, `ns//sets/.yml`), never edit one. +//! Every change-set is filed under the directory of the one namespace its +//! entities belong to ([`home::namespace_of`], LP-3.34). mod acceptance_ops; mod authority_ops; @@ -20,6 +22,7 @@ mod availability_ops; mod batch_accept; mod declare; mod genesis_key; +mod home; mod decision; mod group_accept; mod identity_ops; @@ -40,6 +43,7 @@ pub use policy_ops::PolicyArgs; pub use batch_accept::AcceptBatchArgs; pub use group_accept::AcceptGroupArgs; pub use declare::DeclareArgs; +pub use home::namespace_of; pub use decision::{AddArgs, AllocationArgs}; pub use report::{blame, log, status}; pub use version_ops::{EscapeArgs, ReviseArgs, SupersedeArgs}; @@ -178,8 +182,13 @@ impl Author { sidecars: current.sidecars.iter().chain(self.pending_for(candidate)).cloned().collect(), schema_findings: current.schema_findings.clone(), }; + // A candidate that names nothing the store holds (a revocation of + // an unfiled acceptance, say) has no home; the gate refuses it with + // the finding that says why, so it is placed under no namespace here. + let namespace = home::namespace_of(current, candidate).unwrap_or_default(); with.log.push(LoggedChangeSet { - path: current.dir.join("log").join(candidate.file_name()), + path: crate::layout::log_dir(¤t.root, &namespace).join(candidate.file_name()), + namespace, file: candidate.clone(), }); let fresh: Vec = self @@ -209,10 +218,14 @@ impl Author { self.pending_sidecars.iter().filter(move |s| ulids.contains(&s.ulid)) } - /// Append a change-set as a brand-new log file. Never overwrites: the - /// log is append-only, and a colliding ULID is an error, not a merge. + /// Append a change-set as a brand-new log file under its namespace's + /// directory. Never overwrites: the log is append-only, and a colliding + /// ULID is an error, not a merge. pub(crate) fn append(&self, candidate: &ChangeSet) -> Result { - let path = self.root.join(crate::STORE_DIR).join("log").join(candidate.file_name()); + let namespace = home::namespace_of(&self.load(), candidate)?; + let dir = crate::layout::log_dir(&self.root, &namespace); + std::fs::create_dir_all(&dir).map_err(|e| AuthorError::Io(e.to_string()))?; + let path = dir.join(candidate.file_name()); if path.exists() { return Err(AuthorError::Io(format!( "{} already exists — a log file is written once and never edited", diff --git a/ledger-core/src/author/report.rs b/ledger-core/src/author/report.rs index ce081986..c47d86ec 100644 --- a/ledger-core/src/author/report.rs +++ b/ledger-core/src/author/report.rs @@ -31,7 +31,8 @@ pub fn status(store: &Store, today: NaiveDate) -> String { lines.push(member_line(&view, row)); } } - for row in rows.values().filter(|r| store.set(&r.set).is_none()) { + let namespace_of = |decision: &str| decision.trim_start_matches("dec:").split('/').next().unwrap_or_default().to_string(); + for row in rows.values().filter(|r| store.set_in(&namespace_of(&r.decision), &r.set).is_none()) { lines.push(format!( " {} [undeclared set `{}`] {}", row.decision, diff --git a/ledger-core/src/author/sign_ops.rs b/ledger-core/src/author/sign_ops.rs index 357c3b44..d585ec33 100644 --- a/ledger-core/src/author/sign_ops.rs +++ b/ledger-core/src/author/sign_ops.rs @@ -63,6 +63,7 @@ impl Author { self.refuse_key(store, policy, &what, &key)?; let bytes = ssh::sign(&key, what.namespace, &what.bytes).map_err(AuthorError::Io)?; self.pending_sidecars.push(Sidecar { + namespace: what.namespace.to_string(), ulid: what.ulid.to_string(), scheme: Scheme::Ssh, file: Sidecar::file_name(what.ulid, Scheme::Ssh), @@ -137,10 +138,9 @@ impl Author { /// signatures first, so a log entry never lands without the sidecar it /// was gated with. pub(crate) fn append_signed(&mut self, candidate: &crate::changeset::ChangeSet) -> Result { - let dir = self.root.join(crate::STORE_DIR); let mut written = Vec::new(); for sidecar in std::mem::take(&mut self.pending_sidecars) { - match crate::signing::write(&dir, &sidecar) { + match crate::signing::write(&self.root, &sidecar) { Ok(path) => written.push(path), Err(e) => { written.iter().for_each(|p| drop(std::fs::remove_file(p))); diff --git a/ledger-core/src/author/sign_tests.rs b/ledger-core/src/author/sign_tests.rs index 127f2734..cd126362 100644 --- a/ledger-core/src/author/sign_tests.rs +++ b/ledger-core/src/author/sign_tests.rs @@ -146,7 +146,7 @@ fn forked_store(dir: &Path) -> (Author, DecisionId) { let mut cs = testkit::changeset(vec![right], Vec::new()); cs.decisions.clear(); cs.id = "cs:01K2C4YQJ3F8M0PT5W7NZ9RD99".parse().expect("id"); - let path = dir.join(".decisions/log").join(cs.file_name()); + let path = crate::layout::log_dir(dir, "fixture.verbs").join(cs.file_name()); std::fs::write(&path, serde_yaml::to_string(&cs).expect("yaml")).expect("write"); (a, id) } @@ -187,6 +187,7 @@ fn declare_refuses_a_second_declaration_of_the_same_set() { declare(&mut a); let err = a .declare(DeclareArgs { + namespace: Some("fixture.verbs".into()), id: "verbs".into(), title: "Again".into(), tolerance_floor: Tier::T2, diff --git a/ledger-core/src/author/tests.rs b/ledger-core/src/author/tests.rs index 3b1ad1ed..ea0c7dda 100644 --- a/ledger-core/src/author/tests.rs +++ b/ledger-core/src/author/tests.rs @@ -25,6 +25,7 @@ fn author(dir: &Path) -> Author { fn declare(a: &mut Author) { a.declare(DeclareArgs { + namespace: Some("fixture.verbs".into()), id: "verbs".into(), title: "Verb round-trips".into(), tolerance_floor: Tier::T1, diff --git a/ledger-core/src/author/version_ops.rs b/ledger-core/src/author/version_ops.rs index 96dd3c3c..f565dde7 100644 --- a/ledger-core/src/author/version_ops.rs +++ b/ledger-core/src/author/version_ops.rs @@ -185,7 +185,7 @@ impl Author { raw.parent = Some(latest.raw.hash.clone()); // Re-pin the floor as it stands now; drop an override the new floor // has caught up with, so the version stays representable (§4.2.1). - if let Some(set) = store.set(&raw.set) { + if let Some(set) = store.set_in(raw.decision.namespace(), &raw.set) { raw.tolerance_floor_at_creation = set.tolerance_floor; if raw.tolerance_override.is_some_and(|o| o <= set.tolerance_floor) { raw.tolerance_override = None; diff --git a/ledger-core/src/authority/fixture.rs b/ledger-core/src/authority/fixture.rs index eb00c22b..88e6b5be 100644 --- a/ledger-core/src/authority/fixture.rs +++ b/ledger-core/src/authority/fixture.rs @@ -12,6 +12,8 @@ use super::payload::grant_hash; use super::role::{Capability, Role}; pub const GENESIS_HOLDER: &str = "owner@customer.example"; +/// The namespace the fixture roles and policies belong to. +pub const NS: &str = "hafeok.ledger"; /// A ULID ending in `tail`, distinct per record in one test. pub fn ulid(tail: &str) -> String { @@ -20,6 +22,7 @@ pub fn ulid(tail: &str) -> String { pub fn role(id: &str, may: &[Capability]) -> Role { Role { + namespace: NS.to_string(), format: 6, id: id.into(), title: None, diff --git a/ledger-core/src/authority/role.rs b/ledger-core/src/authority/role.rs index 456040d4..1a29510d 100644 --- a/ledger-core/src/authority/role.rs +++ b/ledger-core/src/authority/role.rs @@ -98,6 +98,10 @@ pub struct Role { pub format: u32, /// Lowercase alphanumerics, dashes and dots — the set-id rule. pub id: String, + /// The namespace whose directory holds the file (LP-3.34); not a field + /// of the file. + #[serde(skip)] + pub namespace: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, pub owner: Identity, diff --git a/ledger-core/src/authority/signers.rs b/ledger-core/src/authority/signers.rs index ff404947..4970bf51 100644 --- a/ledger-core/src/authority/signers.rs +++ b/ledger-core/src/authority/signers.rs @@ -1,7 +1,8 @@ //! `allowed_signers` — the trust root as a derived file (#65; PRD §0 item 11). //! -//! One line per key window, in OpenSSH's `allowed_signers` format, so -//! `ssh-keygen -Y verify -f .decisions/allowed_signers` reads it directly: +//! One file per namespace, `.decisions/ns//allowed_signers` (LP-3.34), +//! one line per key window of that namespace, in OpenSSH's +//! `allowed_signers` format, so `ssh-keygen -Y verify -f` reads it directly: //! //! ```text //! namespaces="ledger-accept@",valid-after=""[,valid-before=""] @@ -18,39 +19,56 @@ //! `verify` re-derives it and holds the committed bytes identical — the //! export's discipline, applied to the trust root. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use crate::graph::export::ExportFinding; use crate::store::Store; -use crate::STORE_DIR; use super::binding::KeyBinding; -/// The derived file's name under `.decisions/`. -pub const FILE: &str = "allowed_signers"; +/// The derived file's name under each namespace's directory. +pub const FILE: &str = crate::layout::SIGNERS_FILE; /// The derived file's two comment lines, as the ledger protocol states them (§4.9). pub const HEADER: &str = "# Derived from the key-binding entries in .decisions/log by `ledger identity`.\n\ # Never edit by hand: `ledger verify` holds this file byte-identical to the log.\n"; -/// Where the derived file lives. -pub fn path(root: &Path) -> PathBuf { - root.join(STORE_DIR).join(FILE) +/// Where a namespace's derived file lives. +pub fn path(root: &Path, namespace: &str) -> PathBuf { + crate::layout::signers_path(root, namespace) } fn when(at: &DateTime) -> String { at.format("%Y%m%d%H%M%SZ").to_string() } -/// The file the log implies — every binding the signature gate trusts -/// (an unsigned or wrongly signed binding never enters it, spec v1.8); -/// `None` when nothing binds a key. -pub fn derive(store: &Store) -> Option { +/// The files the log implies, by namespace — every binding the signature +/// 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(); - derive_from(&crate::signing::check::trusted_bindings(store, &landing), &filed(store)) + derive_each(&crate::signing::check::trusted_bindings(store, &landing), &filed(store)) +} + +/// Each namespace's file from exactly these bindings: the lines of its +/// bindings, under the header. A namespace none of them opens a key in has +/// no entry. A close is looked up among all of `bindings`, in whichever +/// namespace it was filed, as [`derive_from`] does. +pub fn derive_each(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> BTreeMap { + let mut grouped: BTreeMap> = BTreeMap::new(); + for (namespace, line) in lines(bindings, filed) { + grouped.entry(namespace).or_default().push(line); + } + grouped + .into_iter() + .map(|(namespace, mut lines)| { + lines.sort(); + (namespace, format!("{HEADER}{}\n", lines.join("\n"))) + }) + .collect() } /// Every binding filed in the store, trusted or not. @@ -58,16 +76,27 @@ pub fn filed(store: &Store) -> Vec<&KeyBinding> { store.log.iter().flat_map(|l| l.file.key_bindings.iter()).collect() } -/// The allowed-signers text for exactly these bindings; a close's target is -/// looked up in `filed`. +/// The allowed-signers text for exactly these bindings, every namespace's +/// line in one text — what the signature check hands `ssh-keygen`; a +/// close's target is looked up in `filed`. pub fn derive_from(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> Option { + let mut lines: Vec = lines(bindings, filed).into_iter().map(|(_, line)| line).collect(); + if lines.is_empty() { + return None; + } + lines.sort(); + Some(format!("{HEADER}{}\n", lines.join("\n"))) +} + +/// One line per opening binding, with the namespace it belongs to. +fn lines(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> Vec<(String, String)> { // A close ends the key in every namespace (ruled 2026-10-06): every // line of a closed key ends at its key's earliest close. let closes: BTreeMap> = bindings .iter() .filter_map(|b| super::key_close::closes_among(bindings, filed, b).iter().map(|c| c.at).min().map(|t| (b.id.to_string(), t))) .collect(); - let mut lines: Vec = bindings + bindings .iter() .filter(|b| b.act.opens()) .filter_map(|b| { @@ -76,41 +105,48 @@ pub fn derive_from(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> Option Result<(), String> { - let target = path(&store.root); - match derive(store) { - Some(text) => product_core::fileops::write_file_atomic(&target, &text).map_err(|e| e.to_string()), - None => Ok(()), + for (namespace, text) in derive(store) { + let target = path(&store.root, &namespace); + if let Some(dir) = target.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + product_core::fileops::write_file_atomic(&target, &text).map_err(|e| e.to_string())?; } + Ok(()) } -/// The verify stage: the committed file equals the log's derivation. +/// The verify stage: each namespace's committed file equals the log's +/// derivation for that namespace. pub fn check(store: &Store, trusted: &[&KeyBinding]) -> Vec { - let label = format!("{STORE_DIR}/{FILE}"); - let committed = std::fs::read_to_string(path(&store.root)).ok(); + let derived = derive_each(trusted, &filed(store)); + let namespaces: BTreeSet = derived.keys().cloned().chain(store.namespaces()).collect(); let regenerate = "regenerate it with `ledger identity sync` — it is never edited by hand"; - let message = match (derive_from(trusted, &filed(store)), committed) { - (None, None) => return Vec::new(), - (Some(want), Some(have)) if want == have => return Vec::new(), - (Some(_), None) => format!("the log binds keys but no {FILE} is committed — {regenerate}"), - (None, Some(_)) => format!("{FILE} is committed but the log binds no key — it trusts what nothing filed"), - (Some(_), Some(_)) => format!("does not match the key bindings in the log — {regenerate}"), - }; - vec![ExportFinding { subject: label, message }] + namespaces + .into_iter() + .filter_map(|ns| { + let label = format!("{}/{FILE}", crate::layout::relative_dir(&ns, crate::layout::Kind::Signers).trim_end_matches('/')); + let committed = std::fs::read_to_string(path(&store.root, &ns)).ok(); + let message = match (derived.get(&ns), committed) { + (None, None) => return None, + (Some(want), Some(have)) if *want == have => return None, + (Some(_), None) => format!("the log binds keys in `{ns}` but no {FILE} is committed there — {regenerate}"), + (None, Some(_)) => format!("{FILE} is committed in `{ns}` but the log binds no key there — it trusts what nothing filed"), + (Some(_), Some(_)) => format!("does not match the key bindings of `{ns}` in the log — {regenerate}"), + }; + Some(ExportFinding { subject: label, message }) + }) + .collect() } diff --git a/ledger-core/src/authority/view.rs b/ledger-core/src/authority/view.rs index fc06cd48..98bcda89 100644 --- a/ledger-core/src/authority/view.rs +++ b/ledger-core/src/authority/view.rs @@ -178,5 +178,5 @@ impl<'a> Authority<'a> { /// `created_at` is a date its author typed, in no payload — so landing alone /// places it: a role counts for an act that landed no earlier than it. fn role_landing(landing: &Landing, role: &Role) -> usize { - landing.index(&format!("{}/roles/{}", crate::STORE_DIR, role.file_name())) + landing.index(&format!("{}/{}", crate::layout::relative_dir(&role.namespace, crate::layout::Kind::Role), role.file_name())) } diff --git a/ledger-core/src/batch_tests.rs b/ledger-core/src/batch_tests.rs index 0220fdde..938d7253 100644 --- a/ledger-core/src/batch_tests.rs +++ b/ledger-core/src/batch_tests.rs @@ -61,7 +61,8 @@ fn store(changesets: Vec) -> Store { log: changesets .into_iter() .map(|file| LoggedChangeSet { - path: std::path::PathBuf::from(format!("/fixture/.decisions/log/{}.yml", file.id.ulid())), + namespace: testkit::NS.to_string(), + path: std::path::PathBuf::from(format!("/fixture/.decisions/ns/{}/log/{}.yml", testkit::NS, file.id.ulid())), file, }) .collect(), diff --git a/ledger-core/src/diff.rs b/ledger-core/src/diff.rs index 4d065248..6e583cbc 100644 --- a/ledger-core/src/diff.rs +++ b/ledger-core/src/diff.rs @@ -99,7 +99,7 @@ pub fn diff(from_label: &str, from: &Store, to_label: &str, to: &Store) -> DiffR fn diff_sets(report: &mut DiffReport, from: &Store, to: &Store) { for set in &to.sets { - match from.sets.iter().find(|s| s.id == set.id) { + match from.sets.iter().find(|s| s.id == set.id && s.namespace == set.namespace) { None => report.sets_declared.push(format!("{} (floor {})", set.id, set.tolerance_floor)), Some(old) if old.tolerance_floor != set.tolerance_floor => { report.floors_changed.push(format!( diff --git a/ledger-core/src/graph/export.rs b/ledger-core/src/graph/export.rs index 69f8cf95..ddc7263e 100644 --- a/ledger-core/src/graph/export.rs +++ b/ledger-core/src/graph/export.rs @@ -81,6 +81,7 @@ pub fn select(store: &Store, namespace: &str) -> Store { .log .iter() .map(|logged| LoggedChangeSet { + namespace: logged.namespace.clone(), path: logged.path.clone(), file: restrict(&logged.file, &ours, &reach), }) @@ -90,7 +91,7 @@ pub fn select(store: &Store, namespace: &str) -> Store { Store { root: store.root.clone(), dir: store.dir.clone(), - sets: store.sets.iter().filter(|s| named.contains(&s.id)).cloned().collect(), + sets: store.sets.iter().filter(|s| s.namespace == namespace && named.contains(&s.id)).cloned().collect(), roles: store.roles.iter().filter(|r| roles.contains(&r.id)).cloned().collect(), log, sidecars: store.sidecars.clone(), diff --git a/ledger-core/src/init.rs b/ledger-core/src/init.rs index 98821617..0b79b473 100644 --- a/ledger-core/src/init.rs +++ b/ledger-core/src/init.rs @@ -1,9 +1,10 @@ //! Scaffolding a `.decisions/` store. //! -//! §5's layout is three directories, one of them derived. L0 builds none of -//! the derived one — there is no index at this milestone — but the ignore -//! line goes in now so an L2 rebuild cache can never be committed by -//! accident. Adopting the format from `ledger/spec/ledger-protocol.md` alone +//! The layout is one directory per namespace under `.decisions/ns/` +//! (LP-3.34), each made on its first write, plus the derived `index/`. L0 +//! builds none of the derived one — there is no index at this milestone — +//! but the ignore line goes in now so an L2 rebuild cache can never be +//! committed by accident. Adopting the format from `ledger/spec/ledger-protocol.md` alone //! should not require anyone to guess at directory names. use std::path::{Path, PathBuf}; @@ -30,7 +31,7 @@ pub struct InitPlan { /// Read the repo and decide what is missing. pub fn plan_init(root: &Path) -> InitPlan { let store = root.join(STORE_DIR); - let directories = [store.join("sets"), store.join("log")] + let directories = [store.join(crate::layout::NS_DIR)] .into_iter() .filter(|d| !d.is_dir()) .collect::>(); @@ -79,15 +80,15 @@ mod tests { use super::*; #[test] - fn a_fresh_repo_needs_both_directories_plus_the_ignore_line() { + fn a_fresh_repo_needs_the_namespace_directory_plus_the_ignore_line() { let dir = tempfile::tempdir().expect("tempdir"); let plan = plan_init(dir.path()); - assert_eq!(plan.directories.len(), 2); + assert_eq!(plan.directories.len(), 1); assert!(plan.add_ignore_line); assert!(!plan.already_initialised); apply_init(dir.path(), &plan).expect("apply"); - assert!(dir.path().join(".decisions/sets").is_dir()); - assert!(dir.path().join(".decisions/log").is_dir()); + assert!(dir.path().join(".decisions/ns").is_dir()); + assert!(!dir.path().join(".decisions/sets").exists() && !dir.path().join(".decisions/log").exists(), "no flat path"); } #[test] diff --git a/ledger-core/src/landing.rs b/ledger-core/src/landing.rs index 2c0b7e5c..ec6c4721 100644 --- a/ledger-core/src/landing.rs +++ b/ledger-core/src/landing.rs @@ -34,8 +34,9 @@ use std::process::Command; use chrono::{DateTime, Utc}; -/// The directories whose files land: the log, role files, signatures. -const TRACKED: &[&str] = &[".decisions/log", ".decisions/roles", ".decisions/sig"]; +/// The paths whose files land: every namespace's log, role files and +/// signatures ([`crate::layout::TRACKED`]). +const TRACKED: &[&str] = crate::layout::TRACKED; /// Where each file of the store landed. #[derive(Debug, Clone, Default)] @@ -230,7 +231,7 @@ fn entity_landings( touched: &std::collections::BTreeSet, ) -> Result, String> { let mut out = BTreeMap::new(); - let logs: Vec<&String> = touched.iter().filter(|p| p.starts_with(".decisions/log/")).collect(); + let logs: Vec<&String> = touched.iter().filter(|p| crate::layout::is_log(p)).collect(); if logs.is_empty() { return Ok(out); } diff --git a/ledger-core/src/landing_tests.rs b/ledger-core/src/landing_tests.rs index ebb8b162..99d2081c 100644 --- a/ledger-core/src/landing_tests.rs +++ b/ledger-core/src/landing_tests.rs @@ -6,7 +6,7 @@ use super::*; use crate::testkit::git; fn file(root: &Path, name: &str) { - let path = root.join(".decisions/log").join(name); + let path = crate::layout::log_dir(root, "a.ns").join(name); std::fs::create_dir_all(path.parent().expect("dir")).expect("mkdir"); std::fs::write(&path, name).expect("write"); git(root, &["add", "-A"]); @@ -26,7 +26,7 @@ fn without_git_everything_is_at_the_tip() { let dir = tempfile::tempdir().expect("tempdir"); let l = Landing::compute(dir.path(), None).expect("compute"); assert!(!l.available); - assert_eq!(l.index(".decisions/log/a.yml"), l.index(".decisions/log/b.yml")); + assert_eq!(l.index(".decisions/ns/a.ns/log/a.yml"), l.index(".decisions/ns/a.ns/log/b.yml")); } #[test] @@ -35,8 +35,8 @@ fn a_file_lands_at_the_first_first_parent_commit_containing_it() { file(dir.path(), "a.yml"); file(dir.path(), "b.yml"); let l = Landing::compute(dir.path(), None).expect("compute"); - assert!(l.index(".decisions/log/a.yml") < l.index(".decisions/log/b.yml")); - assert!(l.index(".decisions/log/b.yml") < l.index(".decisions/log/uncommitted.yml"), "uncommitted is the tip"); + 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"); } #[test] @@ -52,15 +52,15 @@ fn a_branch_verified_against_its_base_agrees_with_the_merge() { let local = Landing::compute(root, Some("main")).expect("against base"); let naive = Landing::compute(root, None).expect("no base"); assert!( - naive.index(".decisions/log/branch.yml") < naive.index(".decisions/log/main.yml"), + 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 order = |l: &Landing| { - (l.index(".decisions/log/a.yml") < l.index(".decisions/log/main.yml"), - l.index(".decisions/log/main.yml") < l.index(".decisions/log/branch.yml")) + (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"); diff --git a/ledger-core/src/layout.rs b/ledger-core/src/layout.rs new file mode 100644 index 00000000..23dcc0df --- /dev/null +++ b/ledger-core/src/layout.rs @@ -0,0 +1,165 @@ +//! The store layout: one directory per namespace (LP-3.34, rulings 62, 63). +//! +//! Every store holds each namespace under `.decisions/ns//`, with +//! its own `sets/`, `roles/`, `log/`, `sig/` and derived `allowed_signers`. +//! A file's namespace is its directory. The root `.decisions/` holds only +//! `ns/` and the uncommitted `index/` cache; a file at the flat paths of +//! revision v1.8 (`.decisions/sets/`, `.decisions/roles/`, `.decisions/log/`, +//! `.decisions/sig/`, `.decisions/allowed_signers`), or anywhere else under +//! `.decisions/` outside `ns/` and `index/`, is a schema fault at the verified +//! commit and in the working tree — so is a store with no `ns/` directory +//! and any of those paths. The layout is specification revision v1.9, with +//! no format number (ruling 66): no file's content changes. +//! +//! Every reader of a repo-relative path classifies it here, so the one +//! place that knows the layout is this module. + +use std::path::{Path, PathBuf}; + +use crate::STORE_DIR; + +/// The directory under `.decisions/` that holds the namespaces. +pub const NS_DIR: &str = "ns"; +/// The rebuildable cache, never committed (LP-3.13). +pub const INDEX_DIR: &str = "index"; +pub const SETS_DIR: &str = "sets"; +pub const ROLES_DIR: &str = "roles"; +pub const LOG_DIR: &str = "log"; +pub const SIG_DIR: &str = "sig"; +/// The derived trust file's name, in each namespace's directory. +pub const SIGNERS_FILE: &str = "allowed_signers"; + +/// The directories whose files land and are held immutable (`L007`): the +/// log, role files and signature sidecars of every namespace, as git +/// 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/*"]; + +/// What kind of file a path under a namespace directory is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Set, + Role, + Log, + Sig, + Signers, +} + +/// `.decisions/ns/` under `root`. +pub fn namespace_dir(root: &Path, namespace: &str) -> PathBuf { + root.join(STORE_DIR).join(NS_DIR).join(namespace) +} + +/// `.decisions/ns//log` under `root`. +pub fn log_dir(root: &Path, namespace: &str) -> PathBuf { + namespace_dir(root, namespace).join(LOG_DIR) +} + +/// `.decisions/ns//sets` under `root`. +pub fn sets_dir(root: &Path, namespace: &str) -> PathBuf { + namespace_dir(root, namespace).join(SETS_DIR) +} + +/// `.decisions/ns//roles` under `root`. +pub fn roles_dir(root: &Path, namespace: &str) -> PathBuf { + namespace_dir(root, namespace).join(ROLES_DIR) +} + +/// `.decisions/ns//sig` under `root`. +pub fn sig_dir(root: &Path, namespace: &str) -> PathBuf { + namespace_dir(root, namespace).join(SIG_DIR) +} + +/// `.decisions/ns//allowed_signers` under `root`. +pub fn signers_path(root: &Path, namespace: &str) -> PathBuf { + namespace_dir(root, namespace).join(SIGNERS_FILE) +} + +/// The repo-relative directory `.decisions/ns//`. +pub fn relative_dir(namespace: &str, kind: Kind) -> String { + let sub = match kind { + Kind::Set => SETS_DIR, + Kind::Role => ROLES_DIR, + Kind::Log => LOG_DIR, + Kind::Sig => SIG_DIR, + Kind::Signers => "", + }; + format!("{STORE_DIR}/{NS_DIR}/{namespace}/{sub}") +} + +/// The namespace and kind of a repo-relative path under the layout: +/// `.decisions/ns///`, or +/// `.decisions/ns//allowed_signers`. `None` for anything else. +pub fn classify(path: &str) -> Option<(String, Kind)> { + let rest = path.strip_prefix(&format!("{STORE_DIR}/{NS_DIR}/"))?; + let (namespace, rest) = rest.split_once('/')?; + if namespace.is_empty() { + return None; + } + if rest == SIGNERS_FILE { + return Some((namespace.to_string(), Kind::Signers)); + } + let (sub, file) = rest.split_once('/')?; + if file.is_empty() || file.contains('/') { + return None; + } + let kind = match sub { + SETS_DIR => Kind::Set, + ROLES_DIR => Kind::Role, + LOG_DIR => Kind::Log, + SIG_DIR => Kind::Sig, + _ => return None, + }; + Some((namespace.to_string(), kind)) +} + +/// 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) +} + +/// Whether a repo-relative path is a role file of some namespace. +pub fn is_role(path: &str) -> bool { + classify(path).is_some_and(|(_, k)| k == Kind::Role) +} + +/// The namespaces present under `.decisions/ns/`, sorted: every directory +/// there. Entries that are not directories are the loader's to report. +pub fn namespaces(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root.join(STORE_DIR).join(NS_DIR)) else { return Vec::new() }; + let mut out: Vec = + entries.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + out.sort(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_path_under_a_namespace_directory_classifies_by_its_subdirectory() { + assert_eq!(classify(".decisions/ns/a.ns/log/01K.yml"), Some(("a.ns".into(), Kind::Log))); + assert_eq!(classify(".decisions/ns/a.ns/roles/steward.yml"), Some(("a.ns".into(), Kind::Role))); + assert_eq!(classify(".decisions/ns/a.ns/sig/01K.ssh.sig"), Some(("a.ns".into(), Kind::Sig))); + assert_eq!(classify(".decisions/ns/a.ns/sets/design.yml"), Some(("a.ns".into(), Kind::Set))); + assert_eq!(classify(".decisions/ns/a.ns/allowed_signers"), Some(("a.ns".into(), Kind::Signers))); + } + + #[test] + fn the_flat_paths_and_anything_else_classify_as_nothing() { + for flat in [".decisions/log/01K.yml", ".decisions/roles/steward.yml", ".decisions/sig/x.ssh.sig", ".decisions/allowed_signers"] { + assert_eq!(classify(flat), None, "{flat}"); + } + assert_eq!(classify(".decisions/ns/a.ns/log"), None, "a directory, not a file"); + assert_eq!(classify(".decisions/ns/a.ns/pins/b/x.nt"), None, "pins are not read here"); + assert_eq!(classify(".decisions/ns//log/x.yml"), None); + assert!(is_log(".decisions/ns/a/log/x.yml") && !is_log(".decisions/ns/a/roles/x.yml")); + assert!(is_role(".decisions/ns/a/roles/x.yml")); + } + + #[test] + fn the_relative_directories_spell_the_layout() { + assert_eq!(relative_dir("a.ns", Kind::Log), ".decisions/ns/a.ns/log"); + assert_eq!(relative_dir("a.ns", Kind::Sig), ".decisions/ns/a.ns/sig"); + } +} diff --git a/ledger-core/src/lib.rs b/ledger-core/src/lib.rs index d8d5abbd..a978d95a 100644 --- a/ledger-core/src/lib.rs +++ b/ledger-core/src/lib.rs @@ -37,6 +37,7 @@ pub mod key; pub mod inbox; pub mod landed; pub mod landing; +pub mod layout; pub mod merge; pub mod mint; pub mod render; diff --git a/ledger-core/src/merge/plan.rs b/ledger-core/src/merge/plan.rs index 9e9179eb..f23927b6 100644 --- a/ledger-core/src/merge/plan.rs +++ b/ledger-core/src/merge/plan.rs @@ -154,8 +154,8 @@ fn plan_supersessions(out: &mut MergePlan, ours: &View, theirs: &View) { fn plan_sets(out: &mut MergePlan, ours: &Store, theirs: &Store, base: &Store) { for set in &theirs.sets { - let ours_set = ours.sets.iter().find(|s| s.id == set.id); - let base_set = base.sets.iter().find(|s| s.id == set.id); + let ours_set = ours.sets.iter().find(|s| s.id == set.id && s.namespace == set.namespace); + let base_set = base.sets.iter().find(|s| s.id == set.id && s.namespace == set.namespace); match (ours_set, base_set) { (None, _) => out.mechanical.push(format!("set `{}`: theirs only — declared by merge", set.id)), (Some(o), _) if o == set => {} diff --git a/ledger-core/src/merge/resolve.rs b/ledger-core/src/merge/resolve.rs index fdacbd38..d61a4ae0 100644 --- a/ledger-core/src/merge/resolve.rs +++ b/ledger-core/src/merge/resolve.rs @@ -171,7 +171,7 @@ fn current_tips<'a>( /// Re-pin the set's current floor on a version being filed now, exactly as /// the ordinary next-version pipeline does. fn repin_floor(store: &Store, raw: &mut VersionRaw) { - if let Some(set) = store.set(&raw.set) { + if let Some(set) = store.set_in(raw.decision.namespace(), &raw.set) { raw.tolerance_floor_at_creation = set.tolerance_floor; if raw.tolerance_override.is_some_and(|o| o <= set.tolerance_floor) { raw.tolerance_override = None; @@ -207,8 +207,10 @@ fn graph_delta_check(current: &Store, candidate: &ChangeSet) -> Result<(), Autho sidecars: current.sidecars.clone(), schema_findings: current.schema_findings.clone(), }; + let namespace = crate::author::namespace_of(current, candidate)?; with.log.push(LoggedChangeSet { - path: current.dir.join("log").join(candidate.file_name()), + path: crate::layout::log_dir(¤t.root, &namespace).join(candidate.file_name()), + namespace, file: candidate.clone(), }); let fresh: Vec = crate::graph::shapes::graph_findings(&with) diff --git a/ledger-core/src/merge/tests.rs b/ledger-core/src/merge/tests.rs index b78ef478..422f68ee 100644 --- a/ledger-core/src/merge/tests.rs +++ b/ledger-core/src/merge/tests.rs @@ -148,6 +148,7 @@ mod acts { crate::init::apply_init(dir, &crate::init::plan_init(dir)).expect("init"); let mut a = author(dir, 1_755_000_000_000); a.declare(crate::author::DeclareArgs { + namespace: Some("fixture.merge".into()), id: "merge-acts".into(), title: "Arbitration acts".into(), tolerance_floor: Tier::T1, @@ -191,7 +192,7 @@ mod acts { let mut cs = testkit::changeset(versions, Vec::new()); cs.decisions.clear(); cs.id = format!("cs:{ulid}").parse().expect("id"); - let path = dir.join(".decisions/log").join(cs.file_name()); + let path = crate::layout::log_dir(dir, "fixture.merge").join(cs.file_name()); std::fs::write(&path, serde_yaml::to_string(&cs).expect("yaml")).expect("write"); } diff --git a/ledger-core/src/revision.rs b/ledger-core/src/revision.rs index 1e6116c8..fe5d7f7b 100644 --- a/ledger-core/src/revision.rs +++ b/ledger-core/src/revision.rs @@ -10,6 +10,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use crate::layout::Kind; use crate::store::Store; use crate::STORE_DIR; @@ -47,15 +48,15 @@ pub fn load_at(root: &Path, rev: &str) -> Result { ..Store::default() }; 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); - if path.starts_with(&format!("{STORE_DIR}/sets/")) { - crate::store::take_set(&mut store, &label, &stem, &text); - } else if path.starts_with(&format!("{STORE_DIR}/roles/")) { - crate::store::take_role(&mut store, &label, &stem, &text); - } else if path.starts_with(&format!("{STORE_DIR}/log/")) { - crate::store::take_log(&mut store, PathBuf::from(&label), &label, &stem, &text); + 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 => {} } } store.sets.sort_by(|a, b| a.id.cmp(&b.id)); @@ -73,23 +74,23 @@ pub fn overlay_base(store: &mut Store, base: &str) -> Result { let have: std::collections::BTreeSet = store.log.iter().map(|l| l.file.id.to_string()).collect(); let mut added = 0; for logged in at_base.log.into_iter().filter(|l| !have.contains(&l.file.id.to_string())) { - let path = store.dir.join("log").join(logged.file.file_name()); - store.log.push(crate::store::LoggedChangeSet { path, file: logged.file }); + let path = crate::layout::log_dir(&store.root, &logged.namespace).join(logged.file.file_name()); + store.log.push(crate::store::LoggedChangeSet { namespace: logged.namespace, path, file: logged.file }); added += 1; } store.log.sort_by(|a, b| a.file.id.cmp(&b.file.id)); let commit = resolve(&store.root, base)?; - let sig_dir = format!("{STORE_DIR}/{}/", crate::signing::SIG_DIR); - let listing = git(&store.root, &["ls-tree", "-r", "--name-only", &commit, "--", &sig_dir]).unwrap_or_default(); + 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) { + if store.sidecars.iter().any(|c| c.file == name && c.namespace == namespace) { continue; } let Ok((ulid, scheme)) = crate::signing::Sidecar::parse_name(&name) else { continue }; let Ok(out) = Command::new("git").arg("-C").arg(&store.root).args(["show", &format!("{commit}:{path}")]).output() else { continue }; if out.status.success() { - store.sidecars.push(crate::signing::Sidecar { ulid, scheme, file: name, bytes: out.stdout }); + store.sidecars.push(crate::signing::Sidecar { namespace, ulid, scheme, file: name, bytes: out.stdout }); } } Ok(added) diff --git a/ledger-core/src/set.rs b/ledger-core/src/set.rs index f6d13815..db342ee8 100644 --- a/ledger-core/src/set.rs +++ b/ledger-core/src/set.rs @@ -34,6 +34,10 @@ pub enum Ground { pub struct DecisionSet { pub format: u32, pub id: String, + /// The namespace whose directory holds the file (LP-3.34): not a field + /// of the file, so a set id is unique within a namespace (LP-3.36). + #[serde(skip)] + pub namespace: String, pub title: String, /// The floor, not a default. A member may override up, never down. pub tolerance_floor: Tier, diff --git a/ledger-core/src/show.rs b/ledger-core/src/show.rs index 32ee80b8..0cdb67b4 100644 --- a/ledger-core/src/show.rs +++ b/ledger-core/src/show.rs @@ -279,7 +279,7 @@ fn build( Some(Screen { decision: id.to_string(), set: raw.set.clone(), - set_floor: store.set(&raw.set).map(|s| s.tolerance_floor.to_string()), + set_floor: store.set_in(raw.decision.namespace(), &raw.set).map(|s| s.tolerance_floor.to_string()), state: match (row, viewed.parsed.as_ref()) { (Some(r), _) => r.state, (None, Some(tip)) if view.is_forked(id) => state::tip_state(view, tip, today), diff --git a/ledger-core/src/signing/check_tests.rs b/ledger-core/src/signing/check_tests.rs index b83b4f49..666614e4 100644 --- a/ledger-core/src/signing/check_tests.rs +++ b/ledger-core/src/signing/check_tests.rs @@ -30,7 +30,7 @@ fn envelope(key: &SigningKey, bytes: &[u8]) -> Vec { } fn sidecar(ulid: &str, bytes: Vec) -> Sidecar { - Sidecar { ulid: ulid.to_string(), scheme: Scheme::Dsse, file: Sidecar::file_name(ulid, Scheme::Dsse), bytes } + Sidecar { namespace: fixture::NS.to_string(), ulid: ulid.to_string(), scheme: Scheme::Dsse, file: Sidecar::file_name(ulid, Scheme::Dsse), bytes } } fn policy(g: &crate::authority::Grant) -> Policy { diff --git a/ledger-core/src/signing/mod.rs b/ledger-core/src/signing/mod.rs index 44d36038..b984b029 100644 --- a/ledger-core/src/signing/mod.rs +++ b/ledger-core/src/signing/mod.rs @@ -1,8 +1,8 @@ //! Signatures — sidecars, schemes, and what each signable entity signs (#70). //! //! A signature never lives in a log file (#65, D2): it is a sidecar at -//! `.decisions/sig/..sig`, one per scheme, beside the entity -//! whose ULID names it, and it signs that entity's closed payload — the +//! `.decisions/ns//sig/..sig` (LP-3.34), one per scheme, +//! under the namespace of the entity whose ULID names it, and it signs that entity's closed payload — the //! exact bytes its content hash digests ([`crate::hash::signed_bytes`]). //! The signable entities are the acceptance, the revocation of an //! acceptance, the key binding and the namespace policy. Grants and grant @@ -24,12 +24,14 @@ use std::path::{Path, PathBuf}; use crate::authority::Scheme; use crate::finding::Finding; -/// The directory under `.decisions/` that holds sidecars. -pub const SIG_DIR: &str = "sig"; +/// The directory under a namespace's directory that holds sidecars. +pub const SIG_DIR: &str = crate::layout::SIG_DIR; /// One signature file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Sidecar { + /// The namespace whose directory holds it (LP-3.34). + pub namespace: String, /// The ULID of the entity it signs (the id's part after the scheme). pub ulid: String, pub scheme: Scheme, @@ -57,9 +59,9 @@ impl Sidecar { } } -/// Load every sidecar under `dir` (`.decisions/sig`), with a `SCHEMA` +/// Load every sidecar under `dir` (a namespace's `sig/`), with a `SCHEMA` /// finding for each file that is not a well-named sidecar. -pub fn load(dir: &Path) -> (Vec, Vec) { +pub fn load(dir: &Path, namespace: &str) -> (Vec, Vec) { let mut out = Vec::new(); let mut faults = Vec::new(); let Ok(entries) = std::fs::read_dir(dir) else { return (out, faults) }; @@ -68,7 +70,7 @@ pub fn load(dir: &Path) -> (Vec, Vec) { for path in paths { let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); match (Sidecar::parse_name(&name), std::fs::read(&path)) { - (Ok((ulid, scheme)), Ok(bytes)) => out.push(Sidecar { ulid, scheme, file: name, bytes }), + (Ok((ulid, scheme)), Ok(bytes)) => out.push(Sidecar { namespace: namespace.to_string(), ulid, scheme, file: name, bytes }), (Err(e), _) => faults.push(Finding::schema(&format!("sig/{name}"), e)), (_, Err(e)) => faults.push(Finding::schema(&format!("sig/{name}"), e.to_string())), } @@ -76,9 +78,9 @@ pub fn load(dir: &Path) -> (Vec, Vec) { (out, faults) } -/// Write a sidecar under the store's `sig/` directory. -pub fn write(store_dir: &Path, sidecar: &Sidecar) -> Result { - let dir = store_dir.join(SIG_DIR); +/// Write a sidecar under its namespace's `sig/` directory. +pub fn write(root: &Path, sidecar: &Sidecar) -> Result { + let dir = crate::layout::sig_dir(root, &sidecar.namespace); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let path = dir.join(&sidecar.file); if path.exists() { diff --git a/ledger-core/src/store.rs b/ledger-core/src/store.rs index 0a07d8b7..715bef04 100644 --- a/ledger-core/src/store.rs +++ b/ledger-core/src/store.rs @@ -5,18 +5,28 @@ //! that will not parse becomes a named `SCHEMA` finding so `verify` reports //! the whole store in one pass rather than one file per run. Same discipline //! as the `ddd` store, for the same reason. +//! +//! **One directory per namespace** (LP-3.34, rulings 62 and 63). Each +//! namespace's sets, roles, change-sets and sidecars are read from +//! `.decisions/ns//`, and a file's namespace is its directory +//! ([`crate::layout`]). The flat paths of revision v1.8 are read nowhere: +//! a file there, or anywhere under `.decisions/` outside `ns/` and `index/`, +//! is a schema fault. use std::path::{Path, PathBuf}; use crate::changeset::ChangeSet; use crate::finding::Finding; use crate::format; +use crate::layout; use crate::set::DecisionSet; use crate::STORE_DIR; -/// A log file with the path it came from, so findings can name it. +/// A log file with the path it came from, so findings can name it, and +/// the namespace whose directory holds it (LP-3.34). #[derive(Debug, Clone)] pub struct LoggedChangeSet { + pub namespace: String, pub path: PathBuf, pub file: ChangeSet, } @@ -37,9 +47,25 @@ pub struct Store { } impl Store { - /// The set with this id, when one is declared. - pub fn set(&self, id: &str) -> Option<&DecisionSet> { - self.sets.iter().find(|s| s.id == id) + /// The set with this id declared in `namespace`, when one is: a version + /// names a set of its own namespace (LP-5.22). + pub fn set_in(&self, namespace: &str, id: &str) -> Option<&DecisionSet> { + self.sets.iter().find(|s| s.id == id && s.namespace == namespace) + } + + /// The namespaces the store has a directory for, sorted. + pub fn namespaces(&self) -> Vec { + let mut out: Vec = self + .sets + .iter() + .map(|s| s.namespace.clone()) + .chain(self.roles.iter().map(|r| r.namespace.clone())) + .chain(self.log.iter().map(|l| l.namespace.clone())) + .chain(self.sidecars.iter().map(|c| c.namespace.clone())) + .collect(); + out.sort(); + out.dedup(); + out } /// The role with this id, when one is declared. @@ -67,7 +93,9 @@ pub fn find_root(start: &Path) -> Option { None } -/// Load the store rooted at `repo_root`. +/// Load the store rooted at `repo_root`: every namespace directory under +/// `.decisions/ns/`, then the faults for anything under `.decisions/` that +/// the layout does not hold (LP-3.34). pub fn load(repo_root: &Path) -> Store { let dir = repo_root.join(STORE_DIR); let mut store = Store { @@ -75,40 +103,109 @@ pub fn load(repo_root: &Path) -> Store { dir: dir.clone(), ..Store::default() }; - load_sets(&dir.join("sets"), &mut store); - load_roles(&dir.join("roles"), &mut store); - load_log(&dir.join("log"), &mut store); - let (sidecars, faults) = crate::signing::load(&dir.join(crate::signing::SIG_DIR)); - store.sidecars = sidecars; - store.schema_findings.extend(faults); - store.sets.sort_by(|a, b| a.id.cmp(&b.id)); - store.roles.sort_by(|a, b| a.id.cmp(&b.id)); + for namespace in layout::namespaces(repo_root) { + load_namespace(repo_root, &namespace, &mut store); + } + store.schema_findings.extend(layout_faults(&dir)); + store.sets.sort_by(|a, b| (&a.namespace, &a.id).cmp(&(&b.namespace, &b.id))); + store.roles.sort_by(|a, b| (&a.namespace, &a.id).cmp(&(&b.namespace, &b.id))); store.log.sort_by(|a, b| a.file.id.cmp(&b.file.id)); store } -fn load_sets(dir: &Path, store: &mut Store) { - for path in yaml_files(dir) { +/// One namespace's directory: its sets, roles, change-sets and sidecars. +fn load_namespace(root: &Path, namespace: &str, store: &mut Store) { + if let Err(e) = crate::id::validate_namespace(namespace) { + store.schema_findings.push(Finding::schema(&format!("{}/{}", layout::NS_DIR, namespace), format!("is not a namespace directory: {e}"))); + } + for path in yaml_files(&layout::sets_dir(root, namespace)) { match std::fs::read_to_string(&path) { - Ok(text) => take_set(store, &file_label(&path), &stem(&path), &text), + Ok(text) => take_set(store, namespace, &file_label(&path), &stem(&path), &text), Err(e) => store.schema_findings.push(Finding::schema(&file_label(&path), e.to_string())), } } -} - -fn load_roles(dir: &Path, store: &mut Store) { - for path in yaml_files(dir) { + for path in yaml_files(&layout::roles_dir(root, namespace)) { match std::fs::read_to_string(&path) { - Ok(text) => take_role(store, &file_label(&path), &stem(&path), &text), + Ok(text) => take_role(store, namespace, &file_label(&path), &stem(&path), &text), Err(e) => store.schema_findings.push(Finding::schema(&file_label(&path), e.to_string())), } } + for path in yaml_files(&layout::log_dir(root, namespace)) { + match std::fs::read_to_string(&path) { + Ok(text) => take_log(store, namespace, path.clone(), &file_label(&path), &stem(&path), &text), + Err(e) => store.schema_findings.push(Finding::schema(&file_label(&path), e.to_string())), + } + } + let (sidecars, faults) = crate::signing::load(&layout::sig_dir(root, namespace), namespace); + store.sidecars.extend(sidecars); + store.schema_findings.extend(faults); +} + +/// A schema fault for every file under `.decisions/` the layout does not +/// hold: the flat paths of revision v1.8 (`sets/`, `roles/`, `log/`, +/// `sig/`, `allowed_signers`) and anything else outside `ns/` and `index/` +/// (LP-3.34, ruling 63). A file directly under `ns/` is one too: a +/// namespace is a directory. +fn layout_faults(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return out }; + let mut paths: Vec = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + for path in paths { + let name = file_label(&path); + if name == layout::NS_DIR && path.is_dir() { + let Ok(inner) = std::fs::read_dir(&path) else { continue }; + for stray in inner.flatten().map(|e| e.path()).filter(|p| !p.is_dir()) { + out.push(stray_fault(&format!("{STORE_DIR}/{}/{}", layout::NS_DIR, file_label(&stray)))); + } + continue; + } + if name == layout::INDEX_DIR || name == ".gitattributes" { + continue; + } + if path.is_dir() { + for file in files_under(&path) { + out.push(stray_fault(&format!("{STORE_DIR}/{name}/{file}"))); + } + } else { + out.push(stray_fault(&format!("{STORE_DIR}/{name}"))); + } + } + out +} + +fn stray_fault(path: &str) -> Finding { + Finding::schema( + path, + "is outside the layout — every namespace lives under `.decisions/ns//` with its own \ + `sets/`, `roles/`, `log/`, `sig/` and `allowed_signers`, and the flat paths of revision v1.8 are \ + not read (LP-3.34, ruling 63)", + ) +} + +/// Every file under `dir`, recursively, as paths relative to `dir`. +fn files_under(dir: &Path) -> Vec { + fn walk(dir: &Path, base: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for path in entries.flatten().map(|e| e.path()) { + if path.is_dir() { + walk(&path, base, out); + } else { + out.push(path.strip_prefix(base).unwrap_or(&path).to_string_lossy().replace('\\', "/")); + } + } + } + let mut out = Vec::new(); + walk(dir, dir, &mut out); + out.sort(); + out } /// Parse one role file's text into the store. Shared like [`take_set`]. -pub(crate) fn take_role(store: &mut Store, label: &str, stem: &str, text: &str) { +pub(crate) fn take_role(store: &mut Store, namespace: &str, label: &str, stem: &str, text: &str) { match serde_yaml::from_str::(text) { - Ok(role) => { + Ok(mut role) => { + role.namespace = namespace.to_string(); check_format(label, role.format, store); let faults = crate::authority::structure::role_faults(&role, stem, store); store.schema_findings.extend(faults.into_iter().map(|m| Finding::schema(label, m))); @@ -118,20 +215,13 @@ pub(crate) fn take_role(store: &mut Store, label: &str, stem: &str, text: &str) } } -fn load_log(dir: &Path, store: &mut Store) { - for path in yaml_files(dir) { - match std::fs::read_to_string(&path) { - Ok(text) => take_log(store, path.clone(), &file_label(&path), &stem(&path), &text), - Err(e) => store.schema_findings.push(Finding::schema(&file_label(&path), e.to_string())), - } - } -} - /// Parse one set file's text into the store. Shared by the disk loader and -/// the at-revision loader, so both readings apply identical rules. -pub(crate) fn take_set(store: &mut Store, label: &str, stem: &str, text: &str) { +/// the at-revision loader, so both readings apply identical rules. A set id +/// is unique within its namespace (LP-3.36). +pub(crate) fn take_set(store: &mut Store, namespace: &str, label: &str, stem: &str, text: &str) { match serde_yaml::from_str::(text) { - Ok(set) => { + Ok(mut set) => { + set.namespace = namespace.to_string(); check_format(label, set.format, store); if let Err(e) = DecisionSet::validate_id(&set.id) { store.schema_findings.push(Finding::schema(label, e)); @@ -142,10 +232,10 @@ pub(crate) fn take_set(store: &mut Store, label: &str, stem: &str, text: &str) { format!("declares id `{}` but is filed as `{stem}`", set.id), )); } - if store.sets.iter().any(|s| s.id == set.id) { + if store.sets.iter().any(|s| s.id == set.id && s.namespace == namespace) { store .schema_findings - .push(Finding::schema(label, format!("set `{}` is declared twice", set.id))); + .push(Finding::schema(label, format!("set `{}` is declared twice in `{namespace}`", set.id))); } store.sets.push(set); } @@ -154,7 +244,7 @@ pub(crate) fn take_set(store: &mut Store, label: &str, stem: &str, text: &str) { } /// Parse one change-set file's text into the store. Shared like [`take_set`]. -pub(crate) fn take_log(store: &mut Store, path: PathBuf, label: &str, stem: &str, text: &str) { +pub(crate) fn take_log(store: &mut Store, namespace: &str, path: PathBuf, label: &str, stem: &str, text: &str) { match serde_yaml::from_str::(text) { Ok(file) => { check_format(label, file.format, store); @@ -179,7 +269,7 @@ pub(crate) fn take_log(store: &mut Store, path: PathBuf, label: &str, stem: &str } store.schema_findings.extend(format_faults(label, &file)); store.schema_findings.extend(crate::scalars::faults(label, text)); - store.log.push(LoggedChangeSet { path, file }); + store.log.push(LoggedChangeSet { namespace: namespace.to_string(), path, file }); } Err(e) => store.schema_findings.push(parse_fault("change-set", label, &e.to_string())), } diff --git a/ledger-core/src/store_tests.rs b/ledger-core/src/store_tests.rs index 546e4839..046c3d22 100644 --- a/ledger-core/src/store_tests.rs +++ b/ledger-core/src/store_tests.rs @@ -11,8 +11,8 @@ struct Repo { impl Repo { fn new() -> Self { let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(dir.path().join(".decisions/sets")).expect("mkdir"); - std::fs::create_dir_all(dir.path().join(".decisions/log")).expect("mkdir"); + std::fs::create_dir_all(dir.path().join(".decisions/ns").join(testkit::NS).join("sets")).expect("mkdir"); + std::fs::create_dir_all(dir.path().join(".decisions/ns").join(testkit::NS).join("log")).expect("mkdir"); Self { dir } } @@ -20,8 +20,16 @@ impl Repo { self.dir.path() } + /// Write a file under the fixture namespace's directory. fn write(&self, relative: &str, body: &str) { + let path = self.dir.path().join(".decisions/ns").join(testkit::NS).join(relative); + std::fs::write(path, body).expect("write"); + } + + /// Write a file at a flat path of revision v1.8, directly under `.decisions/`. + fn write_flat(&self, relative: &str, body: &str) { let path = self.dir.path().join(".decisions").join(relative); + std::fs::create_dir_all(path.parent().expect("dir")).expect("mkdir"); std::fs::write(path, body).expect("write"); } @@ -51,7 +59,90 @@ fn a_well_formed_store_loads_with_no_faults() { assert_eq!(store.sets.len(), 1); assert_eq!(store.log.len(), 1); assert_eq!(store.entry_count(), 3, "one set, one decision, one version"); - assert!(store.set("ledger-design").is_some()); + assert!(store.set_in(testkit::NS, "ledger-design").is_some()); + assert_eq!(store.log[0].namespace, testkit::NS, "a file's namespace is its directory"); + assert_eq!(store.sets[0].namespace, testkit::NS); +} + +/// AC-63: a store with any flat path at the verified commit is a schema fault +/// (LP-3.34, ruling 63) — each flat file named, the namespaced files still read. +#[test] +fn a_file_at_a_flat_path_is_a_schema_fault_and_names_the_path() { + let repo = Repo::new(); + repo.write_set(); + repo.write_log(); + let text = serde_yaml::to_string(&testkit::set()).expect("serialize"); + repo.write_flat("sets/ledger-design.yml", &text); + repo.write_flat("log/01K2C4YQJ3F8M0PT5W7NZ9RDXZ.yml", "format: 1\n"); + repo.write_flat("roles/steward.yml", "format: 6\n"); + repo.write_flat("sig/01K2C4YQJ3F8M0PT5W7NZ9RDXZ.ssh.sig", "x"); + repo.write_flat("allowed_signers", "# x\n"); + let store = repo.load(); + let faults: Vec<&str> = store.schema_findings.iter().map(|f| f.subject.as_str()).collect(); + assert_eq!( + faults, + [ + ".decisions/allowed_signers", + ".decisions/log/01K2C4YQJ3F8M0PT5W7NZ9RDXZ.yml", + ".decisions/roles/steward.yml", + ".decisions/sets/ledger-design.yml", + ".decisions/sig/01K2C4YQJ3F8M0PT5W7NZ9RDXZ.ssh.sig", + ], + "{:?}", + store.schema_findings + ); + assert!(store.schema_findings.iter().all(|f| f.message.contains("LP-3.34")), "{:?}", store.schema_findings); + assert_eq!((store.sets.len(), store.log.len()), (1, 1), "the namespaced files are read; the flat ones are not"); +} + +/// A store with no `ns/` directory and a flat path is a schema fault too; +/// nothing of the flat layout is read. +#[test] +fn a_flat_only_store_is_refused_and_nothing_of_it_is_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = Repo { dir }; + std::fs::remove_dir_all(repo.path().join(".decisions")).ok(); + let text = serde_yaml::to_string(&testkit::set()).expect("serialize"); + repo.write_flat("sets/ledger-design.yml", &text); + let cs = testkit::changeset(vec![testkit::sealed(testkit::version())], Vec::new()); + repo.write_flat(&format!("log/{}.yml", testkit::CS_ULID), &serde_yaml::to_string(&cs).expect("serialize")); + let store = repo.load(); + assert!(store.sets.is_empty() && store.log.is_empty(), "the flat layout is not read"); + assert_eq!(store.schema_findings.len(), 2, "{:?}", store.schema_findings); +} + +/// Two namespaces may each declare a set of one id (LP-3.36); a version +/// names the set of its own namespace (LP-5.22). +#[test] +fn a_set_id_is_unique_within_its_namespace_not_across_the_store() { + let repo = Repo::new(); + repo.write_set(); + let text = serde_yaml::to_string(&testkit::set()).expect("serialize"); + let other = repo.path().join(".decisions/ns/hafeok.other/sets"); + std::fs::create_dir_all(&other).expect("mkdir"); + std::fs::write(other.join("ledger-design.yml"), &text).expect("write"); + let store = repo.load(); + assert!(store.schema_findings.is_empty(), "{:?}", store.schema_findings); + assert_eq!(store.sets.len(), 2); + assert!(store.set_in("hafeok.other", "ledger-design").is_some()); + assert!(store.set_in("hafeok.third", "ledger-design").is_none()); + repo.write("sets/ledger-design.yaml", &text); + let twice = repo.load(); + assert!(twice.schema_findings.iter().any(|f| f.message.contains("declared twice in `hafeok.ledger`")), "{:?}", twice.schema_findings); +} + +/// A file directly under `ns/`, or a directory there that is no namespace +/// name, is a schema fault: a namespace is a directory named by its id. +#[test] +fn a_stray_file_under_ns_and_a_misnamed_namespace_directory_are_faults() { + let repo = Repo::new(); + repo.write_set(); + std::fs::write(repo.path().join(".decisions/ns/notes.txt"), "x").expect("write"); + std::fs::create_dir_all(repo.path().join(".decisions/ns/Not A Namespace/log")).expect("mkdir"); + let store = repo.load(); + let subjects: Vec<&str> = store.schema_findings.iter().map(|f| f.subject.as_str()).collect(); + assert!(subjects.contains(&".decisions/ns/notes.txt"), "{subjects:?}"); + assert!(subjects.contains(&"ns/Not A Namespace"), "{subjects:?}"); } #[test] diff --git a/ledger-core/src/testkit.rs b/ledger-core/src/testkit.rs index 9977d810..1189fd2a 100644 --- a/ledger-core/src/testkit.rs +++ b/ledger-core/src/testkit.rs @@ -16,6 +16,8 @@ use crate::version::VersionRaw; pub const DEC_ULID: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXV"; pub const CS_ULID: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXW"; pub const ACC_ULID: &str = "01K2C4YQJ3F8M0PT5W7NZ9RDXX"; +/// The namespace the fixture decision lives in. +pub const NS: &str = "hafeok.ledger"; pub fn decision_id() -> DecisionId { format!("dec:hafeok.ledger/{DEC_ULID}").parse().expect("decision id") @@ -75,6 +77,7 @@ pub fn set() -> DecisionSet { DecisionSet { format: 1, id: "ledger-design".into(), + namespace: NS.into(), title: "Decision Ledger — L0 settled design".into(), tolerance_floor: Tier::T1, ground: Ground::Characterised, @@ -133,7 +136,8 @@ pub fn store(changeset: ChangeSet) -> Store { roles: Vec::new(), sidecars: Vec::new(), log: vec![LoggedChangeSet { - path: std::path::PathBuf::from(format!("/fixture/.decisions/log/{CS_ULID}.yml")), + namespace: NS.to_string(), + path: std::path::PathBuf::from(format!("/fixture/.decisions/ns/{NS}/log/{CS_ULID}.yml")), file: changeset, }], schema_findings: Vec::new(), diff --git a/ledger-core/src/verify/disposition.rs b/ledger-core/src/verify/disposition.rs index 4af2952d..739ba4d3 100644 --- a/ledger-core/src/verify/disposition.rs +++ b/ledger-core/src/verify/disposition.rs @@ -65,7 +65,7 @@ pub fn expired(view: &View, today: NaiveDate) -> Vec { pub fn stranded(view: &View, store: &Store) -> Vec { view.latest_versions() .filter_map(|v| v.parsed.as_ref()) - .filter_map(|p| store.set(&p.set).map(|s| (p, s))) + .filter_map(|p| store.set_in(p.decision.namespace(), &p.set).map(|s| (p, s))) .filter(|(p, s)| p.tolerance.is_stranded_below(s.tolerance_floor)) .map(|(p, s)| { Finding::new( diff --git a/ledger-core/src/verify/duplicate_tests.rs b/ledger-core/src/verify/duplicate_tests.rs index 3905ccc8..1ae62745 100644 --- a/ledger-core/src/verify/duplicate_tests.rs +++ b/ledger-core/src/verify/duplicate_tests.rs @@ -33,7 +33,8 @@ fn second() -> ChangeSet { fn run(extra: ChangeSet) -> Report { let mut store = testkit::store(first()); store.log.push(LoggedChangeSet { - path: std::path::PathBuf::from(format!("/fixture/.decisions/log/{SECOND}.yml")), + namespace: testkit::NS.to_string(), + path: std::path::PathBuf::from(format!("/fixture/.decisions/ns/{}/log/{SECOND}.yml", testkit::NS)), file: extra, }); verify(&store, &Options::offline(testkit::date("2026-08-12"))) diff --git a/ledger-core/src/verify/history.rs b/ledger-core/src/verify/history.rs index df472d4a..45694ae5 100644 --- a/ledger-core/src/verify/history.rs +++ b/ledger-core/src/verify/history.rs @@ -40,7 +40,7 @@ pub fn findings(store: &Store, landing: &Landing) -> Vec { let versions = file_versions(&store.root, "HEAD", &path); let Some(first) = versions.first() else { continue }; let now = std::fs::read_to_string(store.root.join(&path)).ok(); - if path.starts_with(".decisions/log/") { + 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) { @@ -128,14 +128,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 path.starts_with(".decisions/roles/") => without_format(now) == without_format(landed), + Some(now) if crate::layout::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 path.starts_with(".decisions/roles/") { + let rule = if crate::layout::is_role(path) { "roles are write-once; a new role and new grants supersede" } else { "a landed signature is never edited or removed" diff --git a/ledger-core/src/verify/order_grid.rs b/ledger-core/src/verify/order_grid.rs index 09d563d8..5a9ee55b 100644 --- a/ledger-core/src/verify/order_grid.rs +++ b/ledger-core/src/verify/order_grid.rs @@ -19,7 +19,10 @@ use crate::store::Store; use crate::testkit; pub(super) const NS: &str = "fixture.ledger"; -pub(super) const PATH: &str = ".decisions/log/01K2C4YQJ3F8M0PT5W7NZ9RDXW.yml"; +/// The fixture change-set's repo-relative path, under its namespace's directory. +pub(super) fn path() -> String { + format!(".decisions/ns/{}/log/{}.yml", testkit::NS, testkit::CS_ULID) +} /// The `at`s on the grid, for the entries and for the act alike. pub(super) const TIMES: [&str; 3] = ["2026-10-02T09:00:00Z", "2026-10-02T10:00:00Z", "2026-10-02T11:00:00Z"]; pub(super) const HOLDER: &str = fixture::GENESIS_HOLDER; @@ -75,7 +78,7 @@ pub(super) fn sidecar(ulid: &str, key: &SigningKey, bytes: &[u8]) -> Sidecar { "payload": base64_encode(bytes), "signatures": [{"keyid": "", "sig": base64_encode(&sig.to_bytes())}], }); - Sidecar { ulid: ulid.into(), scheme: Scheme::Dsse, file: Sidecar::file_name(ulid, Scheme::Dsse), bytes: envelope.to_string().into_bytes() } + Sidecar { namespace: NS.into(), ulid: ulid.into(), scheme: Scheme::Dsse, file: Sidecar::file_name(ulid, Scheme::Dsse), bytes: envelope.to_string().into_bytes() } } pub(super) fn binding(tail: &str, act: BindingAct, key: Option<&SigningKey>, closes: Option<&KeyBinding>, mandate: Option, at: &str) -> KeyBinding { @@ -261,8 +264,12 @@ pub(super) fn build(case: Case, act_index: usize, act_at: &str) -> (Store, Landi store.sidecars = b.sidecars; // The role files land with the enabling entries: a role takes effect // from its file's landing. - let roles: Vec<(String, usize)> = store.roles.iter().map(|r| (format!("{}/roles/{}", crate::STORE_DIR, r.file_name()), 0)).collect(); - (store, Landing::fixed(PATH, &b.placed, &roles, 10), act_id) + let roles: Vec<(String, usize)> = store + .roles + .iter() + .map(|r| (format!("{}/{}", crate::layout::relative_dir(&r.namespace, crate::layout::Kind::Role), r.file_name()), 0)) + .collect(); + (store, Landing::fixed(&path(), &b.placed, &roles, 10), act_id) } /// What the gate says about the act alone: each file-gate and graph-stage diff --git a/ledger-core/src/verify/view.rs b/ledger-core/src/verify/view.rs index 9344e761..6b03e917 100644 --- a/ledger-core/src/verify/view.rs +++ b/ledger-core/src/verify/view.rs @@ -181,10 +181,14 @@ impl<'a> View<'a> { fn check_sets(&mut self, store: &Store) { for v in &self.versions { let Some(parsed) = &v.parsed else { continue }; - if store.set(&parsed.set).is_none() { + if store.set_in(parsed.decision.namespace(), &parsed.set).is_none() { self.findings.push(Finding::schema( &parsed.decision.to_string(), - format!("names set `{}`, which is not declared under sets/", parsed.set), + format!( + "names set `{}`, which is not declared under `{}`'s sets/ — a version names a set of its own namespace (LP-5.22)", + parsed.set, + parsed.decision.namespace() + ), )); } } diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index bda59748..c68560ec 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -127,32 +127,41 @@ A store is a directory of files in a git repository, and the files are the truth ### 3.1 Store layout +Spec v1.9 (2026-10-09; rulings 62, 63 and 66 as amended by 87). Every store holds each namespace under `.decisions/ns//`, and no store has another form. A file's namespace is its directory. + ``` .decisions/ - sets/.yml declared scope: floor, ground, owner - roles/.yml declared role, written once (format 6) - log/.yml append-only; the source of truth - sig/..sig one signature sidecar per scheme (format 7) - allowed_signers derived from key bindings; never edited - index/ gitignored; rebuildable cache -docs/decisions/.nt the committed export of a namespace + ns// + sets/.yml declared scope: floor, ground, owner + roles/.yml declared role, written once (format 6) + log/.yml append-only; the source of truth + sig/..sig one signature sidecar per scheme (format 7) + allowed_signers derived from the namespace's key bindings; never edited + index/ gitignored; rebuildable cache +docs/decisions/.nt the committed export of a namespace ``` | Path | Holds | | --- | --- | -| `.decisions/sets/.yml` | One set per file (section 5.2) | -| `.decisions/roles/.yml` | One role per file (section 5.6) | -| `.decisions/log/.yml` | One change-set per file (section 5.3) | -| `.decisions/sig/..sig` | One signature sidecar per scheme (section 4.8) | -| `.decisions/allowed_signers` | The derived trust file (section 4.9) | +| `.decisions/ns//sets/.yml` | One set per file (section 5.2) | +| `.decisions/ns//roles/.yml` | One role per file (section 5.6) | +| `.decisions/ns//log/.yml` | One change-set per file (section 5.3) | +| `.decisions/ns//sig/..sig` | One signature sidecar per scheme (section 4.8) | +| `.decisions/ns//allowed_signers` | The namespace's derived trust file (section 4.9) | +| `.decisions/ns//pins/` | **Not implemented.** The pinned material of the namespaces this one depends on (section 7.3, ruling 72) | | `.decisions/index/` | The rebuildable graph index (section 9.1) | -| `.decisions/basis/` | **Not implemented, open.** Held basis bytes, named by their digest (section 7.4) | +| `.decisions/ns//basis/` | **Not implemented, open.** Held basis bytes, named by their digest (section 7.4) | | `docs/decisions/.nt` | The committed export of a namespace (section 9) | +Revision v1.8 held one `sets/`, `roles/`, `log/` and `sig/` and one `allowed_signers` directly under `.decisions/` (the *flat layout*). It is read nowhere from v1.9 on: LP-3.34 refuses it at the verified commit, and what a verifier does with it in history is LP-3.35 (issue 2). + - **LP-3.10** (W, V) Files are read with either `.yml` or `.yaml`. Writers emit `.yml`. - **LP-3.11** (W, V) A log file is written once and never edited. A correction is a new version; a reversal is a revocation. - **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.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 @@ -450,7 +459,7 @@ Spec v1.8 (#70; rulings D1 to D4 of #65 and D5 to D9 of 2 October 2026). A names ### 4.9 Trust file -- **LP-4.10** (W, V) `allowed_signers` is derived from key-binding entries. A verifier MUST regenerate it and require the committed file to be byte-identical. +- **LP-4.10** (W, V) `allowed_signers` is derived from key-binding entries. From v1.9 it is one file per namespace, at `ns//allowed_signers` (LP-3.34), holding that namespace's lines. A verifier MUST regenerate each and require the committed file to be byte-identical. *What each namespace's file says about a close in another namespace is LP-4.32's, superseded by ruling 47 once implemented (issue 7).* - **LP-4.11** (W, V) Key bindings are append-only: add, rotate and revoke are new entries. - **LP-4.32** (W, V) The file has one line per key window, in OpenSSH's allowed-signers form, so that an SSHSIG verifier reads it directly: @@ -547,7 +556,7 @@ effective_tier = tolerance_override, else tolerance_floor_at_creation Without the pin the effective tier is not recomputable after a floor raise, so the hash could not be stable and acceptance-binds-the-tier would be unimplementable. The consequence is intended. -### 5.2 Set file: `.decisions/sets/.yml` +### 5.2 Set file: `.decisions/ns//sets/.yml` ```yaml format: 1 @@ -569,7 +578,7 @@ Restating membership in the set file would make every addition a rewrite of a sh The honest limit, which every coverage report must state: coverage is measured against the *enumerated* set, and nothing verifies the set itself. -### 5.3 Change-set file: `.decisions/log/.yml` +### 5.3 Change-set file: `.decisions/ns//log/.yml` ```yaml format: 1 @@ -675,7 +684,7 @@ Spec v1.7 (2026-10-02; #69, #66). The file schema the authority vocabulary proje #### Files ``` -.decisions/ +.decisions/ns// roles/.yml declared scope, like a set file (written once) allowed_signers derived from key bindings; never edited (§3.9.5) ``` @@ -769,6 +778,7 @@ policies: *An unavailability's `basis` field names who may declare it (`self`, `grantor`, `fallback-of-genesis`). It keeps its name (ruling 28). It is not a basis in the sense of section 7, and the export carries it as the literal-valued `ledger:basis`; the pinned-basis edge is `ledger:pinnedBasis` (ruling 27). Since D9 (f) the accept role is never the genesis role, so the example's `accept_role: steward` names a role that does not carry the genesis capabilities (LP-6.17).* +- **LP-5.22** (W, V) A version names a set of its own namespace: the set declared under its namespace's `sets/` (LP-3.34). A set id named across namespaces is a set not declared, so a schema fault (spec v1.9; PRD §3.1). - **LP-5.21** (W, V) **Two revocation shapes.** Formats 1–5 carry the legacy shape `{acceptance, at, by, reason}`; format 6 carries the entity shape above, which revokes a grant or an acceptance. A file carries the shape its declared format defines; the other, or a mixture, is a schema fault. Both shapes are read forever, since a log file is never rewritten. The legacy shape names no grant and has no id for a sidecar, so it is valid only as a **pre-policy act**: one that is not before its namespace's first policy fails `A006` (LP-6.29). ### 5.7 Version fields added by the basis rulings @@ -1461,6 +1471,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: 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) | ### B.1 Revisions of the absorbed format document @@ -1604,6 +1615,45 @@ only adds an unhashed field. --- +#### 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 +on 9 October; `ledger/prd/namespace-independence-prd.md` §3.1; issue 1 of +its §6). Every store holds each namespace under +`.decisions/ns//`, with its own `sets/`, `roles/`, `log/`, `sig/` +and `allowed_signers`, and no store has another form. A file's namespace is +its directory. The root `.decisions/` holds only `ns/` and the uncommitted +`index/`; the export stays at `docs/decisions/.nt`. + +**What changes.** The *flat layout* of revision v1.8 (`.decisions/sets/`, +`.decisions/roles/`, `.decisions/log/`, `.decisions/sig/`, +`.decisions/allowed_signers`) is read nowhere at the verified commit or in +the working tree: a file at any of those paths, or anywhere else under +`.decisions/` outside `ns/` and `index/`, is a schema fault (LP-3.34), and +so is a store with no `ns/` directory and any of those paths. A set id, a +role id and a file name are unique within a namespace, not across the +store (LP-3.36), and a version names a set of its own namespace (LP-5.22). +The derived `allowed_signers` is one file per namespace (LP-4.10). The +writers file every record under the directory of the one namespace its +entities belong to; `ledger declare` and `ledger role declare` take +`--namespace`, inferred when the store holds one namespace. + +**No digest moves.** No file's content changes, so this is a specification +revision with no format number (ruling 66). `CANONICAL_FORM` stays `v1`. +No class is added: the refusal is `SCHEMA`. + +**What to do.** A store made before v1.9 moves its files under +`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. + #### A float or an explicit null in hashed content is refused (2026-10-07, no format change) **Ruled 2026-10-07** (rulings 55 and 56, #118). The reference loader read From 15c24ade6ff251ca8618d52c8e77a1e8b4cf4020 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Sun, 11 Oct 2026 06:11:12 +0000 Subject: [PATCH 2/3] Seam bindings for step 1: the per-namespace store layout 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 every exposed Rust surface this step changes — 62 events over 14 files, five of them new declarations (layout.rs, author/home.rs, author/declare.rs, the CLI declare args, set.rs) with their verdict_knowledge written, and three existing declarations (store.rs, signing/mod.rs, authority/signers.rs) whose verdict the per-namespace layout had made false, rewritten. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ...st-ledger-cli-src-commands-declare-rs.yaml | 14 ++ ...m-rust-ledger-cli-tests-common-mod-rs.yaml | 84 ++++++++++ ...dger-core-src-author-authority-ops-rs.yaml | 6 + ...ust-ledger-core-src-author-declare-rs.yaml | 14 ++ ...m-rust-ledger-core-src-author-home-rs.yaml | 38 +++++ ...-ledger-core-src-authority-fixture-rs.yaml | 8 +- ...ust-ledger-core-src-authority-role-rs.yaml | 6 + ...-ledger-core-src-authority-signers-rs.yaml | 20 ++- .../seam-rust-ledger-core-src-layout-rs.yaml | 158 ++++++++++++++++++ .../seam-rust-ledger-core-src-lib-rs.yaml | 6 + .../seam-rust-ledger-core-src-set-rs.yaml | 14 ++ ...m-rust-ledger-core-src-signing-mod-rs.yaml | 20 ++- .../seam-rust-ledger-core-src-store-rs.yaml | 26 ++- .../seam-rust-ledger-core-src-testkit-rs.yaml | 8 +- 14 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 .ddd/seams/seam-rust-ledger-cli-src-commands-declare-rs.yaml create mode 100644 .ddd/seams/seam-rust-ledger-core-src-author-declare-rs.yaml create mode 100644 .ddd/seams/seam-rust-ledger-core-src-author-home-rs.yaml create mode 100644 .ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml create mode 100644 .ddd/seams/seam-rust-ledger-core-src-set-rs.yaml diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-declare-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-declare-rs.yaml new file mode 100644 index 00000000..b500e70b --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-declare-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-declare-rs +boundary: contract surface of ledger-cli/src/commands/declare.rs +verdict_knowledge: 'CLI users learn declare takes --namespace to name the directory a new set files under, required only when the store holds several namespaces and inferred from the one directory otherwise (LP-3.34).' +contract_location: ledger-cli/src/commands/declare.rs +obligations: [] +metadata: {} +bindings: +- symbol: namespace + file: ledger-cli/src/commands/declare.rs + before: sha256:32068ee222b0b7682349a7b8427c714c821cc75a2b6209a5b9dc5a4b6f5b9367 + after: sha256:2abf1e15dd84887baf833ad418a0bccf0788ae2fcd28db235ae04b420a79b590 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:0ffc177fe09f85a0b7ff3f0abbe19b4b7bb0211a5a6396357ef38b95cef795f8 diff --git a/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml index f61074ad..a3f3aece 100644 --- a/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml @@ -186,3 +186,87 @@ bindings: after: sha256:3a4828d68356ad4027e5a5b45edcceca1224c6a6f7677662eaf11390c79cc396 base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:b3d24f2ad18976db7025c7f442dc3a7e2e3e03f99683cb0ba2a8ee1ccb6bd9b0 +- symbol: stage_fixture + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:62e4f917e229e1905ddd93807c03db4e10b4abd81955fd59d4656949030a651e +- symbol: stage_fixture_into + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:6c62b1c1e7dc60dc7f4d0047fd2533008eb7f4678ed3931cc68b13407ce652e2 +- symbol: stage_store_into + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:1f6518ef7db2e19384727f5045e83c07c70f9dc8f69b0397a14e74e3a0d144ae +- symbol: stage_workspace + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:b47acb536ac847e4b4403b7880d9cc8389d9cb4ad14321397ede5bd4444564dc +- symbol: declare_in + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:28b0181db0b2265ee8c3aa91e93d8be50b1a34af6d61e54ba259eb0f8996c60d +- symbol: log_dir + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:f82034ffe4db2cd20efa572c23087e6d157670a34c6ff014d2f1bc18bdc8c3db +- symbol: ns_dir + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:5d6bf9ccebc1b51cd18f1c5909636f2d9fda84a79869f183cb38429f927fffa7 +- symbol: roles_dir + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:a114a1cf9097284905e70b7f0d204234338824f71842cb320cc456be2f98f427 +- symbol: sets_dir + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:bf78dbc8862617e18e1841123a853e2289a7a9efaea13df8baeaf76f65d46aff +- symbol: sidecar + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:43a2c6e1d6ecfd1e959f19213a60be34bc5b947c513c361e7df664c5fd862f57 +- symbol: sidecar_count + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:ed2167b6430970faa7f3f30ea3de4665cf8eae9d3f1748c2910a2c2f296d75c5 +- symbol: sig_dir + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:3d3db7593710973b5b82c5c27079845d596aaae9680c43f0cab4fbe219fa7efb +- symbol: signers + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:9e2ce878c44c672043143de359d5a4fb69c5e57bc13c01d79e64839977d92bb5 +- symbol: signers_path + file: ledger-cli/tests/common/mod.rs + before: sha256:18fc91bbdbea11fc51d15eab25d300acf77170f69d1c6113c6040976aeb3d4d1 + after: sha256:4950bad589fd7178075bd548a44829ce5427af2c8f9efb20d2cfb88bf1460db8 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:8debaac1a6019c0a4e89fd05aebdc38472dc853fd33d1e9eef714237ff482817 diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml index 010731f1..58298b75 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml @@ -150,3 +150,9 @@ bindings: after: sha256:caa27c39eb0eb41790e5eaba4b7a637a9f7d9f582b93c0f393fd3ed74f09d819 base_revision: c4b9c904c0389a9e3b484e92436dd01637346118 hash: sha256:31c40fcd2a7b523f89a9d4127f1e4faa58a0e867f3f8d67a8323fb9cf83d3d2f +- symbol: namespace + file: ledger-core/src/author/authority_ops.rs + before: sha256:420bc3b1d4f8608d1e986ff69e58ab52dd15e7d2788caccdd9ed8839add42366 + after: sha256:5ee3f00e52414f633ca8b3970070a8ea892e566db63ac49511c311d9f7700c18 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:bc915b2048b8716dff644b51608397512a153cd62eb87e6091331047bd121a90 diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-declare-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-declare-rs.yaml new file mode 100644 index 00000000..84a5828f --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-author-declare-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-author-declare-rs +boundary: contract surface of ledger-core/src/author/declare.rs +verdict_knowledge: 'Callers learn a declared set is filed under the namespace named in DeclareArgs.namespace or, when none is named, the store''s single namespace directory, and that a store holding several refuses an unnamed declaration (LP-3.34).' +contract_location: ledger-core/src/author/declare.rs +obligations: [] +metadata: {} +bindings: +- symbol: namespace + file: ledger-core/src/author/declare.rs + before: sha256:5839ccbcb859e14d370ec3a55c7c1509225b0ed7eae207184bda533db7a31e53 + after: sha256:2605056afab363291eb394dc9c0de329100f1aa31b0a9dc8d7a8cd8712416026 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:d576ae3210be23623249b1a51b7f406aac97e77eb59c5fd095f1d3b6d4689cbb diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-home-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-home-rs.yaml new file mode 100644 index 00000000..3d951cf7 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-author-home-rs.yaml @@ -0,0 +1,38 @@ +format: 2 +id: seam/rust/ledger-core-src-author-home-rs +boundary: contract surface of ledger-core/src/author/home.rs +verdict_knowledge: 'Callers learn how a change-set finds its home: an act''s namespace is read from the entities it carries or names (a decision id, a binding''s or policy''s namespace, a revocation''s target, a grant''s under), a new set''s from --namespace or the store''s single directory, so no verb files a change-set outside .decisions/ns// (LP-3.34, ruling 45).' +contract_location: ledger-core/src/author/home.rs +obligations: [] +metadata: {} +bindings: +- symbol: acceptance_namespace + file: ledger-core/src/author/home.rs + before: absent + after: sha256:4926c14cc6dcd79530f72f17a95e77fa4229cfcd10cc275e51d01a69c33652be + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:fb7dac9caf078789880050a15c968834e667c1fe34014c728c238281b2cea821 +- symbol: grant_namespace + file: ledger-core/src/author/home.rs + before: absent + after: sha256:4926c14cc6dcd79530f72f17a95e77fa4229cfcd10cc275e51d01a69c33652be + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:e8e161b1ddf549c9688f5b0d5147d1bd2e71784915b15cac7566b7e59e842a46 +- symbol: interval_namespace + file: ledger-core/src/author/home.rs + before: absent + after: sha256:4926c14cc6dcd79530f72f17a95e77fa4229cfcd10cc275e51d01a69c33652be + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:6a40e6ca52f2001fa1156188232880901a26638fdd0a8402474c9b7657527d7f +- symbol: namespace_of + file: ledger-core/src/author/home.rs + before: absent + after: sha256:4926c14cc6dcd79530f72f17a95e77fa4229cfcd10cc275e51d01a69c33652be + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:8d5bc372fb239897d0651422b1963028d5280ea622535a527203d92543c58eb3 +- symbol: resolve_namespace_dir + file: ledger-core/src/author/home.rs + before: absent + after: sha256:4926c14cc6dcd79530f72f17a95e77fa4229cfcd10cc275e51d01a69c33652be + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:585c918727532e7362592a70f1b8d87bb8b3094a160857839633191e66fd94a9 diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-fixture-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-fixture-rs.yaml index 648b08fe..588e624e 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-fixture-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-fixture-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-authority-fixture-rs boundary: contract surface of ledger-core/src/authority/fixture.rs -verdict_knowledge: "Test authors learn the builders for roles, grants, acceptances and intervals used by the authority tests." +verdict_knowledge: Test authors learn the builders for roles, grants, acceptances and intervals used by the authority tests. contract_location: ledger-core/src/authority/fixture.rs obligations: [] metadata: {} @@ -72,3 +72,9 @@ bindings: after: sha256:c6fd80134efefd5363add1d3af47052c945cb0401a7d070f7f73341745e039f5 base_revision: e33398d874ec5ddd52404c4f2c16039d5ed7cdbb hash: sha256:94365e2a1f3f1994d49844cd571493df9f24366ebfa91d35285b4f0db21ebb9d +- symbol: NS + file: ledger-core/src/authority/fixture.rs + before: sha256:cc1ab203523883be0a659bc7976c52296df2a9623824e546a7058c926114a45a + after: sha256:d331fc23ea716ac40b2eac0bde6d3903ae6610c421b4142c8a5f5f38afc973cb + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:4d59ed84e67973f26047487cc69314cfbcf6e380c4f1b85c3b440afbb40c37e1 diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-role-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-role-rs.yaml index d6f4bba1..ee56e8bc 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-role-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-role-rs.yaml @@ -222,3 +222,9 @@ bindings: after: sha256:d3389671f033bdfd184eafcc064ce345e2e3f311f80ed0a57d603f748ef5782d base_revision: b2e166ca3fc8d53dc52e1852172a42abd80dd8a2 hash: sha256:a7395323c4e3ba8eeff5d1c08520c2950d3d6b2c2776beae3e07c8567bb3cfee +- symbol: namespace + file: ledger-core/src/authority/role.rs + before: sha256:34668194fcaf0cd4372274a11f946b0161613d54efe4efceffe9411386b94671 + after: sha256:a9aae1aa28b9fea20c12fd1f9c17138a3ca5be654975ba772b2147509affdef1 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:94148fe0bacc3e7ef0033803df5d0603837944923e042db446dbd4d1e474e8ef diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml index 19c24eb0..64380813 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-authority-signers-rs boundary: contract surface of ledger-core/src/authority/signers.rs -verdict_knowledge: Consumers learn allowed_signers is derived from key bindings in OpenSSH form, written by the identity verbs, and held byte-identical by verify's [SIGNERS] stage. +verdict_knowledge: 'Consumers learn allowed_signers is derived per namespace from that namespace''s key bindings in OpenSSH form, written by the identity verbs at .decisions/ns//allowed_signers, and held byte-identical by verify''s [SIGNERS] stage.' contract_location: ledger-core/src/authority/signers.rs obligations: [] metadata: {} @@ -72,3 +72,21 @@ bindings: after: sha256:661c2da986aa08f2d1c994b4cef1dec84dcf93ce26e8df1f9309180ea265940f base_revision: 969c4fa55c488eb473bb892cdced00008607455b hash: sha256:26d49dc9593d0093aeb3fecebd5e60dc569503f230432b4e4f13f7c8e6889477 +- symbol: derive + file: ledger-core/src/authority/signers.rs + before: sha256:d8dd1c2c591ccd7cf75d737fb8bf5ecc79e271600b33d57397ce84dc847a9ce3 + after: sha256:3e0780d22c990a69f8205668616cd6971976ec3130ee73caf7745cf51997396a + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:fd93b1d4d2a52e7a75392ea3793ef00adcf78a2e3047c9fa39fd4d995514a2f8 +- symbol: derive_each + file: ledger-core/src/authority/signers.rs + before: sha256:d8dd1c2c591ccd7cf75d737fb8bf5ecc79e271600b33d57397ce84dc847a9ce3 + after: sha256:3e0780d22c990a69f8205668616cd6971976ec3130ee73caf7745cf51997396a + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:761a2d8414f5a4dfe2bd19e520381a2b5b623a93e7185602f6a3d73f58454a6d +- symbol: path + file: ledger-core/src/authority/signers.rs + before: sha256:d8dd1c2c591ccd7cf75d737fb8bf5ecc79e271600b33d57397ce84dc847a9ce3 + after: sha256:3e0780d22c990a69f8205668616cd6971976ec3130ee73caf7745cf51997396a + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:1100a9e4e2b13c50ad9c31ffd8123af332e6e432cc0eca45e0f38a77ca12ca85 diff --git a/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml new file mode 100644 index 00000000..949dd14d --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-layout-rs.yaml @@ -0,0 +1,158 @@ +format: 2 +id: seam/rust/ledger-core-src-layout-rs +boundary: contract surface of ledger-core/src/layout.rs +verdict_knowledge: 'Consumers learn the one module that knows the store layout: every namespace lives under .decisions/ns// with its own sets/, roles/, log/, sig/ and allowed_signers, a path''s namespace is its directory, and a file at the v1.8 flat paths or anywhere else under .decisions/ outside ns/ and index/ is a schema fault (LP-3.34, rulings 62 and 63).' +contract_location: ledger-core/src/layout.rs +obligations: [] +metadata: {} +bindings: +- symbol: INDEX_DIR + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:c741c8fcb9f46def78a0ca766d5e87084e4cf22796f9b0eaea42f981f8828007 +- symbol: LOG_DIR + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:89f9ad324c082f726b47efb4fd1199676b9703bd98fd4dc669cac5048915f807 +- symbol: NS_DIR + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:65fc6efed4a1f48739ecb179906bbf8e7a07196bdd98fa29658c552d17491410 +- symbol: ROLES_DIR + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:6f487f62aa386f264b499cf67369e6f666bf452fa8ea4315917c861fab69d462 +- symbol: SETS_DIR + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:4b0e01830b00880a238cb5bd33f01bee8e4970fc31d18bbd638e32f1eac26aec +- symbol: SIGNERS_FILE + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:7e04ddf0f5efab8848e2aa7f01419c76f7b4736c482f18225207d91870ec1181 +- symbol: SIG_DIR + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:4d04bc7dd66c371dd990925671abe14b68ecf1ae5ce7f141ccc71b729e7e865e +- symbol: TRACKED + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:aab3d2541a1c872b61582d9f4ca887933f89604282b43d738ed8211c7424d507 +- symbol: Kind + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:ae2018ded07d7b79ea14bd0e980728a0891d212b41004f8c159d2f56ddb9d702 +- symbol: classify + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:3f81e5aa7140768e24d400ad81e4f0d57b9485110cf114dee1b76328ccf629c1 +- symbol: is_log + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:31fe747d1a34eca81ea67f5c2a84bdfcec0880e341ebf8d8279ba847703b5c32 +- symbol: is_role + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:fb8baf265d93d82dad235f3777d0d35369f1e710759660717e9228d253770388 +- symbol: log_dir + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:90bad5cb5f4beca07dc2bf4e346e90fc5293c0ef24ba6f8e4348723d9d29092e +- symbol: namespace_dir + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:86898367b415fd030fee45ac37192bf8356f58c3e1a6483a76193735212d30b4 +- symbol: namespaces + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:328b7768121872a93f7f866a21ca096c9eef635ad86472a202828ac3201ac238 +- symbol: relative_dir + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:7f2d58bbf4805acfa253c811429c0ed858312df1f9a4351a97f16a98d525dcb7 +- symbol: roles_dir + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:1edc3c9bcacc1f8839505cbc82713ac81cf3939d144f67ab1762c2497c513e62 +- symbol: sets_dir + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:526601299582f6a330346698826d530ba154ad8c0919e14dcc98481ae23c5cad +- symbol: sig_dir + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:4748d6aa461a3cccaf04aed5469a1ede5fc738ba61fea6ac3960de188b2e13de +- symbol: signers_path + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:231cc0a982f655a85911a57c5d9e30717b7640d0134c662858c9eaa9bf00fe53 +- symbol: Log + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:74bba6f9cf2ff98746bef9f583825e1c4c69ad4bd0b3ad49a8a5692680199c70 +- symbol: Role + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:0c15782a457e51143447f99c9915e0f6807766f96c9f48ffcce0e498f98c82b2 +- symbol: Set + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:33ad030da654a5e243a31296c6a3305463126e8d76fdbf2492d21627563fdc44 +- symbol: Sig + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:29fe02faf5c8a6c7dadc4c2d2d451d8d632a0752a061d9deb91c39824fcb0cf7 +- symbol: Signers + file: ledger-core/src/layout.rs + before: absent + after: sha256:59ba9bd3f17cd6ac0a9388a82e33aaff1137703f223e829c13c09851ba31781e + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:d8280851d3938727e513a912caa8b9a94fedad29bf46a227ec623b7dc9e6d29e diff --git a/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml index 9763818d..2cbac8df 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml @@ -96,3 +96,9 @@ bindings: after: sha256:6ce01384853de72d2656e128c947a1057e7e45da161033d4ef402110af63b6a7 base_revision: 2d242ec237c2a1f4658dd74725540217e0631d38 hash: sha256:5e6b96b5aeda2974cca7b972d3641dcf5b07f1ec132031c2e24ac9c15d612954 +- symbol: layout + file: ledger-core/src/lib.rs + before: sha256:d375c6365b2c57365bb160c3ed33d280c79a529082692570fb14219193d08795 + after: sha256:9419150e532b2b73f58608f64cdcff72ca633028a4724e0ef8fcbad50820cdd6 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:7f5ecd2f15d2d83e8ab3030c1b834324e978550537a06c42b9d940638266e0f0 diff --git a/.ddd/seams/seam-rust-ledger-core-src-set-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-set-rs.yaml new file mode 100644 index 00000000..94f4cfd6 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-set-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-set-rs +boundary: contract surface of ledger-core/src/set.rs +verdict_knowledge: 'Consumers learn a loaded set carries the namespace of the directory it was read from, never a field of the file, so a set id is unique within a namespace and a version names a set of its own namespace (LP-3.34, LP-3.36).' +contract_location: ledger-core/src/set.rs +obligations: [] +metadata: {} +bindings: +- symbol: namespace + file: ledger-core/src/set.rs + before: sha256:6e5373bbc153ec765072228a6dfcd05e8dca3cc41cb18b110a6cdf92797f13e0 + after: sha256:2186efbeddde315cf5447cbecf8470d4a714245815b7b0a9daa0953104e729d2 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:dab88117a5c1f0d9056223d19b3bc6f6f941cd08bf2c6cb81d92cc0350bbac4c diff --git a/.ddd/seams/seam-rust-ledger-core-src-signing-mod-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-signing-mod-rs.yaml index cb882a06..f9bc6ec3 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-signing-mod-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-signing-mod-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-signing-mod-rs boundary: contract surface of ledger-core/src/signing/mod.rs -verdict_knowledge: "Callers learn where signatures live: one sidecar per scheme at .decisions/sig/..sig over the entity's signed bytes, never inside a log file." +verdict_knowledge: 'Callers learn where signatures live: one sidecar per scheme at .decisions/ns//sig/..sig over the entity''s signed bytes, never inside a log file, and that a sidecar carries the namespace of the directory it was read from (LP-3.34).' contract_location: ledger-core/src/signing/mod.rs obligations: [] metadata: {} @@ -96,3 +96,21 @@ bindings: after: sha256:f71243d14488722ab26f7109a55fdb2275c26f7b24ba8c1c04a51e58bacdb7ec base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:35c44b5c3a8c36b720f456f4e2152b863259a3a938cfd8a0f00cb37c6d63ecb3 +- symbol: load + file: ledger-core/src/signing/mod.rs + before: sha256:5a068293304a64b85dfcf05bf1e2110cf2bd454583e488871a67c142990a6cda + after: sha256:240cf5465e6bd95128a689a65bcff3677b018b701508f4e90b3905216411b0bc + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:678d4db3b28fd58cef871872492ffa500cbc76d39d050639e6f4e1920b947d1a +- symbol: write + file: ledger-core/src/signing/mod.rs + before: sha256:5a068293304a64b85dfcf05bf1e2110cf2bd454583e488871a67c142990a6cda + after: sha256:240cf5465e6bd95128a689a65bcff3677b018b701508f4e90b3905216411b0bc + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:42fcaa475d610ac945988d7a851f10c6e9ccc8b1103bec45262e9eaadaf71f4b +- symbol: namespace + file: ledger-core/src/signing/mod.rs + before: sha256:5a068293304a64b85dfcf05bf1e2110cf2bd454583e488871a67c142990a6cda + after: sha256:240cf5465e6bd95128a689a65bcff3677b018b701508f4e90b3905216411b0bc + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:3daff5ea4f44c0ae0924da952404bcb3a2ece8b76bf80788416df2ff4c13e096 diff --git a/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml index 40504667..820ac8e2 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-store-rs boundary: contract surface of ledger-core/src/store.rs -verdict_knowledge: Consumers learn the store loads role files beside sets and checks every file declares the format its fields need. +verdict_knowledge: 'Consumers learn the store loads every namespace''s sets, roles and log from .decisions/ns//, that a loaded set or change-set carries the namespace of the directory it was read from, that a set is looked up by (namespace, id) with set_in, and that every file must declare the format its fields need (LP-3.34).' contract_location: ledger-core/src/store.rs obligations: [] metadata: {} @@ -30,3 +30,27 @@ bindings: after: sha256:91e788b08e27bd57d8df7ad10beaafeb67fa394db4870f158cc98314c775c06d base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:55d9f56e905c78fa627dfa0985fa255f30bf2fe5b8096cc60b1fbda37f210527 +- symbol: namespace + file: ledger-core/src/store.rs + before: sha256:29b5f62eba6f1e766370947967e81e8aa4c895c285fc85ff06904461602c7dd1 + after: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:d1f0c51cd02ec46d2b71aa7092a9e170ce30a8458ec0684a0867e14d63939420 +- symbol: namespaces + file: ledger-core/src/store.rs + before: sha256:29b5f62eba6f1e766370947967e81e8aa4c895c285fc85ff06904461602c7dd1 + after: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:a538a51f63f3a900440d2548a1de125b1f361689ffae88e9da2d7b13c4a50055 +- symbol: set_in + file: ledger-core/src/store.rs + before: sha256:29b5f62eba6f1e766370947967e81e8aa4c895c285fc85ff06904461602c7dd1 + after: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:aa377b4ca8341890f42879be31162d6dcee3f639d40cb212b431a31b786ce6b6 +- symbol: set + file: ledger-core/src/store.rs + before: sha256:29b5f62eba6f1e766370947967e81e8aa4c895c285fc85ff06904461602c7dd1 + after: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:68d5ee772625beaa0a9d2645073dca38bf54888b200ad90e9423cc28343e2128 diff --git a/.ddd/seams/seam-rust-ledger-core-src-testkit-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-testkit-rs.yaml index 73fcaa4f..a7ee0dc0 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-testkit-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-testkit-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-testkit-rs boundary: contract surface of ledger-core/src/testkit.rs -verdict_knowledge: "Test authors learn the legacy-shape revocation builder." +verdict_knowledge: Test authors learn the legacy-shape revocation builder. contract_location: ledger-core/src/testkit.rs obligations: [] metadata: {} @@ -12,3 +12,9 @@ bindings: after: sha256:1f89a7244f9ea8e5eee92e9f17155d2da914018281aa05bb504cd3f72fbc2c28 base_revision: e33398d874ec5ddd52404c4f2c16039d5ed7cdbb hash: sha256:150f4cac51cfe3c2fe3fb139938d522f50d4b6b553a3ce090bfad0f2454799ce +- symbol: NS + file: ledger-core/src/testkit.rs + before: sha256:e2b7efbdd1c70ce82ac229683c44d8574e3b41c895f163d5cceb4c34be3eec3a + after: sha256:7c347de958a591b8eb95cd2755e4b32d536dc36152d4d4a2821254949f15c7d5 + base_revision: 70395dfe317cb9a00665433813766ca763f3c58a + hash: sha256:5b23f61c3fb15980c07cdd103a1943ae677542a4d8db65f72861c89e443f1623 From 9c64c21a40e6f3c8b3840155293286c167dc5857 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Sun, 11 Oct 2026 06:22:10 +0000 Subject: [PATCH 3/3] Seam verdict: ledger-cli/tests/common is test-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration over ledger-cli/tests/common/mod.rs now says what the bound helpers are — the suites' shared harness, with no consumer outside ledger-cli/tests/ — before what a test author learns from it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- .ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml index a3f3aece..4ff3d0d4 100644 --- a/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-cli-tests-common-mod-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-cli-tests-common-mod-rs boundary: contract surface of ledger-cli/tests/common/mod.rs -verdict_knowledge: Test authors learn signing invocations run under a pseudo-terminal through script(1) and everything else under pipes, so the binary's real TTY check is what the suites exercise. +verdict_knowledge: 'Test-only: the shared harness of the ledger-cli integration suites, with no consumer outside ledger-cli/tests/ — test authors learn signing invocations run under a pseudo-terminal through script(1) and everything else under pipes, so the binary''s real TTY check is what the suites exercise, and that a repo''s per-namespace paths and a fixture store copied in under ns// come from here.' contract_location: ledger-cli/tests/common/mod.rs obligations: [] metadata: {}