From af9e6514dc4eb228f691203c60f61aa1ebb48a81 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Fri, 9 Oct 2026 17:06:02 +0000 Subject: [PATCH 1/2] Make keys per namespace: close, D7, first policy; drop carried_over; two notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #149 (PRD issue 6, §3.3; rulings 47 and 69; AC-47 second bullet). A key is bound, closed and trusted in one namespace. `Authority::of` and `as_of` now filter key bindings by namespace like every other record; `key_close::same_key` compares the namespace, so a close ends the key in its own namespace only and `refusal`'s three rules (another principal's, closed, already open) look at the binding's namespace; D7 (`filing`) drops the store-wide branch — the genesis holder's first key in a namespace is that namespace's self-bound binding, once per namespace, under its own mandate; the signing check drops `carried_over`, judges a first policy by a key trusted in its own namespace, and the re-acceptance deadline runs from the key's earliest close there; `allowed_signers` ends a line at a close in its own namespace. The writer binds the configured key self-bound at every `init --namespace` (`carry_genesis_key` and `no_live_key` go; `--without-key` stays until issue 13), `identity add`'s self-bound test is per namespace, and signing refuses a key not live in the namespace (`ToSign.any_namespace` goes). Two repository notices, never findings (`verify::key_notices`, in the text and JSON report): a key closed in one namespace and open in another; one key bound to two principals in two namespaces. Tests (each failing before the change): `key_across_namespaces.rs`, rewritten as §3.11 says — bound in A then B, or B then A, a close in A leaves B open: the key still signs there, A's `allowed_signers` line ends and B's does not, the split is a notice, the key signs nothing in A, and a third namespace opens with the same key self-bound; a rotate in A leaves B's acceptances, review items and `allowed_signers` byte-identical (AC-47, second bullet); the within-namespace rules of 2026-10-06 stand (four tests kept); and the carried test, that no agent identity and no non-interactive session produces a key close. The writer's close in every namespace held is issue 8's and is not claimed. `genesis_key.rs`: `init` in a later namespace self-binds the holder's key there under the namespace's own mandate and signs the first policy with it; with every key closed elsewhere `init` binds the key again and `verify` notices the split (JSON `key_split`); with no usable key, `--without-key` initialises unbound and the unbound-genesis notice names the namespace. The prompt assigned these rewrites to issue 8; they could not wait, since the cross-namespace lean they asserted is what this step removes. `key_close_tests.rs`: three tests now assert the namespace reading. The pre-policy cases now bind the holder's first key in the second namespace self-bound: `pre_policy_binding.rs` (`unsigned_before` is self-bound under `hand::found`'s mandate; the signed case signs the first policy with the key bound before it, as LP-4.31 now requires; opening a namespace over a binding filed in it before any policy is refused again, since the holder's own self-bound binding would no longer be the first there — `…_and_init_is_refused_over_it`); `binding_accounting.rs` (`self_bound_by_hand`, one namespace per first binding). `trust.rs`: `init` self-binds in the second namespace and a further key there is the holder's own add (renamed from "once per store"); a hand-written self-bound binding beside `init`'s fails, "first in the namespace". `genesis_key.rs`'s forged-binding test reads the same wording. `key_across_namespaces.rs` keeps its four within-namespace tests. Protocol: LP-4.12, LP-4.13, LP-4.31, LP-4.32, LP-4.37, LP-4.38, LP-4.39 (ruling 101's wording), LP-6.31 (keys note dropped), LP-6.32 (loses its mark; the writer's close-everywhere noted for issue 8), LP-8.31 (two notices, report shape); Appendix B row; Appendix C note "Keys are per namespace". CLAUDE.md updated. No class added; no digest moves (a binding's hashed fields are unchanged and `namespace` was among them). Stacked on #159 (issue 5). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- CLAUDE.md | 25 ++-- ledger-cli/tests/binding_accounting.rs | 33 ++++- ledger-cli/tests/genesis_key.rs | 51 ++++--- ledger-cli/tests/key_across_namespaces.rs | 141 +++++++++++-------- ledger-cli/tests/pre_policy_binding.rs | 43 +++--- ledger-cli/tests/trust.rs | 19 +-- ledger-core/src/author/acceptance_ops.rs | 4 +- ledger-core/src/author/genesis_key.rs | 115 +++------------ ledger-core/src/author/group_accept.rs | 3 +- ledger-core/src/author/identity_ops.rs | 25 ++-- ledger-core/src/author/policy_ops.rs | 1 - ledger-core/src/author/sign_ops.rs | 27 ++-- ledger-core/src/authority/filing.rs | 41 +++--- ledger-core/src/authority/key_close.rs | 41 +++--- ledger-core/src/authority/key_close_tests.rs | 25 ++-- ledger-core/src/authority/signers.rs | 13 +- ledger-core/src/authority/view.rs | 27 ++-- ledger-core/src/render.rs | 15 +- ledger-core/src/signing/check.rs | 42 ++---- ledger-core/src/signing/review.rs | 5 +- ledger-core/src/verify/key_notices.rs | 78 ++++++++++ ledger-core/src/verify/mod.rs | 21 ++- ledger/spec/ledger-protocol.md | 65 +++++++-- 23 files changed, 485 insertions(+), 375 deletions(-) create mode 100644 ledger-core/src/verify/key_notices.rs diff --git a/CLAUDE.md b/CLAUDE.md index 762374d7..8eecace2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -337,8 +337,11 @@ store (`.decisions/`), separate ontology. function, `authority::authorize`, over `Authority::of(store, ns)`, which `accept`, `revoke` and the grant verbs call; a namespace **without** a policy is pre-v2 and not role-checked. The graph stage runs per - namespace (`A003`/`A005` count one trust root each). Keys are still - bound and trusted store-wide until issue 6 of the namespace PRD. `allowed_signers` is derived (never hand-edited) and held by + namespace (`A003`/`A005` count one trust root each). Keys are per + namespace too (ruling 47): `init --namespace` self-binds the holder's + configured key in every namespace it opens, a close ends a key in its + own namespace only, and a key closed in one namespace and open in + another is a `verify` notice (ruling 69), never a finding. `allowed_signers` is derived (never hand-edited) and held by `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 @@ -353,18 +356,18 @@ store (`.decisions/`), separate ontology. D7 (`authority/filing.rs`, called by the verbs and by `verify`). `init --namespace` binds the genesis holder's configured key in the same act (refused with no usable key; `--without-key` proceeds unbound), and a - first policy is signed when its author holds a trusted key (#96); `verify` - names `[none]` namespaces and an unbound genesis holder in notices. In a - later namespace `init` binds the holder's live key there, signed by a key - of theirs already trusted. A key binding is never exempt as a pre-policy + first policy is signed when its author holds a key trusted in that + namespace (#96); `verify` names `[none]` namespaces and an unbound genesis + holder (per namespace) in notices. A key binding is never exempt as a pre-policy act (D5 (c) covers acceptances and revocations only): one before the first policy is judged by D7 and that policy's requirement, so every filed binding is trusted or named by a finding. A close ends the key, not - the binding (ruled 2026-10-06, `authority/key_close.rs`): closing any - binding of a principal's key closes it in every namespace, every check - asks over all bindings of the key; a key is bound once per namespace, a - closed key never again, and a key belongs to one principal (each a schema - fault, refused at filing). Integration suites need `ssh-keygen` (OpenSSH) as well as `script(1)`. + the binding (ruled 2026-10-06, `authority/key_close.rs`), in its own + namespace (ruling 47): closing any binding of a principal's key there + closes it there, every check asks over all bindings of the key in the + namespace; a key is bound once per namespace, a closed key never again + there, and a key belongs to one principal there (each a schema fault, + refused at filing). Integration suites need `ssh-keygen` (OpenSSH) as well as `script(1)`. - **Authority verbs and signing verbs refuse a non-interactive caller** (#71, #85). `accept `, `accept … --confirm`, `revoke`, and every verb that writes an authority record exit non-zero and write nothing unless diff --git a/ledger-cli/tests/binding_accounting.rs b/ledger-cli/tests/binding_accounting.rs index 242cd2b2..fa1c4170 100644 --- a/ledger-cli/tests/binding_accounting.rs +++ b/ledger-cli/tests/binding_accounting.rs @@ -70,13 +70,30 @@ fn governed() -> (Repo, String) { /// A hand-filed binding for `principal` by `by` in `ns`, dated `at`, /// signed by `signer` when given. fn by_hand(repo: &Repo, by: &str, principal: &str, ns: &str, at: chrono::DateTime, signer: Option<&str>) -> String { + hand_binding(repo, by, principal, ns, at, signer, false) +} + +/// The genesis holder's self-bound first binding in a namespace `hand::found` +/// opened (its mandate is that namespace's), dated `at`, signed by the key +/// it binds when `signed` — keys are per namespace (ruling 47), so a key +/// trusted elsewhere signs nothing here. +fn self_bound_by_hand(repo: &Repo, ns: &str, at: chrono::DateTime, signed: bool) -> String { + hand_binding(repo, OWNER, OWNER, ns, at, None, signed) +} + +fn hand_binding(repo: &Repo, by: &str, principal: &str, ns: &str, at: chrono::DateTime, signer: Option<&str>, self_signed: bool) -> String { let id: ledger_core::id::KeyBindingId = UlidMint::system().mint_id("key").expect("id"); let key = repo.keygen(&format!("k{}", id.ulid())); let mut b = hand::binding(BindingAct::Add, principal, by, ns, Some(&format!("{key}.pub")), None, None); b.at = at; + if self_signed || (signer.is_none() && by == OWNER && principal == OWNER && ns != NS && ns != "third.ledger") { + b.self_bound = true; + b.mandate = Some(format!("mandate for {ns}")); + } b.hash = ledger_core::authority::payload::binding_hash(&b); let ulid = b.id.ulid().to_string(); - let signers: Vec<(&str, &str)> = signer.map(|s| vec![(ulid.as_str(), s)]).unwrap_or_default(); + let own = self_signed.then_some(key.as_str()); + let signers: Vec<(&str, &str)> = signer.or(own).map(|s| vec![(ulid.as_str(), s)]).unwrap_or_default(); hand::file(repo, vec![b], Vec::new(), &signers); blob(&key) } @@ -102,13 +119,17 @@ fn every_filed_binding_is_trusted_or_named_by_a_finding() { let refused = by_hand(&repo, OTHER, OTHER, NS, Utc::now(), None); // Ungoverned: a binding in a namespace no policy governs. let ungoverned = by_hand(&repo, OWNER, OWNER, "third.ledger", Utc::now(), Some(&owner)); - // Before a signed first policy: one signed, one unsigned. - let at = first_policy(&repo, SECOND, vec![Scheme::Ssh], Some(&owner)); - let before_signed = by_hand(&repo, OWNER, OWNER, SECOND, at - Duration::seconds(1), Some(&owner)); - let before_unsigned = by_hand(&repo, OWNER, OWNER, SECOND, at - Duration::seconds(1), None); + // Before a signed first policy, the genesis holder's self-bound first + // binding in that namespace (ruling 47): one signed by the key it + // binds, one unsigned — each in a namespace of its own, since a + // namespace has one first binding. + let at = first_policy(&repo, SECOND, vec![Scheme::Ssh], None); + let before_signed = self_bound_by_hand(&repo, SECOND, at - Duration::seconds(1), true); + let at = first_policy(&repo, "fifth.ledger", vec![Scheme::Ssh], None); + let before_unsigned = self_bound_by_hand(&repo, "fifth.ledger", at - Duration::seconds(1), false); // Before a `[none]` first policy, unsigned. let at = first_policy(&repo, "fourth.ledger", vec![Scheme::None], None); - let before_none = by_hand(&repo, OWNER, OWNER, "fourth.ledger", at - Duration::seconds(1), None); + let before_none = self_bound_by_hand(&repo, "fourth.ledger", at - Duration::seconds(1), false); hand::commit(&repo, "every way a binding is filed"); let cases = [ diff --git a/ledger-cli/tests/genesis_key.rs b/ledger-cli/tests/genesis_key.rs index de2e490c..2fe561e2 100644 --- a/ledger-cli/tests/genesis_key.rs +++ b/ledger-cli/tests/genesis_key.rs @@ -107,7 +107,7 @@ fn bound_at_init_a_forged_self_bound_binding_is_never_trusted() { let id = forge_self_bound(&repo, NS); let (code, text) = verify(&repo); assert_eq!(code, 1, "{text}"); - assert!(text.contains(&id) && text.contains("D7") && text.contains("first in the store"), "{text}"); + assert!(text.contains(&id) && text.contains("D7") && text.contains("first in the namespace"), "{text}"); } #[test] @@ -148,10 +148,10 @@ fn a_first_policy_filed_before_any_key_stays_valid_unsigned() { fn a_later_namespaces_first_policy_is_signed_and_unsigned_it_is_l011() { let (repo, _, _) = keyed(); let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]); - assert!(out.contains("signed"), "{out}"); + assert!(out.contains("it signs the policy"), "{out}"); let policy = policy_of(&repo, SECOND); let sig = sidecar(&repo, SECOND, policy.id.ulid()); - assert!(sig.exists(), "signed with the key trusted in the first namespace"); + assert!(sig.exists(), "signed with the key self-bound in the second namespace (ruling 47)"); hand::commit(&repo, "second namespace"); let (code, text) = verify(&repo); assert_eq!(code, 0, "{text}"); @@ -175,20 +175,23 @@ fn verify_names_a_none_namespace_in_a_notice_and_passes() { assert_eq!(report["unsigned"][0], NS); } +/// Every namespace is opened as the first is (ruling 47): the holder's key +/// is self-bound in the new namespace under its own mandate, whatever they +/// hold elsewhere, and signs there straight away. #[test] -fn init_in_a_later_namespace_binds_the_holders_key_there_and_they_sign_with_no_identity_add() { +fn init_in_a_later_namespace_self_binds_the_holders_key_there_and_they_sign_with_no_identity_add() { let (repo, key, _) = keyed(); - let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]); - assert!(out.contains(&format!("in `{SECOND}`, signed by their key trusted elsewhere")), "{out}"); + let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/118"]); + assert!(out.contains(&format!("first key in `{SECOND}`")) && out.contains("self-bound"), "{out}"); let policy = policy_of(&repo, SECOND); let store = ledger_core::store::load(repo.path()); let here: Vec<_> = store.log.iter().flat_map(|l| l.file.key_bindings.iter()).filter(|b| b.namespace == SECOND).collect(); assert_eq!(here.len(), 1, "one binding, in the init change-set"); let b = here[0]; - assert!(!b.self_bound && b.mandate.is_none() && b.by.as_str() == OWNER, "their own add: {b:?}"); + assert!(b.self_bound && b.mandate.as_deref() == Some("contract 2026/118") && b.by.as_str() == OWNER, "self-bound under the second namespace's mandate: {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, SECOND, b.id.ulid()).exists(), "signed by their key trusted in the first namespace"); + assert!(std::fs::read_to_string(format!("{key}.pub")).expect("pub").contains(b.key.as_deref().unwrap_or("?")), "the same key, bound again here"); + assert!(sidecar(&repo, SECOND, b.id.ulid()).exists(), "signed by the key it binds"); // 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]); @@ -214,26 +217,34 @@ fn every_key_closed() -> (Repo, String) { (repo, id) } +/// A close ends the key in its own namespace (ruling 47, LP-6.32): a key +/// closed in the first namespace is another key in the second, and `init` +/// self-binds it there. The split is a repository notice (ruling 69). #[test] -fn with_every_key_closed_init_in_a_later_namespace_refuses_and_names_them() { +fn with_every_key_closed_elsewhere_init_in_a_later_namespace_self_binds_the_key_and_verify_notices_the_split() { let (repo, closed) = every_key_closed(); - let out = repo.ledger(&["init", "--namespace", SECOND, "--external-ref", MANDATE]); - let refused = common::both(&out); - assert_eq!(out.status.code(), Some(2), "a usage refusal: {refused}"); - assert!(refused.contains("no live key") && refused.contains(&closed), "{refused}"); - assert!(refused.contains("--without-key"), "the way to proceed is named: {refused}"); - let store = ledger_core::store::load(repo.path()); - assert!(store.log.iter().all(|l| l.file.policies.iter().all(|p| p.namespace != SECOND)), "nothing written"); + let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE]); + assert!(out.contains("self-bound") && !out.contains(&closed), "{out}"); + hand::commit(&repo, "second namespace, the key bound again there"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); + assert!(text.contains(&format!("notice: key ssh-ed25519")) && text.contains(&format!("of {OWNER} is closed in `{NS}` and open in `{SECOND}`")), "{text}"); + let json = repo.ledger(&["verify", "--no-blame", "--json"]); + let report: serde_json::Value = serde_json::from_slice(&json.stdout).expect("json"); + assert_eq!(report["key_split"][0]["closed_in"][0], NS); + assert_eq!(report["key_split"][0]["open_in"][0], SECOND); } #[test] -fn with_every_key_closed_and_without_key_init_warns_and_initialises_unbound() { - let (repo, closed) = every_key_closed(); +fn with_no_usable_key_and_without_key_init_in_a_later_namespace_warns_and_initialises_unbound() { + let (repo, _) = every_key_closed(); + repo.use_key("/nonexistent/owner-key"); let out = repo.ok(&["init", "--namespace", SECOND, "--external-ref", MANDATE, "--without-key"]); - assert!(out.contains("warning: no live key") && out.contains(&closed), "{out}"); + assert!(out.contains("warning: no key bound") && out.contains("--without-key"), "{out}"); let store = ledger_core::store::load(repo.path()); assert!(store.log.iter().flat_map(|l| l.file.key_bindings.iter()).all(|b| b.namespace != SECOND), "no binding in {SECOND}"); hand::commit(&repo, "second namespace, unbound"); let (code, text) = verify(&repo); assert_eq!(code, 0, "{text}"); + assert!(text.contains(&format!("notice: the genesis holder {OWNER} has no trusted key — governed namespace `{SECOND}`")), "{text}"); } diff --git a/ledger-cli/tests/key_across_namespaces.rs b/ledger-cli/tests/key_across_namespaces.rs index 6442ba83..5dfd0811 100644 --- a/ledger-cli/tests/key_across_namespaces.rs +++ b/ledger-cli/tests/key_across_namespaces.rs @@ -1,11 +1,12 @@ -//! A close ends the key, not the binding (ruled 2026-10-06, #104). +//! A close ends the key in its own namespace (ruling 47, LP-6.32; ruling 69). //! -//! Closing any binding of a principal's key closes that key in every -//! namespace of the store, from the close's position (D6), and every -//! signature check considers all bindings of the matched key, not the first -//! one it matched. A key already open in a namespace is never bound there -//! twice. Whichever order the bindings were filed in, a closed key signs -//! nothing and vouches for nothing. +//! The same key bound in two namespaces is two keys: closing it in `A` +//! leaves `B`'s bindings, acceptances, review items and `allowed_signers` +//! untouched (AC-47, second bullet), and `verify` reports the split as a +//! notice, never a finding. Within a namespace the rules of 2026-10-06 +//! stand: a key is bound once there, a closed key is never bound again +//! there, and every check asks over all bindings of the key there. The +//! writer's close in every namespace it holds is issue 8's. mod common; @@ -27,11 +28,6 @@ fn pause() { std::thread::sleep(std::time::Duration::from_millis(1100)); } -/// Every namespace's derived `allowed_signers`, concatenated. -fn signers(repo: &Repo) -> String { - ledger_core::layout::namespaces(repo.path()).into_iter().map(|ns| repo.signers(&ns)).collect() -} - fn verify(repo: &Repo, extra: &[&str]) -> (i32, String) { let mut args = vec!["verify", "--no-blame"]; args.extend_from_slice(extra); @@ -71,7 +67,8 @@ fn decision(repo: &Repo, ns: &str, statement: &str) -> String { common::decision_id(&out) } -/// The key bound in A at `init`, then in B by `init` B: A's binding first. +/// The key self-bound in A at `init`, then self-bound again in B by `init` +/// B (ruling 47: every namespace is opened as the first is). fn a_then_b() -> (Repo, String) { let repo = Repo::with_identity(OWNER); repo.declare(); @@ -83,12 +80,13 @@ fn a_then_b() -> (Repo, String) { repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE]); repo.declare_in(B); acceptor(&repo, B); - hand::commit(&repo, "B, the same key bound"); + hand::commit(&repo, "B, the same key self-bound there"); pause(); (repo, key) } -/// The key bound in B first (self-bound), then in A by `identity add`. +/// The key self-bound in B first, then in A by `identity add` — the genesis +/// holder's first key in A, so self-bound there too. fn b_then_a() -> (Repo, String) { let repo = Repo::with_identity(OWNER); repo.declare(); @@ -107,50 +105,77 @@ fn b_then_a() -> (Repo, String) { hand::commit(&repo, "A, the same key bound second"); pause(); assert!(bindings(&repo, B)[0].id < bindings(&repo, A)[0].id, "B's binding is filed first"); + assert!(bindings(&repo, A)[0].self_bound, "the genesis holder's first key in A is self-bound there"); (repo, key) } -/// Revoke the key's binding in A, then show it is closed in B as well and -/// vouches for no first binding in C. -fn closed_in_a_is_closed_everywhere(repo: &Repo, key: &str) { +/// Revoke the key's binding in A, then show that B is untouched: the key +/// still signs there, the split is a notice, and a third namespace opens +/// with the same key self-bound. +fn closed_in_a_stays_closed_in_a(repo: &Repo, key: &str) { let in_a = bindings(repo, A)[0].id.to_string(); let close = hand::word(&repo.ok(&["identity", "revoke", &in_a]), "key:"); hand::commit(repo, "closed in A"); pause(); - // Every allowed_signers line of the key ends at the close — B's too. - for line in signers(repo).lines().filter(|l| !l.starts_with('#')) { - assert!(line.contains("valid-before="), "every line of the closed key ends: {line}"); - } - // An acceptance in B signed with the key is refused by the verb, naming - // the close and its namespace. + // A's line ends at the close; B's does not. + assert!(repo.signers(A).lines().filter(|l| !l.starts_with('#')).all(|l| l.contains("valid-before=")), "{}", repo.signers(A)); + assert!(repo.signers(B).lines().filter(|l| !l.starts_with('#')).all(|l| !l.contains("valid-before=")), "{}", repo.signers(B)); + // The key still signs in B. let id = decision(repo, B, "Signed in B after the key was closed in A."); - hand::commit(repo, "a decision in B"); - let refused = refusal(repo, &["accept", &id]); - assert!(refused.contains(&format!("live key in `{B}`: it was closed in `{A}` by {close}")), "{refused}"); - // And it vouches for no first binding in a new namespace. - let out = repo.ledger(&["init", "--namespace", C, "--external-ref", MANDATE]); - let text = common::both(&out); - assert_eq!(out.status.code(), Some(2), "{text}"); - assert!(text.contains("no live key"), "{text}"); - assert!(bindings(repo, C).is_empty(), "nothing bound in C"); + repo.ok_tty(&["accept", &id]); + hand::commit(repo, "accepted in B with the key closed in A"); let (code, text) = verify(repo, &[]); assert_eq!(code, 0, "{text}"); - // Filed by hand past the verb, the gate fails it: the finding names the - // namespace it is refused in, the namespace of the close, and the close. - let text = hand_signed_finding(repo, B, &id, key); - assert!(text.contains(&format!("closed in `{A}` by {close}")) && text.contains(&format!("signs nothing in `{B}`")), "{text}"); + assert!(text.contains(&format!("of {OWNER} is closed in `{A}` and open in `{B}`")), "the notice of ruling 69: {text}"); + // In A it signs nothing: the verb names the close, the gate fails a + // hand-filed acceptance. + repo.declare_in(A); + acceptor(repo, A); + let in_a_only = decision(repo, A, "Signed in A after the close."); + hand::commit(repo, "a decision in A"); + let refused = refusal(repo, &["accept", &in_a_only]); + assert!(refused.contains(&format!("live key in `{A}`: it was closed in `{A}` by {close}")), "{refused}"); + let text = hand_signed_finding(repo, A, &in_a_only, key); + assert!(text.contains(&format!("closed in `{A}` by {close}")), "{text}"); + // And a third namespace opens with the same key, self-bound there. + repo.ok(&["init", "--namespace", C, "--external-ref", MANDATE]); + assert!(bindings(repo, C)[0].self_bound, "self-bound in C"); } #[test] -fn bound_in_a_then_b_and_closed_in_a_the_key_is_closed_in_b_and_vouches_in_no_third() { +fn bound_in_a_then_b_a_close_in_a_leaves_b_open_and_is_a_notice() { let (repo, key) = a_then_b(); - closed_in_a_is_closed_everywhere(&repo, &key); + closed_in_a_stays_closed_in_a(&repo, &key); } #[test] -fn bound_in_b_then_a_and_closed_in_a_the_key_is_closed_in_b_and_vouches_in_no_third() { +fn bound_in_b_then_a_a_close_in_a_leaves_b_open_and_is_a_notice() { let (repo, key) = b_then_a(); - closed_in_a_is_closed_everywhere(&repo, &key); + closed_in_a_stays_closed_in_a(&repo, &key); +} + +/// AC-47, second bullet: a rotate in A leaves B's acceptances, review items +/// and `allowed_signers` unchanged. +#[test] +fn a_rotate_in_a_leaves_bs_acceptances_review_items_and_allowed_signers_unchanged() { + let (repo, key) = a_then_b(); + let id = decision(&repo, B, "Accepted in B before the rotate in A."); + repo.ok_tty(&["accept", &id]); + repo.ok(&["policy", "set", "--namespace", B, "--reaccept-within-days", "30"]); + hand::commit(&repo, "accepted in B"); + pause(); + let signers_b = repo.signers(B); + let (code, before) = verify(&repo, &["--today", "2099-01-01"]); + assert_eq!(code, 0, "{before}"); + let in_a = bindings(&repo, A)[0].id.to_string(); + let next = repo.keygen("owner-next"); + repo.ok(&["identity", "rotate", &in_a, "--key-file", &format!("{next}.pub")]); + hand::commit(&repo, "rotated in A"); + let (code, text) = verify(&repo, &["--today", "2099-01-01"]); + assert_eq!(code, 0, "no L012, however late: {text}"); + assert!(!text.contains("need re-acceptance") && !text.contains("[L012]"), "no review item in B: {text}"); + assert_eq!(repo.signers(B), signers_b, "B's allowed_signers is byte-identical"); + assert!(text.contains(&format!("of {OWNER} is closed in `{A}` and open in `{B}`")), "{text}"); } /// A store governed in A with the owner's key bound, an acceptor grant, and @@ -225,25 +250,25 @@ fn revoking_the_second_of_two_bindings_closes_the_key_as_well() { assert!(text.contains(&format!("closed in `{A}` by {close}")), "{text}"); } +/// Carried: no agent identity and no non-interactive session produces a +/// key close, in either namespace. #[test] -fn an_acceptance_before_a_close_in_another_namespace_is_a_review_item_then_l012() { +fn no_agent_identity_and_no_non_interactive_session_produces_a_key_close() { let (repo, _) = a_then_b(); - let id = decision(&repo, B, "Accepted in B before the key closes in A."); - repo.ok_tty(&["accept", &id]); - let acc = { - let store = ledger_core::store::load(repo.path()); - store.log.iter().flat_map(|l| l.file.acceptances.iter()).map(|a| a.id.to_string()).next_back().expect("acceptance") - }; - repo.ok(&["policy", "set", "--namespace", B, "--reaccept-within-days", "30"]); - hand::commit(&repo, "accepted in B"); - pause(); + let before = repo.log_files(); + for ns in [A, B] { + let id = bindings(&repo, ns)[0].id.to_string(); + let piped = repo.piped(&["identity", "revoke", &id]); + assert_ne!(piped.status.code(), Some(0), "{}", common::both(&piped)); + for agent in ["claude@anthropic.com", "noreply@anthropic.com", "github-actions@github.com"] { + repo.act_as(agent); + let out = repo.tty(&["identity", "revoke", &id]); + assert_ne!(out.status.code(), Some(0), "{agent} in `{ns}`: {}", common::both(&out)); + } + repo.act_as(OWNER); + } + assert_eq!(repo.log_files(), before, "nothing filed"); let in_a = bindings(&repo, A)[0].id.to_string(); repo.ok(&["identity", "revoke", &in_a]); - hand::commit(&repo, "closed in A"); - let (code, text) = verify(&repo, &[]); - assert_eq!(code, 0, "a review item, not a failure: {text}"); - assert!(text.contains("need re-acceptance") && text.contains(&acc), "{text}"); - let (code, text) = verify(&repo, &["--today", "2099-01-01"]); - assert_eq!(code, 1, "{text}"); - assert!(text.contains("[L012]") && text.contains(&acc), "{text}"); + assert_eq!(repo.log_files().len(), before.len() + 1, "the holder, at a terminal, closes"); } diff --git a/ledger-cli/tests/pre_policy_binding.rs b/ledger-cli/tests/pre_policy_binding.rs index d2f2b6c8..a365bd11 100644 --- a/ledger-cli/tests/pre_policy_binding.rs +++ b/ledger-cli/tests/pre_policy_binding.rs @@ -68,10 +68,14 @@ fn governed() -> Repo { repo } -/// An unsigned binding for the owner in `ns`, dated `behind` `at`. +/// An unsigned self-bound binding for the owner in `ns` — the genesis +/// holder's first key there (ruling 47), under the mandate `hand::found` +/// gives the namespace — dated a second before `at`. fn unsigned_before(repo: &Repo, ns: &str, at: chrono::DateTime) -> (String, String) { let forged = repo.keygen("forged"); let mut b = hand::binding(BindingAct::Add, OWNER, OWNER, ns, Some(&format!("{forged}.pub")), None, None); + b.self_bound = true; + b.mandate = Some(format!("mandate for {ns}")); b.at = at - Duration::seconds(1); b.hash = ledger_core::authority::payload::binding_hash(&b); let id = b.id.to_string(); @@ -94,12 +98,15 @@ fn in_a_fresh_store_d7_refuses_a_binding_dated_before_the_first_policy() { #[test] fn a_signed_binding_before_the_first_policy_is_trusted_and_signs() { let repo = governed(); - let owner = repo.path().join("keys/owner").display().to_string(); - // A namespace founded by hand with its genesis older than its policy; - // then a key added on a clock behind the policy, signed by the owner's - // key trusted in the first namespace (keys are store-wide until issue 6). - hand::found(&repo, SECOND, OWNER, vec![Scheme::Ssh], Some(&owner)); + // A namespace founded by hand with its genesis older than its policy, + // the policy signed by the key below; then the holder's first key there, + // added on a clock behind the policy: their self-bound binding in that + // namespace, signed by the key it binds (keys are per namespace, ruling + // 47). Bound before the policy, it is the key the policy must be signed + // by (LP-4.31). let next = repo.keygen("owner-second"); + hand::found(&repo, SECOND, OWNER, vec![Scheme::Ssh], Some(&next)); + repo.use_key(&next); let (key_type, blob) = public(&next); bind_behind(&repo, SECOND, Duration::seconds(1), key_type, blob.clone()).expect("D7 admits it, and it is signed"); hand::commit(&repo, "second namespace, governed and bound in one commit"); @@ -138,10 +145,10 @@ fn a_skewed_add_dated_before_inits_genesis_is_refused_by_d7() { #[test] fn an_unsigned_binding_before_a_signed_first_policy_fails_l011_and_is_never_trusted() { let repo = governed(); - let owner = repo.path().join("keys/owner").display().to_string(); - // A namespace founded by hand, its genesis older than its signed first - // policy; a binding filed with the policy, dated a second before it. - let policy = hand::found(&repo, SECOND, OWNER, vec![Scheme::Ssh], Some(&owner)); + // A namespace founded by hand, its genesis older than its first policy + // requiring `ssh`; the holder's self-bound first binding there, filed + // with the policy, dated a second before it, unsigned. + let policy = hand::found(&repo, SECOND, OWNER, vec![Scheme::Ssh], None); let (id, blob) = unsigned_before(&repo, SECOND, policy.at); hand::commit(&repo, "second namespace, with a hand-filed binding"); let out = repo.ledger(&["verify", "--no-blame"]); @@ -153,7 +160,7 @@ fn an_unsigned_binding_before_a_signed_first_policy_fails_l011_and_is_never_trus } #[test] -fn a_binding_in_a_namespace_no_policy_governs_is_a_schema_fault_and_init_does_not_launder_it() { +fn a_binding_in_a_namespace_no_policy_governs_is_a_schema_fault_and_init_is_refused_over_it() { let repo = governed(); let (id, _) = unsigned_before(&repo, SECOND, chrono::Utc::now()); hand::commit(&repo, "a binding in a namespace nobody governs"); @@ -161,15 +168,11 @@ fn a_binding_in_a_namespace_no_policy_governs_is_a_schema_fault_and_init_does_no let text = common::both(&out); assert_eq!(out.status.code(), Some(1), "{text}"); assert!(text.contains("[SCHEMA]") && text.contains(&id) && text.contains("a namespace with no policy"), "{text}"); - // Opening the namespace dates its genesis with its policy, after the - // binding (ruling 47): the binding is then before the genesis, and D7 - // refuses it — still a schema fault, now for that reason. - repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); - hand::commit(&repo, "opened after the fact"); - let out = repo.ledger(&["verify", "--no-blame"]); - let text = common::both(&out); - assert_eq!(out.status.code(), Some(1), "{text}"); - assert!(text.contains(&format!("[SCHEMA] {id}")) && text.contains("D7"), "{text}"); + // Opening the namespace would self-bind the holder's key there — no + // longer their first binding in it, with that one filed (D7, ruling 47). + // The gate refuses to introduce the fault, and the binding stays one. + let refused = repo.refused(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); + assert!(refused.contains("D7") && refused.contains("first in the namespace"), "{refused}"); } #[test] diff --git a/ledger-cli/tests/trust.rs b/ledger-cli/tests/trust.rs index 5a7f057d..421f5fe4 100644 --- a/ledger-cli/tests/trust.rs +++ b/ledger-cli/tests/trust.rs @@ -285,25 +285,28 @@ fn a_policy_change_in_a_format_6_file_is_a_schema_fault() { const SECOND: &str = "second.ledger"; +/// Keys are per namespace (ruling 47): `init` self-binds the holder's key in +/// the second namespace too, and a further key of theirs there is their own +/// `add`, signed by the key live in that namespace. #[test] -fn the_genesis_holder_self_binds_once_per_store_and_signs_later_namespaces_with_a_trusted_key() { +fn the_genesis_holder_self_binds_once_per_namespace_and_a_further_key_there_is_their_own_add() { let (repo, _) = governed(); - repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); - // Their first key in the second namespace: their own add, signed by the - // key already trusted in the first. + repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/118"]); let next = repo.keygen("owner-second"); let out = repo.ok(&["identity", "add", "--namespace", SECOND, "--key-file", &format!("{next}.pub")]); assert!(out.contains(&format!("in `{SECOND}`")), "{out}"); let store = ledger_core::store::load(repo.path()); - let second = store.log.iter().flat_map(|l| l.file.key_bindings.iter()).find(|b| b.namespace == SECOND).expect("binding"); - assert!(!second.self_bound && second.mandate.is_none(), "not self-bound: {second:?}"); + let second: Vec<_> = store.log.iter().flat_map(|l| l.file.key_bindings.iter()).filter(|b| b.namespace == SECOND).collect(); + assert_eq!(second.len(), 2, "{second:?}"); + assert!(second[0].self_bound && second[0].mandate.as_deref() == Some("contract 2026/118"), "init's, self-bound under the second namespace's mandate: {:?}", second[0]); + assert!(!second[1].self_bound && second[1].mandate.is_none(), "the further key, their own add: {:?}", second[1]); hand::commit(&repo, "second namespace"); let (code, text) = verify(&repo); assert_eq!(code, 0, "{text}"); } #[test] -fn a_hand_written_self_bound_binding_in_a_second_namespace_fails() { +fn a_hand_written_self_bound_binding_beside_inits_in_a_second_namespace_fails() { let (repo, _) = governed(); repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); hand::commit(&repo, "second namespace opted in"); @@ -317,5 +320,5 @@ fn a_hand_written_self_bound_binding_in_a_second_namespace_fails() { hand::file(&repo, vec![forged], Vec::new(), &[(&ulid, &forged_key)]); let (code, text) = verify(&repo); assert_eq!(code, 1, "{text}"); - assert!(text.contains(&id) && text.contains("D7") && text.contains("first in the store"), "{text}"); + assert!(text.contains(&id) && text.contains("D7") && text.contains("first in the namespace"), "{text}"); } diff --git a/ledger-core/src/author/acceptance_ops.rs b/ledger-core/src/author/acceptance_ops.rs index b6355dd0..a5d2e90f 100644 --- a/ledger-core/src/author/acceptance_ops.rs +++ b/ledger-core/src/author/acceptance_ops.rs @@ -100,7 +100,6 @@ impl Author { ulid: acceptance.id.ulid(), bytes: crate::authority::payload::acceptance_bytes(&acceptance), own_key: None, - any_namespace: false, }; self.sign_under(&store, policy.as_ref(), what)?; let signed = self.pending_sidecars.iter().map(|s| format!("signed — sig/{}", s.file)).collect::>(); @@ -181,8 +180,7 @@ impl Author { ulid: &ulid, bytes: crate::authority::payload::revocation_bytes(&revocation), own_key: None, - any_namespace: false, - }; + }; self.sign_under(&store, policy.as_ref(), what)?; } let mut candidate = self.shell(None)?; diff --git a/ledger-core/src/author/genesis_key.rs b/ledger-core/src/author/genesis_key.rs index d554abfe..212c5edf 100644 --- a/ledger-core/src/author/genesis_key.rs +++ b/ledger-core/src/author/genesis_key.rs @@ -1,27 +1,24 @@ -//! The genesis holder's key at `init --namespace` (#96). +//! The genesis holder's key at `init --namespace` (#96; per namespace, ruling 47). //! -//! **At bootstrap** the key `git config user.signingkey` names is bound in -//! the same change-set as the genesis grant and the first policy: the -//! genesis holder's self-bound first binding (protocol §4.10), carrying the -//! mandate, dated with the policy and signed by the key it binds. That -//! closes the window in which the first self-bound binding to land for the -//! address — anyone's — would be the one trusted. The same key signs the -//! first policy. The same holds in a later namespace while the genesis -//! holder has no key anywhere in the store. +//! The key `git config user.signingkey` names is bound in the same +//! change-set as the namespace's genesis grant and first policy: the +//! genesis holder's self-bound first binding in the namespace (protocol +//! §4.10), carrying the namespace's mandate, dated with the policy and +//! signed by the key it binds. That closes the window in which the first +//! self-bound binding to land for the address in this namespace — anyone's +//! — would be the one trusted. The same key signs the first policy. Every +//! namespace is opened this way, whatever the holder has bound elsewhere: a +//! key is trusted in the namespace it is bound in and nowhere else, so the +//! same key may be self-bound in several namespaces, and a key closed in +//! one namespace is a different key here (a repository notice names the +//! split, never a finding). //! //! **No usable key** (ruled 2026-10-05): `init --namespace` refuses, naming //! what is missing, unless `--without-key` says to proceed unbound — and //! then `verify` keeps saying the window is open until a key is bound. -//! -//! **Once the genesis holder has a key**, `init` in a later namespace binds -//! that live key there in the same act — their own `add`, signed by a key of -//! theirs already trusted, dated with the policy — and signs the first -//! policy with it, the signature `verify` then requires. If every key of -//! theirs is closed, `init` refuses as it does with no usable key, unless -//! `--without-key` — then it warns. use crate::authority::payload::{binding_bytes, binding_hash, policy_bytes}; -use crate::authority::{Authority, BindingAct, Grant, KeyBinding, Policy}; +use crate::authority::{BindingAct, Grant, KeyBinding, Policy}; use crate::changeset::ChangeSet; use crate::signing::ssh; use crate::store::Store; @@ -30,9 +27,8 @@ use super::sign_ops::ToSign; use super::{Author, AuthorError}; impl Author { - /// The genesis holder's key at `init`: bind it while they have none in - /// the store, else sign the first policy with their live key. Returns - /// the lines to print. + /// The genesis holder's key at `init`: the namespace's self-bound first + /// binding, which signs the first policy. Returns the lines to print. pub(super) fn genesis_key( &mut self, store: &Store, @@ -41,17 +37,14 @@ impl Author { policy: &Policy, without_key: bool, ) -> Result, AuthorError> { - if Authority::build(store).bindings.iter().any(|b| b.principal == self.who) { - return self.carry_genesis_key(store, candidate, policy, without_key); - } match configured(&self.root) { Ok((key, key_type, blob)) => self.bind_genesis_key(store, candidate, genesis, policy, (&key, key_type, blob)), Err(missing) if without_key => Ok(vec![format!( - "warning: no key bound ({missing}; --without-key) — until `ledger identity add --namespace {}`, the first self-bound binding to land for {} is the one trusted (D7)", + "warning: no key bound ({missing}; --without-key) — until `ledger identity add --namespace {}`, the first self-bound binding to land for {} there is the one trusted (D7)", policy.namespace, self.who )]), Err(missing) => Err(AuthorError::Usage(format!( - "refused — {missing}. `init --namespace` binds the genesis holder's key in the same act, so the first self-bound binding for {} is theirs (#96): set `git config user.signingkey` to your SSH key, or pass `--without-key` to initialise unbound", + "refused — {missing}. `init --namespace` binds the genesis holder's key in the same act, so the first self-bound binding for {} in the namespace is theirs (#96): set `git config user.signingkey` to your SSH key, or pass `--without-key` to initialise unbound", self.who ))), } @@ -85,81 +78,15 @@ impl Author { binding.hash = binding_hash(&binding); let own = Some((key_type.as_str(), blob.as_str())); let ulid = binding.id.ulid().to_string(); - let what = ToSign { namespace: &binding.namespace, ulid: &ulid, bytes: binding_bytes(&binding), own_key: own, any_namespace: false }; + let what = ToSign { namespace: &binding.namespace, ulid: &ulid, bytes: binding_bytes(&binding), own_key: own }; self.sign_under(store, Some(policy), what)?; let ulid = policy.id.ulid().to_string(); - let what = ToSign { namespace: &policy.namespace, ulid: &ulid, bytes: policy_bytes(policy), own_key: own, any_namespace: false }; + let what = ToSign { namespace: &policy.namespace, ulid: &ulid, bytes: policy_bytes(policy), own_key: own }; self.sign_under(store, Some(policy), what)?; - let line = format!("bound {} as {}'s first key ({key_type}), self-bound; it signs the policy — {}", key.display(), self.who, binding.id); + let line = format!("bound {} as {}'s first key in `{}` ({key_type}), self-bound; it signs the policy — {}", key.display(), self.who, policy.namespace, binding.id); candidate.key_bindings.push(binding); Ok(vec![line]) } - - /// In a later namespace, while the genesis holder holds a live key in - /// the store: bind it here in the same act — their own `add`, signed by - /// a key of theirs already trusted, dated with the policy — and sign the - /// first policy with it. They can then sign in this namespace with no - /// separate `identity add`. - fn carry_genesis_key(&mut self, store: &Store, candidate: &mut ChangeSet, policy: &Policy, without_key: bool) -> Result, AuthorError> { - let auth = Authority::build(store); - // A close ends the key in every namespace (ruled 2026-10-06). - let live = |b: &&KeyBinding| b.act.opens() && b.principal == self.who && !crate::authority::key_close::is_closed(&auth.bindings, b); - if !auth.bindings.iter().any(&live) { - return self.no_live_key(&auth, policy, without_key); - } - let mut lines = Vec::new(); - if !auth.bindings.iter().any(|b| live(b) && b.namespace == policy.namespace) { - let (key, key_type, blob) = configured(&self.root).map_err(|missing| { - AuthorError::Usage(format!("refused — {missing}; `init --namespace` binds {}'s live key in the new namespace and signs its policy with it", self.who)) - })?; - let mut binding = KeyBinding { - id: self.mint.mint_id("key").map_err(AuthorError::Io)?, - act: BindingAct::Add, - principal: self.who.clone(), - namespace: policy.namespace.clone(), - key_type: Some(key_type.clone()), - key: Some(blob), - closes: None, - self_bound: false, - mandate: None, - by: self.who.clone(), - under: None, - at: policy.at, - hash: crate::hash::VersionHash::zero(), - }; - binding.hash = binding_hash(&binding); - let ulid = binding.id.ulid().to_string(); - let what = ToSign { namespace: &binding.namespace, ulid: &ulid, bytes: binding_bytes(&binding), own_key: None, any_namespace: true }; - self.sign_under(store, Some(policy), what)?; - lines.push(format!("bound {} ({key_type}) for {} in `{}`, signed by their key trusted elsewhere — {}", key.display(), self.who, policy.namespace, binding.id)); - candidate.key_bindings.push(binding); - } - let ulid = policy.id.ulid().to_string(); - let what = ToSign { namespace: &policy.namespace, ulid: &ulid, bytes: policy_bytes(policy), own_key: None, any_namespace: true }; - self.sign_under(store, Some(policy), what)?; - lines.push(format!("signed {} with {}'s live key", policy.id, self.who)); - Ok(lines) - } -} - -impl Author { - /// The genesis holder has bindings, every one closed: nothing of theirs - /// can vouch for a binding here or sign the policy. Refused, naming the - /// closed keys, unless `--without-key` says to proceed unbound (ruled - /// 2026-10-05). - fn no_live_key(&self, auth: &Authority<'_>, policy: &Policy, without_key: bool) -> Result, AuthorError> { - let ids: Vec = auth.bindings.iter().filter(|b| b.act.opens() && b.principal == self.who).map(|b| b.id.to_string()).collect(); - let missing = format!("no live key: every key bound to {} is closed ({})", self.who, ids.join(", ")); - if without_key { - return Ok(vec![format!( - "warning: {missing}; --without-key — `{}` is initialised with no key bound for {} and its first policy unsigned", - policy.namespace, self.who - )]); - } - Err(AuthorError::Usage(format!( - "refused — {missing}. `init --namespace` binds the genesis holder's live key in the new namespace and signs its policy with it: bind a live key first, or pass `--without-key` to initialise unbound" - ))) - } } /// The configured key and its public half, or what is missing. diff --git a/ledger-core/src/author/group_accept.rs b/ledger-core/src/author/group_accept.rs index 61da1a98..8765f022 100644 --- a/ledger-core/src/author/group_accept.rs +++ b/ledger-core/src/author/group_accept.rs @@ -107,8 +107,7 @@ impl Author { ulid: acceptance.id.ulid(), bytes: crate::authority::payload::acceptance_bytes(&acceptance), own_key: None, - any_namespace: false, - }; + }; self.sign_under(store, policy.as_ref(), what)?; candidate.acceptances.push(acceptance); signed.push(m.decision.clone()); diff --git a/ledger-core/src/author/identity_ops.rs b/ledger-core/src/author/identity_ops.rs index b5507b86..1b5f4a7b 100644 --- a/ledger-core/src/author/identity_ops.rs +++ b/ledger-core/src/author/identity_ops.rs @@ -5,12 +5,13 @@ //! the log, so the derived file always matches what `verify` re-derives. //! Who may file which binding is D7's rule, [`crate::authority::filing::may_file`] //! — the one `verify` re-judges every filed binding with: the genesis -//! holder's self-bound first binding (signed by the key it binds), a -//! principal's first key filed and signed by the genesis holder -//! (`identity add --for `), and every further `add`, `rotate` -//! and `revoke` the principal's own, signed by a live key — or a `revoke` -//! by the genesis holder. A namespace whose policy requires `-sk` keys -//! refuses a software key. +//! holder's self-bound first binding in the namespace (signed by the key +//! it binds), a principal's first key filed and signed by the genesis +//! holder (`identity add --for `), and every further `add`, +//! `rotate` and `revoke` the principal's own, signed by a key of theirs +//! live in the namespace — or a `revoke` by the genesis holder. Keys are +//! per namespace (ruling 47): nothing bound elsewhere counts here. A +//! namespace whose policy requires `-sk` keys refuses a software key. use crate::authority::filing::may_file; use crate::authority::{Authority, BindingAct, KeyBinding}; @@ -81,13 +82,10 @@ impl Author { ))); } let genesis = auth.genesis().filter(|g| g.holder == self.who); - // D7: the genesis holder self-binds once per store; in a later - // namespace their first key is signed by a key of theirs already - // bound elsewhere. - let first_in_store = auth.bindings.iter().all(|b| b.principal != principal); - let first_in_ns = auth.bindings.iter().all(|b| b.namespace != namespace || b.principal != principal); - let self_bound = act == BindingAct::Add && principal == self.who && first_in_store && genesis.is_some(); - let any_namespace = act == BindingAct::Add && principal == self.who && first_in_ns && !self_bound; + // D7: the genesis holder's first key in the namespace is self-bound + // (ruling 47: once per namespace, whatever they hold elsewhere). + let first_in_ns = auth.bindings.iter().all(|b| b.principal != principal); + let self_bound = act == BindingAct::Add && principal == self.who && first_in_ns && genesis.is_some(); let mut binding = KeyBinding { id: self.mint.mint_id("key").map_err(AuthorError::Io)?, act, @@ -112,7 +110,6 @@ impl Author { ulid: &ulid, bytes: crate::authority::payload::binding_bytes(&binding), own_key: own.as_ref().filter(|_| self_bound).or(closed_key.as_ref()).map(|(t, k)| (t.as_str(), k.as_str())), - any_namespace, }; self.sign_under(&store, Some(&policy), what)?; let line = format!("identity {act}: {} in `{}` — {}", binding.principal, binding.namespace, binding.id); diff --git a/ledger-core/src/author/policy_ops.rs b/ledger-core/src/author/policy_ops.rs index 079d7bb5..646671d3 100644 --- a/ledger-core/src/author/policy_ops.rs +++ b/ledger-core/src/author/policy_ops.rs @@ -73,7 +73,6 @@ impl Author { ulid: &ulid, bytes: crate::authority::payload::policy_bytes(&next), own_key: None, - any_namespace: false, }; self.sign_under(&store, Some(¤t), what)?; let mut candidate = self.shell(None)?; diff --git a/ledger-core/src/author/sign_ops.rs b/ledger-core/src/author/sign_ops.rs index d585ec33..388adf72 100644 --- a/ledger-core/src/author/sign_ops.rs +++ b/ledger-core/src/author/sign_ops.rs @@ -8,7 +8,7 @@ //! //! - a configured key that is not the signer's live key in the namespace //! (or, for a self-bound binding, the key being bound; for a `rotate`, -//! the key it closes); +//! the key it closes) — a key bound elsewhere does not sign here (ruling 47); //! - a software key where the policy requires hardware-backed (`-sk`) keys; //! - an **agent-reported unconfirmed key**: a software key whose private //! half only an agent holds (the configured file is a public key). Nothing @@ -36,9 +36,6 @@ pub(crate) struct ToSign<'a> { /// self-bound binding binds (it signs itself), or the key a `rotate` /// closes (LP-4.12, ruling 53). pub own_key: Option<(&'a str, &'a str)>, - /// The genesis holder's first key in a later namespace (D7): signed by - /// a live key of theirs bound in any namespace. - pub any_namespace: bool, } impl Author { @@ -98,10 +95,10 @@ impl Author { ))); } Some(_) => true, - None => self.live_key(store, (!what.any_namespace).then_some(what.namespace), &key_type, &blob), + None => self.live_key(store, what.namespace, &key_type, &blob), }; if !bound { - let closed = self.closed_by(store, &blob).map(|(id, ns)| format!(": it was closed in `{ns}` by {id}, and a close ends the key in every namespace")).unwrap_or_default(); + let closed = self.closed_by(store, what.namespace, &blob).map(|id| format!(": it was closed in `{}` by {id}", what.namespace)).unwrap_or_default(); return Err(AuthorError::Unauthorized(format!( "{} is not {}'s live key in `{}`{closed} — the signature would not verify", key.display(), @@ -112,13 +109,12 @@ impl Author { Ok(()) } - /// Whether `(key_type, blob)` is an open window of this author's in - /// `ns`, or in any namespace when `ns` is `None`. - fn live_key(&self, store: &Store, ns: Option<&str>, key_type: &str, blob: &str) -> bool { - let auth = Authority::build(store); + /// Whether `(key_type, blob)` is an open window of this author's in `ns` + /// (a key is trusted in the namespace it is bound in, ruling 47). + fn live_key(&self, store: &Store, ns: &str, key_type: &str, blob: &str) -> bool { + let auth = Authority::of(store, ns); auth.bindings.iter().any(|b| { b.act.opens() - && ns.is_none_or(|ns| b.namespace == ns) && b.principal == self.who && b.key_type.as_deref() == Some(key_type) && b.key.as_deref() == Some(blob) @@ -126,12 +122,11 @@ impl Author { }) } - /// The close that ended this author's key `blob`, and its namespace. - fn closed_by(&self, store: &Store, blob: &str) -> Option<(String, String)> { - let auth = Authority::build(store); + /// The close in `ns` that ended this author's key `blob` there. + fn closed_by(&self, store: &Store, ns: &str, blob: &str) -> Option { + let auth = Authority::of(store, ns); let mine = auth.bindings.iter().copied().find(|b| b.act.opens() && b.principal == self.who && b.key.as_deref() == Some(blob))?; - let close = crate::authority::key_close::closes_of(&auth.bindings, mine).into_iter().next()?; - Some((close.id.to_string(), close.namespace.clone())) + crate::authority::key_close::closes_of(&auth.bindings, mine).into_iter().next().map(|c| c.id.to_string()) } /// Append the change-set and write its pending signatures beside it — diff --git a/ledger-core/src/authority/filing.rs b/ledger-core/src/authority/filing.rs index 69ad61e4..02e897e7 100644 --- a/ledger-core/src/authority/filing.rs +++ b/ledger-core/src/authority/filing.rs @@ -5,11 +5,11 @@ //! as it stood before the binding (`Authority::as_of`), so a hand-written //! binding file meets the same rule as the verb. //! -//! - **The genesis holder's first binding in the store** is self-bound: -//! filed by the genesis holder for itself, carrying the genesis grant's -//! `external_ref` as its mandate, signed by the key it binds. Once per -//! store: in any later namespace the genesis holder's first binding is -//! their own `add`, signed by a key of theirs already trusted elsewhere. +//! - **The genesis holder's first binding in the namespace** is self-bound: +//! filed by the genesis holder for itself, carrying the namespace's +//! genesis grant's `external_ref` as its mandate, signed by the key it +//! binds. Once per namespace (ruling 47): every namespace is opened as the +//! first is, and a key trusted elsewhere vouches for nothing here. //! - **A principal's first key** (no open window in the namespace) is filed //! and signed by the genesis holder: `by` is the genesis holder, //! `principal` the new holder, `under` the genesis grant. Recovery from a @@ -35,12 +35,15 @@ pub fn may_file(auth: &Authority<'_>, b: &KeyBinding) -> Result<(), String> { None => "a principal's act on its own keys names no `under`".to_string(), }); } - let open_in_ns = |who| open_window(auth, b, who, Some(&b.namespace)); + let open_in_ns = |who| open_window(auth, b, who); match (b.act, &b.closes) { (BindingAct::Add, _) if b.self_bound => self_bound(auth, b, genesis.and_then(|g| g.external_ref.clone()), by_genesis), (BindingAct::Add, _) if b.by == b.principal => match open_in_ns(&b.principal) { true => Ok(()), - false if by_genesis && open_window(auth, b, &b.principal, None) => Ok(()), + false if by_genesis => Err(format!( + "{} has no live key in `{}`: the genesis holder's first key in a namespace is its self-bound binding (D7)", + b.principal, b.namespace + )), false => Err(format!( "{} has no live key in `{}`: a principal's first key is filed by the genesis holder (D7)", b.principal, b.namespace @@ -56,28 +59,24 @@ pub fn may_file(auth: &Authority<'_>, b: &KeyBinding) -> Result<(), String> { } } -/// Whether `who` holds an open key window (other than `b` itself) in `ns`, -/// or in any namespace when `ns` is `None`. A window is open while its key -/// is: a close of the same key in any namespace closes it (ruled 2026-10-06). -fn open_window(auth: &Authority<'_>, b: &KeyBinding, who: &crate::identity::Identity, ns: Option<&str>) -> bool { +/// Whether `who` holds an open key window (other than `b` itself) in `b`'s +/// namespace. A window is open while its key is: a close of the same key in +/// the namespace closes it (ruled 2026-10-06; per namespace, ruling 47). +fn open_window(auth: &Authority<'_>, b: &KeyBinding, who: &crate::identity::Identity) -> bool { auth.bindings.iter().any(|o| { - o.act.opens() - && ns.is_none_or(|ns| o.namespace == ns) - && o.principal == *who - && o.id != b.id - && !super::key_close::is_closed(&auth.bindings, o) + o.act.opens() && o.namespace == b.namespace && o.principal == *who && o.id != b.id && !super::key_close::is_closed(&auth.bindings, o) }) } -/// A self-bound binding: the genesis holder's first in the store, under -/// the genesis mandate. +/// A self-bound binding: the genesis holder's first in the namespace, under +/// the namespace's genesis mandate. fn self_bound(auth: &Authority<'_>, b: &KeyBinding, mandate: Option, by_genesis: bool) -> Result<(), String> { - let first = auth.bindings.iter().all(|o| o.principal != b.principal || o.id == b.id); + let first = auth.bindings.iter().all(|o| o.namespace != b.namespace || o.principal != b.principal || o.id == b.id); if by_genesis && b.principal == b.by && first && b.mandate == mandate { return Ok(()); } - Err("a self-bound binding is the genesis holder's first in the store, under the genesis mandate — \ - in a later namespace their first key is signed by a key of theirs already trusted" + Err("a self-bound binding is the genesis holder's first in the namespace, under its genesis mandate — \ + every further key of theirs there is their own `add`, signed by a key of theirs live in the namespace" .into()) } diff --git a/ledger-core/src/authority/key_close.rs b/ledger-core/src/authority/key_close.rs index 791bc8e1..b7cf8fb4 100644 --- a/ledger-core/src/authority/key_close.rs +++ b/ledger-core/src/authority/key_close.rs @@ -1,22 +1,27 @@ -//! A close ends the key, not the binding (ruled 2026-10-06). +//! A close ends the key, not the binding — in its own namespace (ruled +//! 2026-10-06; per namespace by ruling 47, LP-6.32). //! -//! Closing any binding of a principal's key — by `rotate` or `revoke`, in -//! any namespace — closes that key in every namespace of the store, from the -//! close's position (D6). The key is the principal and the key blob; every +//! Closing any binding of a principal's key in a namespace — by `rotate` or +//! `revoke` — closes that key in that namespace, from the close's position +//! (D6). The key is the principal, the key blob and the namespace; every //! check that asks whether a key is closed asks it here, over every binding -//! of that key, never only the binding a signature happened to match. Which -//! keys may be bound at all — once per namespace, never once closed, never -//! another principal's — is [`refusal`]. +//! of that key in the namespace, never only the binding a signature +//! happened to match. The same key bound in another namespace is another +//! key there, judged by that namespace alone: a key closed in one and open +//! in another is a repository notice (ruling 69), never a finding. Which +//! keys may be bound at all — once per namespace, never once closed there, +//! never another principal's there — is [`refusal`]. use super::binding::KeyBinding; -/// Whether `a` and `b` open the same principal's same key. +/// Whether `a` and `b` open the same principal's same key in the same +/// namespace (ruling 47: a key is bound, closed and trusted per namespace). pub fn same_key(a: &KeyBinding, b: &KeyBinding) -> bool { - a.act.opens() && b.act.opens() && a.principal == b.principal && a.key.is_some() && a.key == b.key + a.act.opens() && b.act.opens() && a.principal == b.principal && a.namespace == b.namespace && a.key.is_some() && a.key == b.key } /// Every close among `bindings` that ends `b`'s key: a close of any opening -/// binding of the same principal and key, in any namespace. +/// binding of the same principal and key in `b`'s namespace. pub fn closes_of<'a>(bindings: &[&'a KeyBinding], b: &KeyBinding) -> Vec<&'a KeyBinding> { closes_among(bindings, bindings, b) } @@ -48,25 +53,29 @@ pub fn already_open<'a>(bindings: &[&'a KeyBinding], b: &KeyBinding) -> Option<& /// it (trusted, in landing order), a close's target looked up in `filed` /// (ruled 2026-10-06). `None`: it may. /// +/// Each rule looks at the binding's own namespace (ruling 47, LP-4.37): +/// /// - **A key belongs to one principal** (ruling 4): a key that is, or was, -/// bound to a different principal anywhere in the store is not bound again. +/// bound to a different principal in the namespace is not bound again +/// there. The same key bound to two principals in two namespaces is a +/// repository notice. /// - **A closed key is never bound again** (ruling 3): a key closed for its -/// principal, in any namespace, is not bound again — the genesis holder -/// vouching for it as someone's first key included. +/// principal in the namespace is not bound again there — the genesis +/// holder vouching for it as someone's first key included. /// - **A key is bound once per namespace** (ruling 1). pub fn refusal(bound: &[&KeyBinding], filed: &[&KeyBinding], b: &KeyBinding) -> Option { if !b.act.opens() || b.key.is_none() { return None; } - if let Some(other) = bound.iter().find(|o| o.act.opens() && o.key == b.key && o.principal != b.principal) { + if let Some(other) = bound.iter().find(|o| o.act.opens() && o.namespace == b.namespace && o.key == b.key && o.principal != b.principal) { return Some(format!( - "this key is bound to {} ({} in `{}`) — a key belongs to one principal, so it is never bound to {}", + "this key is bound to {} ({} in `{}`) — a key belongs to one principal, so it is never bound to {} there", other.principal, other.id, other.namespace, b.principal )); } if let Some(close) = closes_among(bound, filed, b).first() { return Some(format!( - "this key was closed for {} in `{}` by {} — a closed key is never bound again", + "this key was closed for {} in `{}` by {} — a closed key is never bound again there", b.principal, close.namespace, close.id )); } diff --git a/ledger-core/src/authority/key_close_tests.rs b/ledger-core/src/authority/key_close_tests.rs index c4b16256..df2d0170 100644 --- a/ledger-core/src/authority/key_close_tests.rs +++ b/ledger-core/src/authority/key_close_tests.rs @@ -1,4 +1,4 @@ -//! A close ends the key in every namespace, whichever binding it names. +//! A close ends the key in its own namespace, whichever binding of it there it names (ruling 47). use crate::authority::fixture; use crate::authority::{BindingAct, KeyBinding}; @@ -29,13 +29,14 @@ fn bind(tail: &str, act: BindingAct, principal: &str, ns: &str, key: Option<&str } #[test] -fn a_close_in_one_namespace_ends_the_same_key_in_every_other() { +fn a_close_ends_the_key_in_its_own_namespace_only() { let in_a = bind("1", BindingAct::Add, OWNER, "a.ledger", Some("AAAAk"), None); let in_b = bind("2", BindingAct::Add, OWNER, "b.ledger", Some("AAAAk"), None); let revoke = bind("3", BindingAct::Revoke, OWNER, "a.ledger", None, Some(&in_a)); let all = [&in_a, &in_b, &revoke]; - assert_eq!(closes_of(&all, &in_b).iter().map(|c| c.id.clone()).collect::>(), vec![revoke.id.clone()]); - assert!(is_closed(&all, &in_a) && is_closed(&all, &in_b)); + assert_eq!(closes_of(&all, &in_a).iter().map(|c| c.id.clone()).collect::>(), vec![revoke.id.clone()]); + assert!(is_closed(&all, &in_a), "closed in a.ledger"); + assert!(closes_of(&all, &in_b).is_empty() && !is_closed(&all, &in_b), "the same key in b.ledger is another key there, still open"); } #[test] @@ -57,22 +58,26 @@ fn a_key_is_bound_once_per_namespace_and_once_closed_never_again() { assert_eq!(already_open(&[&first], &twice).map(|o| o.id.clone()), Some(first.id.clone())); assert!(already_open(&[&first], &elsewhere).is_none()); let revoke = bind("4", BindingAct::Revoke, OWNER, "a.ledger", None, Some(&first)); - // Ruling 3: a closed key is refused, not accepted — in its own namespace - // and in every other. + // Ruling 3: a closed key is refused, not accepted — in the namespace it + // was closed in. Elsewhere it is another key (ruling 47), bindable. let why = refusal(&[&first, &revoke], &[&first, &revoke], &twice).expect("refused"); assert!(why.contains("a closed key is never bound again") && why.contains(&revoke.id.to_string()), "{why}"); - assert!(refusal(&[&first, &revoke], &[&first, &revoke], &elsewhere).is_some(), "closed in every namespace"); + assert!(refusal(&[&first, &revoke], &[&first, &revoke], &elsewhere).is_none(), "closed in a.ledger says nothing of b.ledger"); } #[test] -fn a_key_bound_to_another_principal_is_never_bound_to_this_one() { +fn a_key_bound_to_another_principal_is_never_bound_to_this_one_in_the_namespace() { let theirs = bind("1", BindingAct::Add, OTHER, "a.ledger", Some("AAAAk"), None); - let mine = bind("2", BindingAct::Add, OWNER, "b.ledger", Some("AAAAk"), None); + let mine = bind("2", BindingAct::Add, OWNER, "a.ledger", Some("AAAAk"), None); let why = refusal(&[&theirs], &[&theirs], &mine).expect("refused"); assert!(why.contains("a key belongs to one principal") && why.contains(OTHER), "{why}"); // Was bound counts as well as is: closed for them, still theirs. let revoke = bind("3", BindingAct::Revoke, OTHER, "a.ledger", None, Some(&theirs)); assert!(refusal(&[&theirs, &revoke], &[&theirs, &revoke], &mine).is_some_and(|w| w.contains("one principal"))); - let fresh = bind("4", BindingAct::Add, OWNER, "b.ledger", Some("AAAAj"), None); + let fresh = bind("4", BindingAct::Add, OWNER, "a.ledger", Some("AAAAj"), None); assert!(refusal(&[&theirs], &[&theirs], &fresh).is_none(), "another key is fine"); + // In another namespace the blob is another key (ruling 47): bindable, + // and the repository notice of ruling 69 names the sharing. + let elsewhere = bind("5", BindingAct::Add, OWNER, "b.ledger", Some("AAAAk"), None); + assert!(refusal(&[&theirs], &[&theirs], &elsewhere).is_none(), "judged within the namespace"); } diff --git a/ledger-core/src/authority/signers.rs b/ledger-core/src/authority/signers.rs index cd30965c..7369a72c 100644 --- a/ledger-core/src/authority/signers.rs +++ b/ledger-core/src/authority/signers.rs @@ -12,8 +12,9 @@ //! requires (spec v1.8 corrects the space-separated form spec v1.7 wrote, //! which `ssh-keygen` refuses as an invalid key). `valid-after` is the //! opening binding's time; `valid-before` is the time of the earliest -//! `rotate` or `revoke` that closed its key, when one did — in any -//! namespace, since a close ends the key, not the binding (ruled 2026-10-06). The principal +//! `rotate` or `revoke` that closed its key in that namespace, when one did +//! — a close ends the key, not the binding (ruled 2026-10-06), in its own +//! namespace (ruling 47, LP-6.32). The principal //! is the bare address (ruling 6: files keep the bare address). The file is //! never edited by hand: every `ledger identity` verb rewrites it, and //! `verify` re-derives it and holds the committed bytes identical — the @@ -55,8 +56,8 @@ pub fn derive(store: &Store) -> BTreeMap { /// 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. +/// no entry. A close counts in its own namespace only, 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) { @@ -90,8 +91,8 @@ pub fn derive_from(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> Option 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. + // A close ends the key in its own namespace (ruled 2026-10-06; ruling + // 47): every line of a closed key ends at its earliest close there. 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))) diff --git a/ledger-core/src/authority/view.rs b/ledger-core/src/authority/view.rs index d131353f..0e71f22d 100644 --- a/ledger-core/src/authority/view.rs +++ b/ledger-core/src/authority/view.rs @@ -14,13 +14,11 @@ //! namespace's records, and every role check, genesis lookup and policy //! verdict goes through them. [`Authority::build`] is the store-wide union, //! for readings that are namespace-independent (a policy by its own -//! `namespace`, the bindings a key question ranges over); its `roles` map -//! collides across namespaces and its `genesis` is undefined with more than -//! one, so neither is read off it. -//! -//! *Key bindings are the one list not yet filtered by namespace:* a key is -//! still bound, closed and trusted store-wide until issue 6 (LP-6.32, -//! LP-4.37) scopes them. Every other list is the namespace's own. +//! `namespace`, every binding the store holds for a repository notice); its +//! `roles` map collides across namespaces and its `genesis` is undefined +//! with more than one, so neither is read off it. Key bindings are the +//! namespace's own too: a key is bound, closed and trusted per namespace +//! (LP-6.32, LP-4.37, LP-4.39). use std::collections::{BTreeMap, BTreeSet}; @@ -69,11 +67,10 @@ impl<'a> Authority<'a> { } for logged in &store.log { let cs = &logged.file; - // Keys range over the store until issue 6 (module note). - a.bindings.extend(&cs.key_bindings); if namespace.is_some_and(|ns| logged.namespace != ns) { continue; } + a.bindings.extend(&cs.key_bindings); a.grants.extend(cs.grants.iter().map(|g| (g.id.to_string(), g))); a.grant_acceptances.extend(&cs.grant_acceptances); a.unavailabilities.extend(cs.unavailabilities.iter().map(|u| (u.id.to_string(), u))); @@ -91,26 +88,22 @@ impl<'a> Authority<'a> { /// closes) and governing ones (policies) unless the act is before them; /// and the availability intervals landed no later (their clock /// decides). This is what `verify` judges a historic act against - /// (`A006`, D7). Key bindings of every namespace are read (module note). + /// (`A006`, D7). pub fn as_of(store: &'a Store, landing: &Landing, pos: Position, namespace: &str) -> Self { let mut a = Self::default(); for role in store.roles.iter().filter(|r| r.namespace == namespace && role_landing(landing, r) <= pos.index) { a.roles.insert(role.id.clone(), role); } - for logged in &store.log { + for logged in store.log.iter().filter(|l| l.namespace == namespace) { let path = relative(&store.root, &logged.path); let cs = &logged.file; - let at_key = |id: String, t| landing.position(&path, &crate::landed::key("key_bindings", &id), t); + let at = |list: &str, id: String, t| landing.position(&path, &crate::landed::key(list, &id), t); // A key's close is terminating, like a revocation: it applies // unless the act is before it. An opening binding enables. a.bindings.extend(cs.key_bindings.iter().filter(|b| { - let here = at_key(b.id.to_string(), b.at); + let here = at("key_bindings", b.id.to_string(), b.at); if b.closes.is_some() { !pos.before(&here) } else { here.not_after(&pos) } })); - if logged.namespace != namespace { - continue; - } - let at = |list: &str, id: String, t| landing.position(&path, &crate::landed::key(list, &id), t); let landed = |list: &str, id: String| landing.entity_index(&path, &crate::landed::key(list, &id)) <= pos.index; a.grants.extend( cs.grants.iter().filter(|g| at("grants", g.id.to_string(), g.at).not_after(&pos)).map(|g| (g.id.to_string(), g)), diff --git a/ledger-core/src/render.rs b/ledger-core/src/render.rs index 553a9eba..c080a134 100644 --- a/ledger-core/src/render.rs +++ b/ledger-core/src/render.rs @@ -86,8 +86,8 @@ fn status_lines(report: &Report) -> Vec { out } -/// The governance notices: unchecked and unsigned namespaces, and an -/// unbound genesis holder. Never a failure. +/// The governance notices: unchecked and unsigned namespaces, unbound +/// genesis holders, and the two key notices of ruling 69. Never a failure. fn notices(n: &crate::verify::Notices, out: &mut Vec) { for ns in &n.unchecked { out.push(format!( @@ -105,6 +105,17 @@ fn notices(n: &crate::verify::Notices, out: &mut Vec) { u.holder, u.namespace, u.namespace )); } + let quoted = |ns: &[String]| ns.iter().map(|n| format!("`{n}`")).collect::>().join(", "); + for k in &n.key_split { + out.push(format!( + "notice: key {} of {} is closed in {} and open in {} — a close ends the key in its own namespace (LP-6.32); `ledger identity revoke` closes it there too", + k.key, k.holder, quoted(&k.closed_in), quoted(&k.open_in) + )); + } + for k in &n.key_shared { + let who = k.bound_to.iter().map(|(p, ns)| format!("{p} in `{ns}`")).collect::>().join(" and "); + out.push(format!("notice: key {} is bound to {who} — one key, two principals, two namespaces (ruling 69)", k.key)); + } } #[cfg(test)] diff --git a/ledger-core/src/signing/check.rs b/ledger-core/src/signing/check.rs index 6285e355..220237d3 100644 --- a/ledger-core/src/signing/check.rs +++ b/ledger-core/src/signing/check.rs @@ -22,9 +22,11 @@ //! filed binding ends trusted or with a finding — none is left silent. //! //! **Closed keys** (ruling 12, D6). A close ends the key, not the binding -//! (ruled 2026-10-06): closing any binding of a principal's key closes it in -//! every namespace, and every check asks over all bindings of the matched -//! key ([`key_close`]). An entity signed by a closed key is judged by order: +//! (ruled 2026-10-06), in its own namespace (ruling 47, LP-6.32): closing +//! any binding of a principal's key in a namespace closes it there, and +//! every check asks over all bindings of the matched key in that namespace +//! ([`key_close`]). A key is trusted in the namespace it is bound in and +//! nowhere else. An entity signed by a closed key is judged by order: //! dated after the close fails `-Overify-time`, so `L011`; landed after the //! close, whatever its date, is `L011`, naming the close and its namespace; //! dated *and* landed before it, an acceptance goes to the re-acceptance @@ -150,10 +152,9 @@ fn first_policy<'a>(s: &Subject<'a>) -> Option<&'a Policy> { } /// A namespace's first policy (#96): signed under its own schemes when its -/// author held a live trusted key at its position — in this namespace or -/// another, which for this check stands here, as [`carried_over`] does for -/// a binding. A sidecar that names it is verified whatever. `None`: neither, -/// so nothing is required. +/// author held a live trusted key in this namespace at its position (ruling +/// 47: a key trusted elsewhere has no effect here). A sidecar that names it +/// is verified whatever. `None`: neither, so nothing is required. fn judge_first_policy( store: &Store, s: &Subject<'_>, @@ -166,15 +167,12 @@ fn judge_first_policy( key_close::closes_among(trusted, &filed(store), k).iter().any(|c| at(&c.id).is_some_and(|c| !s.position.before(&c))) }; let held = trusted.iter().any(|k| { - k.principal == s.signer && k.act.opens() && at(&k.id).is_some_and(|o| o.not_after(&s.position)) && !closed(k) + k.principal == s.signer && k.namespace == s.namespace && k.act.opens() && at(&k.id).is_some_and(|o| o.not_after(&s.position)) && !closed(k) }); if !held && !store.sidecars.iter().any(|c| c.ulid == s.ulid) { return None; } - let here: Vec = - trusted.iter().filter(|k| k.principal == s.signer).map(|k| KeyBinding { namespace: s.namespace.clone(), ..(*k).clone() }).collect(); - let keys: Vec<&KeyBinding> = here.iter().collect(); - Some(judge(store, s, p, &keys, positions).map(|_| ())) + Some(judge(store, s, p, trusted, positions).map(|_| ())) } fn ordered(mut all: Vec>) -> Vec> { @@ -217,8 +215,6 @@ fn trust_bindings<'a>(store: &'a Store, landing: &Landing, all: &[Subject<'a>], if b.self_bound { keys.push(b); } - let elsewhere = carried_over(&out.trusted, b); - keys.extend(elsewhere.iter()); let rotate = b.act == crate::authority::BindingAct::Rotate; if rotate { keys = closed_key_only(&keys, &filed(store), b); @@ -254,24 +250,6 @@ fn first_policy_of(store: &Store, namespace: &str) -> Option { Authority::build(store).policies.iter().find(|p| p.namespace == namespace && p.replaces.is_none()).map(|p| (*p).clone()) } -/// The genesis holder's first key in a later namespace is their own `add`, -/// signed by a key of theirs already trusted in another namespace (D7). For -/// that check alone, those keys stand in this namespace: the signature is -/// made in `ledger-accept@`, and `allowed_signers` scopes -/// each line to its own. `may_file` has already ruled who may file it. -fn carried_over(trusted: &[&KeyBinding], b: &KeyBinding) -> Vec { - let own_add = b.act == crate::authority::BindingAct::Add && !b.self_bound && b.by == b.principal; - let here = trusted.iter().any(|k| k.principal == b.by && k.namespace == b.namespace && k.act.opens()); - if !own_add || here { - return Vec::new(); - } - trusted - .iter() - .filter(|k| k.principal == b.by && k.act.opens()) - .map(|k| KeyBinding { namespace: b.namespace.clone(), ..(*k).clone() }) - .collect() -} - /// Judge one subject's sidecars against its policy. `Ok(Some(close))`: valid /// but under a key closed after it. fn judge<'a>( diff --git a/ledger-core/src/signing/review.rs b/ledger-core/src/signing/review.rs index 506a91de..1652c49d 100644 --- a/ledger-core/src/signing/review.rs +++ b/ledger-core/src/signing/review.rs @@ -3,8 +3,9 @@ //! An acceptance dated *and* landed before its key's close is a review item //! ("needs re-acceptance") until a later valid acceptance of the same //! version by the same actor affirms it, and `L012` once the policy's -//! re-acceptance deadline has passed. The close is the key's earliest, in -//! any namespace (ruled 2026-10-06), as the signature check found it. +//! re-acceptance deadline has passed. The close is the key's earliest in +//! its own namespace (ruled 2026-10-06; per namespace by ruling 47), as the +//! signature check found it. use std::collections::BTreeMap; diff --git a/ledger-core/src/verify/key_notices.rs b/ledger-core/src/verify/key_notices.rs new file mode 100644 index 00000000..e39aca1d --- /dev/null +++ b/ledger-core/src/verify/key_notices.rs @@ -0,0 +1,78 @@ +//! The two key notices of ruling 69: a key closed in one namespace yet open in another, or bound to two principals in two namespaces. +//! +//! Keys are per namespace (ruling 47, LP-6.32): a close ends a key in its +//! own namespace, and the same key bound elsewhere is another key there. +//! What a store-wide reading once refused is now reported, never a finding +//! (LP-8.31): the holder decides whether a key closed in `A` should be +//! closed in `B` too (`ledger identity revoke` there), and two principals +//! sharing one key across namespaces is theirs to know. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::authority::key_close; +use crate::authority::KeyBinding; +use crate::store::Store; + +/// A key closed in some namespaces and open in others. +#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)] +pub struct KeySplit { + pub holder: String, + /// The key, as ` `. + pub key: String, + pub closed_in: Vec, + pub open_in: Vec, +} + +/// One key bound to different principals in different namespaces. +#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)] +pub struct KeyShared { + /// The key, as ` `. + pub key: String, + /// `(principal, namespace)` pairs, sorted. + pub bound_to: Vec<(String, String)>, +} + +fn key_of(b: &KeyBinding) -> Option { + Some(format!("{} {}", b.key_type.as_deref()?, b.key.as_deref()?)) +} + +/// Every key of a principal that is closed in one namespace and open in +/// another, over all filed bindings. +pub fn split(store: &Store) -> Vec { + let filed: Vec<&KeyBinding> = store.log.iter().flat_map(|l| l.file.key_bindings.iter()).collect(); + let mut by_key: BTreeMap<(String, String), (BTreeSet, BTreeSet)> = BTreeMap::new(); + for b in filed.iter().filter(|b| b.act.opens()) { + let Some(key) = key_of(b) else { continue }; + let entry = by_key.entry((b.principal.to_string(), key)).or_default(); + match key_close::is_closed(&filed, b) { + true => entry.0.insert(b.namespace.clone()), + false => entry.1.insert(b.namespace.clone()), + }; + } + by_key + .into_iter() + .filter(|(_, (closed, open))| !closed.is_empty() && !open.is_empty()) + .map(|((holder, key), (closed, open))| KeySplit { + holder, + key, + closed_in: closed.into_iter().filter(|ns| !open.contains(ns)).collect(), + open_in: open.into_iter().collect(), + }) + .filter(|s| !s.closed_in.is_empty()) + .collect() +} + +/// Every key bound to more than one principal across namespaces (within +/// one namespace that is a schema fault, LP-4.37). +pub fn shared(store: &Store) -> Vec { + let mut by_key: BTreeMap> = BTreeMap::new(); + for b in store.log.iter().flat_map(|l| l.file.key_bindings.iter()).filter(|b| b.act.opens()) { + let Some(key) = key_of(b) else { continue }; + by_key.entry(key).or_default().insert((b.principal.to_string(), b.namespace.clone())); + } + by_key + .into_iter() + .filter(|(_, bound)| bound.iter().map(|(p, _)| p).collect::>().len() > 1) + .map(|(key, bound)| KeyShared { key, bound_to: bound.into_iter().collect() }) + .collect() +} diff --git a/ledger-core/src/verify/mod.rs b/ledger-core/src/verify/mod.rs index 5f586e6f..cb8da2af 100644 --- a/ledger-core/src/verify/mod.rs +++ b/ledger-core/src/verify/mod.rs @@ -14,6 +14,7 @@ pub mod acts; pub mod authority; pub mod belonging; pub mod crossing; +pub mod key_notices; pub mod disposition; mod genesis_role; pub mod history; @@ -120,6 +121,13 @@ pub struct Notices { /// (LP-8.31, ruling 47). #[serde(skip_serializing_if = "Vec::is_empty")] pub genesis_unbound: Vec, + /// A key closed in one namespace and open in another (ruling 69): a + /// close ends the key in its own namespace only (LP-6.32). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub key_split: Vec, + /// One key bound to two principals in two namespaces (ruling 69). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub key_shared: Vec, } /// A namespace whose genesis holder has no trusted key. @@ -130,18 +138,25 @@ pub struct GenesisUnbound { } fn notices(store: &Store, trusted: &[&crate::authority::KeyBinding]) -> Notices { - Notices { unchecked: unchecked(store), unsigned: unsigned(store), genesis_unbound: genesis_unbound(store, trusted) } + Notices { + unchecked: unchecked(store), + unsigned: unsigned(store), + genesis_unbound: genesis_unbound(store, trusted), + key_split: key_notices::split(store), + key_shared: key_notices::shared(store), + } } /// Each namespace with a live genesis grant whose holder has no trusted -/// key (a trusted key is store-wide until issue 6). +/// key bound in that namespace (ruling 47: a key is trusted where it is +/// bound). fn genesis_unbound(store: &Store, trusted: &[&crate::authority::KeyBinding]) -> Vec { store .namespaces() .into_iter() .filter_map(|ns| { let genesis = crate::authority::Authority::of(store, &ns).genesis()?; - let bound = trusted.iter().any(|k| k.principal == genesis.holder && k.act.opens()); + let bound = trusted.iter().any(|k| k.namespace == ns && k.principal == genesis.holder && k.act.opens()); (!bound).then(|| GenesisUnbound { namespace: ns, holder: genesis.holder.to_string() }) }) .collect() diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index 5a7fd254..ffbef403 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -455,7 +455,7 @@ Spec v1.8 (#70; rulings D1 to D4 of #65 and D5 to D9 of 2 October 2026). A names - **LP-4.30** (V) **Requirement.** The policy in force for the namespace at the entity's position (section 8.7), or for a policy change the policy it replaces (D1), lists the required schemes. Under `[none]` an entity needs no sidecar; under any other policy each listed scheme needs one. A policy listing `none` with another scheme is a schema fault, and a writer refuses to file one. A sidecar that is present always has to verify. An acceptance or revocation before its namespace's first policy is not checked (D5 (c)), and a sidecar on one is a schema fault (LP-4.7, ruling 52). **A key binding is never exempt** (ruled 2026-10-05, narrowing D5 (c)): one before its namespace's first policy is judged by D7 and by **that first policy's** requirement (section 4.10). - **LP-4.9** (W, V) A policy change MUST be signed under the policy in force before it, which is the policy it replaces. -- **LP-4.31** (V) **A namespace's first policy** (2026-10-05, #96) replaces nothing, so no policy is in force before it. It is judged under **its own** schemes, and only when its `by` held a live trusted key at its position: bound in this namespace (in the same change-set, dated with the policy, when the namespace is opened) or in another, which for this check alone stands in this namespace as it does for the genesis holder's later first binding (section 4.10). Then a missing or invalid signature is `L011`. A first policy filed before its author held any trusted key needs none, and stands unsigned. *Superseded by ruling 47 once implemented: a key trusted in another namespace then has no effect here (LP-6.31).* +- **LP-4.31** (V) **A namespace's first policy** (2026-10-05, #96) replaces nothing, so no policy is in force before it. It is judged under **its own** schemes, and only when its `by` held a live key trusted in this namespace at its position — bound here, in the same change-set, dated with the policy, when the namespace is opened (LP-4.38); a key trusted in another namespace has no effect here (spec v1.9, ruling 47). Then a missing or invalid signature is `L011`. A first policy filed before its author held any trusted key in the namespace needs none, and stands unsigned. ### 4.9 Trust file @@ -469,7 +469,7 @@ Spec v1.8 (#70; rulings D1 to D4 of #65 and D5 to D9 of 2 October 2026). A names - Options are comma-separated, as OpenSSH's grammar requires. - `` is the bare address. - - `valid-after` is the opening binding's `at`. `valid-before` is the `at` of the earliest `rotate` or `revoke` that closed its **key**, in any namespace, since a close ends the key, not the binding (ruled 2026-10-06, section 4.11). So every line of a closed key carries the end date. + - `valid-after` is the opening binding's `at`. `valid-before` is the `at` of the earliest `rotate` or `revoke` that closed its **key** in that namespace, since a close ends the key, not the binding (ruled 2026-10-06, section 4.11), in its own namespace (spec v1.9, LP-6.32). So every line of a key closed in the namespace carries the end date, and a line of the same key in another namespace does not. - Only bindings that open a window (`add`, `rotate`) give a line, and only **trusted** bindings are written (section 4.10). An unsigned or wrongly signed binding never reaches the file. - Lines are sorted by code point, joined by LF, and the last line ends with one LF. - *Superseded by ruling 47 once implemented: `valid-before` then comes from a close in the binding's own namespace (LP-6.31).* @@ -490,25 +490,25 @@ v1.7 wrote the options space-separated, which OpenSSH refuses as an invalid key. - **LP-4.34** (V) Key bindings are judged first, in order (section 8.7). A binding is **trusted** when its filer is one D7 allows and, where the policy in force requires a signature, its signature verifies against the bindings already trusted. - **LP-4.35** (V) **Before the first policy** (ruled 2026-10-05). A binding before its namespace's first policy (D6: landed no later, dated earlier) is judged by D7 and by the requirement of that first policy, as if it were in force. Signed, it is trusted. Unsigned where that policy requires a signature, it is `L011` and never trusted. Under a `[none]` first policy, D7 alone decides it. A binding in a namespace no policy governs at all is a schema fault. **Every filed binding is trusted or named by a finding**; none is left silently untrusted. - **LP-4.12** (V) D7 allows these filers: - - the genesis holder's **self-bound** first binding in the store, carrying the genesis grant's `external_ref` as `mandate`, signed by the key it binds. Once per store: in any later namespace the genesis holder's first binding is their own `add`, signed by a key of theirs already trusted in another namespace (for that check alone, that key stands in the new namespace). A key trusted elsewhere vouches for that **first** binding only, never a further `add` (ruled 2026-10-05). Otherwise a key closed in one namespace and live in another could re-enter the namespace it was closed in. So a further key dated before the opening binding, which would turn that binding into a further `add`, is refused. *Superseded by ruling 47 once implemented: the self-bound first binding is then once per namespace, and a key trusted in another namespace vouches for nothing here (LP-6.31);* + - the genesis holder's **self-bound** first binding in the namespace, carrying that namespace's genesis grant's `external_ref` as `mandate`, signed by the key it binds. Once per namespace (spec v1.9, ruling 47): every namespace is opened as the first is, and a key trusted in another namespace vouches for nothing here — the same key is self-bound again in each namespace it is to sign in. A further key of the genesis holder's in the namespace is their own `add`, signed by a key of theirs live there; one dated before their self-bound binding is before the namespace's genesis and refused. *Superseded by ruling 47 once implemented: the self-bound first binding is then once per namespace, and a key trusted in another namespace vouches for nothing here (LP-6.31);* - a principal's **first key** (no open window in the namespace) is filed and signed by the genesis holder: `by` the genesis holder, `principal` the new holder, `under` the genesis grant; - every further `add`, and every `rotate`, is the principal's own, signed by a live key of theirs (a `rotate` by the key it closes). A verifier verifies a `rotate`'s signature against the key of the binding it closes and no other, so one signed by another live key of the same principal is `L011`, and a writer signs a `rotate` with that key and refuses any other (ruling 53); - a `revoke` is the principal's, or the genesis holder's under the genesis grant. - **LP-4.36** (V) A binding filed by anyone else is a schema fault (D7); one whose required signature does not verify is `L011`. Neither is trusted, and only trusted bindings reach `allowed_signers`. - **LP-4.37** (W, V) **Which keys may be bound** (ruled 2026-10-06). A binding that opens a key is refused at filing, and at verification it is a schema fault and never trusted, when, against the bindings trusted before it: - - **the key belongs to another principal**: it is, or was, bound to a different principal anywhere in the store. A key belongs to one principal; - - **the key is closed** for its principal, in any namespace (section 4.11). A closed key is never bound again, the genesis holder vouching for it as someone's first key included; - - *Superseded in part by ruling 47 once implemented: both bullets then look at the binding's own namespace only (LP-6.31).* + - **the key belongs to another principal**: it is, or was, bound to a different principal in that namespace. A key belongs to one principal within a namespace; the same key bound to two principals in two namespaces is a repository notice (LP-8.31, ruling 69); + - **the key is closed** for its principal in that namespace (section 4.11). A closed key is never bound again there, the genesis holder vouching for it as someone's first key included; closed in one namespace, it is another key in the next, bindable there; - **the key is already open** for its principal in that namespace. A key is bound once per namespace. The same key may be bound in another namespace. -- **LP-4.38** (W) **Opening a namespace** (2026-10-05, #96). A writer that opens a namespace files the genesis holder's self-bound binding in the same change-set as the genesis grant and the first policy, dated with the policy (so the policy is in force at it), whenever the genesis holder has no key in the store. The window in which the first self-bound binding to land for the address, anyone's, is the one trusted is then closed by the act that opens the namespace. With no usable key the writer refuses and names what is missing, unless explicitly told to proceed unbound (ruled 2026-10-05); the verifier then says the window is open (LP-8.31). In a **later namespace**, while the genesis holder holds a live key, the writer binds that key there in the same change-set: their own `add`, signed by a key of theirs already trusted, dated with the policy, so they sign in the new namespace with no separate binding act. If every key of theirs is closed, the writer refuses and names the closed keys, unless explicitly told to proceed unbound; then it warns, binds nothing and signs nothing. *Superseded by ruling 47 once implemented: each namespace then has its own genesis grant and its own self-bound first binding, and a later namespace is opened as the first one is (LP-6.31).* + + Each rule looks at the binding's own namespace (spec v1.9, ruling 47). +- **LP-4.38** (W) **Opening a namespace** (2026-10-05, #96; per namespace since spec v1.9, ruling 47). A writer that opens a namespace files the genesis holder's self-bound binding in the same change-set as the namespace's genesis grant and first policy, dated with the policy (so the policy is in force at it), under that namespace's mandate — every namespace, whatever the holder has bound elsewhere, since a key is trusted only in the namespace it is bound in. The window in which the first self-bound binding to land for the address in that namespace, anyone's, is the one trusted is then closed by the act that opens it. With no usable key the writer refuses and names what is missing, unless explicitly told to proceed unbound (ruled 2026-10-05); the verifier then says the window is open (LP-8.31). A key closed in another namespace is no obstacle: it is another key here, and the split is a notice (ruling 69). *Note: in the reference writer the genesis holder's key is the one its git configuration names as the signing key, and "proceed unbound" is an explicit option of the command that opens a namespace.* ### 4.11 Closed keys -- **LP-4.39** (V) A close ends the **key**, not the binding (ruled 2026-10-06). Closing any binding of a principal's key, by `rotate` or `revoke`, in any namespace, the target a trusted binding or not, closes that key (the principal and the key) in every namespace of the store, from the close's position (D6). Every signature check considers all bindings of the matched key, never the first it matched; with several closes, the earliest the act is not before decides. The `L011` finding names the namespace the act is refused in, the namespace of the close, and the close. *Superseded by ruling 47 once implemented: a close then takes effect in its own namespace only, and a writer MAY file a close in every namespace it holds (LP-6.32).* -- **LP-4.13** (V) An entity signed by a closed key: dated at or after the close, it fails verification at its `at` (`L011`); landed after the close, whatever its date, it is `L011`; dated and landed before it, an acceptance is a review item ("needs re-acceptance") until a later valid acceptance of the same version by the same actor affirms it, and `L012` once the policy's `reaccept_within_days` deadline (from the close: the key's earliest, in whichever namespace) has passed. +- **LP-4.39** (V) A close ends the **key**, not the binding (ruled 2026-10-06), in its own namespace (spec v1.9, ruling 47; LP-6.32). Closing any binding of a principal's key in a namespace, by `rotate` or `revoke`, the target a trusted binding or not, closes that key (the principal, the key and the namespace) there, from the close's position (D6). Every signature check considers all bindings of the matched key in that namespace, never the first it matched; where several closes end one key, an act stands only if it is before each of them (ruling 101: the genesis holder's revoke may close a key a `rotate` has already closed, so a thief's `rotate` that names forged acts keeps nothing standing once the holder's revoke, dated at the compromise, does not). The `L011` finding names the namespace the act is refused in and the close. +- **LP-4.13** (V) An entity signed by a closed key: dated at or after the close, it fails verification at its `at` (`L011`); landed after the close, whatever its date, it is `L011`; dated and landed before it, an acceptance is a review item ("needs re-acceptance") until a later valid acceptance of the same version by the same actor affirms it, and `L012` once the policy's `reaccept_within_days` deadline (from the close: the key's earliest in the entity's own namespace, LP-6.32) has passed. - **LP-4.14** (V, R) An affirmation is a new acceptance under a live key. The reader takes the latest valid acceptance of a version. ## 5. Entities @@ -871,8 +871,8 @@ Until these rules, a policy by anyone with a bound key (or, under `[none]` or fo All of section 6.7 is **not implemented**. -- **LP-6.31** (W, V) Authority is per namespace (spec v1.9, ruling 47). A grant, grant acceptance, unavailability, availability, revocation or role belongs to the namespace whose directory holds it (LP-3.33, LP-3.34); a binding and a policy carry their namespace. Each namespace has its own genesis grant, roles, grants and policy, and nothing in one namespace's authority has effect in another: every role check, genesis lookup and policy verdict reads one namespace's records (LP-6.16, LP-6.28), the authority shapes run over each namespace's graph alone (LP-8.19, LP-8.23), the unbound-genesis notice is per namespace (LP-8.31), and an export carries its own namespace's authority only (LP-9.11). A writer opens every namespace as it opens the first, on its own `external_ref` mandate, with its own root and accept roles and first policy (LP-4.38); a store whose namespaces shared one genesis is re-founded, not migrated (ruling 68, Appendix C). *Keys are the one part still store-wide: the self-bound binding once per store and a later namespace's first key trusted through another namespace (LP-4.12, LP-4.31, LP-4.38), and keys judged across namespaces (LP-4.32, LP-4.37, LP-4.39), until issue 6 (LP-6.32).* -- **LP-6.32** (W, V) **Not implemented.** A close of a key takes effect in its own namespace only. A writer MAY file a close in every namespace it holds (ruling 47). It supersedes LP-4.39 once implemented. +- **LP-6.31** (W, V) Authority is per namespace (spec v1.9, ruling 47). A grant, grant acceptance, unavailability, availability, revocation or role belongs to the namespace whose directory holds it (LP-3.33, LP-3.34); a binding and a policy carry their namespace. Each namespace has its own genesis grant, roles, grants and policy, and nothing in one namespace's authority has effect in another: every role check, genesis lookup and policy verdict reads one namespace's records (LP-6.16, LP-6.28), the authority shapes run over each namespace's graph alone (LP-8.19, LP-8.23), the unbound-genesis notice is per namespace (LP-8.31), and an export carries its own namespace's authority only (LP-9.11). A writer opens every namespace as it opens the first, on its own `external_ref` mandate, with its own root and accept roles and first policy (LP-4.38); a store whose namespaces shared one genesis is re-founded, not migrated (ruling 68, Appendix C). Keys are per namespace as well: the self-bound binding once per namespace (LP-4.12, LP-4.38), a first policy signed by a key trusted in its own namespace (LP-4.31), and keys judged within their namespace (LP-4.32, LP-4.37, LP-4.39, LP-6.32). +- **LP-6.32** (W, V) A close of a key takes effect in its own namespace only (spec v1.9, ruling 47; LP-4.39). A key closed in one namespace and open in another is a repository notice, never a finding (ruling 69, LP-8.31). A writer MAY file a close in every namespace it holds, one change-set per namespace in one commit (ruling 69). *The writer's close in every namespace held is not yet built (issue 8).* Authority as its own unit, which namespaces depend on by pin, is intended for later and is not designed (ruling 48, section 12.3). @@ -1126,8 +1126,10 @@ CI has to tell "the gate said no" apart from "the gate broke". This differs from - **Unchecked namespaces.** Each namespace the log speaks that has no policy. - **Unsigned namespaces** (2026-10-05, #96). Each namespace whose policy in force is `[none]`, with what does not hold there: an absent signature is no finding, `L012` cannot arise, key bindings are trusted unsigned, a policy change is unsigned, and no `ssh` verification runs unless an `ssh` sidecar exists. A sidecar that is present is still verified. - **An unbound genesis holder** (2026-10-05, #96), per namespace (spec v1.9, ruling 47). For each namespace whose genesis holder has no trusted key, the verifier says so, naming the namespace and the holder: the first self-bound binding to land for that address will be the one trusted (D7). + - **A key closed in one namespace and open in another** (spec v1.9, ruling 69): a close ends the key in its own namespace only (LP-6.32), so the verifier names the key, its holder, the namespaces it is closed in and those it is open in. The holder decides whether to close it there too. + - **One key bound to two principals in two namespaces** (spec v1.9, ruling 69): within a namespace a key belongs to one principal (LP-4.37); across namespaces the verifier names the key and each `(principal, namespace)` it is bound to. -*Note: the reference verifier's machine-readable report carries the second list as `unsigned` and the third as `genesis_unbound`, one entry per namespace (`namespace`, `holder`).* +*Note: the reference verifier's machine-readable report carries the second list as `unsigned`, the third as `genesis_unbound`, one entry per namespace (`namespace`, `holder`), the fourth as `key_split` (`holder`, `key`, `closed_in`, `open_in`) and the fifth as `key_shared` (`key`, `bound_to`).* ### 8.9 Classes added by the basis rulings @@ -1473,6 +1475,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 | **Rulings 47 and 69 applied: keys per namespace** (PRD §3.3; issue 6). LP-6.32 loses its not-implemented mark: a close ends the key in its own namespace only; LP-4.37's three rules, LP-4.39 (with ruling 101's wording) and LP-4.13's deadline look at the binding's own namespace; the genesis holder's first key in a namespace is that namespace's self-bound binding, once per namespace, and a key trusted elsewhere vouches for nothing here (LP-4.12, LP-4.31, LP-4.38 — the later-namespace paragraph goes, with the reference's `carried_over`); `allowed_signers` ends a line at a close in its own namespace (LP-4.32); LP-8.31 gains the two notices of ruling 69. LP-6.31 drops its keys note. No class added; no digest moves. The Appendix C note "Keys are per namespace" is added. | | 9 October 2026 | **Ruling 47 applied: authority per namespace** (with rulings 67, 68 and 50 per namespace; PRD §3.2, §3.9; issue 5). LP-6.31 loses its not-implemented mark: a grant, grant acceptance, interval, revocation or role belongs to the namespace whose directory holds it; each namespace has its own genesis grant, roles and policy; a scope is read inside its own namespace, `ns:` being a schema fault (LP-6.16, LP-8.8); a policy is judged under its own namespace's genesis (LP-6.28); the authority shapes run over each namespace's graph alone, so `A003` and `A005` count per namespace, while the decision shapes keep the store's graph (LP-8.19, LP-8.23); roles are per namespace (LP-5.19); the unbound-genesis notice is per namespace (LP-8.31); an export carries its own authority only (LP-9.11). LP-6.5 loses its note. Keys stay store-wide until issue 6 (noted in LP-6.31). A writer opens every namespace on its own mandate. No class added; no digest moves; no grant payload gains a field. The Appendix C note "Authority is per namespace" is added. | | 9 October 2026 | **Ruling 43 applied, on live claims (ruling 75)** (PRD §3.7; issue 4). LP-3.31 loses its not-implemented mark and its note on the implemented format: a decision's latest version whose `supersedes` names a decision of another namespace is a schema fault (LP-8.8); earlier versions are not judged, and a next version that drops the edge repairs the store. `G001` keeps the dangling target. No class added; no digest moves; no such edge in this repository or the fixtures. | | 9 October 2026 | **Ruling 45 applied** (PRD §3.7; issue 3). LP-3.33 loses its not-implemented mark and its note on the implemented format: every entity under `ns//` belongs to ``, a revocation's target and a sidecar's entity included, and one that belongs elsewhere is a schema fault (LP-8.8). Closes N12's cross-namespace half with ruling 49, and N14. No class added; no digest moves; no committed file in this repository is mixed (0 of 187). | @@ -1621,6 +1624,42 @@ only adds an unhashed field. --- +#### Keys are per namespace (2026-10-09, no format change) + +**Ruled 7 October 2026** (rulings 47 and 69; +`ledger/prd/namespace-independence-prd.md` §3.3; issue 6 of its §6). A key +is bound, closed and trusted in one namespace: the same key self-bound in +two namespaces is two keys, each judged by its namespace alone. From this +change: + +- a close — `rotate` or `revoke` — ends the key in its own namespace only + (LP-6.32, LP-4.39); an acceptance in another namespace signed with the + same key is untouched, has no review item, and its `allowed_signers` + line keeps no end date (LP-4.13, LP-4.32); +- "a key belongs to one principal" and "a closed key is never bound again" + are judged within the namespace (LP-4.37); closed in one namespace, a key + is bindable in the next; +- the genesis holder's first key in a namespace is that namespace's + self-bound binding, under its own mandate, signed by the key it binds — + once per namespace, whatever the holder has bound elsewhere (LP-4.12, + LP-4.38). The reference writer binds the configured key self-bound at + every `init --namespace`; a first policy is signed by a key trusted in + its own namespace only (LP-4.31). The rule that let a key trusted in one + namespace vouch for a holder's first binding in another, and the + reference's `carried_over` that implemented it, go; +- two repository notices, never findings (LP-8.31, ruling 69): a key closed + in one namespace and open in another; one key bound to two principals in + two namespaces. + +**Who must act.** Nobody, for a store with one namespace. A store with +several governed namespaces made before v1.9 is re-founded (ruling 68; the +note "Authority is per namespace" below): its later namespaces' first +bindings leaned on a key trusted elsewhere, which now trusts nothing there. + +**No digest moves** and no class is added: a binding's hashed fields are +unchanged, and `namespace` was already among them. No committed store in +this repository binds a key, so no stored verdict changes. + #### Authority is per namespace (2026-10-09, no format change) **Ruled 7 October 2026** (ruling 47, with 67 and 68; From 53becab1189fa12204312bad43cec4e5b6ef88f0 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Sun, 11 Oct 2026 06:18:30 +0000 Subject: [PATCH 2/2] Seam bindings for step 6: keys per namespace Files the signed bindings the contract-surface gate demands for this step's 14 exposed Rust surface changes over 3 files (13 bindings: the two `key` fields of key_notices.rs share one): a new declaration for verify/key_notices.rs with its verdict_knowledge written, verify/mod.rs amended with bindings, and author/genesis_key.rs amended with its verdict rewritten now that init binds the key in every namespace it opens. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ...ledger-core-src-author-genesis-key-rs.yaml | 8 ++- ...ledger-core-src-verify-key-notices-rs.yaml | 62 +++++++++++++++++++ ...am-rust-ledger-core-src-verify-mod-rs.yaml | 18 ++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .ddd/seams/seam-rust-ledger-core-src-verify-key-notices-rs.yaml diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-genesis-key-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-genesis-key-rs.yaml index 59cc094a..3cd08483 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-author-genesis-key-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-author-genesis-key-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-author-genesis-key-rs boundary: contract surface of ledger-core/src/author/genesis_key.rs -verdict_knowledge: 'Callers learn the genesis holder''s key at init: bound self-bound in the same change-set as the first policy when a usable signing key is configured, signing that policy; in a later namespace the first policy is signed with a live key.' +verdict_knowledge: 'Callers learn the genesis holder''s key at init: in every namespace init opens, the configured key is self-bound in the same change-set as the first policy, under the genesis mandate, and signs that policy; a later namespace binds the holder''s live key there, signed by a key of theirs already trusted, and init is refused with no usable key unless --without-key proceeds unbound (LP-4.31, LP-4.38, ruling 47).' contract_location: ledger-core/src/author/genesis_key.rs obligations: [] metadata: {} @@ -24,3 +24,9 @@ bindings: after: sha256:b1f3d002e552fc53131758042caaa73f52c3d2a9da2f2550a0e6a4269478f7ea base_revision: 8edaa49385d53722a01a95c588f109af94a59783 hash: sha256:da0eab5c2006e4377a59d6279ad1a219a4bd638f9d97593677a109704363c292 +- symbol: impl Author + file: ledger-core/src/author/genesis_key.rs + before: sha256:f56e3ebc3bb733576bb09e4130a239088b8e87803c199ce2ef4504a4e5799092 + after: sha256:5bd15ca42caf8a69e2ef6194d1439b8f2cc215f38499e1295443806f185a23af + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:441c0b7bd086c83d20224dfb089ea984ce4890be043dcd910844b3dd9c453a0c diff --git a/.ddd/seams/seam-rust-ledger-core-src-verify-key-notices-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-verify-key-notices-rs.yaml new file mode 100644 index 00000000..ce77a416 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-verify-key-notices-rs.yaml @@ -0,0 +1,62 @@ +format: 2 +id: seam/rust/ledger-core-src-verify-key-notices-rs +boundary: contract surface of ledger-core/src/verify/key_notices.rs +verdict_knowledge: 'Callers learn the two repository notices about keys across namespaces: a key closed in one namespace and open in another (KeySplit), and one key blob bound to different principals in different namespaces (KeyShared), each reported by verify and never a finding, because a key is per namespace (LP-4.37, LP-4.39, rulings 47 and 69).' +contract_location: ledger-core/src/verify/key_notices.rs +obligations: [] +metadata: {} +bindings: +- symbol: shared + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:657965eba48123977a8979e45086c93e5a6557cdab576693197de4a144ac27d8 +- symbol: split + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:f7a9e3dffce583822fdc779b7348ac013faf32d07002e7f97775b5c949e98dd5 +- symbol: KeyShared + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:01e30b271f83e276a396083c5c7f05be9861e1482e90219b3f058b9a745c1af3 +- symbol: KeySplit + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:2a1273dc7af2ba52f5fc52465971389f23ee648f8b6bbcbb12e33cce4fff47e7 +- symbol: bound_to + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:e7c3108b3eb0f0e1053ff28f6976f9ae2d5acc08e1f38482da23636308484f47 +- symbol: key + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:1345c32ee60edaefa2293714331bfc0a60691974f49a2a9b67259f582f5a79d1 +- symbol: closed_in + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:033977638acd7c020565757cf7e11d24f479e7e5e4c0e78173030f5c8f06e9ed +- symbol: holder + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:3b6772a7f9407d7d911d567b12b90873cd8ea8272ff044d5a4816c12678abf71 +- symbol: open_in + file: ledger-core/src/verify/key_notices.rs + before: absent + after: sha256:394d25018419a14f6aca7195dc32171330641d7a50f05ac276c472c7ea890cbd + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:bf512f9996b72b984629634e920592994d296bc86a2357d355dc4a22b1020a81 diff --git a/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml index c2692f6f..524b95a1 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-verify-mod-rs.yaml @@ -162,3 +162,21 @@ bindings: after: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 base_revision: ec43481f591b1022d8840755c8c0381b06a59387 hash: sha256:b6181d32eae2268519f21749b68bbc52b97f0d30aeb7ce7e162221d88ed05a96 +- symbol: key_notices + file: ledger-core/src/verify/mod.rs + before: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 + after: sha256:3d0170bdc873a5d1a7672933cabf160375a13fba73aa987c817b56eede69c94a + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:c0f4d954f116eccf6adc9c07540ae27de7a319027a1bffabf6396d53f28516b9 +- symbol: key_shared + file: ledger-core/src/verify/mod.rs + before: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 + after: sha256:3d0170bdc873a5d1a7672933cabf160375a13fba73aa987c817b56eede69c94a + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:56eee557b7edf1f3bb1b05b25029f2d6e624cecbf90efddb6619d3f35da524b6 +- symbol: key_split + file: ledger-core/src/verify/mod.rs + before: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 + after: sha256:3d0170bdc873a5d1a7672933cabf160375a13fba73aa987c817b56eede69c94a + base_revision: 73f98fb8133e0e2f334d0f0061b311a92408b882 + hash: sha256:149bea573e9a5e142ab3142c2eaac8f223e9bdab5d1ac5223fada1b6e2695aea