From cffc9a26f025591bcb1e692119ad25d3fdd03fcd Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Fri, 9 Oct 2026 17:16:59 +0000 Subject: [PATCH 1/3] Derive allowed_signers per namespace, remove a stale file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #150 (PRD issue 7, §3.4; ruling 47). Each namespace's `ns//allowed_signers` is derived from that namespace's trusted bindings alone — a line ended by a close in that namespace only, as issue 6 made the derivation read — and a namespace that binds nothing has no file. `signers::write` now removes a file committed in a namespace none of whose bindings is trusted (inventory item N10) and reports what it wrote and removed per namespace (`Synced`); `ledger identity sync`, the identity verbs and `init --namespace` print those lines, and `sync` says when no namespace binds a key. `[SIGNERS]` already compared each namespace's file with its own derivation (issue 1); its protocol line now says so. Tests (each failing before the change where the behaviour is new): `ledger-cli/tests/signers_per_namespace.rs` — each namespace has its own file holding its own `ledger-accept@` lines; a close in A leaves B's file byte-identical; a file hand-written in a namespace that binds nothing fails `[SIGNERS]` naming the namespace, and `identity sync` removes it, prints the removal, and the store verifies again; a namespace whose only binding is untrusted has no file after `sync`. Protocol: LP-4.10 loses its note and gains the removal; LP-4.33 names the namespace; Appendix B row; Appendix C note "A stale `allowed_signers` is removed". No class added; no digest moves. Stacked on #160 (issue 6). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ledger-cli/src/commands/authority.rs | 9 +- ledger-cli/tests/signers_per_namespace.rs | 109 ++++++++++++++++++++++ ledger-core/src/author/authority_ops.rs | 3 +- ledger-core/src/author/identity_ops.rs | 6 +- ledger-core/src/authority/signers.rs | 48 ++++++++-- ledger/spec/ledger-protocol.md | 25 ++++- 6 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 ledger-cli/tests/signers_per_namespace.rs diff --git a/ledger-cli/src/commands/authority.rs b/ledger-cli/src/commands/authority.rs index 5721d8e9..4c638f18 100644 --- a/ledger-cli/src/commands/authority.rs +++ b/ledger-cli/src/commands/authority.rs @@ -68,8 +68,13 @@ fn key_file(path: &Path, namespace: String) -> Result { pub fn identity(root: Option, cmd: IdentityCmd) -> Result { if let IdentityCmd::Sync = cmd { let store = ledger_core::store::load(&resolve_root(root)?); - ledger_core::authority::signers::write(&store)?; - println!("{} rewritten from the log", ledger_core::authority::signers::FILE); + let synced = ledger_core::authority::signers::write(&store)?; + for line in synced.lines() { + println!("{line}"); + } + if synced.written.is_empty() && synced.removed.is_empty() { + println!("no namespace binds a key — no {} to derive", ledger_core::authority::signers::FILE); + } return Ok(EXIT_OK); } let mut author = open_author(root)?; diff --git a/ledger-cli/tests/signers_per_namespace.rs b/ledger-cli/tests/signers_per_namespace.rs new file mode 100644 index 00000000..81ba66c8 --- /dev/null +++ b/ledger-cli/tests/signers_per_namespace.rs @@ -0,0 +1,109 @@ +//! `allowed_signers` is one derived file per namespace (LP-4.10, ruling 47; PRD §3.4; issue 7). +//! +//! Each namespace's file holds that namespace's lines, with `valid-before` +//! from closes in that namespace only; `[SIGNERS]` compares each committed +//! file with that namespace's trusted bindings; a namespace that binds +//! nothing has no file, and a stale one is removed when the files are +//! regenerated. + +mod common; + +use common::{hand, Repo}; + +const OWNER: &str = "owner@customer.example"; +const MANDATE: &str = "contract 2026/117"; +const A: &str = "a.ledger"; +const B: &str = "b.ledger"; + +fn verify(repo: &Repo) -> (i32, String) { + let out = repo.ledger(&["verify", "--no-blame"]); + (out.status.code().unwrap_or(-1), common::both(&out)) +} + +/// Two namespaces, the owner's key self-bound in each by `init`. +fn two_bound() -> (Repo, String) { + let repo = Repo::with_identity(OWNER); + let key = repo.keygen("owner"); + repo.use_key(&key); + repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]); + repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE]); + hand::commit(&repo, "two namespaces, one key bound in each"); + (repo, key) +} + +fn lines(text: &str) -> Vec<&str> { + text.lines().filter(|l| !l.starts_with('#')).collect() +} + +#[test] +fn each_namespace_has_its_own_file_holding_its_own_lines() { + let (repo, _) = two_bound(); + for ns in [A, B] { + assert!(repo.signers_path(ns).is_file(), "`{ns}` has its file"); + let text = repo.signers(ns); + let own = lines(&text); + assert_eq!(own.len(), 1, "{ns}: {own:?}"); + assert!(own[0].contains(&format!("namespaces=\"ledger-accept@{ns}\"")), "{ns}: {}", own[0]); + } + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); +} + +#[test] +fn a_close_in_another_namespace_does_not_reach_the_file() { + let (repo, _) = two_bound(); + let in_a = { + let store = ledger_core::store::load(repo.path()); + store.log.iter().flat_map(|l| l.file.key_bindings.iter()).find(|b| b.namespace == A).map(|b| b.id.to_string()).expect("binding in A") + }; + let before = repo.signers(B); + repo.ok(&["identity", "revoke", &in_a]); + hand::commit(&repo, "closed in A"); + assert!(lines(&repo.signers(A)).iter().all(|l| l.contains("valid-before=")), "{}", repo.signers(A)); + assert_eq!(repo.signers(B), before, "B's file is byte-identical: the close in A is A's"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); +} + +#[test] +fn a_file_in_a_namespace_that_binds_nothing_fails_signers_and_sync_removes_it() { + let repo = Repo::with_identity(OWNER); + let key = repo.keygen("owner"); + repo.use_key(&key); + repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]); + repo.declare_in(B); + // A file nobody derived, in a namespace with no binding. + let stale = repo.signers_path(B); + std::fs::create_dir_all(stale.parent().expect("dir")).expect("mkdir"); + std::fs::write(&stale, format!("{}{OWNER} namespaces=\"ledger-accept@{B}\",valid-after=\"20261001000000Z\" ssh-ed25519 AAAA\n", ledger_core::authority::signers::HEADER)).expect("write"); + hand::commit(&repo, "a stale allowed_signers in B"); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains("[SIGNERS]") && text.contains(&format!("committed in `{B}` but the log binds no key there")), "{text}"); + let out = repo.ok(&["identity", "sync"]); + assert!(out.contains(&format!("removed ns/{B}/allowed_signers")) && out.contains(&format!("regenerated ns/{A}/allowed_signers")), "{out}"); + assert!(!stale.exists(), "the stale file is gone"); + assert!(repo.signers_path(A).is_file(), "A's file stands"); + hand::commit(&repo, "synced"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); +} + +#[test] +fn a_namespace_whose_only_binding_is_untrusted_has_no_file_after_sync() { + let repo = Repo::with_identity(OWNER); + let key = repo.keygen("owner"); + repo.use_key(&key); + repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]); + repo.declare_in(B); + // A forged binding in B, hand-filed with no policy to govern it: a + // schema fault, never trusted — so B derives no file, and one written + // beside it is stale. + let forged = repo.keygen("forged"); + let b = hand::binding(ledger_core::authority::BindingAct::Add, OWNER, OWNER, B, Some(&format!("{forged}.pub")), None, None); + hand::file(&repo, vec![b], Vec::new(), &[]); + std::fs::write(repo.signers_path(B), ledger_core::authority::signers::HEADER).expect("write"); + let out = repo.ok(&["identity", "sync"]); + assert!(out.contains(&format!("removed ns/{B}/allowed_signers")), "{out}"); + assert!(!repo.signers_path(B).exists()); +} diff --git a/ledger-core/src/author/authority_ops.rs b/ledger-core/src/author/authority_ops.rs index 983418a7..dc5675ed 100644 --- a/ledger-core/src/author/authority_ops.rs +++ b/ledger-core/src/author/authority_ops.rs @@ -141,8 +141,7 @@ impl Author { } let path = self.append_signed(candidate)?; if !candidate.key_bindings.is_empty() { - crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?; - lines.push(format!("regenerated {}", crate::authority::signers::FILE)); + lines.extend(crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?.lines()); } Ok(Applied { path, lines }) } diff --git a/ledger-core/src/author/identity_ops.rs b/ledger-core/src/author/identity_ops.rs index 1b5f4a7b..722a16e7 100644 --- a/ledger-core/src/author/identity_ops.rs +++ b/ledger-core/src/author/identity_ops.rs @@ -117,7 +117,9 @@ impl Author { candidate.key_bindings.push(binding); self.refusal_check(&store, &candidate, |_| false)?; let path = self.append_signed(&candidate)?; - crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?; - Ok(Applied { path, lines: vec![line, format!("regenerated {}", crate::authority::signers::FILE)] }) + let synced = crate::authority::signers::write(&self.load()).map_err(AuthorError::Io)?; + let mut lines = vec![line]; + lines.extend(synced.lines()); + Ok(Applied { path, lines }) } } diff --git a/ledger-core/src/authority/signers.rs b/ledger-core/src/authority/signers.rs index 7369a72c..06f9cf47 100644 --- a/ledger-core/src/authority/signers.rs +++ b/ledger-core/src/authority/signers.rs @@ -16,9 +16,10 @@ //! — 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 -//! export's discipline, applied to the trust root. +//! never edited by hand: every `ledger identity` verb rewrites each +//! namespace's file and removes one no binding of that namespace derives +//! (LP-4.10), and `verify` re-derives each and holds the committed bytes +//! identical — the export's discipline, applied to the trust root. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -117,16 +118,45 @@ fn lines(bindings: &[&KeyBinding], filed: &[&KeyBinding]) -> Vec<(String, String .collect() } -/// Rewrite each namespace's derived file from the log. -pub fn write(store: &Store) -> Result<(), String> { - for (namespace, text) in derive(store) { - let target = path(&store.root, &namespace); +/// What a rewrite did: the namespaces whose file was written, and those +/// whose stale file was removed. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Synced { + pub written: Vec, + pub removed: Vec, +} + +impl Synced { + /// One line per namespace touched, for a verb's output. + pub fn lines(&self) -> Vec { + let mut out: Vec = self.written.iter().map(|ns| format!("regenerated ns/{ns}/{FILE}")).collect(); + out.extend(self.removed.iter().map(|ns| format!("removed ns/{ns}/{FILE} — no trusted binding of `{ns}` derives it"))); + out + } +} + +/// Rewrite each namespace's derived file from the log, and remove a file +/// committed in a namespace none of whose bindings is trusted: a namespace +/// that binds nothing has no file (LP-4.10, ruling 47). +pub fn write(store: &Store) -> Result { + let derived = derive(store); + let mut synced = Synced::default(); + for (namespace, text) in &derived { + let target = path(&store.root, namespace); if let Some(dir) = target.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } - product_core::fileops::write_file_atomic(&target, &text).map_err(|e| e.to_string())?; + product_core::fileops::write_file_atomic(&target, text).map_err(|e| e.to_string())?; + synced.written.push(namespace.clone()); + } + for namespace in store.namespaces().into_iter().filter(|ns| !derived.contains_key(ns)) { + let stale = path(&store.root, &namespace); + if stale.is_file() { + std::fs::remove_file(&stale).map_err(|e| e.to_string())?; + synced.removed.push(namespace); + } } - Ok(()) + Ok(synced) } /// The verify stage: each namespace's committed file equals the log's diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index ffbef403..052ec503 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -459,7 +459,7 @@ Spec v1.8 (#70; rulings D1 to D4 of #65 and D5 to D9 of 2 October 2026). A names ### 4.9 Trust file -- **LP-4.10** (W, V) `allowed_signers` is derived from key-binding entries. From v1.9 it is one file per namespace, at `ns//allowed_signers` (LP-3.34), holding that namespace's lines. A verifier MUST regenerate each and require the committed file to be byte-identical. *What each namespace's file says about a close in another namespace is LP-4.32's, superseded by ruling 47 once implemented (issue 7).* +- **LP-4.10** (W, V) `allowed_signers` is derived from key-binding entries. From v1.9 it is one file per namespace, at `ns//allowed_signers` (LP-3.34), holding that namespace's lines — the trusted bindings of that namespace, each line ended by a close in that namespace only (LP-4.32, ruling 47). A namespace that binds nothing has no file. A verifier MUST regenerate each and require the committed file to be byte-identical; a writer that regenerates the files removes one that no trusted binding of its namespace derives. - **LP-4.11** (W, V) Key bindings are append-only: add, rotate and revoke are new entries. - **LP-4.32** (W, V) The file has one line per key window, in OpenSSH's allowed-signers form, so that an SSHSIG verifier reads it directly: @@ -481,7 +481,7 @@ Spec v1.8 (#70; rulings D1 to D4 of #65 and D5 to D9 of 2 October 2026). A names ``` - When no trusted binding opens a key, there is no file. -- **LP-4.33** (V) A verifier re-derives the file and fails a **`[SIGNERS]`** stage when the committed bytes differ, when the log binds keys and no file is committed, or when a file is committed and the log binds none. The stage is outside the file gate's classes, like the export stage. +- **LP-4.33** (V) A verifier re-derives each namespace's file and fails a **`[SIGNERS]`** stage, naming the namespace, when the committed bytes differ from that namespace's derivation, when the log binds keys in a namespace and no file is committed there, or when a file is committed in a namespace whose log binds no key there. The stage is outside the file gate's classes, like the export stage. v1.7 wrote the options space-separated, which OpenSSH refuses as an invalid key. v1.8 corrected the derivation, and no committed store carried the file. @@ -1475,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 | **Ruling 47 applied: `allowed_signers` per namespace** (PRD §3.4; issue 7). LP-4.10 loses its note: each namespace's file holds that namespace's trusted bindings, a line ended by a close in that namespace only (LP-4.32), a namespace that binds nothing has no file, and a writer that regenerates the files removes a stale one; LP-4.33 names the namespace a `[SIGNERS]` finding is about. No class added; no digest moves. The Appendix C note "A stale `allowed_signers` is removed" is added. | | 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. | @@ -1624,6 +1625,26 @@ only adds an unhashed field. --- +#### A stale `allowed_signers` is removed; a close reaches its own namespace's file only (2026-10-09, no format change) + +**Ruled 7 October 2026** (ruling 47; `ledger/prd/namespace-independence-prd.md` +§3.4; issue 7 of its §6). `allowed_signers` is one derived file per +namespace since spec v1.9 (LP-4.10). From this change each file is +derived from that namespace's trusted bindings alone: a line's +`valid-before` is the earliest close of its key *in that namespace* +(LP-4.32), so a close elsewhere leaves the file byte-identical; a +namespace that binds nothing has no file; and the writers that regenerate +the files — every `ledger identity` verb, `ledger init --namespace` when +it binds a key, `ledger identity sync` — remove a file committed in a +namespace none of whose bindings is trusted (inventory item N10). The +`[SIGNERS]` stage already failed such a file; now a verb mends it. + +**No digest moves** and no class is added. A derived file is never +hashed or signed. No committed store in this repository binds a key, so no +stored verdict changes; a store whose later namespaces' lines once ended +at a close in another namespace regenerates them with `ledger identity +sync` (and is re-founded in any case, ruling 68). + #### Keys are per namespace (2026-10-09, no format change) **Ruled 7 October 2026** (rulings 47 and 69; From 3cdc11881f78a34b3e5cc71606e8a39067325e72 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Sun, 11 Oct 2026 06:19:34 +0000 Subject: [PATCH 2/3] Seam bindings for step 7: allowed_signers per namespace Files the signed bindings the contract-surface gate demands for this step's six exposed Rust surface changes, all in authority/signers.rs: the declaration is amended with the bindings and its verdict extended with what write now reports and removes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ...-ledger-core-src-authority-signers-rs.yaml | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml index 64380813..a9091e02 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-signers-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-authority-signers-rs boundary: contract surface of ledger-core/src/authority/signers.rs -verdict_knowledge: 'Consumers learn allowed_signers is derived per namespace from that namespace''s key bindings in OpenSSH form, written by the identity verbs at .decisions/ns//allowed_signers, and held byte-identical by verify''s [SIGNERS] stage.' +verdict_knowledge: 'Consumers learn allowed_signers is derived per namespace from that namespace''s key bindings in OpenSSH form, written by the identity verbs at .decisions/ns//allowed_signers, that write reports what it wrote and removed (Synced) and removes a stale file in a namespace with no derived entry, and that verify''s [SIGNERS] stage holds every namespace''s file byte-identical (LP-4.10, LP-4.33).' contract_location: ledger-core/src/authority/signers.rs obligations: [] metadata: {} @@ -90,3 +90,39 @@ bindings: after: sha256:3e0780d22c990a69f8205668616cd6971976ec3130ee73caf7745cf51997396a base_revision: 70395dfe317cb9a00665433813766ca763f3c58a hash: sha256:1100a9e4e2b13c50ad9c31ffd8123af332e6e432cc0eca45e0f38a77ca12ca85 +- symbol: write + file: ledger-core/src/authority/signers.rs + before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26 + after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef + base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838 + hash: sha256:e2f9b122875266f74fe7422bd3d395e6d56b27a6553e5705714064a0e1f9634d +- symbol: impl Synced + file: ledger-core/src/authority/signers.rs + before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26 + after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef + base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838 + hash: sha256:2c7c975a8eb135d7d59edc7a27ae08414f00a8c5b15f05a0e5461379def1e0c9 +- symbol: Synced + file: ledger-core/src/authority/signers.rs + before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26 + after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef + base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838 + hash: sha256:8082982faff2d4c185bb57ea5b759dc5f3d66de12199e6c680292cc3711d22d6 +- symbol: removed + file: ledger-core/src/authority/signers.rs + before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26 + after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef + base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838 + hash: sha256:c844b258c6699385c8c9220fdb85e871a3664fbe085370d9b46d7067970d8136 +- symbol: written + file: ledger-core/src/authority/signers.rs + before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26 + after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef + base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838 + hash: sha256:2e2545b091a37a0fff2559ad54b41fdcdc0a5887cc84fd6ea96b62ba8177eafc +- symbol: lines + file: ledger-core/src/authority/signers.rs + before: sha256:948fea8114295ba83d7d9bf9b18e074db9694a3c982a2eba59e9dc993f2d4c26 + after: sha256:611b39e2f472007f915fb984ac23e8eae26c37044cb08e2c8d8c55d35264a2ef + base_revision: 792983ac6f2a057451b11d6a3a46101a707e6838 + hash: sha256:af2fbba83148f97a5f0a40da4b08890d60b8876f80aafefc6f23584be66de424 From ee59361a7f64dcbaf30c988df06f68ef18b8fe70 Mon Sep 17 00:00:00 2001 From: "Claude (Emil)" Date: Sun, 11 Oct 2026 06:29:12 +0000 Subject: [PATCH 3/3] Pause a second after founding the two namespaces in signers_per_namespace `ssh-keygen` reads validity windows at second granularity, so a close filed in the same second as the policies it is judged under fails L011 ("Could not verify signature"), which `a_close_in_another_namespace_does_ not_reach_the_file` did once in CI. The same pause step 8 added to this harness, ported here so this step is green on its own; it no-ops when step 8 lands. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GgRcHXmnAu43hChNxhnqxU --- ledger-cli/tests/signers_per_namespace.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ledger-cli/tests/signers_per_namespace.rs b/ledger-cli/tests/signers_per_namespace.rs index 81ba66c8..230bc2e9 100644 --- a/ledger-cli/tests/signers_per_namespace.rs +++ b/ledger-cli/tests/signers_per_namespace.rs @@ -28,6 +28,9 @@ fn two_bound() -> (Repo, String) { repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE]); repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE]); hand::commit(&repo, "two namespaces, one key bound in each"); + // A later act in the same second as the policies would sit at the + // policies' second: `ssh-keygen` reads windows at second granularity. + std::thread::sleep(std::time::Duration::from_millis(1100)); (repo, key) }